507 lines
14 KiB
TypeScript
507 lines
14 KiB
TypeScript
// Copyright 2015 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import os from 'os';
|
|
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';
|
|
import * as log from '../logging/log';
|
|
import { makeEnumParser } from '../util/enum';
|
|
import { missingCaseError } from '../util/missingCaseError';
|
|
import type { StorageInterface } from '../types/Storage.d';
|
|
import type { LocalizerType } from '../types/Util';
|
|
import { drop } from '../util/drop';
|
|
|
|
type NotificationDataType = Readonly<{
|
|
conversationId: string;
|
|
isExpiringMessage: boolean;
|
|
messageId: string;
|
|
message: string;
|
|
notificationIconUrl?: undefined | string;
|
|
notificationIconAbsolutePath?: undefined | string;
|
|
reaction?: {
|
|
emoji: 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 | null;
|
|
storyId: string | null;
|
|
}>;
|
|
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',
|
|
}
|
|
|
|
// 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');
|
|
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'
|
|
);
|
|
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()) {
|
|
// 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,
|
|
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) {
|
|
window.reduxActions?.calling?.setPresenting();
|
|
} else if (type === NotificationType.IncomingCall) {
|
|
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. `emoji`, `targetAuthorAci`, `targetTimestamp` matches (if present)
|
|
public removeBy({
|
|
conversationId,
|
|
messageId,
|
|
emoji,
|
|
targetAuthorAci,
|
|
targetTimestamp,
|
|
}: Readonly<{
|
|
conversationId?: string;
|
|
messageId?: string;
|
|
emoji?: string;
|
|
targetAuthorAci?: string;
|
|
targetTimestamp?: number;
|
|
}>): void {
|
|
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;
|
|
}
|
|
|
|
const { reaction } = this.notificationData;
|
|
if (
|
|
reaction &&
|
|
emoji &&
|
|
targetAuthorAci &&
|
|
targetTimestamp &&
|
|
(reaction.emoji !== emoji ||
|
|
reaction.targetAuthorAci !== targetAuthorAci ||
|
|
reaction.targetTimestamp !== targetTimestamp)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.clear();
|
|
this.update();
|
|
}
|
|
|
|
private fastUpdate(): void {
|
|
const storage = this.getStorage();
|
|
const i18n = this.getI18n();
|
|
const { notificationData } = this;
|
|
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) {
|
|
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,
|
|
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) {
|
|
if (reaction) {
|
|
notificationMessage = i18n('icu:notificationReaction', {
|
|
sender: senderTitle,
|
|
emoji: reaction.emoji,
|
|
});
|
|
} else {
|
|
notificationMessage = i18n('icu:newMessage');
|
|
}
|
|
} else if (storyId) {
|
|
notificationMessage = message;
|
|
} else if (reaction) {
|
|
notificationMessage = i18n('icu:notificationReactionMessage', {
|
|
sender: senderTitle,
|
|
emoji: reaction.emoji,
|
|
message,
|
|
});
|
|
} else {
|
|
notificationMessage = message;
|
|
}
|
|
break;
|
|
}
|
|
case NotificationSetting.NoNameOrMessage:
|
|
notificationTitle = FALLBACK_NOTIFICATION_TITLE;
|
|
notificationMessage = i18n('icu:newMessage');
|
|
break;
|
|
default:
|
|
log.error(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')
|
|
);
|
|
}
|
|
|
|
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, '<')
|
|
.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);
|
|
}
|
|
}
|