signal-desktop/ts/services/notifications.ts

508 lines
14 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2015 Signal Messenger, LLC
2021-09-23 18:16:09 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import os from 'os';
2021-09-23 18:16:09 +00:00
import { debounce } from 'lodash';
import EventEmitter from 'events';
import { Sound, SoundType } from '../util/Sound';
import { shouldHideExpiringMessageBody } from '../types/Settings';
import OS from '../util/os/osMain';
2021-09-23 18:16:09 +00:00
import * as log from '../logging/log';
import { makeEnumParser } from '../util/enum';
import { missingCaseError } from '../util/missingCaseError';
2021-09-23 18:16:09 +00:00
import type { StorageInterface } from '../types/Storage.d';
import type { LocalizerType } from '../types/Util';
import { drop } from '../util/drop';
2021-09-23 18:16:09 +00:00
type NotificationDataType = Readonly<{
2021-09-23 18:16:09 +00:00
conversationId: string;
isExpiringMessage: boolean;
2021-09-23 18:16:09 +00:00
messageId: string;
message: string;
notificationIconUrl?: undefined | string;
2023-08-01 16:06:29 +00:00
notificationIconAbsolutePath?: undefined | string;
2022-10-11 17:59:02 +00:00
reaction?: {
2021-09-23 18:16:09 +00:00
emoji: string;
2023-08-16 20:54:39 +00:00
targetAuthorAci: string;
2021-09-23 18:16:09 +00:00
targetTimestamp: number;
};
senderTitle: string;
2023-06-14 20:55:50 +00:00
sentAt: number;
storyId?: string;
2023-08-01 16:06:29 +00:00
type: NotificationType;
useTriToneSound?: boolean;
wasShown?: boolean;
}>;
2021-09-23 18:16:09 +00:00
2023-08-01 16:06:29 +00:00
export type NotificationClickData = Readonly<{
conversationId: string;
2023-11-02 19:42:31 +00:00
messageId: string | null;
storyId: string | null;
2023-08-01 16:06:29 +00:00
}>;
export type WindowsNotificationData = {
avatarPath?: string;
body: string;
conversationId: string;
heading: string;
messageId?: string;
storyId?: string;
type: NotificationType;
};
export enum NotificationType {
IncomingCall = 'IncomingCall',
IncomingGroupCall = 'IncomingGroupCall',
IsPresenting = 'IsPresenting',
Message = 'Message',
Reaction = 'Reaction',
}
2021-09-23 18:16:09 +00:00
// 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 {
log.info('NotificationService initialized');
2021-09-23 18:16:09 +00:00
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: Omit<NotificationDataType, 'wasShown'>): void {
log.info(
'NotificationService: adding a notification and requesting an update'
);
2021-09-23 18:16:09 +00:00
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({
2023-08-01 16:06:29 +00:00
conversationId,
iconUrl,
iconPath,
2021-09-23 18:16:09 +00:00
message,
2023-03-27 23:48:57 +00:00
messageId,
2023-06-14 20:55:50 +00:00
sentAt,
2021-09-23 18:16:09 +00:00
silent,
2023-08-01 16:06:29 +00:00
storyId,
2021-09-23 18:16:09 +00:00
title,
2023-08-01 16:06:29 +00:00
type,
useTriToneSound,
2021-09-23 18:16:09 +00:00
}: Readonly<{
2023-08-01 16:06:29 +00:00
conversationId: string;
iconUrl?: string;
iconPath?: string;
2021-09-23 18:16:09 +00:00
message: string;
2023-03-27 23:48:57 +00:00
messageId?: string;
2023-06-14 20:55:50 +00:00
sentAt: number;
2021-09-23 18:16:09 +00:00
silent: boolean;
2023-08-01 16:06:29 +00:00
storyId?: string;
2021-09-23 18:16:09 +00:00
title: string;
2023-08-01 16:06:29 +00:00
type: NotificationType;
useTriToneSound?: boolean;
2021-09-23 18:16:09 +00:00
}>): void {
2023-06-14 20:55:50 +00:00
log.info('NotificationService: showing a notification', sentAt);
2023-08-01 16:06:29 +00:00
if (OS.isWindows()) {
// Note: showing a windows notification clears all previous notifications first
drop(
window.IPC.showWindowsNotification({
avatarPath: iconPath,
body: message,
conversationId,
heading: title,
messageId,
storyId,
type,
})
);
} 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,
2023-11-02 19:42:31 +00:00
messageId: messageId ?? null,
storyId: storyId ?? null,
});
} else if (type === NotificationType.IncomingGroupCall) {
window.IPC.showWindow();
window.reduxActions?.calling?.startCallingLobby({
conversationId,
isVideoCall: true,
});
} else if (type === NotificationType.IsPresenting) {
2024-09-20 01:03:44 +00:00
window.reduxActions?.calling?.cancelPresenting();
} else if (type === NotificationType.IncomingCall) {
window.IPC.showWindow();
} else {
throw missingCaseError(type);
}
};
2023-08-01 16:06:29 +00:00
this.lastNotification = notification;
}
2021-09-23 18:16:09 +00:00
if (!silent) {
const soundType =
messageId && !useTriToneSound ? SoundType.Pop : SoundType.TriTone;
2021-09-23 18:16:09 +00:00
// We kick off the sound to be played. No need to await it.
drop(new Sound({ soundType }).play());
2021-09-23 18:16:09 +00:00
}
}
// Remove the last notification if both conditions hold:
//
// 1. Either `conversationId` or `messageId` matches (if present)
2023-08-16 20:54:39 +00:00
// 2. `emoji`, `targetAuthorAci`, `targetTimestamp` matches (if present)
2021-09-23 18:16:09 +00:00
public removeBy({
conversationId,
messageId,
emoji,
2023-08-16 20:54:39 +00:00
targetAuthorAci,
2021-09-23 18:16:09 +00:00
targetTimestamp,
}: Readonly<{
conversationId?: string;
messageId?: string;
emoji?: string;
2023-08-16 20:54:39 +00:00
targetAuthorAci?: string;
2021-09-23 18:16:09 +00:00
targetTimestamp?: number;
}>): void {
if (!this.notificationData) {
log.info('NotificationService#removeBy: no notification data');
2021-09-23 18:16:09 +00:00
return;
}
let shouldClear = false;
if (
conversationId &&
this.notificationData.conversationId === conversationId
) {
log.info('NotificationService#removeBy: conversation ID matches');
2021-09-23 18:16:09 +00:00
shouldClear = true;
}
if (messageId && this.notificationData.messageId === messageId) {
log.info('NotificationService#removeBy: message ID matches');
2021-09-23 18:16:09 +00:00
shouldClear = true;
}
if (!shouldClear) {
return;
}
const { reaction } = this.notificationData;
if (
reaction &&
emoji &&
2023-08-16 20:54:39 +00:00
targetAuthorAci &&
2021-09-23 18:16:09 +00:00
targetTimestamp &&
(reaction.emoji !== emoji ||
2023-08-16 20:54:39 +00:00
reaction.targetAuthorAci !== targetAuthorAci ||
2021-09-23 18:16:09 +00:00
reaction.targetTimestamp !== targetTimestamp)
) {
return;
}
this.clear();
this.update();
}
private fastUpdate(): void {
const storage = this.getStorage();
const i18n = this.getI18n();
2023-08-01 16:06:29 +00:00
const { notificationData } = this;
const isAppFocused = window.SignalContext.activeWindowService.isActive();
const userSetting = this.getNotificationSetting();
2021-09-23 18:16:09 +00:00
2023-08-01 16:06:29 +00:00
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) {
drop(window.IPC.clearAllWindowsNotifications());
}
} else if (this.lastNotification) {
2021-09-23 18:16:09 +00:00
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;
2021-09-23 18:16:09 +00:00
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`
);
2021-09-23 18:16:09 +00:00
if (isAppFocused) {
this.notificationData = null;
}
return;
}
const shouldPlayNotificationSound = Boolean(
storage.get('audio-notification')
);
2021-09-23 18:16:09 +00:00
const shouldDrawAttention = storage.get(
'notification-draw-attention',
false
2021-09-23 18:16:09 +00:00
);
if (shouldDrawAttention) {
log.info('NotificationService: drawing attention');
2023-01-13 00:24:59 +00:00
window.IPC.drawAttention();
2021-09-23 18:16:09 +00:00
}
let notificationTitle: string;
let notificationMessage: string;
let notificationIconUrl: undefined | string;
2023-08-01 16:06:29 +00:00
let notificationIconAbsolutePath: undefined | string;
2021-09-23 18:16:09 +00:00
const {
conversationId,
isExpiringMessage,
message,
messageId,
2021-09-23 18:16:09 +00:00
reaction,
senderTitle,
storyId,
2023-06-14 20:55:50 +00:00
sentAt,
useTriToneSound,
wasShown,
2023-08-01 16:06:29 +00:00
type,
2021-09-23 18:16:09 +00:00
} = 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;
2023-08-01 16:06:29 +00:00
({ notificationIconUrl, notificationIconAbsolutePath } =
notificationData);
if (
isExpiringMessage &&
shouldHideExpiringMessageBody(OS, os.release())
) {
2023-03-30 00:03:25 +00:00
notificationMessage = i18n('icu:newMessage');
} else if (userSetting === NotificationSetting.NameOnly) {
if (reaction) {
2023-03-30 00:03:25 +00:00
notificationMessage = i18n('icu:notificationReaction', {
sender: senderTitle,
emoji: reaction.emoji,
});
} else {
2023-03-30 00:03:25 +00:00
notificationMessage = i18n('icu:newMessage');
}
} else if (storyId) {
notificationMessage = message;
} else if (reaction) {
2023-03-30 00:03:25 +00:00
notificationMessage = i18n('icu:notificationReactionMessage', {
2021-09-23 18:16:09 +00:00
sender: senderTitle,
emoji: reaction.emoji,
message,
2021-09-23 18:16:09 +00:00
});
} else {
notificationMessage = message;
2021-09-23 18:16:09 +00:00
}
break;
2021-09-23 18:16:09 +00:00
}
case NotificationSetting.NoNameOrMessage:
notificationTitle = FALLBACK_NOTIFICATION_TITLE;
2023-03-30 00:03:25 +00:00
notificationMessage = i18n('icu:newMessage');
break;
default:
log.error(missingCaseError(userSetting));
notificationTitle = FALLBACK_NOTIFICATION_TITLE;
2023-03-30 00:03:25 +00:00
notificationMessage = i18n('icu:newMessage');
break;
2021-09-23 18:16:09 +00:00
}
log.info('NotificationService: requesting a notification to be shown');
this.notificationData = {
...notificationData,
wasShown: true,
};
2021-09-23 18:16:09 +00:00
this.notify({
2023-08-01 16:06:29 +00:00
conversationId,
iconUrl: notificationIconUrl,
iconPath: notificationIconAbsolutePath,
messageId,
2021-09-23 18:16:09 +00:00
message: notificationMessage,
2023-06-14 20:55:50 +00:00
sentAt,
silent: !shouldPlayNotificationSound,
2023-08-01 16:06:29 +00:00
storyId,
title: notificationTitle,
2023-08-01 16:06:29 +00:00
type,
useTriToneSound,
2021-09-23 18:16:09 +00:00
});
}
public getNotificationSetting(): NotificationSetting {
return parseNotificationSetting(
this.getStorage().get('notification-setting')
);
}
public clear(): void {
log.info(
'NotificationService: clearing notification and requesting an update'
);
2021-09-23 18:16:09 +00:00
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');
2021-09-23 18:16:09 +00:00
this.notificationData = null;
this.fastUpdate();
}
public enable(): void {
log.info('NotificationService: enabling');
2021-09-23 18:16:09 +00:00
const needUpdate = !this.isEnabled;
this.isEnabled = true;
if (needUpdate) {
this.update();
}
}
public disable(): void {
log.info('NotificationService: disabling');
2021-09-23 18:16:09 +00:00
this.isEnabled = false;
}
}
export const notificationService = new NotificationService();
function filterNotificationText(text: string) {
return (text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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);
}
}