diff --git a/ts/messages/maybeNotify.preload.ts b/ts/messages/maybeNotify.preload.ts index b724c25659..bc2a7fde2a 100644 --- a/ts/messages/maybeNotify.preload.ts +++ b/ts/messages/maybeNotify.preload.ts @@ -189,19 +189,16 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise { sender: senderName, group: conversation.getTitle(), }); - const { url, absolutePath } = await conversation.getAvatarOrIdenticon(); - const messageId = messageForNotification.id; - notificationService.add({ senderTitle, conversationId, storyId: isMessageInDirectConversation ? undefined : messageForNotification.storyId, - notificationIconUrl: url, - notificationIconAbsolutePath: absolutePath, + iconUrl: url, + iconAbsolutePath: absolutePath ?? null, isExpiringMessage: isExpiringMessage(messageForNotification), message: getNotificationTextForMessage(messageForNotification), messageId, diff --git a/ts/services/calling.preload.ts b/ts/services/calling.preload.ts index 20f0c5a91b..3d1ae5c385 100644 --- a/ts/services/calling.preload.ts +++ b/ts/services/calling.preload.ts @@ -2779,13 +2779,12 @@ class CallingClass { absolutePath = result.absolutePath; } - notificationService.notify({ + notificationService.rawNotify({ conversationId, - iconPath: absolutePath, + iconAbsolutePath: absolutePath ?? null, iconUrl: url, - message: i18n('icu:calling__presenting--notification-body'), + body: i18n('icu:calling__presenting--notification-body'), type: NotificationType.IsPresenting, - sentAt: 0, silent: true, title: i18n('icu:calling__presenting--notification-title'), }); @@ -2850,15 +2849,12 @@ class CallingClass { const { url, absolutePath } = await conversation.getAvatarOrIdenticon(); - notificationService.notify({ + notificationService.rawNotify({ conversationId, - iconPath: absolutePath, + iconAbsolutePath: absolutePath ?? null, iconUrl: url, - message: i18n( - 'icu:calling__presenting--reconnecting--notification-body' - ), + body: i18n('icu:calling__presenting--reconnecting--notification-body'), type: NotificationType.IsPresenting, - sentAt: 0, silent: true, title: i18n( 'icu:calling__presenting--reconnecting--notification-title' @@ -4431,13 +4427,12 @@ class CallingClass { } } - notificationService.notify({ + notificationService.rawNotify({ conversationId: conversation.id, - iconPath: absolutePath, - iconUrl: url, - message: notificationMessage, + iconAbsolutePath: absolutePath ?? null, + iconUrl: url ?? null, + body: notificationMessage, type: NotificationType.IncomingGroupCall, - sentAt: 0, silent: false, title: notificationTitle, }); @@ -4487,15 +4482,14 @@ class CallingClass { } } - notificationService.notify({ + notificationService.rawNotify({ conversationId, title: notificationTitle, - iconPath: absolutePath, - iconUrl: url, - message: isVideoCall + iconAbsolutePath: absolutePath ?? null, + iconUrl: url ?? null, + body: isVideoCall ? i18n('icu:incomingVideoCall') : i18n('icu:incomingAudioCall'), - sentAt: 0, // The ringtone plays so we don't need sound for the notification silent: true, type: NotificationType.IncomingCall, diff --git a/ts/services/notifications.preload.ts b/ts/services/notifications.preload.ts index ffd29727b0..b2ee02b279 100644 --- a/ts/services/notifications.preload.ts +++ b/ts/services/notifications.preload.ts @@ -24,13 +24,14 @@ const { debounce } = lodash; const log = createLogger('notifications'); -type NotificationDataType = Readonly<{ +type QueuedNotificationData = Readonly<{ + type: NotificationType.Message | NotificationType.Reaction; conversationId: string; isExpiringMessage: boolean; messageId: string; message: string; - notificationIconUrl?: undefined | string; - notificationIconAbsolutePath?: undefined | string; + iconUrl: string | null; + iconAbsolutePath: string | null; reaction?: { emoji: Emoji.Variant; targetAuthorAci: string; @@ -44,11 +45,54 @@ type NotificationDataType = Readonly<{ senderTitle: string; sentAt: number; storyId?: string; - type: NotificationType; - useTriToneSound?: boolean; - wasShown?: boolean; }>; +export type ProcessedNotificationData = Readonly< + { + conversationId: string; + title: string; + body: string; + iconUrl: string | null; + iconAbsolutePath: string | null; + silent: boolean; + } & ( + | { + type: NotificationType.Message | NotificationType.Reaction; + messageId: string; + sentAt: number; + storyId: string | null; + reaction?: { + emoji: Emoji.Variant; + targetAuthorAci: string; + targetTimestamp: number; + }; + pollVote?: { + voterConversationId: string; + targetAuthorAci: string; + targetTimestamp: number; + }; + } + | { + type: Exclude< + NotificationType, + NotificationType.Message | NotificationType.Reaction + >; + } + ) +>; + +type RemoveByOptions = Readonly< + { + emoji?: Emoji.Variant; + targetAuthorAci?: string; + targetTimestamp?: number; + onlyRemoveAssociatedPollVotes?: boolean; + } & ( + | { conversationId: string; messageId?: string } + | { messageId: string; conversationId?: string } + ) +>; + export type NotificationClickData = Readonly<{ conversationId: string; messageId: string | undefined; @@ -78,20 +122,44 @@ const parseNotificationSetting = makeEnumParser( export const FALLBACK_NOTIFICATION_TITLE = 'Signal'; +const SHOWN_EVENT = 'shown'; + +function getSoundTypeFor(type: NotificationType): SoundType { + switch (type) { + case NotificationType.Message: + case NotificationType.Reaction: + return SoundType.Pop; + case NotificationType.IncomingCall: + case NotificationType.IncomingGroupCall: + case NotificationType.IsPresenting: + return SoundType.TriTone; + default: + throw missingCaseError(type); + } +} + // Electron, at least on Windows and macOS, only shows one notification at a time (see // issues [#15364][0] and [#21646][1], among others). Because of that, we have a // single slot for notifications, and once a notification is dismissed, all of // Signal's notifications are dismissed. // [0]: https://github.com/electron/electron/issues/15364 // [1]: https://github.com/electron/electron/issues/21646 -class NotificationService extends EventEmitter { +export class NotificationService extends EventEmitter { #i18n?: LocalizerType; #storage?: StorageInterface; public isEnabled = false; - #lastNotification: null | Notification = null; - #notificationData: null | NotificationDataType = null; + // queued via add() + #queuedNotification: QueuedNotificationData | null = null; + // Last shown notification. NB: On Windows we currently do not retain a Notification object + #lastShown: { + notification: Notification | null; + data: ProcessedNotificationData; + } | null = null; + + #shouldClearLastShown = false; + #tokenData: { token: string; data: NotificationClickData } | undefined; // Testing indicated that trying to create/destroy notifications too quickly @@ -140,14 +208,11 @@ class NotificationService extends EventEmitter { } /** - * A higher-level wrapper around `window.Notification`. You may prefer to use `notify`, + * A higher-level wrapper around `window.Notification`. You may prefer to use `rawNotify`, * which doesn't check permissions, do any filtering, etc. */ - public add(notificationData: Omit): void { - log.info( - 'NotificationService: adding a notification and requesting an update' - ); - this.#notificationData = notificationData; + public add(notificationData: QueuedNotificationData): void { + this.#queuedNotification = notificationData; this.#update(); } @@ -155,32 +220,27 @@ class NotificationService extends EventEmitter { * A lower-level wrapper around `window.Notification`. You may prefer to use `add`, * which includes debouncing and user permission logic. */ - public notify({ - conversationId, - iconUrl, - iconPath, - message, - messageId, - sentAt, - silent, - storyId, - title, - type, - useTriToneSound, - }: Readonly<{ - conversationId: string; - iconUrl?: string; - iconPath?: string; - message: string; - messageId?: string; - sentAt: number; - silent: boolean; - storyId?: string; - title: string; - type: NotificationType; - useTriToneSound?: boolean; - }>): void { - log.info('NotificationService: showing a notification', sentAt); + public rawNotify(data: ProcessedNotificationData): void { + const { + conversationId, + iconUrl, + iconAbsolutePath, + body, + silent, + title, + type, + } = data; + + const messageId = 'messageId' in data ? data.messageId : undefined; + const storyId = 'storyId' in data ? (data.storyId ?? undefined) : undefined; + + log.info( + 'NotificationService: showing a notification', + type, + 'sentAt' in data ? data.sentAt : undefined + ); + + this.#closeLastShown(); if (OS.isWindows()) { const token = this._createToken({ @@ -192,19 +252,19 @@ class NotificationService extends EventEmitter { // Note: showing a windows notification clears all previous notifications first drop( window.IPC.showWindowsNotification({ - avatarPath: iconPath, - body: message, + avatarPath: iconAbsolutePath ?? undefined, + body, heading: title, type, token, }) ); - } else { - this.#lastNotification?.close(); + this.#lastShown = { notification: null, data }; + } else { const notification = new window.Notification(title, { - body: OS.isLinux() ? filterNotificationText(message) : message, - icon: iconUrl, + body: OS.isLinux() ? filterNotificationBody(body) : body, + icon: iconUrl ?? undefined, silent: true, tag: messageId, }); @@ -219,7 +279,7 @@ class NotificationService extends EventEmitter { window.Events.showConversationViaNotification({ conversationId, messageId, - storyId, + storyId: storyId ?? undefined, }); } else if (type === NotificationType.IncomingGroupCall) { window.IPC.showWindow(); @@ -236,14 +296,38 @@ class NotificationService extends EventEmitter { } }; - this.#lastNotification = notification; + this.#lastShown = { notification, data }; } if (!silent) { - const soundType = - messageId && !useTriToneSound ? SoundType.Pop : SoundType.TriTone; // We kick off the sound to be played. No need to await it. - drop(new Sound({ soundType }).play()); + drop(new Sound({ soundType: getSoundTypeFor(type) }).play()); + } + + this.emit(SHOWN_EVENT, data); + } + + public onShown(handler: (data: ProcessedNotificationData) => void): void { + this.on(SHOWN_EVENT, handler); + } + + public offShown(handler: (data: ProcessedNotificationData) => void): void { + this.off(SHOWN_EVENT, handler); + } + + #closeLastShown(): void { + const lastShown = this.#lastShown; + this.#lastShown = null; + this.#shouldClearLastShown = false; + + if (lastShown?.notification != null) { + lastShown.notification.close(); + return; + } + + if (OS.isWindows()) { + this.#tokenData = undefined; + drop(window.IPC.clearAllWindowsNotifications()); } } @@ -252,19 +336,36 @@ class NotificationService extends EventEmitter { // 1. Either `conversationId` or `messageId` matches (if present) // 2. Reaction: `emoji`, `targetAuthorAci`, `targetTimestamp` matches // 3. Poll vote: `onlyRemoveAssociatedPollVotes` flag is true - public removeBy( - options: Readonly< - { - emoji?: Emoji.Variant; - targetAuthorAci?: string; - targetTimestamp?: number; - onlyRemoveAssociatedPollVotes?: boolean; - } & ( - | { conversationId: string; messageId?: string } - | { messageId: string; conversationId?: string } - ) - > - ): void { + public removeBy(identifier: RemoveByOptions): void { + if ( + this.#queuedNotification && + this.#matchesNotification(identifier, this.#queuedNotification) + ) { + log.info('Removing queued notification'); + this.#queuedNotification = null; + } + + if ( + this.#lastShown && + this.#matchesNotification(identifier, this.#lastShown.data) + ) { + log.info('Requesting dismissal of shown notification'); + this.#shouldClearLastShown = true; + this.#update(); + } + } + + #matchesNotification( + identifier: RemoveByOptions, + data: QueuedNotificationData | ProcessedNotificationData + ): boolean { + if ( + data.type !== NotificationType.Message && + data.type !== NotificationType.Reaction + ) { + return false; + } + const { conversationId, messageId, @@ -272,38 +373,30 @@ class NotificationService extends EventEmitter { targetAuthorAci, targetTimestamp, onlyRemoveAssociatedPollVotes, - } = options; - if (!this.#notificationData) { - log.info('NotificationService#removeBy: no notification data'); - return; - } + } = identifier; - let shouldClear = false; - if ( - conversationId && - this.#notificationData.conversationId === conversationId - ) { - log.info('NotificationService#removeBy: conversation ID matches'); - shouldClear = true; - } - if (messageId && this.#notificationData.messageId === messageId) { - log.info('NotificationService#removeBy: message ID matches'); - shouldClear = true; - } + const matchesConversationId = + conversationId != null && data.conversationId === conversationId; + const matchesMessageId = messageId != null && data.messageId === messageId; - if (!shouldClear) { - return; + if (!matchesConversationId && !matchesMessageId) { + return false; } // If reaction filters are provided, only remove reaction notifications that match - const { reaction } = this.#notificationData; const hasReactionFilters = Boolean( emoji && targetAuthorAci && targetTimestamp ); + if (hasReactionFilters) { + if (data.type !== NotificationType.Reaction) { + return false; + } + + const { reaction } = data; if (!reaction) { // Looking for reactions but this isn't one - return; + return false; } if ( reaction.emoji !== emoji || @@ -311,64 +404,52 @@ class NotificationService extends EventEmitter { reaction.targetTimestamp !== targetTimestamp ) { // Reaction doesn't match the filter - return; + return false; } } // If onlyRemoveAssociatedPollVotes is true, only remove poll vote notifications // that match the targetAuthorAci and targetTimestamp if (onlyRemoveAssociatedPollVotes && targetAuthorAci && targetTimestamp) { - const { pollVote } = this.#notificationData; + if (data.type !== NotificationType.Message) { + return false; + } + + const { pollVote } = data; if ( !pollVote || pollVote.targetAuthorAci !== targetAuthorAci || pollVote.targetTimestamp !== targetTimestamp ) { // Looking for poll votes but this isn't one - return; + return false; } } - this.clear(); - this.#update(); + return true; } #fastUpdate(): void { const storage = this.#getStorage(); const i18n = this.#getI18n(); - const notificationData = this.#notificationData; + const queuedNotificationData = this.#queuedNotification; const isAppFocused = window.SignalContext.activeWindowService.isActive(); const userSetting = this.getNotificationSetting(); - if (OS.isWindows()) { - // Note: notificationData will be set if we're replacing the previous notification - // with a new one, so we won't clear here. That's because we always clear before - // 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) { - this.#lastNotification.close(); - this.#lastNotification = null; + if (this.#shouldClearLastShown) { + this.#closeLastShown(); } - // This isn't a boolean because TypeScript isn't smart enough to know that, if - // `Boolean(notificationData)` is true, `notificationData` is truthy. - const shouldShowNotification = - this.isEnabled && !isAppFocused && notificationData; - if (!shouldShowNotification) { - log.info( - `NotificationService not updating notifications. Notifications are ${ - this.isEnabled ? 'enabled' : 'disabled' - }; app is ${isAppFocused ? '' : 'not '}focused; there is ${ - notificationData ? '' : 'no ' - }notification data` - ); - if (isAppFocused) { - this.#notificationData = null; - } + if (isAppFocused) { + this.#queuedNotification = null; + return; + } + + if (!this.isEnabled || queuedNotificationData == null) { + return; + } + + if (userSetting === NotificationSetting.Off) { return; } @@ -385,101 +466,37 @@ class NotificationService extends EventEmitter { window.IPC.drawAttention(); } - let notificationTitle: string; - let notificationMessage: string; - let notificationIconUrl: undefined | string; - let notificationIconAbsolutePath: undefined | string; + const { conversationId, type } = queuedNotificationData; - const { - conversationId, - isExpiringMessage, - message, - messageId, - reaction, - pollVote, - senderTitle, - storyId, - sentAt, - useTriToneSound, - wasShown, - type, - } = notificationData; + this.#queuedNotification = null; - if (wasShown) { - log.info( - 'NotificationService: not showing a notification because it was already shown' - ); - return; - } + switch (type) { + case NotificationType.Message: + case NotificationType.Reaction: { + const content = redactMessageNotificationContent({ + notificationData: queuedNotificationData, + contentSetting: userSetting, + i18n, + }); - switch (userSetting) { - case NotificationSetting.Off: - log.info( - 'NotificationService: not showing a notification because user has disabled it' - ); - return; - case NotificationSetting.NameOnly: - case NotificationSetting.NameAndMessage: { - notificationTitle = senderTitle; - ({ notificationIconUrl, notificationIconAbsolutePath } = - notificationData); - - if ( - isExpiringMessage && - shouldHideExpiringMessageBody(OS, os.release()) - ) { - notificationMessage = i18n('icu:newMessage'); - } else if (userSetting === NotificationSetting.NameOnly) { - notificationMessage = i18n('icu:newMessage'); - } else if (storyId) { - notificationMessage = message; - } else if (reaction) { - notificationMessage = i18n('icu:notificationReactionMessage', { - sender: senderTitle, - emoji: reaction.emoji, - message, - }); - } else if (pollVote) { - notificationMessage = i18n('icu:notificationPollVoteMessage', { - sender: senderTitle, - pollQuestion: message, - }); - } else { - notificationMessage = message; - } - break; + return this.rawNotify({ + conversationId, + type, + title: content.title, + body: content.body, + iconUrl: content.iconUrl, + iconAbsolutePath: content.iconAbsolutePath, + sentAt: queuedNotificationData.sentAt, + messageId: queuedNotificationData.messageId, + storyId: queuedNotificationData.storyId ?? null, + reaction: queuedNotificationData.reaction, + pollVote: queuedNotificationData.pollVote, + silent: !shouldPlayNotificationSound, + }); } - case NotificationSetting.NoNameOrMessage: - notificationTitle = FALLBACK_NOTIFICATION_TITLE; - notificationMessage = i18n('icu:newMessage'); - break; default: - log.error(toLogFormat(missingCaseError(userSetting))); - notificationTitle = FALLBACK_NOTIFICATION_TITLE; - notificationMessage = i18n('icu:newMessage'); - break; + throw missingCaseError(type); } - - log.info('NotificationService: requesting a notification to be shown'); - - this.#notificationData = { - ...notificationData, - wasShown: true, - }; - - this.notify({ - conversationId, - iconUrl: notificationIconUrl, - iconPath: notificationIconAbsolutePath, - messageId, - message: notificationMessage, - sentAt, - silent: !shouldPlayNotificationSound, - storyId, - title: notificationTitle, - type, - useTriToneSound, - }); } public getNotificationSetting(): NotificationSetting { @@ -515,10 +532,15 @@ class NotificationService extends EventEmitter { } public clear(): void { - log.info( - 'NotificationService: clearing notification and requesting an update' - ); - this.#notificationData = null; + if (this.#lastShown) { + log.info( + 'NotificationService: clearing notification and requesting an update' + ); + } + // We defer immediately clearing the notification so that we retain the token for + // Windows locally for a debounce interval + this.#shouldClearLastShown = true; + this.#queuedNotification = null; this.#update(); } @@ -527,7 +549,8 @@ class NotificationService extends EventEmitter { // normal debounce. public fastClear(): void { log.info('NotificationService: clearing notification and updating'); - this.#notificationData = null; + this.#closeLastShown(); + this.#queuedNotification = null; this.#fastUpdate(); } @@ -548,7 +571,7 @@ class NotificationService extends EventEmitter { export const notificationService = new NotificationService(); -function filterNotificationText(text: string) { +function filterNotificationBody(text: string) { return (text || '') .replace(/&/g, '&') .replace(/; +}): { + title: string; + body: string; + iconUrl: string | null; + iconAbsolutePath: string | null; +} { + let title: string; + let body: string; + let iconUrl: string | null = null; + let iconAbsolutePath: string | null = null; + + switch (contentSetting) { + case NotificationSetting.NameOnly: + case NotificationSetting.NameAndMessage: { + title = notificationData.senderTitle; + ({ iconUrl, iconAbsolutePath } = notificationData); + + if ( + notificationData.isExpiringMessage && + shouldHideExpiringMessageBody(OS, os.release()) + ) { + body = i18n('icu:newMessage'); + } else if (contentSetting === NotificationSetting.NameOnly) { + body = i18n('icu:newMessage'); + } else if (notificationData.storyId) { + body = notificationData.message; + } else if (notificationData.reaction) { + body = i18n('icu:notificationReactionMessage', { + sender: notificationData.senderTitle, + emoji: notificationData.reaction.emoji, + message: notificationData.message, + }); + } else if (notificationData.pollVote) { + body = i18n('icu:notificationPollVoteMessage', { + sender: notificationData.senderTitle, + pollQuestion: notificationData.message, + }); + } else { + body = notificationData.message; + } + break; + } + case NotificationSetting.NoNameOrMessage: + title = FALLBACK_NOTIFICATION_TITLE; + body = i18n('icu:newMessage'); + break; + default: + log.error(toLogFormat(missingCaseError(contentSetting))); + title = FALLBACK_NOTIFICATION_TITLE; + body = i18n('icu:newMessage'); + break; + } + return { + title, + body, + iconAbsolutePath, + iconUrl, + }; +} diff --git a/ts/test-electron/services/notifications_test.preload.ts b/ts/test-electron/services/notifications_test.preload.ts new file mode 100644 index 0000000000..9685bb9532 --- /dev/null +++ b/ts/test-electron/services/notifications_test.preload.ts @@ -0,0 +1,371 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import os from 'node:os'; + +import type { Emoji } from '../../axo/emoji.std.ts'; +import { + NotificationService, + NotificationSetting, +} from '../../services/notifications.preload.ts'; +import { itemStorage } from '../../textsecure/Storage.preload.ts'; +import { NotificationType } from '../../types/notifications.std.ts'; +import OS from '../../util/os/osMain.node.ts'; +import { Sound } from '../../util/Sound.std.ts'; + +const DEBOUNCE_INTERVAL = 1000; + +describe('NotificationService', () => { + for (const platform of ['windows', 'macos', 'linux']) { + describe(platform, () => { + let sandbox: sinon.SinonSandbox; + let clock: sinon.SinonFakeTimers; + let service: NotificationService; + let closeNotification: sinon.SinonStub; + let showNotification: sinon.SinonStub; + let getNotificationSetting: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sandbox.useFakeTimers(); + sandbox.stub(OS, 'isWindows').returns(platform === 'windows'); + sandbox.stub(OS, 'isMacOS').returns(platform === 'macos'); + sandbox.stub(OS, 'isLinux').returns(platform === 'linux'); + if (platform === 'macos') { + sandbox.stub(os, 'release').returns('27.0.0'); + } + sandbox.stub(Sound.prototype, 'play').resolves(); + sandbox.stub(window.IPC, 'drawAttention'); + sandbox + .stub(window.SignalContext.activeWindowService, 'isActive') + .returns(false); + + const closeNativeNotification = sandbox.stub(); + const createNativeNotification = sandbox + .stub(window, 'Notification') + .returns({ + close: closeNativeNotification, + }); + const clearWindowsNotifications = sandbox + .stub(window.IPC, 'clearAllWindowsNotifications') + .resolves(); + const showWindowsNotification = sandbox + .stub(window.IPC, 'showWindowsNotification') + .resolves(); + + closeNotification = + platform === 'windows' + ? clearWindowsNotifications + : closeNativeNotification; + showNotification = + platform === 'windows' + ? showWindowsNotification + : createNativeNotification; + + service = new NotificationService(); + service.initialize({ + storage: itemStorage, + i18n: window.SignalContext.i18n, + }); + getNotificationSetting = sandbox + .stub(service, 'getNotificationSetting') + .returns(NotificationSetting.NameAndMessage); + service.enable(); + clock.tick(DEBOUNCE_INTERVAL); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function queueMessage( + messageId = 'message', + overrides: Partial< + Extract< + Parameters[0], + { type: NotificationType.Message | NotificationType.Reaction } + > + > = {} + ): void { + service.add({ + type: NotificationType.Message, + conversationId: 'chat', + messageId, + isExpiringMessage: false, + message: 'Body', + senderTitle: 'Alice', + sentAt: 1, + iconUrl: null, + iconAbsolutePath: null, + ...overrides, + }); + } + + function showMessage(): void { + queueMessage(); + clock.tick(DEBOUNCE_INTERVAL); + closeNotification.resetHistory(); + } + + describe('redacts message notification content based on user setting', () => { + const iconUrl = 'file:///avatar.png'; + const iconAbsolutePath = '/avatar.png'; + + function assertShowNotificationCalledWith(expected: { + title: string; + body: string; + iconUrl: string | undefined; + iconAbsolutePath: string | undefined; + }): void { + sinon.assert.calledOnce(showNotification); + if (platform === 'windows') { + assert.include(showNotification.firstCall.args[0], { + heading: expected.title, + body: expected.body, + avatarPath: expected.iconAbsolutePath, + }); + } else { + assert.strictEqual( + showNotification.firstCall.args[0], + expected.title + ); + assert.include(showNotification.firstCall.args[1], { + body: expected.body, + icon: expected.iconUrl, + }); + } + } + + it(`displays full message content for NameAndMessage`, () => { + getNotificationSetting.returns(NotificationSetting.NameAndMessage); + queueMessage('message', { iconUrl, iconAbsolutePath }); + clock.tick(DEBOUNCE_INTERVAL); + assertShowNotificationCalledWith({ + title: 'Alice', + body: 'Body', + iconUrl, + iconAbsolutePath, + }); + }); + + it(`redacts body for NameAndMessage and disappearing message on Windows`, () => { + getNotificationSetting.returns(NotificationSetting.NameAndMessage); + queueMessage('message', { + isExpiringMessage: true, + iconUrl, + iconAbsolutePath, + }); + clock.tick(DEBOUNCE_INTERVAL); + if (platform === 'windows') { + assertShowNotificationCalledWith({ + title: 'Alice', + body: window.SignalContext.i18n('icu:newMessage'), + iconUrl, + iconAbsolutePath, + }); + } else { + assertShowNotificationCalledWith({ + title: 'Alice', + body: 'Body', + iconUrl, + iconAbsolutePath, + }); + } + }); + + it(`redacts body for NameOnly`, () => { + getNotificationSetting.returns(NotificationSetting.NameOnly); + queueMessage('message', { iconUrl, iconAbsolutePath }); + clock.tick(DEBOUNCE_INTERVAL); + assertShowNotificationCalledWith({ + title: 'Alice', + body: window.SignalContext.i18n('icu:newMessage'), + iconUrl, + iconAbsolutePath, + }); + }); + + it(`redacts body, title, and avatar for NoNameOrMessage`, () => { + getNotificationSetting.returns(NotificationSetting.NoNameOrMessage); + queueMessage('message', { iconUrl, iconAbsolutePath }); + clock.tick(DEBOUNCE_INTERVAL); + assertShowNotificationCalledWith({ + title: 'Signal', + body: window.SignalContext.i18n('icu:newMessage'), + iconUrl: undefined, + iconAbsolutePath: undefined, + }); + }); + }); + + it('cancels a queued message before it reaches the screen', () => { + queueMessage(); + service.removeBy({ messageId: 'message' }); + clock.tick(DEBOUNCE_INTERVAL); + sinon.assert.notCalled(showNotification); + }); + + it('keeps a newer queued message when dismissing the displayed message', () => { + showMessage(); + queueMessage('newer'); + service.removeBy({ messageId: 'message' }); + clock.tick(DEBOUNCE_INTERVAL); + sinon.assert.calledTwice(showNotification); + }); + + it('keeps the displayed message when a different queued message is read', () => { + showMessage(); + queueMessage('newer'); + service.removeBy({ messageId: 'newer' }); + clock.tick(DEBOUNCE_INTERVAL); + sinon.assert.notCalled(closeNotification); + sinon.assert.calledOnce(showNotification); + }); + + it('removes both displayed and queued notifications for a conversation', () => { + showMessage(); + queueMessage('second'); + service.removeBy({ conversationId: 'chat' }); + clock.tick(DEBOUNCE_INTERVAL); + sinon.assert.calledOnce(closeNotification); + sinon.assert.calledOnce(showNotification); + }); + + describe('removeBy matching', () => { + const reaction = { + emoji: '👍' as Emoji.Variant, + targetAuthorAci: 'author', + targetTimestamp: 123, + }; + const pollVote = { + voterConversationId: 'voter', + targetAuthorAci: 'author', + targetTimestamp: 123, + }; + const reactionFilter = { conversationId: 'chat', ...reaction }; + const pollFilter = { + conversationId: 'chat', + targetAuthorAci: 'author', + targetTimestamp: 123, + onlyRemoveAssociatedPollVotes: true, + }; + type RemovalCase = { + name: string; + kind?: 'reaction' | 'pollVote' | 'reminder'; + identifier: Parameters[0]; + removed: boolean; + }; + const cases: ReadonlyArray = [ + { + name: 'matches conversation ID', + identifier: { conversationId: 'chat' }, + removed: true, + }, + { + name: 'matches message ID', + identifier: { messageId: 'message' }, + removed: true, + }, + { + name: 'ignores unrelated IDs', + identifier: { conversationId: 'other', messageId: 'other' }, + removed: false, + }, + { + name: 'matches either ID when both are supplied', + identifier: { conversationId: 'other', messageId: 'message' }, + removed: true, + }, + { + name: 'matches conversation even when message ID differs', + identifier: { conversationId: 'chat', messageId: 'other' }, + removed: true, + }, + { + name: 'matches an exact reaction', + kind: 'reaction', + identifier: reactionFilter, + removed: true, + }, + { + name: 'keeps a different reaction emoji', + kind: 'reaction', + identifier: { ...reactionFilter, emoji: '👎' as Emoji.Variant }, + removed: false, + }, + { + name: 'keeps a reaction to a different author', + kind: 'reaction', + identifier: { ...reactionFilter, targetAuthorAci: 'other' }, + removed: false, + }, + { + name: 'keeps a reaction to a different timestamp', + kind: 'reaction', + identifier: { ...reactionFilter, targetTimestamp: 456 }, + removed: false, + }, + { + name: 'matches an associated poll vote', + kind: 'pollVote', + identifier: pollFilter, + removed: true, + }, + { + name: 'keeps a vote on a different author’s poll', + kind: 'pollVote', + identifier: { ...pollFilter, targetAuthorAci: 'other' }, + removed: false, + }, + { + name: 'keeps a vote on a different poll timestamp', + kind: 'pollVote', + identifier: { ...pollFilter, targetTimestamp: 456 }, + removed: false, + }, + ]; + + cases.forEach(({ name, kind, identifier, removed }) => { + it(name, () => { + queueMessage('message', { + type: + kind === 'reaction' + ? NotificationType.Reaction + : NotificationType.Message, + reaction: kind === 'reaction' ? reaction : undefined, + pollVote: kind === 'pollVote' ? pollVote : undefined, + }); + clock.tick(DEBOUNCE_INTERVAL); + closeNotification.resetHistory(); + + service.removeBy(identifier); + clock.tick(DEBOUNCE_INTERVAL); + + assert.strictEqual(closeNotification.callCount, removed ? 1 : 0); + sinon.assert.calledOnce(showNotification); + }); + }); + }); + + if (platform === 'windows') { + it('preserves the click target during focus-triggered clearing', () => { + showMessage(); + const { token } = showNotification.firstCall.args[0]; + + // Windows can focus the app (triggering clear()) before we resolve the link + service.clear(); + assert.deepEqual(service.resolveToken(token), { + conversationId: 'chat', + messageId: 'message', + storyId: undefined, + }); + + clock.tick(DEBOUNCE_INTERVAL); + assert.isUndefined(service.resolveToken(token)); + }); + } + }); + } +}); diff --git a/ts/test-node/util/messageFailures.preload.ts b/ts/test-node/util/messageFailures.preload.ts index 0761b3f5bf..fc68ad8252 100644 --- a/ts/test-node/util/messageFailures.preload.ts +++ b/ts/test-node/util/messageFailures.preload.ts @@ -136,6 +136,8 @@ export function notifyStorySendFailed(message: MessageModel): void { isExpiringMessage: false, sentAt: timestamp, type: NotificationType.Message, + iconUrl: null, + iconAbsolutePath: null, }); }