add encrypted Nostr session sharing (#8922)
Signed-off-by: callebtc <93376500+callebtc@users.noreply.github.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Signed-off-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -3145,6 +3145,50 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/sessions/import/nostr": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Session Management"
|
||||
],
|
||||
"operationId": "import_session_nostr",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ImportSessionNostrRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Nostr shared session imported successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Session"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - Invalid Nostr share link"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Invalid or missing API key"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/sessions/insights": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3656,6 +3700,61 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/share/nostr": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Session Management"
|
||||
],
|
||||
"operationId": "share_session_nostr",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"description": "Unique identifier for the session",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ShareSessionNostrRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Session shared to Nostr successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ShareSessionNostrResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Invalid or missing API key"
|
||||
},
|
||||
"404": {
|
||||
"description": "Session not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/user_recipe_values": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -5625,6 +5724,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImportSessionNostrRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deeplink"
|
||||
],
|
||||
"properties": {
|
||||
"deeplink": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImportSessionRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -8105,6 +8215,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ShareSessionNostrRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"relays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ShareSessionNostrResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deeplink",
|
||||
"nevent",
|
||||
"eventId",
|
||||
"relays"
|
||||
],
|
||||
"properties": {
|
||||
"deeplink": {
|
||||
"type": "string"
|
||||
},
|
||||
"eventId": {
|
||||
"type": "string"
|
||||
},
|
||||
"nevent": {
|
||||
"type": "string"
|
||||
},
|
||||
"relays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SlashCommand": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
+18
-9
@@ -8,7 +8,7 @@ import {
|
||||
useLocation,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import { openSharedSessionFromDeepLink } from './sessionLinks';
|
||||
import { openSharedSessionFromDeepLink, importNostrSessionFromDeepLink } from './sessionLinks';
|
||||
import { type SharedSessionDetails } from './sharedSessions';
|
||||
import { ErrorUI } from './components/ErrorBoundary';
|
||||
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
|
||||
@@ -428,6 +428,11 @@ export function AppInner() {
|
||||
setIsLoadingSharedSession(true);
|
||||
setSharedSessionError(null);
|
||||
try {
|
||||
if (link.startsWith('goose://sessions/nostr')) {
|
||||
await importNostrSessionFromDeepLink(link);
|
||||
navigate('/sessions');
|
||||
return;
|
||||
}
|
||||
await openSharedSessionFromDeepLink(link, (_view: View, options?: ViewOptions) => {
|
||||
navigate('/shared-session', { state: options });
|
||||
});
|
||||
@@ -438,14 +443,18 @@ export function AppInner() {
|
||||
action: 'open_shared_session',
|
||||
recoverable: true,
|
||||
});
|
||||
// Navigate to shared session view with error
|
||||
const shareToken = link.replace('goose://sessions/', '');
|
||||
const options = {
|
||||
sessionDetails: null,
|
||||
error: errorMessage(error, 'Unknown error'),
|
||||
shareToken,
|
||||
};
|
||||
navigate('/shared-session', { state: options });
|
||||
if (link.startsWith('goose://sessions/nostr')) {
|
||||
toast.error(`Failed to import Nostr session: ${errorMessage(error, 'Unknown error')}`);
|
||||
navigate('/sessions');
|
||||
} else {
|
||||
const shareToken = link.replace('goose://sessions/', '');
|
||||
const options = {
|
||||
sessionDetails: null,
|
||||
error: errorMessage(error, 'Unknown error'),
|
||||
shareToken,
|
||||
};
|
||||
navigate('/shared-session', { state: options });
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingSharedSession(false);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -593,6 +593,10 @@ export type ImportAppResponse = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ImportSessionNostrRequest = {
|
||||
deeplink: string;
|
||||
};
|
||||
|
||||
export type ImportSessionRequest = {
|
||||
json: string;
|
||||
};
|
||||
@@ -1368,6 +1372,17 @@ export type SetupResponse = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrRequest = {
|
||||
relays?: Array<string>;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrResponse = {
|
||||
deeplink: string;
|
||||
eventId: string;
|
||||
nevent: string;
|
||||
relays: Array<string>;
|
||||
};
|
||||
|
||||
export type SlashCommand = {
|
||||
command: string;
|
||||
command_type: CommandType;
|
||||
@@ -4091,6 +4106,37 @@ export type ImportSessionResponses = {
|
||||
|
||||
export type ImportSessionResponse = ImportSessionResponses[keyof ImportSessionResponses];
|
||||
|
||||
export type ImportSessionNostrData = {
|
||||
body: ImportSessionNostrRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/sessions/import/nostr';
|
||||
};
|
||||
|
||||
export type ImportSessionNostrErrors = {
|
||||
/**
|
||||
* Bad request - Invalid Nostr share link
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Unauthorized - Invalid or missing API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type ImportSessionNostrResponses = {
|
||||
/**
|
||||
* Nostr shared session imported successfully
|
||||
*/
|
||||
200: Session;
|
||||
};
|
||||
|
||||
export type ImportSessionNostrResponse = ImportSessionNostrResponses[keyof ImportSessionNostrResponses];
|
||||
|
||||
export type GetSessionInsightsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
@@ -4473,6 +4519,42 @@ export type UpdateSessionNameResponses = {
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrData = {
|
||||
body: ShareSessionNostrRequest;
|
||||
path: {
|
||||
/**
|
||||
* Unique identifier for the session
|
||||
*/
|
||||
session_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/sessions/{session_id}/share/nostr';
|
||||
};
|
||||
|
||||
export type ShareSessionNostrErrors = {
|
||||
/**
|
||||
* Unauthorized - Invalid or missing API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Session not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrResponses = {
|
||||
/**
|
||||
* Session shared to Nostr successfully
|
||||
*/
|
||||
200: ShareSessionNostrResponse;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrResponse2 = ShareSessionNostrResponses[keyof ShareSessionNostrResponses];
|
||||
|
||||
export type UpdateSessionUserRecipeValuesData = {
|
||||
body: UpdateSessionUserRecipeValuesRequest;
|
||||
path: {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Trash2,
|
||||
Download,
|
||||
Upload,
|
||||
Share2,
|
||||
LoaderCircle,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Puzzle,
|
||||
@@ -28,18 +30,29 @@ import { Skeleton } from '../ui/skeleton';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ConfirmationModal } from '../ui/ConfirmationModal';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import {
|
||||
deleteSession,
|
||||
exportSession,
|
||||
forkSession,
|
||||
importSession,
|
||||
importSessionNostr,
|
||||
listSessions,
|
||||
searchSessions,
|
||||
shareSessionNostr,
|
||||
Session,
|
||||
updateSessionName,
|
||||
ExtensionConfig,
|
||||
ExtensionData,
|
||||
} from '../../api';
|
||||
import { getTunnelStatus } from '../../api/sdk.gen';
|
||||
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
|
||||
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
|
||||
import { shouldShowNewChatTitle } from '../../sessions';
|
||||
@@ -55,6 +68,11 @@ const i18n = defineMessages({
|
||||
sessionUpdateFailed: { id: 'sessions.toast.updateFailed', defaultMessage: 'Failed to update session description: {error}' },
|
||||
chatHistory: { id: 'sessions.chatHistory', defaultMessage: 'Chat history' },
|
||||
importSession: { id: 'sessions.import', defaultMessage: 'Import Session' },
|
||||
importNostrSession: { id: 'sessions.importNostr', defaultMessage: 'Import Link' },
|
||||
importNostrTitle: { id: 'sessions.importNostr.title', defaultMessage: 'Import Nostr Session' },
|
||||
importNostrDesc: { id: 'sessions.importNostr.description', defaultMessage: 'Paste a Goose Nostr share link to fetch, decrypt, and import the session.' },
|
||||
importNostrPlaceholder: { id: 'sessions.importNostr.placeholder', defaultMessage: 'goose://sessions/nostr?nevent=...&key=...' },
|
||||
importing: { id: 'sessions.importing', defaultMessage: 'Importing...' },
|
||||
chatHistoryDesc: { id: 'sessions.chatHistoryDesc', defaultMessage: 'View and search your past conversations with Goose. {shortcut} to search.' },
|
||||
searchPlaceholder: { id: 'sessions.searchPlaceholder', defaultMessage: 'Search history...' },
|
||||
errorLoading: { id: 'sessions.error.loading', defaultMessage: 'Error Loading Sessions' },
|
||||
@@ -73,12 +91,19 @@ const i18n = defineMessages({
|
||||
importSuccess: { id: 'sessions.toast.imported', defaultMessage: 'Session imported successfully' },
|
||||
importFailed: { id: 'sessions.toast.importFailed', defaultMessage: 'Failed to import session: {error}' },
|
||||
exportSuccess: { id: 'sessions.toast.exported', defaultMessage: 'Session exported successfully' },
|
||||
shareNostrSuccess: { id: 'sessions.toast.shareNostr', defaultMessage: 'Encrypted Nostr share link created' },
|
||||
shareNostrFailed: { id: 'sessions.toast.shareNostrFailed', defaultMessage: 'Failed to create Nostr share link: {error}' },
|
||||
copied: { id: 'sessions.toast.copied', defaultMessage: 'Copied to clipboard' },
|
||||
openInNewWindow: { id: 'sessions.action.openNewWindow', defaultMessage: 'Open in new window' },
|
||||
editSessionName: { id: 'sessions.action.editName', defaultMessage: 'Edit session name' },
|
||||
duplicateSession: { id: 'sessions.action.duplicate', defaultMessage: 'Duplicate session' },
|
||||
deleteSession: { id: 'sessions.action.delete', defaultMessage: 'Delete session' },
|
||||
exportSession: { id: 'sessions.action.export', defaultMessage: 'Export session' },
|
||||
shareNostrSession: { id: 'sessions.action.shareNostr', defaultMessage: 'Share encrypted Nostr link' },
|
||||
extensions: { id: 'sessions.extensions', defaultMessage: 'Extensions:' },
|
||||
shareNostrTitle: { id: 'sessions.shareNostr.title', defaultMessage: 'Encrypted Nostr Share Link' },
|
||||
shareNostrDesc: { id: 'sessions.shareNostr.description', defaultMessage: 'Anyone with this link can fetch and decrypt the session. Treat it like a secret.' },
|
||||
close: { id: 'sessions.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
function getSessionExtensionNames(extensionData: ExtensionData): string[] {
|
||||
@@ -265,6 +290,14 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<Session | null>(null);
|
||||
|
||||
const [showImportLinkModal, setShowImportLinkModal] = useState(false);
|
||||
const [nostrImportLink, setNostrImportLink] = useState('');
|
||||
const [isImportingNostr, setIsImportingNostr] = useState(false);
|
||||
const [shareLink, setShareLink] = useState('');
|
||||
const [showShareLinkModal, setShowShareLinkModal] = useState(false);
|
||||
const [sharingSessionId, setSharingSessionId] = useState<string | null>(null);
|
||||
const [nostrEnabled, setNostrEnabled] = useState(true);
|
||||
|
||||
// Search state for debouncing
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
@@ -338,6 +371,17 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// Hide Nostr sharing when tunnel is disabled (restricted/enterprise bundles)
|
||||
useEffect(() => {
|
||||
getTunnelStatus()
|
||||
.then(({ data }) => {
|
||||
if (data?.state === 'disabled') {
|
||||
setNostrEnabled(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Timing logic to prevent flicker between skeleton and content on initial load
|
||||
useEffect(() => {
|
||||
if (!isLoading && showSkeleton) {
|
||||
@@ -542,10 +586,62 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
toast.success(intl.formatMessage(i18n.exportSuccess));
|
||||
}, [intl]);
|
||||
|
||||
const handleShareSessionNostr = useCallback(
|
||||
async (session: Session, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSharingSessionId(session.id);
|
||||
try {
|
||||
const response = await shareSessionNostr({
|
||||
path: { session_id: session.id },
|
||||
body: {},
|
||||
throwOnError: true,
|
||||
});
|
||||
setShareLink(response.data.deeplink);
|
||||
setShowShareLinkModal(true);
|
||||
toast.success(intl.formatMessage(i18n.shareNostrSuccess));
|
||||
} catch (error) {
|
||||
toast.error(intl.formatMessage(i18n.shareNostrFailed, { error: errorMessage(error, 'Unknown error') }));
|
||||
} finally {
|
||||
setSharingSessionId(null);
|
||||
}
|
||||
},
|
||||
[intl]
|
||||
);
|
||||
|
||||
const handleImportClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleImportNostrLink = useCallback(async () => {
|
||||
const deeplink = nostrImportLink.trim();
|
||||
if (!deeplink) return;
|
||||
|
||||
setIsImportingNostr(true);
|
||||
try {
|
||||
await importSessionNostr({
|
||||
body: { deeplink },
|
||||
throwOnError: true,
|
||||
});
|
||||
setNostrImportLink('');
|
||||
setShowImportLinkModal(false);
|
||||
toast.success(intl.formatMessage(i18n.importSuccess));
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
toast.error(intl.formatMessage(i18n.importFailed, { error: errorMessage(error, 'Unknown error') }));
|
||||
} finally {
|
||||
setIsImportingNostr(false);
|
||||
}
|
||||
}, [intl, loadSessions, nostrImportLink]);
|
||||
|
||||
const handleCopyShareLink = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
toast.success(intl.formatMessage(i18n.copied));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy: ${errorMessage(error, 'Unknown error')}`);
|
||||
}
|
||||
}, [intl, shareLink]);
|
||||
|
||||
const handleImportSession = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -586,14 +682,18 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
onExportClick,
|
||||
onShareClick,
|
||||
onOpenInNewWindow,
|
||||
isSharing,
|
||||
}: {
|
||||
session: Session;
|
||||
onEditClick: (session: Session) => void;
|
||||
onDuplicateClick: (session: Session) => void;
|
||||
onDeleteClick: (session: Session) => void;
|
||||
onExportClick: (session: Session, e: React.MouseEvent) => void;
|
||||
onShareClick: (session: Session, e: React.MouseEvent) => void;
|
||||
onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void;
|
||||
isSharing: boolean;
|
||||
}) {
|
||||
const handleEditClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -630,6 +730,13 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
[onExportClick, session]
|
||||
);
|
||||
|
||||
const handleShareClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
onShareClick(session, e);
|
||||
},
|
||||
[onShareClick, session]
|
||||
);
|
||||
|
||||
const handleOpenInNewWindowClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
onOpenInNewWindow(session, e);
|
||||
@@ -736,6 +843,20 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
>
|
||||
<Download className="w-3 h-3 text-text-secondary hover:text-text-primary" />
|
||||
</button>
|
||||
{nostrEnabled && (
|
||||
<button
|
||||
onClick={handleShareClick}
|
||||
disabled={isSharing}
|
||||
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
||||
title={intl.formatMessage(i18n.shareNostrSession)}
|
||||
>
|
||||
{isSharing ? (
|
||||
<LoaderCircle className="w-3 h-3 text-text-secondary animate-spin" />
|
||||
) : (
|
||||
<Share2 className="w-3 h-3 text-text-secondary hover:text-text-primary" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -828,7 +949,9 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
onDuplicateClick={handleDuplicateSession}
|
||||
onDeleteClick={handleDeleteSession}
|
||||
onExportClick={handleExportSession}
|
||||
onShareClick={handleShareSessionNostr}
|
||||
onOpenInNewWindow={handleOpenInNewWindow}
|
||||
isSharing={sharingSessionId === session.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -855,15 +978,28 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">{intl.formatMessage(i18n.chatHistory)}</h1>
|
||||
<Button
|
||||
onClick={handleImportClick}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{intl.formatMessage(i18n.importSession)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{nostrEnabled && (
|
||||
<Button
|
||||
onClick={() => setShowImportLinkModal(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
{intl.formatMessage(i18n.importNostrSession)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleImportClick}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{intl.formatMessage(i18n.importSession)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{intl.formatMessage(i18n.chatHistoryDesc, { shortcut: getSearchShortcutText() })}
|
||||
@@ -957,6 +1093,83 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
onSave={handleModalSave}
|
||||
/>
|
||||
|
||||
<Dialog open={showImportLinkModal} onOpenChange={setShowImportLinkModal}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Share2 className="w-5 h-5" />
|
||||
{intl.formatMessage(i18n.importNostrTitle)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{intl.formatMessage(i18n.importNostrDesc)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<textarea
|
||||
value={nostrImportLink}
|
||||
onChange={(event) => setNostrImportLink(event.target.value)}
|
||||
placeholder={intl.formatMessage(i18n.importNostrPlaceholder)}
|
||||
className="min-h-28 w-full resize-none rounded-lg border border-border-primary bg-background-primary p-3 text-sm text-text-primary outline-none focus:ring-2 focus:ring-border-active"
|
||||
disabled={isImportingNostr}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowImportLinkModal(false)}
|
||||
disabled={isImportingNostr}
|
||||
>
|
||||
{intl.formatMessage(i18n.cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportNostrLink}
|
||||
disabled={isImportingNostr || !nostrImportLink.trim()}
|
||||
>
|
||||
{isImportingNostr ? (
|
||||
<>
|
||||
<LoaderCircle className="w-4 h-4 animate-spin" />
|
||||
{intl.formatMessage(i18n.importing)}
|
||||
</>
|
||||
) : (
|
||||
intl.formatMessage(i18n.importSession)
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showShareLinkModal} onOpenChange={setShowShareLinkModal}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Share2 className="w-5 h-5" />
|
||||
{intl.formatMessage(i18n.shareNostrTitle)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{intl.formatMessage(i18n.shareNostrDesc)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative rounded-lg border border-border-primary bg-background-secondary p-3 pr-12">
|
||||
<code className="block max-h-36 overflow-y-auto break-all text-sm text-text-primary">
|
||||
{shareLink}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={handleCopyShareLink}
|
||||
disabled={!shareLink}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span className="sr-only">{intl.formatMessage(i18n.copied)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowShareLinkModal(false)}>
|
||||
{intl.formatMessage(i18n.close)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={showDeleteConfirmation}
|
||||
title={intl.formatMessage(i18n.deleteTitle)}
|
||||
|
||||
@@ -3908,6 +3908,9 @@
|
||||
"sessions.action.openNewWindow": {
|
||||
"defaultMessage": "Open in new window"
|
||||
},
|
||||
"sessions.action.shareNostr": {
|
||||
"defaultMessage": "Share encrypted Nostr link"
|
||||
},
|
||||
"sessions.cancel": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
@@ -3917,6 +3920,9 @@
|
||||
"sessions.chatHistoryDesc": {
|
||||
"defaultMessage": "View and search your past conversations with Goose. {shortcut} to search."
|
||||
},
|
||||
"sessions.close": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"sessions.delete.message": {
|
||||
"defaultMessage": "Are you sure you want to delete the session \"{name}\"? This action cannot be undone."
|
||||
},
|
||||
@@ -3947,6 +3953,21 @@
|
||||
"sessions.import": {
|
||||
"defaultMessage": "Import Session"
|
||||
},
|
||||
"sessions.importNostr": {
|
||||
"defaultMessage": "Import Link"
|
||||
},
|
||||
"sessions.importNostr.description": {
|
||||
"defaultMessage": "Paste a Goose Nostr share link to fetch, decrypt, and import the session."
|
||||
},
|
||||
"sessions.importNostr.placeholder": {
|
||||
"defaultMessage": "goose://sessions/nostr?nevent=...&key=..."
|
||||
},
|
||||
"sessions.importNostr.title": {
|
||||
"defaultMessage": "Import Nostr Session"
|
||||
},
|
||||
"sessions.importing": {
|
||||
"defaultMessage": "Importing..."
|
||||
},
|
||||
"sessions.loadingMore": {
|
||||
"defaultMessage": "Loading more sessions..."
|
||||
},
|
||||
@@ -3965,6 +3986,15 @@
|
||||
"sessions.searchPlaceholder": {
|
||||
"defaultMessage": "Search history..."
|
||||
},
|
||||
"sessions.shareNostr.description": {
|
||||
"defaultMessage": "Anyone with this link can fetch and decrypt the session. Treat it like a secret."
|
||||
},
|
||||
"sessions.shareNostr.title": {
|
||||
"defaultMessage": "Encrypted Nostr Share Link"
|
||||
},
|
||||
"sessions.toast.copied": {
|
||||
"defaultMessage": "Copied to clipboard"
|
||||
},
|
||||
"sessions.toast.deleteFailed": {
|
||||
"defaultMessage": "Failed to delete session \"{name}\": {error}"
|
||||
},
|
||||
@@ -3986,6 +4016,12 @@
|
||||
"sessions.toast.imported": {
|
||||
"defaultMessage": "Session imported successfully"
|
||||
},
|
||||
"sessions.toast.shareNostr": {
|
||||
"defaultMessage": "Encrypted Nostr share link created"
|
||||
},
|
||||
"sessions.toast.shareNostrFailed": {
|
||||
"defaultMessage": "Failed to create Nostr share link: {error}"
|
||||
},
|
||||
"sessions.toast.updateFailed": {
|
||||
"defaultMessage": "Failed to update session description: {error}"
|
||||
},
|
||||
|
||||
@@ -511,7 +511,7 @@ app.on('open-url', async (_event, url) => {
|
||||
if (process.platform !== 'win32') {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
log.info('[Main] Received open-url event:', url);
|
||||
log.info('[Main] Received open-url event:', url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url);
|
||||
|
||||
await app.whenReady();
|
||||
|
||||
@@ -540,7 +540,7 @@ app.on('open-url', async (_event, url) => {
|
||||
|
||||
// For extension/session URLs, store the deep link for processing after React is ready
|
||||
pendingDeepLink = url;
|
||||
log.info('[Main] Stored pending deep link for processing after React ready:', url);
|
||||
log.info('[Main] Stored pending deep link for processing after React ready:', url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url);
|
||||
|
||||
const existingWindows = BrowserWindow.getAllWindows();
|
||||
if (existingWindows.length > 0) {
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { fetchSharedSessionDetails, SharedSessionDetails } from './sharedSessions';
|
||||
import { View, ViewOptions } from './utils/navigationUtils';
|
||||
import { errorMessage } from './utils/conversionUtils';
|
||||
import { importSessionNostr } from './api';
|
||||
|
||||
/**
|
||||
* Imports a session from an encrypted Nostr deep link.
|
||||
* Separated from shared-session handling so callers can route independently.
|
||||
*/
|
||||
export async function importNostrSessionFromDeepLink(url: string): Promise<void> {
|
||||
await importSessionNostr({
|
||||
body: { deeplink: url },
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles opening a shared session from a deep link
|
||||
|
||||
Reference in New Issue
Block a user