Notification improvements
This commit is contained in:
parent
04a4e6e5ff
commit
d2ef82686d
16 changed files with 408 additions and 410 deletions
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
})();
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
}
|
||||
});
|
||||
|
||||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
profileKeyResponseQueue.pause();
|
||||
lightSessionResetQueue.pause();
|
||||
window.Whisper.deliveryReceiptQueue.pause();
|
||||
window.Whisper.Notifications.disable();
|
||||
notificationService.disable();
|
||||
}
|
||||
|
||||
let initialStartupCount = 0;
|
||||
|
|
|
@ -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<void> {
|
|||
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());
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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<void> {
|
||||
// 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,
|
||||
|
|
|
@ -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<MessageAttributesType> {
|
|||
});
|
||||
|
||||
// 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<MessageAttributesType> {
|
|||
}
|
||||
|
||||
clearNotifications(reaction: Partial<ReactionType> = {}): void {
|
||||
window.Whisper.Notifications.removeBy({
|
||||
notificationService.removeBy({
|
||||
...reaction,
|
||||
messageId: this.id,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -1,6 +0,0 @@
|
|||
// Copyright 2018-2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { getStatus } from './getStatus';
|
||||
|
||||
export { getStatus };
|
|
@ -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<MessageAttributesType>,
|
||||
|
@ -27,7 +28,7 @@ function markReadOrViewed(
|
|||
);
|
||||
}
|
||||
|
||||
window.Whisper.Notifications.removeBy({ messageId });
|
||||
notificationService.removeBy({ messageId });
|
||||
|
||||
if (!skipSave) {
|
||||
window.Signal.Util.queueUpdateMessage(nextMessageAttributes);
|
||||
|
|
|
@ -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: () => {
|
||||
|
|
352
ts/services/notifications.ts
Normal file
352
ts/services/notifications.ts
Normal file
|
@ -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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
|
@ -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, '<')
|
||||
.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;
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
15
ts/window.d.ts
vendored
15
ts/window.d.ts
vendored
|
@ -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<unknown>) => 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;
|
||||
|
|
Loading…
Reference in a new issue