// Copyright 2015 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import os from 'node:os'; import lodash from 'lodash'; import EventEmitter from 'node:events'; import { v4 as getGuid } from 'uuid'; import { Sound, SoundType } from '../util/Sound.std.js'; import { shouldHideExpiringMessageBody } from '../types/Settings.std.js'; import { itemStorage as fallbackStorage } from '../textsecure/Storage.preload.js'; import OS from '../util/os/osMain.node.js'; import { createLogger } from '../logging/log.std.js'; import { makeEnumParser } from '../util/enum.std.js'; import { missingCaseError } from '../util/missingCaseError.std.js'; import { toLogFormat } from '../types/errors.std.js'; import type { StorageInterface } from '../types/Storage.d.ts'; import type { LocalizerType } from '../types/Util.std.js'; import { NotificationType } from '../types/notifications.std.js'; import { drop } from '../util/drop.std.js'; const { debounce } = lodash; const log = createLogger('notifications'); type NotificationDataType = Readonly<{ conversationId: string; isExpiringMessage: boolean; messageId: string; message: string; notificationIconUrl?: undefined | string; notificationIconAbsolutePath?: undefined | string; reaction?: { emoji: string; targetAuthorAci: string; targetTimestamp: number; }; pollVote?: { voterConversationId: string; targetAuthorAci: string; targetTimestamp: number; }; senderTitle: string; sentAt: number; storyId?: string; type: NotificationType; useTriToneSound?: boolean; wasShown?: boolean; }>; export type NotificationClickData = Readonly<{ conversationId: string; messageId: string | undefined; storyId: string | undefined; }>; // The keys and values don't match here. This is because the values correspond to old // setting names. In the future, we may wish to migrate these to match. export enum NotificationSetting { Off = 'off', NoNameOrMessage = 'count', NameOnly = 'name', NameAndMessage = 'message', } const parseNotificationSetting = makeEnumParser( NotificationSetting, NotificationSetting.NameAndMessage ); export const FALLBACK_NOTIFICATION_TITLE = 'Signal'; // 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 { #i18n?: LocalizerType; #storage?: StorageInterface; public isEnabled = false; #lastNotification: null | Notification = null; #notificationData: null | NotificationDataType = null; #tokenData: { token: string; data: NotificationClickData } | undefined; // Testing indicated that trying to create/destroy notifications too quickly // resulted in notifications that stuck around forever, requiring the user // to manually close them. This introduces a minimum amount of time between calls, // and batches up the quick successive update() calls we get from an incoming // read sync, which might have a number of messages referenced inside of it. #update: () => unknown; constructor() { super(); this.#update = debounce(this.#fastUpdate.bind(this), 1000); } public initialize({ i18n, storage, }: Readonly<{ i18n: LocalizerType; storage: StorageInterface }>): void { log.info('NotificationService initialized'); this.#i18n = i18n; this.#storage = storage; } #getStorage(): StorageInterface { if (this.#storage) { return this.#storage; } log.error( 'NotificationService not initialized. Falling back to storage, but you should fix this' ); return fallbackStorage; } #getI18n(): LocalizerType { if (this.#i18n) { return this.#i18n; } log.error( 'NotificationService not initialized. ' + 'Falling back to window.SignalContext.i18n, but you should fix this' ); return window.SignalContext.i18n; } /** * A higher-level wrapper around `window.Notification`. You may prefer to use `notify`, * 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; this.#update(); } /** * 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); if (OS.isWindows()) { const token = this._createToken({ conversationId, messageId, storyId, }); // Note: showing a windows notification clears all previous notifications first drop( window.IPC.showWindowsNotification({ avatarPath: iconPath, body: message, heading: title, type, token, }) ); } else { this.#lastNotification?.close(); const notification = new window.Notification(title, { body: OS.isLinux() ? filterNotificationText(message) : message, icon: iconUrl, silent: true, tag: messageId, }); notification.onclick = () => { // Note: this maps to the xmlTemplate() function in app/WindowsNotifications.ts if ( type === NotificationType.Message || type === NotificationType.Reaction ) { window.IPC.showWindow(); window.Events.showConversationViaNotification({ conversationId, messageId, storyId, }); } else if (type === NotificationType.IncomingGroupCall) { window.IPC.showWindow(); window.reduxActions?.calling?.startCallingLobby({ conversationId, isVideoCall: true, }); } else if (type === NotificationType.IsPresenting) { window.reduxActions?.calling?.cancelPresenting(); } else if (type === NotificationType.IncomingCall) { window.IPC.showWindow(); } else if (type === NotificationType.MinimizedToTray) { // Unused since the notification is shown by main process instead window.IPC.showWindow(); } else { throw missingCaseError(type); } }; this.#lastNotification = notification; } 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()); } } // Remove the last notification if both conditions hold: // // 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?: string; targetAuthorAci?: string; targetTimestamp?: number; onlyRemoveAssociatedPollVotes?: boolean; } & ( | { conversationId: string; messageId?: string } | { messageId: string; conversationId?: string } ) > ): void { const { conversationId, messageId, emoji, targetAuthorAci, targetTimestamp, onlyRemoveAssociatedPollVotes, } = options; if (!this.#notificationData) { log.info('NotificationService#removeBy: no notification data'); return; } 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; } if (!shouldClear) { return; } // If reaction filters are provided, only remove reaction notifications that match const { reaction } = this.#notificationData; const hasReactionFilters = Boolean( emoji && targetAuthorAci && targetTimestamp ); if (hasReactionFilters) { if (!reaction) { // Looking for reactions but this isn't one return; } if ( reaction.emoji !== emoji || reaction.targetAuthorAci !== targetAuthorAci || reaction.targetTimestamp !== targetTimestamp ) { // Reaction doesn't match the filter return; } } // If onlyRemoveAssociatedPollVotes is true, only remove poll vote notifications // that match the targetAuthorAci and targetTimestamp if (onlyRemoveAssociatedPollVotes && targetAuthorAci && targetTimestamp) { const { pollVote } = this.#notificationData; if ( !pollVote || pollVote.targetAuthorAci !== targetAuthorAci || pollVote.targetTimestamp !== targetTimestamp ) { // Looking for poll votes but this isn't one return; } } this.clear(); this.#update(); } #fastUpdate(): void { const storage = this.#getStorage(); const i18n = this.#getI18n(); const notificationData = this.#notificationData; 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; } // 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; } return; } const shouldPlayNotificationSound = Boolean( storage.get('audio-notification') ); const shouldDrawAttention = storage.get( 'notification-draw-attention', false ); if (shouldDrawAttention) { log.info('NotificationService: drawing attention'); window.IPC.drawAttention(); } let notificationTitle: string; let notificationMessage: string; let notificationIconUrl: undefined | string; let notificationIconAbsolutePath: undefined | string; const { conversationId, isExpiringMessage, message, messageId, reaction, pollVote, senderTitle, storyId, sentAt, useTriToneSound, wasShown, type, } = notificationData; if (wasShown) { log.info( 'NotificationService: not showing a notification because it was already shown' ); return; } 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; } 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; } 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 { return parseNotificationSetting( this.#getStorage().get('notification-setting') ); } /** @internal */ public _createToken(data: NotificationClickData): string { const token = getGuid(); this.#tokenData = { token, data, }; return token; } public resolveToken(token: string): NotificationClickData | undefined { if (!this.#tokenData) { log.warn(`NotificationService: no data when looking up ${token}`); return undefined; } if (this.#tokenData.token !== token) { log.warn(`NotificationService: token mismatch ${token}`); return undefined; } return this.#tokenData.data; } public clear(): void { log.info( 'NotificationService: clearing notification and requesting an update' ); this.#notificationData = null; this.#update(); } // We don't usually call this, but when the process is shutting down, we should at // least try to remove the notification immediately instead of waiting for the // normal debounce. public fastClear(): void { log.info('NotificationService: clearing notification and updating'); this.#notificationData = null; this.#fastUpdate(); } public enable(): void { log.info('NotificationService: enabling'); const needUpdate = !this.isEnabled; this.isEnabled = true; if (needUpdate) { this.#update(); } } public disable(): void { log.info('NotificationService: disabling'); this.isEnabled = false; } } export const notificationService = new NotificationService(); function filterNotificationText(text: string) { return (text || '') .replace(/&/g, '&') .replace(//g, '>'); } export function shouldSaveNotificationAvatarToDisk(): boolean { const notificationSetting = notificationService.getNotificationSetting(); switch (notificationSetting) { case NotificationSetting.NameOnly: case NotificationSetting.NameAndMessage: // According to the MSDN, avatars can only be loaded from disk or an // http server: // https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-image?redirectedfrom=MSDN return OS.isWindows(); case NotificationSetting.Off: case NotificationSetting.NoNameOrMessage: return false; default: throw missingCaseError(notificationSetting); } }