mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-09-28 07:57:21 +01:00
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:
co-authored by
trevor-signal
Backport Bot
parent
9517854b53
commit
15d6f6c729
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user