diff --git a/app/main.ts b/app/main.ts index adc56736f1..c0da7d5992 100644 --- a/app/main.ts +++ b/app/main.ts @@ -2899,14 +2899,13 @@ function handleSignalRoute(route: ParsedSignalRoute) { value: route.args.encryptedUsername, }); } else if (route.key === 'showConversation') { - mainWindow.webContents.send('show-conversation-via-notification', { - conversationId: route.args.conversationId, - messageId: route.args.messageId, - storyId: route.args.storyId, - }); + mainWindow.webContents.send( + 'show-conversation-via-token', + route.args.token + ); } else if (route.key === 'startCallLobby') { mainWindow.webContents.send('start-call-lobby', { - conversationId: route.args.conversationId, + token: route.args.token, }); } else if (route.key === 'linkCall') { mainWindow.webContents.send('start-call-link', { diff --git a/app/renderWindowsToast.tsx b/app/renderWindowsToast.tsx index 7881161854..3d3e0ccb6c 100644 --- a/app/renderWindowsToast.tsx +++ b/app/renderWindowsToast.tsx @@ -37,10 +37,8 @@ const Image = (props: { id: string; src: string; 'hint-crop': string }) => export function renderWindowsToast({ avatarPath, body, - conversationId, heading, - messageId, - storyId, + token, type, }: WindowsNotificationData): string { // Note: with these templates, the first is one line, bolded @@ -58,13 +56,11 @@ export function renderWindowsToast({ // 2) this also maps to the url-handling in main.ts if (type === NotificationType.Message || type === NotificationType.Reaction) { launch = showConversationRoute.toAppUrl({ - conversationId, - messageId: messageId ?? null, - storyId: storyId ?? null, + token, }); } else if (type === NotificationType.IncomingGroupCall) { launch = startCallLobbyRoute.toAppUrl({ - conversationId, + token, }); } else if (type === NotificationType.IncomingCall) { launch = showWindowRoute.toAppUrl({}); diff --git a/ts/CI.ts b/ts/CI.ts index 411bb8b8af..3d9d5dd9c6 100644 --- a/ts/CI.ts +++ b/ts/CI.ts @@ -10,6 +10,7 @@ import * as log from './logging/log'; import { explodePromise } from './util/explodePromise'; import { AccessType, ipcInvoke } from './sql/channels'; import { backupsService } from './services/backups'; +import { notificationService } from './services/notifications'; import { AttachmentBackupManager } from './jobs/AttachmentBackupManager'; import { migrateAllMessages } from './messages/migrateMessageData'; import { SECOND } from './util/durations'; @@ -22,6 +23,7 @@ type ResolveType = (data: unknown) => void; export type CIType = { deviceName: string; getConversationId: (address: string | null) => string | null; + createNotificationToken: (address: string) => string | undefined; getMessagesBySentAt( sentAt: number ): Promise>; @@ -157,6 +159,19 @@ export function getCI({ return window.ConversationController.getConversationId(address); } + function createNotificationToken(address: string): string | undefined { + const id = window.ConversationController.getConversationId(address); + if (!id) { + return undefined; + } + + return notificationService._createToken({ + conversationId: id, + messageId: undefined, + storyId: undefined, + }); + } + async function openSignalRoute(url: string) { strictAssert( isSignalRoute(url), @@ -201,6 +216,7 @@ export function getCI({ return { deviceName, getConversationId, + createNotificationToken, getMessagesBySentAt, handleEvent, setProvisioningURL, diff --git a/ts/services/notifications.ts b/ts/services/notifications.ts index 1f8e3e4aae..caae1e6d0a 100644 --- a/ts/services/notifications.ts +++ b/ts/services/notifications.ts @@ -4,6 +4,8 @@ import os from 'os'; import { debounce } from 'lodash'; import EventEmitter from 'events'; +import { v4 as getGuid } from 'uuid'; + import { Sound, SoundType } from '../util/Sound'; import { shouldHideExpiringMessageBody } from '../types/Settings'; import OS from '../util/os/osMain'; @@ -36,16 +38,14 @@ type NotificationDataType = Readonly<{ export type NotificationClickData = Readonly<{ conversationId: string; - messageId: string | null; - storyId: string | null; + messageId: string | undefined; + storyId: string | undefined; }>; export type WindowsNotificationData = { avatarPath?: string; body: string; - conversationId: string; heading: string; - messageId?: string; - storyId?: string; + token: string; type: NotificationType; }; export enum NotificationType { @@ -86,6 +86,7 @@ class NotificationService extends EventEmitter { #lastNotification: null | Notification = null; #notificationData: null | NotificationDataType = null; + #tokenData: { token: string; data: NotificationClickData } | undefined; // Testing indicated that trying to create/destroy notifications too quickly // resulted in notifications that stuck around forever, requiring the user @@ -175,16 +176,20 @@ class NotificationService extends EventEmitter { log.info('NotificationService: showing a notification', sentAt); if (OS.isWindows()) { + const token = this._createToken({ + conversationId, + messageId, + storyId, + }); + // Note: showing a windows notification clears all previous notifications first drop( window.IPC.showWindowsNotification({ avatarPath: iconPath, body: message, - conversationId, heading: title, - messageId, - storyId, type, + token, }) ); } else { @@ -206,8 +211,8 @@ class NotificationService extends EventEmitter { window.IPC.showWindow(); window.Events.showConversationViaNotification({ conversationId, - messageId: messageId ?? null, - storyId: storyId ?? null, + messageId, + storyId, }); } else if (type === NotificationType.IncomingGroupCall) { window.IPC.showWindow(); @@ -304,6 +309,7 @@ class NotificationService extends EventEmitter { // adding anythhing new; just one notification at a time. Electron forces it, so // we replicate it with our Windows notifications. if (!notificationData) { + this.#tokenData = undefined; drop(window.IPC.clearAllWindowsNotifications()); } } else if (this.#lastNotification) { @@ -446,6 +452,32 @@ class NotificationService extends EventEmitter { ); } + /** @internal */ + public _createToken(data: NotificationClickData): string { + const token = getGuid(); + + this.#tokenData = { + token, + data, + }; + + return token; + } + + public resolveToken(token: string): NotificationClickData | undefined { + if (!this.#tokenData) { + log.warn(`NotificationService: no data when looking up ${token}`); + return undefined; + } + + if (this.#tokenData.token !== token) { + log.warn(`NotificationService: token mismatch ${token}`); + return undefined; + } + + return this.#tokenData.data; + } + public clear(): void { log.info( 'NotificationService: clearing notification and requesting an update' diff --git a/ts/test-mock/routing/routing_test.ts b/ts/test-mock/routing/routing_test.ts index 01c3cb57b9..4f325ab92e 100644 --- a/ts/test-mock/routing/routing_test.ts +++ b/ts/test-mock/routing/routing_test.ts @@ -55,18 +55,13 @@ describe('routing', function (this: Mocha.Suite) { const [friend] = contacts; const page = await app.getWindow(); await page.locator('#LeftPane').waitFor(); - const conversationId = await page.evaluate( - serviceId => window.SignalCI?.getConversationId(serviceId), + const token = await page.evaluate( + serviceId => window.SignalCI?.createNotificationToken(serviceId), friend.toContact().aci ); - strictAssert( - typeof conversationId === 'string', - 'conversationId must exist' - ); + strictAssert(typeof token === 'string', 'token must be returned'); const conversationUrl = showConversationRoute.toAppUrl({ - conversationId, - messageId: null, - storyId: null, + token, }); await app.openSignalRoute(conversationUrl); const title = page.locator( diff --git a/ts/test-node/app/renderWindowsToast_test.tsx b/ts/test-node/app/renderWindowsToast_test.tsx index 06b28f5f14..2abea2c591 100644 --- a/ts/test-node/app/renderWindowsToast_test.tsx +++ b/ts/test-node/app/renderWindowsToast_test.tsx @@ -12,12 +12,12 @@ describe('renderWindowsToast', () => { avatarPath: 'C:/temp/ab/abcd', body: 'Hi there!', heading: 'Alice', - conversationId: 'conversation5', + token: 'token', type: NotificationType.Message, }); const expected = - 'AliceHi there!'; + 'AliceHi there!'; assert.strictEqual(xml, expected); }); @@ -26,12 +26,12 @@ describe('renderWindowsToast', () => { const xml = renderWindowsToast({ body: 'Hi there!', heading: 'Alice', - conversationId: 'conversation5', + token: 'token', type: NotificationType.Message, }); const expected = - 'AliceHi there!'; + 'AliceHi there!'; assert.strictEqual(xml, expected); }); @@ -40,14 +40,12 @@ describe('renderWindowsToast', () => { const xml = renderWindowsToast({ body: 'Hi there!', heading: 'Alice', - conversationId: 'conversation5', - messageId: 'message6', - storyId: 'story7', + token: 'token', type: NotificationType.Message, }); const expected = - 'AliceHi there!'; + 'AliceHi there!'; assert.strictEqual(xml, expected); }); @@ -56,7 +54,7 @@ describe('renderWindowsToast', () => { const xml = renderWindowsToast({ body: 'Hi there!', heading: 'Alice', - conversationId: 'conversation5', + token: 'token', type: NotificationType.IncomingCall, }); @@ -70,12 +68,12 @@ describe('renderWindowsToast', () => { const xml = renderWindowsToast({ body: 'Hi there!', heading: 'Alice', - conversationId: 'conversation5', + token: 'token', type: NotificationType.IncomingGroupCall, }); const expected = - 'AliceHi there!'; + 'AliceHi there!'; assert.strictEqual(xml, expected); }); @@ -84,7 +82,7 @@ describe('renderWindowsToast', () => { const xml = renderWindowsToast({ body: 'Hi there!', heading: 'Alice', - conversationId: 'conversation5', + token: 'token', type: NotificationType.IsPresenting, }); diff --git a/ts/test-node/util/signalRoutes_test.ts b/ts/test-node/util/signalRoutes_test.ts index f273d2d19a..ccf8cef468 100644 --- a/ts/test-node/util/signalRoutes_test.ts +++ b/ts/test-node/util/signalRoutes_test.ts @@ -186,37 +186,23 @@ describe('signalRoutes', () => { it('showConversation', () => { const check = createCheck({ isRoute: true, hasWebUrl: false }); - const args1 = `conversationId=${foo}`; - const args2 = `conversationId=${foo}&messageId=${foo}`; - const args3 = `conversationId=${foo}&messageId=${foo}&storyId=${foo}`; + const args1 = `token=${foo}`; const result1: ParsedSignalRoute = { key: 'showConversation', - args: { conversationId: foo, messageId: null, storyId: null }, - }; - const result2: ParsedSignalRoute = { - key: 'showConversation', - args: { conversationId: foo, messageId: foo, storyId: null }, - }; - const result3: ParsedSignalRoute = { - key: 'showConversation', - args: { conversationId: foo, messageId: foo, storyId: foo }, + args: { token: foo }, }; check(`sgnl://show-conversation/?${args1}`, result1); check(`sgnl://show-conversation?${args1}`, result1); - check(`sgnl://show-conversation/?${args2}`, result2); - check(`sgnl://show-conversation?${args2}`, result2); - check(`sgnl://show-conversation/?${args3}`, result3); - check(`sgnl://show-conversation?${args3}`, result3); }); it('startCallLobby', () => { const result: ParsedSignalRoute = { key: 'startCallLobby', - args: { conversationId: foo }, + args: { token: foo }, }; const check = createCheck({ isRoute: true, hasWebUrl: false }); - check(`sgnl://start-call-lobby/?conversationId=${foo}`, result); - check(`sgnl://start-call-lobby?conversationId=${foo}`, result); + check(`sgnl://start-call-lobby/?token=${foo}`, result); + check(`sgnl://start-call-lobby?token=${foo}`, result); }); it('showWindow', () => { diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 56a74821d8..1a33c8bdcd 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -35,7 +35,10 @@ import * as Registration from './registration'; import { lookupConversationWithoutServiceId } from './lookupConversationWithoutServiceId'; import * as log from '../logging/log'; import { deleteAllMyStories } from './deleteAllMyStories'; -import type { NotificationClickData } from '../services/notifications'; +import { + type NotificationClickData, + notificationService, +} from '../services/notifications'; import { StoryViewModeType, StoryViewTargetType } from '../types/Stories'; import { isValidE164 } from './isValidE164'; import { fromWebSafeBase64 } from './webSafeBase64'; @@ -121,6 +124,7 @@ export type IPCEventsCallbacksType = { resetDefaultChatColor: () => void; setMediaPlaybackDisabled: (playbackDisabled: boolean) => void; showConversationViaNotification: (data: NotificationClickData) => void; + showConversationViaToken: (token: string) => void; showConversationViaSignalDotMe: ( kind: string, value: string @@ -129,6 +133,7 @@ export type IPCEventsCallbacksType = { showGroupViaLink: (value: string) => Promise; showReleaseNotes: () => void; showStickerPack: (packId: string, key: string) => void; + startCallingLobbyViaToken: (token: string) => void; requestCloseConfirmation: () => Promise; getIsInCall: () => boolean; shutdown: () => Promise; @@ -575,23 +580,31 @@ export function createIPCEvents( messageId, storyId, }: NotificationClickData) { - if (conversationId) { - if (storyId) { - window.reduxActions.stories.viewStory({ - storyId, - storyViewMode: StoryViewModeType.Single, - viewTarget: StoryViewTargetType.Replies, - }); - } else { - window.reduxActions.conversations.showConversation({ - conversationId, - messageId: messageId ?? undefined, - }); - } - } else { + if (!conversationId) { window.reduxActions.app.openInbox(); + } else if (storyId) { + window.reduxActions.stories.viewStory({ + storyId, + storyViewMode: StoryViewModeType.Single, + viewTarget: StoryViewTargetType.Replies, + }); + } else { + window.reduxActions.conversations.showConversation({ + conversationId, + messageId: messageId ?? undefined, + }); } }, + + showConversationViaToken(token: string) { + const data = notificationService.resolveToken(token); + if (!data) { + window.reduxActions.app.openInbox(); + } else { + window.Events.showConversationViaNotification(data); + } + }, + async showConversationViaSignalDotMe(kind: string, value: string) { if (!Registration.everDone()) { log.info( @@ -638,6 +651,17 @@ export function createIPCEvents( showUnknownSgnlLinkModal(); }, + startCallingLobbyViaToken(token: string) { + const data = notificationService.resolveToken(token); + if (!data) { + return; + } + window.reduxActions?.calling?.startCallingLobby({ + conversationId: data.conversationId, + isVideoCall: true, + }); + }, + requestCloseConfirmation: async (): Promise => { try { await new Promise((resolve, reject) => { diff --git a/ts/util/signalRoutes.ts b/ts/util/signalRoutes.ts index 81ff0e46cb..d113ef77e9 100644 --- a/ts/util/signalRoutes.ts +++ b/ts/util/signalRoutes.ts @@ -201,7 +201,6 @@ function _route( } const paramSchema = z.string().min(1); -const optionalParamSchema = paramSchema.nullish().default(null); /** * signal.me by phone number @@ -452,11 +451,9 @@ export const artAddStickersRoute = _route('artAddStickers', { * @example * ```ts * showConversationRoute.toAppUrl({ - * conversationId: "123", - * messageId: "abc", - * storyId: "def", + * token: 'abc', * }) - * // URL { "sgnl://show-conversation?conversationId=123&messageId=abc&storyId=def" } + * // URL { "sgnl://show-conversation?token=abc" } * ``` */ export const showConversationRoute = _route('showConversation', { @@ -464,28 +461,16 @@ export const showConversationRoute = _route('showConversation', { _pattern('sgnl:', 'show-conversation', '{/}?', { search: ':params' }), ], schema: z.object({ - conversationId: paramSchema, - messageId: optionalParamSchema, - storyId: optionalParamSchema, + token: paramSchema, }), parse(result) { const params = new URLSearchParams(result.search.groups.params); return { - conversationId: params.get('conversationId'), - messageId: params.get('messageId'), - storyId: params.get('storyId'), + token: params.get('token'), }; }, toAppUrl(args) { - const params = new URLSearchParams({ - conversationId: args.conversationId, - }); - if (args.messageId != null) { - params.set('messageId', args.messageId); - } - if (args.storyId != null) { - params.set('storyId', args.storyId); - } + const params = new URLSearchParams({ token: args.token }); return new URL(`sgnl://show-conversation?${params.toString()}`); }, }); @@ -495,9 +480,9 @@ export const showConversationRoute = _route('showConversation', { * @example * ```ts * startCallLobbyRoute.toAppUrl({ - * conversationId: "123", + * token: "123", * }) - * // URL { "sgnl://start-call-lobby?conversationId=123" } + * // URL { "sgnl://start-call-lobby?token=123" } * ``` */ export const startCallLobbyRoute = _route('startCallLobby', { @@ -505,18 +490,16 @@ export const startCallLobbyRoute = _route('startCallLobby', { _pattern('sgnl:', 'start-call-lobby', '{/}?', { search: ':params' }), ], schema: z.object({ - conversationId: paramSchema, + token: paramSchema, }), parse(result) { const params = new URLSearchParams(result.search.groups.params); return { - conversationId: params.get('conversationId'), + token: params.get('token'), }; }, toAppUrl(args) { - const params = new URLSearchParams({ - conversationId: args.conversationId, - }); + const params = new URLSearchParams({ token: args.token }); return new URL(`sgnl://start-call-lobby?${params.toString()}`); }, }); diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index aaeb270f24..192c782002 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -18,10 +18,7 @@ import * as Errors from '../../types/errors'; import { strictAssert } from '../../util/assert'; import { drop } from '../../util/drop'; import { DataReader } from '../../sql/Client'; -import type { - NotificationClickData, - WindowsNotificationData, -} from '../../services/notifications'; +import type { WindowsNotificationData } from '../../services/notifications'; import { AggregatedStats } from '../../textsecure/WebsocketResources'; import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager'; @@ -340,12 +337,9 @@ ipc.on('show-group-via-link', (_event, info) => { drop(window.Events.showGroupViaLink?.(info.value)); }); -ipc.on('start-call-lobby', (_event, { conversationId }) => { +ipc.on('start-call-lobby', (_event, info) => { window.IPC.showWindow(); - window.reduxActions?.calling?.startCallingLobby({ - conversationId, - isVideoCall: true, - }); + window.Events.startCallingLobbyViaToken(info.token); }); ipc.on('start-call-link', (_event, { key }) => { @@ -362,15 +356,12 @@ ipc.on('cancel-presenting', () => { window.reduxActions?.calling?.cancelPresenting(); }); -ipc.on( - 'show-conversation-via-notification', - (_event, data: NotificationClickData) => { - const { showConversationViaNotification } = window.Events; - if (showConversationViaNotification) { - void showConversationViaNotification(data); - } +ipc.on('show-conversation-via-token', (_event, token: string) => { + const { showConversationViaToken } = window.Events; + if (showConversationViaToken) { + void showConversationViaToken(token); } -); +}); ipc.on('show-conversation-via-signal.me', (_event, info) => { const { kind, value } = info; strictAssert(typeof kind === 'string', 'Got an invalid kind over IPC');