From d2ef82686d6eac0f5dac5dca3f62ef77ba727554 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 23 Sep 2021 13:16:09 -0500 Subject: [PATCH] Notification improvements --- js/modules/signal.js | 4 - js/notifications.js | 251 ---------------------- ts/background.ts | 19 +- ts/messageModifiers/ReadSyncs.ts | 7 +- ts/messageModifiers/ViewSyncs.ts | 3 +- ts/models/conversations.ts | 5 +- ts/models/messages.ts | 5 +- ts/notifications/getStatus.ts | 61 ------ ts/notifications/index.ts | 6 - ts/services/MessageUpdater.ts | 3 +- ts/services/calling.ts | 4 +- ts/services/notifications.ts | 352 +++++++++++++++++++++++++++++++ ts/services/notify.ts | 51 ----- ts/state/smart/CallManager.tsx | 29 ++- ts/util/markConversationRead.ts | 3 +- ts/window.d.ts | 15 +- 16 files changed, 408 insertions(+), 410 deletions(-) delete mode 100644 js/notifications.js delete mode 100644 ts/notifications/getStatus.ts delete mode 100644 ts/notifications/index.ts create mode 100644 ts/services/notifications.ts delete mode 100644 ts/services/notify.ts diff --git a/js/modules/signal.js b/js/modules/signal.js index 8cafdcf88..f496bcd2d 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -14,7 +14,6 @@ const EmojiLib = require('../../ts/components/emoji/lib'); const Groups = require('../../ts/groups'); const GroupChange = require('../../ts/groupChange'); const IndexedDB = require('./indexeddb'); -const Notifications = require('../../ts/notifications'); const OS = require('../../ts/OS'); const Stickers = require('../../ts/types/Stickers'); const Settings = require('./settings'); @@ -159,7 +158,6 @@ const { const { initializeUpdateListener, } = require('../../ts/services/updateListener'); -const { notify } = require('../../ts/services/notify'); const { calling } = require('../../ts/services/calling'); const { onTimeout, removeTimeout } = require('../../ts/services/timers'); const { @@ -406,7 +404,6 @@ exports.setup = (options = {}) => { initializeNetworkObserver, initializeUpdateListener, onTimeout, - notify, removeTimeout, runStorageServiceSyncJob, storageServiceUploadJob, @@ -454,7 +451,6 @@ exports.setup = (options = {}) => { GroupChange, IndexedDB, Migrations, - Notifications, OS, RemoteConfig, Settings, diff --git a/js/notifications.js b/js/notifications.js deleted file mode 100644 index 113026737..000000000 --- a/js/notifications.js +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright 2015-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* global Signal:false */ -/* global Backbone: false */ - -/* global drawAttention: false */ -/* global i18n: false */ -/* global storage: false */ -/* global Whisper: false */ -/* global _: false */ - -// eslint-disable-next-line func-names -(function () { - window.Whisper = window.Whisper || {}; - - // 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. - const SettingNames = { - NO_NAME_OR_MESSAGE: 'count', - NAME_ONLY: 'name', - NAME_AND_MESSAGE: 'message', - }; - - // 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 - Whisper.Notifications = { - ...Backbone.Events, - - isEnabled: false, - - // This is either a standard `Notification` or null. - lastNotification: null, - - // This is either null or an object of this shape: - // - // { - // conversationId: string; - // messageId: string; - // senderTitle: string; - // message: string; - // notificationIconUrl: string | void; - // isExpiringMessage: boolean; - // reaction: { - // emoji: string; - // fromId: string; - // }; - // } - notificationData: null, - - add(notificationData) { - this.notificationData = notificationData; - this.update(); - }, - - // Remove the last notification if both conditions hold: - // - // 1. Either `conversationId` or `messageId` matches (if present) - // 2. `emoji`, `targetAuthorUuid`, `targetTimestamp` matches (if present) - removeBy({ - conversationId, - messageId, - emoji, - targetAuthorUuid, - targetTimestamp, - }) { - if (!this.notificationData) { - return; - } - - let shouldClear = false; - if ( - conversationId && - this.notificationData.conversationId === conversationId - ) { - shouldClear = true; - } - if (messageId && this.notificationData.messageId === messageId) { - shouldClear = true; - } - - if (!shouldClear) { - return; - } - - const { reaction } = this.notificationData; - if ( - reaction && - emoji && - targetAuthorUuid && - targetTimestamp && - (reaction.emoji !== emoji || - reaction.targetAuthorUuid !== targetAuthorUuid || - reaction.targetTimestamp !== targetTimestamp) - ) { - return; - } - - this.clear(); - this.update(); - }, - - fastUpdate() { - if (this.lastNotification) { - this.lastNotification.close(); - this.lastNotification = null; - } - - const { isEnabled } = this; - const isAppFocused = window.isActive(); - const isAudioNotificationEnabled = - storage.get('audio-notification') || false; - const userSetting = this.getUserSetting(); - - const status = Signal.Notifications.getStatus({ - isAppFocused, - isAudioNotificationEnabled, - isEnabled, - hasNotifications: Boolean(this.notificationData), - userSetting, - }); - - const shouldDrawAttention = storage.get( - 'notification-draw-attention', - true - ); - if (shouldDrawAttention) { - drawAttention(); - } - - if (status.type !== 'ok') { - window.SignalWindow.log.info( - `Not updating notifications; notification status is ${status.type}. ${ - status.shouldClearNotifications ? 'Also clearing notifications' : '' - }` - ); - - if (status.shouldClearNotifications) { - this.notificationData = null; - } - - return; - } - window.SignalWindow.log.info('Showing a notification'); - - let notificationTitle; - let notificationMessage; - let notificationIconUrl; - - const { - conversationId, - messageId, - senderTitle, - message, - isExpiringMessage, - reaction, - } = this.notificationData; - - if ( - userSetting === SettingNames.NAME_ONLY || - userSetting === SettingNames.NAME_AND_MESSAGE - ) { - notificationTitle = senderTitle; - ({ notificationIconUrl } = this.notificationData); - - const shouldHideExpiringMessageBody = - isExpiringMessage && (Signal.OS.isMacOS() || Signal.OS.isWindows()); - if (shouldHideExpiringMessageBody) { - notificationMessage = i18n('newMessage'); - } else if (userSetting === SettingNames.NAME_ONLY) { - if (reaction) { - notificationMessage = i18n('notificationReaction', { - sender: senderTitle, - emoji: reaction.emoji, - }); - } else { - notificationMessage = i18n('newMessage'); - } - } else if (reaction) { - notificationMessage = i18n('notificationReactionMessage', { - sender: senderTitle, - emoji: reaction.emoji, - message, - }); - } else { - notificationMessage = message; - } - } else { - if (userSetting !== SettingNames.NO_NAME_OR_MESSAGE) { - window.SignalWindow.log.error( - `Error: Unknown user notification setting: '${userSetting}'` - ); - } - notificationTitle = 'Signal'; - notificationMessage = i18n('newMessage'); - } - - this.lastNotification = window.Signal.Services.notify({ - title: notificationTitle, - icon: notificationIconUrl, - message: notificationMessage, - silent: !status.shouldPlayNotificationSound, - onNotificationClick: () => { - this.trigger('click', conversationId, messageId); - }, - }); - }, - - getUserSetting() { - return ( - storage.get('notification-setting') || SettingNames.NAME_AND_MESSAGE - ); - }, - clear() { - window.SignalWindow.log.info('Removing notification'); - 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. - fastClear() { - this.notificationData = null; - this.fastUpdate(); - }, - enable() { - const needUpdate = !this.isEnabled; - this.isEnabled = true; - if (needUpdate) { - this.update(); - } - }, - disable() { - this.isEnabled = false; - }, - }; - - // 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. - Whisper.Notifications.update = _.debounce( - Whisper.Notifications.fastUpdate.bind(Whisper.Notifications), - 1000 - ); -})(); diff --git a/ts/background.ts b/ts/background.ts index 1047237f0..447fa7941 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -38,6 +38,7 @@ import { updateConversationsWithUuidLookup } from './updateConversationsWithUuid import { initializeAllJobQueues } from './jobs/initializeAllJobQueues'; import { removeStorageKeyJobQueue } from './jobs/removeStorageKeyJobQueue'; import { ourProfileKeyService } from './services/ourProfileKey'; +import { notificationService } from './services/notifications'; import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey'; import { LatestQueue } from './util/LatestQueue'; import { parseIntOrThrow } from './util/parseIntOrThrow'; @@ -139,6 +140,10 @@ export async function startApp(): Promise { window.Signal.Util.MessageController.install(); window.Signal.conversationControllerStart(); window.startupProcessingQueue = new window.Signal.Util.StartupQueue(); + notificationService.initialize({ + i18n: window.i18n, + storage: window.storage, + }); window.attachmentDownloadQueue = []; try { log.info('Initializing SQL in renderer'); @@ -1768,12 +1773,10 @@ export async function startApp(): Promise { } }); - window.registerForActive(() => window.Whisper.Notifications.clear()); - window.addEventListener('unload', () => - window.Whisper.Notifications.fastClear() - ); + window.registerForActive(() => notificationService.clear()); + window.addEventListener('unload', () => notificationService.fastClear()); - window.Whisper.Notifications.on('click', (id, messageId) => { + notificationService.on('click', (id, messageId) => { window.showWindow(); if (id) { window.Whisper.events.trigger('showConversation', id, messageId); @@ -2062,7 +2065,7 @@ export async function startApp(): Promise { profileKeyResponseQueue.pause(); lightSessionResetQueue.pause(); window.Whisper.deliveryReceiptQueue.pause(); - window.Whisper.Notifications.disable(); + notificationService.disable(); window.Signal.Services.initializeGroupCredentialFetcher(); @@ -2314,7 +2317,7 @@ export async function startApp(): Promise { profileKeyResponseQueue.start(); lightSessionResetQueue.start(); window.Whisper.deliveryReceiptQueue.start(); - window.Whisper.Notifications.enable(); + notificationService.enable(); await onAppView; @@ -2378,7 +2381,7 @@ export async function startApp(): Promise { profileKeyResponseQueue.pause(); lightSessionResetQueue.pause(); window.Whisper.deliveryReceiptQueue.pause(); - window.Whisper.Notifications.disable(); + notificationService.disable(); } let initialStartupCount = 0; diff --git a/ts/messageModifiers/ReadSyncs.ts b/ts/messageModifiers/ReadSyncs.ts index 20cad3e0b..1fafbddb3 100644 --- a/ts/messageModifiers/ReadSyncs.ts +++ b/ts/messageModifiers/ReadSyncs.ts @@ -1,4 +1,4 @@ -// Copyright 2017-2020 Signal Messenger, LLC +// Copyright 2017-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable max-classes-per-file */ @@ -8,6 +8,7 @@ import { Collection, Model } from 'backbone'; import { MessageModel } from '../models/messages'; import { isIncoming } from '../state/selectors/message'; import { isMessageUnread } from '../util/isMessageUnread'; +import { notificationService } from '../services/notifications'; import * as log from '../logging/log'; type ReadSyncAttributesType = { @@ -39,7 +40,7 @@ async function maybeItIsAReactionReadSync(sync: ReadSyncModel): Promise { return; } - window.Whisper.Notifications.removeBy({ + notificationService.removeBy({ conversationId: readReaction.conversationId, emoji: readReaction.emoji, targetAuthorUuid: readReaction.targetAuthorUuid, @@ -99,7 +100,7 @@ export class ReadSyncs extends Collection { return; } - window.Whisper.Notifications.removeBy({ messageId: found.id }); + notificationService.removeBy({ messageId: found.id }); const message = window.MessageController.register(found.id, found); const readAt = Math.min(sync.get('readAt'), Date.now()); diff --git a/ts/messageModifiers/ViewSyncs.ts b/ts/messageModifiers/ViewSyncs.ts index 1e9a45fc2..ce418c8b7 100644 --- a/ts/messageModifiers/ViewSyncs.ts +++ b/ts/messageModifiers/ViewSyncs.ts @@ -9,6 +9,7 @@ import { MessageModel } from '../models/messages'; import { ReadStatus } from '../messages/MessageReadStatus'; import { markViewed } from '../services/MessageUpdater'; import { isIncoming } from '../state/selectors/message'; +import { notificationService } from '../services/notifications'; import * as log from '../logging/log'; type ViewSyncAttributesType = { @@ -83,7 +84,7 @@ export class ViewSyncs extends Collection { return; } - window.Whisper.Notifications.removeBy({ messageId: found.id }); + notificationService.removeBy({ messageId: found.id }); const message = window.MessageController.register(found.id, found); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index f50eac045..e2d33f06f 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -54,6 +54,7 @@ import { migrateColor } from '../util/migrateColor'; import { isNotNil } from '../util/isNotNil'; import { dropNull } from '../util/dropNull'; import { ourProfileKeyService } from '../services/ourProfileKey'; +import { notificationService } from '../services/notifications'; import { getSendOptions } from '../util/getSendOptions'; import { isConversationAccepted } from '../util/isConversationAccepted'; import { markConversationRead } from '../util/markConversationRead'; @@ -4847,7 +4848,7 @@ export class ConversationModel extends window.Backbone ): Promise { // As a performance optimization don't perform any work if notifications are // disabled. - if (!window.Whisper.Notifications.isEnabled) { + if (!notificationService.isEnabled) { return; } @@ -4900,7 +4901,7 @@ export class ConversationModel extends window.Backbone const messageId = message.id; const isExpiringMessage = Message.hasExpiration(messageJSON); - window.Whisper.Notifications.add({ + notificationService.add({ senderTitle, conversationId, notificationIconUrl, diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 183331b55..19792d1cd 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -112,6 +112,7 @@ import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; import * as LinkPreview from '../types/LinkPreview'; import { SignalService as Proto } from '../protobuf'; import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue'; +import { notificationService } from '../services/notifications'; import type { PreviewType as OutgoingPreviewType } from '../textsecure/SendMessage'; import * as log from '../logging/log'; @@ -3292,7 +3293,7 @@ export class MessageModel extends window.Backbone.Model { }); // Remove any notifications for this message - window.Whisper.Notifications.removeBy({ messageId: this.get('id') }); + notificationService.removeBy({ messageId: this.get('id') }); // Erase the contents of this message await this.eraseContents( @@ -3306,7 +3307,7 @@ export class MessageModel extends window.Backbone.Model { } clearNotifications(reaction: Partial = {}): void { - window.Whisper.Notifications.removeBy({ + notificationService.removeBy({ ...reaction, messageId: this.id, }); diff --git a/ts/notifications/getStatus.ts b/ts/notifications/getStatus.ts deleted file mode 100644 index 262ca6cce..000000000 --- a/ts/notifications/getStatus.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2018-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -type Environment = { - isAppFocused: boolean; - isAudioNotificationEnabled: boolean; - isEnabled: boolean; - hasNotifications: boolean; - userSetting: UserSetting; -}; - -type Status = { - shouldClearNotifications: boolean; - shouldPlayNotificationSound: boolean; - shouldShowNotifications: boolean; - type: Type; -}; - -type UserSetting = 'off' | 'count' | 'name' | 'message'; - -type Type = - | 'ok' - | 'disabled' - | 'appIsFocused' - | 'noNotifications' - | 'userSetting'; - -export const getStatus = ({ - isAppFocused, - isAudioNotificationEnabled, - isEnabled, - hasNotifications, - userSetting, -}: Environment): Status => { - const type = ((): Type => { - if (!isEnabled) { - return 'disabled'; - } - - if (!hasNotifications) { - return 'noNotifications'; - } - - if (isAppFocused) { - return 'appIsFocused'; - } - - if (userSetting === 'off') { - return 'userSetting'; - } - - return 'ok'; - })(); - - return { - shouldClearNotifications: type === 'appIsFocused', - shouldPlayNotificationSound: isAudioNotificationEnabled, - shouldShowNotifications: type === 'ok', - type, - }; -}; diff --git a/ts/notifications/index.ts b/ts/notifications/index.ts deleted file mode 100644 index 2dc0e38e1..000000000 --- a/ts/notifications/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { getStatus } from './getStatus'; - -export { getStatus }; diff --git a/ts/services/MessageUpdater.ts b/ts/services/MessageUpdater.ts index b83670e54..68aa8121e 100644 --- a/ts/services/MessageUpdater.ts +++ b/ts/services/MessageUpdater.ts @@ -3,6 +3,7 @@ import type { MessageAttributesType } from '../model-types.d'; import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus'; +import { notificationService } from './notifications'; function markReadOrViewed( messageAttrs: Readonly, @@ -27,7 +28,7 @@ function markReadOrViewed( ); } - window.Whisper.Notifications.removeBy({ messageId }); + notificationService.removeBy({ messageId }); if (!skipSave) { window.Signal.Util.queueUpdateMessage(nextMessageAttributes); diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 2bc6aa9e8..d2b631602 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -80,10 +80,10 @@ import { REQUESTED_VIDEO_FRAMERATE, } from '../calling/constants'; import { callingMessageToProto } from '../util/callingMessageToProto'; -import { notify } from './notify'; import { getSendOptions } from '../util/getSendOptions'; import { SignalService as Proto } from '../protobuf'; import dataInterface from '../sql/Client'; +import { notificationService } from './notifications'; import * as log from '../logging/log'; const { @@ -1117,7 +1117,7 @@ export class CallingClass { if (source) { ipcRenderer.send('show-screen-share', source.name); - notify({ + notificationService.notify({ icon: 'images/icons/v2/video-solid-24.svg', message: window.i18n('calling__presenting--notification-body'), onNotificationClick: () => { diff --git a/ts/services/notifications.ts b/ts/services/notifications.ts new file mode 100644 index 000000000..b2767b017 --- /dev/null +++ b/ts/services/notifications.ts @@ -0,0 +1,352 @@ +// Copyright 2015-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { debounce } from 'lodash'; +import EventEmitter from 'events'; +import { Sound } from '../util/Sound'; +import { + AudioNotificationSupport, + getAudioNotificationSupport, +} from '../types/Settings'; +import * as OS from '../OS'; +import * as log from '../logging/log'; +import { makeEnumParser } from '../util/enum'; +import type { StorageInterface } from '../types/Storage.d'; +import type { LocalizerType } from '../types/Util'; + +type NotificationDataType = { + conversationId: string; + messageId: string; + senderTitle: string; + message: string; + notificationIconUrl?: undefined | string; + isExpiringMessage: boolean; + reaction: { + emoji: string; + targetAuthorUuid: string; + targetTimestamp: number; + }; +}; + +// 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 { + private i18n?: LocalizerType; + + private storage?: StorageInterface; + + public isEnabled = false; + + private lastNotification: null | Notification = null; + + private notificationData: null | NotificationDataType = null; + + // 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. + private update: () => unknown; + + constructor() { + super(); + + this.update = debounce(this.fastUpdate.bind(this), 1000); + } + + public initialize({ + i18n, + storage, + }: Readonly<{ i18n: LocalizerType; storage: StorageInterface }>): void { + this.i18n = i18n; + this.storage = storage; + } + + private getStorage(): StorageInterface { + if (this.storage) { + return this.storage; + } + + log.error( + 'NotificationService not initialized. Falling back to window.storage, but you should fix this' + ); + return window.storage; + } + + private getI18n(): LocalizerType { + if (this.i18n) { + return this.i18n; + } + + log.error( + 'NotificationService not initialized. Falling back to window.i18n, but you should fix this' + ); + return window.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: NotificationDataType): void { + 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({ + icon, + message, + onNotificationClick, + silent, + title, + }: Readonly<{ + icon?: string; + message: string; + onNotificationClick: () => void; + silent: boolean; + title: string; + }>): void { + this.lastNotification?.close(); + + const audioNotificationSupport = getAudioNotificationSupport(); + + const notification = new window.Notification(title, { + body: OS.isLinux() ? filterNotificationText(message) : message, + icon, + silent: + silent || audioNotificationSupport !== AudioNotificationSupport.Native, + }); + notification.onclick = onNotificationClick; + + if ( + !silent && + audioNotificationSupport === AudioNotificationSupport.Custom + ) { + // We kick off the sound to be played. No need to await it. + new Sound({ src: 'sounds/notification.ogg' }).play(); + } + + this.lastNotification = notification; + } + + // Remove the last notification if both conditions hold: + // + // 1. Either `conversationId` or `messageId` matches (if present) + // 2. `emoji`, `targetAuthorUuid`, `targetTimestamp` matches (if present) + public removeBy({ + conversationId, + messageId, + emoji, + targetAuthorUuid, + targetTimestamp, + }: Readonly<{ + conversationId?: string; + messageId?: string; + emoji?: string; + targetAuthorUuid?: string; + targetTimestamp?: number; + }>): void { + if (!this.notificationData) { + return; + } + + let shouldClear = false; + if ( + conversationId && + this.notificationData.conversationId === conversationId + ) { + shouldClear = true; + } + if (messageId && this.notificationData.messageId === messageId) { + shouldClear = true; + } + + if (!shouldClear) { + return; + } + + const { reaction } = this.notificationData; + if ( + reaction && + emoji && + targetAuthorUuid && + targetTimestamp && + (reaction.emoji !== emoji || + reaction.targetAuthorUuid !== targetAuthorUuid || + reaction.targetTimestamp !== targetTimestamp) + ) { + return; + } + + this.clear(); + this.update(); + } + + private fastUpdate(): void { + const storage = this.getStorage(); + const i18n = this.getI18n(); + + if (this.lastNotification) { + this.lastNotification.close(); + this.lastNotification = null; + } + + const { notificationData } = this; + const isAppFocused = window.isActive(); + const userSetting = this.getNotificationSetting(); + + // 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 && + userSetting !== NotificationSetting.Off; + if (!shouldShowNotification) { + log.info('Not updating notifications'); + if (isAppFocused) { + this.notificationData = null; + } + return; + } + + log.info('Showing a notification'); + + const shouldDrawAttention = storage.get( + 'notification-draw-attention', + true + ); + if (shouldDrawAttention) { + window.drawAttention(); + } + + let notificationTitle: string; + let notificationMessage: string; + let notificationIconUrl: undefined | string; + + const { + conversationId, + messageId, + senderTitle, + message, + isExpiringMessage, + reaction, + } = notificationData; + + if ( + userSetting === NotificationSetting.NameOnly || + userSetting === NotificationSetting.NameAndMessage + ) { + notificationTitle = senderTitle; + ({ notificationIconUrl } = notificationData); + + const shouldHideExpiringMessageBody = + isExpiringMessage && (OS.isMacOS() || OS.isWindows()); + if (shouldHideExpiringMessageBody) { + notificationMessage = i18n('newMessage'); + } else if (userSetting === NotificationSetting.NameOnly) { + if (reaction) { + notificationMessage = i18n('notificationReaction', { + sender: senderTitle, + emoji: reaction.emoji, + }); + } else { + notificationMessage = i18n('newMessage'); + } + } else if (reaction) { + notificationMessage = i18n('notificationReactionMessage', { + sender: senderTitle, + emoji: reaction.emoji, + message, + }); + } else { + notificationMessage = message; + } + } else { + if (userSetting !== NotificationSetting.NoNameOrMessage) { + window.SignalWindow.log.error( + `Error: Unknown user notification setting: '${userSetting}'` + ); + } + notificationTitle = FALLBACK_NOTIFICATION_TITLE; + notificationMessage = i18n('newMessage'); + } + + this.notify({ + title: notificationTitle, + icon: notificationIconUrl, + message: notificationMessage, + silent: Boolean(storage.get('audio-notification')), + onNotificationClick: () => { + this.emit('click', conversationId, messageId); + }, + }); + } + + public getNotificationSetting(): NotificationSetting { + return parseNotificationSetting( + this.getStorage().get('notification-setting') + ); + } + + public clear(): void { + window.SignalWindow.log.info('Removing notification'); + 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 { + this.notificationData = null; + this.fastUpdate(); + } + + public enable(): void { + const needUpdate = !this.isEnabled; + this.isEnabled = true; + if (needUpdate) { + this.update(); + } + } + + public disable(): void { + this.isEnabled = false; + } +} + +export const notificationService = new NotificationService(); + +function filterNotificationText(text: string) { + return (text || '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} diff --git a/ts/services/notify.ts b/ts/services/notify.ts deleted file mode 100644 index 495b7667b..000000000 --- a/ts/services/notify.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { Sound } from '../util/Sound'; -import { - AudioNotificationSupport, - getAudioNotificationSupport, -} from '../types/Settings'; -import * as OS from '../OS'; - -function filter(text: string) { - return (text || '') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(//g, '>'); -} - -type NotificationType = { - icon: string; - message: string; - onNotificationClick: () => void; - silent: boolean; - title: string; -}; - -export function notify({ - icon, - message, - onNotificationClick, - silent, - title, -}: NotificationType): Notification { - const audioNotificationSupport = getAudioNotificationSupport(); - - const notification = new window.Notification(title, { - body: OS.isLinux() ? filter(message) : message, - icon, - silent: - silent || audioNotificationSupport !== AudioNotificationSupport.Native, - }); - notification.onclick = onNotificationClick; - - if (!silent && audioNotificationSupport === AudioNotificationSupport.Custom) { - // We kick off the sound to be played. No need to await it. - new Sound({ src: 'sounds/notification.ogg' }).play(); - } - - return notification; -} diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index c8f97f54e..0f58ecd95 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -26,12 +26,16 @@ import { SmartSafetyNumberViewer, Props as SafetyNumberViewerProps, } from './SafetyNumberViewer'; -import { notify } from '../../services/notify'; import { callingTones } from '../../util/callingTones'; import { bounceAppIconStart, bounceAppIconStop, } from '../../shims/bounceAppIcon'; +import { + FALLBACK_NOTIFICATION_TITLE, + NotificationSetting, + notificationService, +} from '../../services/notifications'; import * as log from '../../logging/log'; function renderDeviceSelection(): JSX.Element { @@ -55,8 +59,27 @@ async function notifyForCall( if (!shouldNotify) { return; } - notify({ - title, + + let notificationTitle: string; + + const notificationSetting = notificationService.getNotificationSetting(); + switch (notificationSetting) { + case NotificationSetting.Off: + case NotificationSetting.NoNameOrMessage: + notificationTitle = FALLBACK_NOTIFICATION_TITLE; + break; + case NotificationSetting.NameOnly: + case NotificationSetting.NameAndMessage: + notificationTitle = title; + break; + default: + log.error(missingCaseError(notificationSetting)); + notificationTitle = FALLBACK_NOTIFICATION_TITLE; + break; + } + + notificationService.notify({ + title: notificationTitle, icon: isVideoCall ? 'images/icons/v2/video-solid-24.svg' : 'images/icons/v2/phone-right-solid-24.svg', diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts index c6f28c6ff..60a95d4dd 100644 --- a/ts/util/markConversationRead.ts +++ b/ts/util/markConversationRead.ts @@ -5,6 +5,7 @@ import { ConversationAttributesType } from '../model-types.d'; import { sendReadReceiptsFor } from './sendReadReceiptsFor'; import { hasErrors } from '../state/selectors/message'; import { readSyncJobQueue } from '../jobs/readSyncJobQueue'; +import { notificationService } from '../services/notifications'; import * as log from '../logging/log'; export async function markConversationRead( @@ -39,7 +40,7 @@ export async function markConversationRead( return false; } - window.Whisper.Notifications.removeBy({ conversationId }); + notificationService.removeBy({ conversationId }); const unreadReactionSyncData = new Map< string, diff --git a/ts/window.d.ts b/ts/window.d.ts index 1d3ca5c2c..1518c3bb5 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -176,6 +176,7 @@ declare global { baseAttachmentsPath: string; baseStickersPath: string; baseTempPath: string; + drawAttention: () => void; enterKeyboardMode: () => void; enterMouseMode: () => void; getAccountManager: () => AccountManager; @@ -591,20 +592,6 @@ export type WhisperType = { ) => void; }; - Notifications: { - isEnabled: boolean; - removeBy: (filter: Partial) => void; - add: (notification: unknown) => void; - clear: () => void; - disable: () => void; - enable: () => void; - fastClear: () => void; - on: ( - event: string, - callback: (id: string, messageId: string) => void - ) => void; - }; - ExpiringMessagesListener: { init: (events: Backbone.Events) => void; update: () => void;