Refactor notification service

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
Co-authored-by: Backport Bot <backport-bot@signal.org>
This commit is contained in:
automated-signal
2026-09-23 22:05:30 +00:00
committed by GitHub
co-authored by trevor-signal Backport Bot
parent 9517854b53
commit 15d6f6c729
5 changed files with 696 additions and 239 deletions
+2 -5
View File
@@ -189,19 +189,16 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise<void> {
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,
+14 -20
View File
@@ -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,
+307 -214
View File
@@ -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<NotificationDataType, 'wasShown'>): 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, '&amp;')
.replace(/</g, '&lt;')
@@ -571,3 +594,73 @@ export function shouldSaveNotificationAvatarToDisk(): boolean {
throw missingCaseError(notificationSetting);
}
}
function redactMessageNotificationContent({
notificationData,
i18n,
contentSetting,
}: {
notificationData: QueuedNotificationData & {
type: NotificationType.Message | NotificationType.Reaction;
};
i18n: LocalizerType;
contentSetting: Exclude<NotificationSetting, NotificationSetting.Off>;
}): {
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,
};
}
@@ -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<NotificationService['add']>[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<NotificationService['removeBy']>[0];
removed: boolean;
};
const cases: ReadonlyArray<RemovalCase> = [
{
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));
});
}
});
}
});
@@ -136,6 +136,8 @@ export function notifyStorySendFailed(message: MessageModel): void {
isExpiringMessage: false,
sentAt: timestamp,
type: NotificationType.Message,
iconUrl: null,
iconAbsolutePath: null,
});
}