From 4d737568c3eba6400f7e2092da4fab4d73aeca97 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:20:17 -0500 Subject: [PATCH] Support unread reminders Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- _locales/en/messages.json | 60 +++ app/renderWindowsToast.std.tsx | 6 +- protos/Backups.proto | 3 + protos/SignalStorage.proto | 4 + ts/background.preload.ts | 13 + ts/components/Preferences.dom.stories.tsx | 2 + ts/components/Preferences.dom.tsx | 11 + ...ationNotificationsSettings.dom.stories.tsx | 11 + .../ConversationNotificationsSettings.dom.tsx | 35 ++ ts/model-types.d.ts | 2 + ts/models/conversations.preload.ts | 39 +- ts/services/backups/export.preload.ts | 4 + ts/services/backups/import.preload.ts | 9 + ts/services/notifications.preload.ts | 94 +++-- ts/services/storageRecordOps.preload.ts | 22 + ts/services/unreadReminders.preload.ts | 268 +++++++++++++ ts/sql/Interface.std.ts | 12 + ts/sql/Server.node.ts | 6 + ts/sql/server/unreadReminders.std.ts | 190 +++++++++ ts/state/ducks/conversations.preload.ts | 24 +- ts/state/selectors/items.dom.ts | 7 + ...versationNotificationsSettings.preload.tsx | 24 +- ts/state/smart/Preferences.preload.tsx | 21 +- .../services/notifications_test.preload.ts | 100 +++++ ts/test-helpers/generateBackup.node.ts | 3 + .../sql/server/unreadReminders_test.node.ts | 375 ++++++++++++++++++ .../util/unreadReminders_test.node.ts | 273 +++++++++++++ ts/types/StorageKeys.std.ts | 7 + ts/types/notifications.std.ts | 1 + ts/util/getConversation.preload.ts | 1 + ts/util/unreadReminders.std.ts | 258 ++++++++++++ 31 files changed, 1841 insertions(+), 44 deletions(-) create mode 100644 ts/services/unreadReminders.preload.ts create mode 100644 ts/sql/server/unreadReminders.std.ts create mode 100644 ts/test-node/sql/server/unreadReminders_test.node.ts create mode 100644 ts/test-node/util/unreadReminders_test.node.ts create mode 100644 ts/util/unreadReminders.std.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 60162b9180..b63668b757 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6974,6 +6974,66 @@ "messageformat": "Notify when someone replies to your message in muted chats.", "description": "On the global 'While muted' settings page, sub-label for the switch that allows reply notifications in muted chats" }, + "icu:UnreadReminders__title": { + "messageformat": "Unread reminders", + "description": "Label of the switch, in both global and per-chat notification settings, that occasionally re-notifies about unread messages in muted chats" + }, + "icu:UnreadReminders__description": { + "messageformat": "Occasionally notify when there are unread messages while this chat is muted.", + "description": "In conversation notification settings, sub-label for the 'Unread reminders' switch" + }, + "icu:Preferences__UnreadReminders__description": { + "messageformat": "Occasionally notify when there are unread messages in muted chats.", + "description": "In the global notification settings, sub-label for the 'Unread reminders' switch" + }, + "icu:UnreadReminders__notification--messages--1": { + "messageformat": "You have {messageCount, plural, one {# unread message} other {# unread messages}} from {person1}.", + "description": "Body of the 'unread reminder' notification for a muted chat when there are unread messages from one person" + }, + "icu:UnreadReminders__notification--messages--2": { + "messageformat": "You have {messageCount, plural, one {# unread message} other {# unread messages}} from {person1} and {person2}.", + "description": "Body of the 'unread reminder' notification for a muted chat when there are unread messages from two people" + }, + "icu:UnreadReminders__notification--messages--many": { + "messageformat": "You have {messageCount, plural, one {# unread message} other {# unread messages}} from {person1}, {person2}, and others.", + "description": "Body of the 'unread reminder' notification for a muted chat when there are unread messages from three or more people" + }, + "icu:UnreadReminders__notification--messages-including-mentions": { + "messageformat": "You have {messageCount, plural, one {# unread message} other {# unread messages}}, including {mentionSummary}.", + "description": "Body of the 'unread reminder' notification for a muted chat when some unread messages mention you. {mentionSummary} is a phrase like 'a mention of you by Alice', see: icu:UnreadReminders__notification__mentionSummary--1" + }, + "icu:UnreadReminders__notification--messages-including-replies": { + "messageformat": "You have {messageCount, plural, one {# unread message} other {# unread messages}}, including {replySummary}.", + "description": "Body of the 'unread reminder' notification for a muted chat when some unread messages are replies to you, none mention you, and there are no missed calls. {replySummary} is a phrase like 'a reply from Alice', see: icu:UnreadReminders__notification__replySummary--1" + }, + "icu:UnreadReminders__notification--counts--messages": { + "messageformat": "You have {messageCount, plural, one {# unread message} other {# unread messages}}.", + "description": "Body of the 'unread reminder' notification for a muted chat when no names may be shown" + }, + "icu:UnreadReminders__notification__mentionSummary--1": { + "messageformat": "{mentionCount, plural, one {a mention} other {# mentions}} of you by {person1}", + "description": "Body of the 'unread reminder' notification for a muted chat when one person mentioned you" + }, + "icu:UnreadReminders__notification__mentionSummary--2": { + "messageformat": "{mentionCount, plural, one {# mention} other {# mentions}} of you by {person1} and {person2}", + "description": "Body of the 'unread reminder' notification for a muted chat when two people mentioned you" + }, + "icu:UnreadReminders__notification__mentionSummary--many": { + "messageformat": "{mentionCount, plural, one {# mention} other {# mentions}} of you by {person1} and others", + "description": "Body of the 'unread reminder' notification for a muted chat when three or more people mentioned you" + }, + "icu:UnreadReminders__notification__replySummary--1": { + "messageformat": "{replyCount, plural, one {a reply} other {# replies}} from {person1}", + "description": "Body of the 'unread reminder' notification for a muted chat when one person replied to you" + }, + "icu:UnreadReminders__notification__replySummary--2": { + "messageformat": "{replyCount, plural, one {# reply} other {# replies}} from {person1} and {person2}", + "description": "Body of the 'unread reminder' notification for a muted chat when two people replied to you" + }, + "icu:UnreadReminders__notification__replySummary--many": { + "messageformat": "{replyCount, plural, one {# reply} other {# replies}} from {person1} and others", + "description": "Body of the 'unread reminder' notification for a muted chat when three or more people replied to you" + }, "icu:ConversationNotificationsSettings__mentions__label": { "messageformat": "Mentions", "description": "(Deleted 2026/09/03) In the conversation notifications settings, this is the label for the mentions option" diff --git a/app/renderWindowsToast.std.tsx b/app/renderWindowsToast.std.tsx index 52f401ae6b..8649fa01e7 100644 --- a/app/renderWindowsToast.std.tsx +++ b/app/renderWindowsToast.std.tsx @@ -65,7 +65,11 @@ export function renderWindowsToast({ // Note: // 1) this maps to the notify() function in services/notifications.ts // 2) this also maps to the url-handling in main.ts - if (type === NotificationType.Message || type === NotificationType.Reaction) { + if ( + type === NotificationType.Message || + type === NotificationType.Reaction || + type === NotificationType.UnreadReminder + ) { launch = showConversationRoute.toAppUrl({ token, }); diff --git a/protos/Backups.proto b/protos/Backups.proto index 8e7c2a25e3..1260d223e5 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -150,6 +150,8 @@ message AccountData { optional bool notifyForCallsIfMuted = 36; // If unset, consider this disabled optional bool notifyForMentionsIfMuted = 37; // If unset, consider this enabled optional bool notifyForRepliesIfMuted = 38; // If unset, consider this enabled + optional bool showUnreadReminders = 39; // If unset, consider this enabled + optional bool notifyWhenContactJoins = 40; // If unset, consider this disabled } message SubscriberData { @@ -408,6 +410,7 @@ message Chat { optional bool notifyForCallsIfMuted = 11; // If unset, use default global settings optional bool notifyForMentionsIfMuted = 12; // If unset, use default global settings. Only for groups. If [dontNotifyForMentionsIfMuted] is true, this should be initialized to false. optional bool notifyForRepliesIfMuted = 13; // If unset, use default global settings. Only for groups. + optional bool showUnreadReminders = 14; // If unset, use default global settings } /** diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 067a7d487a..d1e1540921 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -146,6 +146,7 @@ message ContactRecord { bytes pniBinary = 26; // 16-byte UUID uint64 blockedAtTimestamp = 27; // if `blocked` is true, 0 means unknown block time OptionalBool notifyForCallsIfMuted = 28; // If unset, use the default settings + OptionalBool showUnreadReminders = 29; // If unset, use the default settings // Next ID: 30 } @@ -181,6 +182,7 @@ message GroupV2Record { OptionalBool notifyForCallsIfMuted = 14; // If unset, use the default settings OptionalBool notifyForMentionsIfMuted = 15; // If unset, use the default settings. If [dontNotifyForMentionsIfMuted] is true, this should be initialized to false. OptionalBool notifyForRepliesIfMuted = 16; // If unset, use the default settings + OptionalBool showUnreadReminders = 17; // If unset, use the default settings } message Payments { @@ -324,6 +326,8 @@ message AccountRecord { OptionalBool notifyForCallsIfMuted = 56; // If unset, consider this off OptionalBool notifyForMentionsIfMuted = 57; // If unset, consider this on OptionalBool notifyForRepliesIfMuted = 58; // If unset, consider this on + OptionalBool showUnreadReminders = 59; // If unset, consider this on + OptionalBool notifyWhenContactJoins = 60; // If unset, consider this off } message StoryDistributionListRecord { diff --git a/ts/background.preload.ts b/ts/background.preload.ts index 61dd9e8384..15afe7e87c 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -56,6 +56,7 @@ import { initialize as initializeNotificationProfilesService, fastUpdate as updateNotificationProfileService, } from './services/notificationProfilesService.preload.ts'; +import { initialize as initializeUnreadReminderService } from './services/unreadReminders.preload.ts'; import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService.preload.ts'; import { senderCertificateService } from './services/senderCertificate.preload.ts'; import { @@ -1471,6 +1472,16 @@ async function startApp(): Promise { ); } + // Existing accounts should default unreadReminders to off; new installs + // leave this unset, which defaults to true + if ( + window.isBeforeVersion(lastVersion, '8.31.0-alpha') && + itemStorage.get('showUnreadReminders') == null + ) { + log.info('Defaulting unreadReminders to false for existing accounts'); + await itemStorage.put('showUnreadReminders', false); + } + if (!itemStorage.get('avatarsHaveBeenMigrated', false)) { window.ConversationController.migrateAvatarsForNonAcceptedConversations(); } @@ -2289,6 +2300,8 @@ async function startApp(): Promise { drop(initializeDonationService()); initMegaphoneCheckService(); pinReminderService.init(); + // delay unread reminders by some arbitrary amount to avoid reminding at startup + Timers.setTimeout(() => initializeUnreadReminderService(), FIVE_MINUTES); if (isFromMessageReceiver) { drop( diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index 1476d37fd0..e2701d8e7f 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -497,6 +497,7 @@ export default { hasStoriesDisabled: false, hasTextFormatting: true, hasTypingIndicators: true, + hasUnreadReminders: true, hasKeepMutedChatsArchived: false, initialSpellCheckSetting: true, isAutoDownloadUpdatesSupported: true, @@ -644,6 +645,7 @@ export default { onTypingIndicatorsChange: action('onTypingIndicatorsChange'), onUniversalExpireTimerChange: action('onUniversalExpireTimerChange'), onUnreadCountBadgeTypeChange: action('onUnreadCountBadgeTypeChange'), + onUnreadRemindersChange: action('onUnreadRemindersChange'), onWhoCanFindMeChange: action('onWhoCanFindMeChange'), onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'), onZoomFactorChange: action('onZoomFactorChange'), diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index d32b7c32bb..0e48e89230 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -181,6 +181,7 @@ export type PropsDataType = { hasStoriesDisabled: boolean; hasTextFormatting: boolean; hasTypingIndicators: boolean; + hasUnreadReminders: boolean; hasKeepMutedChatsArchived: boolean; settingsLocation: SettingsLocation; lastSyncTime?: number; @@ -387,6 +388,7 @@ type PropsFunctionType = { onTypingIndicatorsChange: CheckboxChangeHandlerType; onUniversalExpireTimerChange: SelectChangeHandlerType; onUnreadCountBadgeTypeChange: SelectChangeHandlerType; + onUnreadRemindersChange: CheckboxChangeHandlerType; onWhoCanFindMeChange: SelectChangeHandlerType; onWhoCanSeeMeChange: SelectChangeHandlerType; onZoomFactorChange: SelectChangeHandlerType; @@ -518,6 +520,7 @@ export function Preferences({ hasStoriesDisabled, hasTextFormatting, hasTypingIndicators, + hasUnreadReminders, hasKeepMutedChatsArchived, i18n, initialSpellCheckSetting, @@ -585,6 +588,7 @@ export function Preferences({ onTypingIndicatorsChange, onUniversalExpireTimerChange, onUnreadCountBadgeTypeChange, + onUnreadRemindersChange, onWhoCanFindMeChange, onWhoCanSeeMeChange, onZoomFactorChange, @@ -1704,6 +1708,13 @@ export function Preferences({ checked={hasReactionNotifications} onCheckedChange={onReactionNotificationsChange} /> + {isNotificationAttentionSupported && ( ({ i18n, onOpenWhileMutedSettings: action('onOpenWhileMutedSettings'), setMuteExpiration: action('setMuteExpiration'), + setShowUnreadReminders: action('setShowUnreadReminders'), + showUnreadReminders: true, }); export function NotMuted(): JSX.Element { @@ -67,6 +69,15 @@ export function EverythingWhileMuted(): JSX.Element { ); } +export function UnreadRemindersOff(): JSX.Element { + return ( + + ); +} + export function DirectConversation(): JSX.Element { return ( unknown; + setShowUnreadReminders: (value: boolean) => unknown; + showUnreadReminders: boolean; }; export function ConversationNotificationsSettings({ @@ -36,8 +40,11 @@ export function ConversationNotificationsSettings({ notifyWhileMuted, onOpenWhileMutedSettings, setMuteExpiration, + setShowUnreadReminders, + showUnreadReminders, }: PropsType): JSX.Element { const whileMutedLabelId = useId(); + const unreadRemindersLabel = i18n('icu:UnreadReminders__title'); const mutedUntilText = muteExpiresAt != null && isConversationMuted({ muteExpiresAt }) @@ -140,6 +147,34 @@ export function ConversationNotificationsSettings({ + + + + + + + + + {unreadRemindersLabel} + + {i18n('icu:UnreadReminders__description')} + + + + + + + + + ); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 4881759d22..caf8efa006 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -417,6 +417,8 @@ export type ConversationAttributesType = { notifyForCallsIfMuted?: boolean; notifyForMentionsIfMuted?: boolean; notifyForRepliesIfMuted?: boolean; + showUnreadReminders?: boolean; + lastUnreadReminderAt?: number; sharingPhoneNumber?: boolean; profileAvatar?: ContactAvatarType | null; profileKeyCredential?: string | null; diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index 5cd5bb663c..6a90a851b8 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -32,10 +32,7 @@ import { } from '../util/migrations.preload.ts'; import { drop } from '../util/drop.std.ts'; import { isShallowEqual } from '../util/isShallowEqual.std.ts'; -import type { - NotifyWhileMutedFields, - NotifyWhileMutedKey, -} from '../util/notifyWhileMuted.std.ts'; +import type { NotifyWhileMutedKey } from '../util/notifyWhileMuted.std.ts'; import { NOTIFY_WHILE_MUTED_FIELDS } from '../util/notifyWhileMuted.std.ts'; import { getInitials } from '../util/getInitials.std.ts'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.ts'; @@ -5773,6 +5770,7 @@ export class ConversationModel { // [X] notifyForCallsIfMuted // [X] notifyForMentionsIfMuted // [X] notifyForRepliesIfMuted + // [X] showUnreadReminders // [x] firstUnregisteredAt captureChange(logMessage: string): void { log.info('storageService[captureChange]', logMessage, this.idForLogging()); @@ -6127,9 +6125,28 @@ export class ConversationModel { this.captureChange(attributeName); } - resetNotifyWhileMuted(): void { - const attributeNames = Object.values(NOTIFY_WHILE_MUTED_FIELDS); - if (attributeNames.every(name => this.get(name) === undefined)) { + setShowUnreadReminders(newValue: boolean): void { + if (this.get('showUnreadReminders') === newValue) { + return; + } + + this.set({ showUnreadReminders: newValue }); + + drop(DataWriter.updateConversation(this.attributes)); + this.captureChange('showUnreadReminders'); + } + + private static readonly RESETTABLE_NOTIFICATION_ITEMS = [ + ...Object.values(NOTIFY_WHILE_MUTED_FIELDS), + 'showUnreadReminders', + ] as const; + + resetNotificationSettings(): void { + if ( + ConversationModel.RESETTABLE_NOTIFICATION_ITEMS.every( + name => this.get(name) === undefined + ) + ) { return; } @@ -6137,10 +6154,14 @@ export class ConversationModel { notifyForCallsIfMuted: undefined, notifyForMentionsIfMuted: undefined, notifyForRepliesIfMuted: undefined, - } satisfies Record); + showUnreadReminders: undefined, + } satisfies Record< + (typeof ConversationModel.RESETTABLE_NOTIFICATION_ITEMS)[number], + undefined + >); drop(DataWriter.updateConversation(this.attributes)); - this.captureChange('resetNotifyWhileMuted'); + this.captureChange('resetNotificationSettings'); } acknowledgeGroupMemberNameCollisions( diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index b533281d07..adc015f7d3 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -673,6 +673,7 @@ export class BackupExportStream extends Readable { ? (attributes.notifyForMentionsIfMuted ?? null) : null, notifyForRepliesIfMuted: attributes.notifyForRepliesIfMuted ?? null, + showUnreadReminders: attributes.showUnreadReminders ?? null, style: this.#toChatStyle({ wallpaperPhotoPointer: attributes.wallpaperPhotoPointerBase64 @@ -1121,6 +1122,9 @@ export class BackupExportStream extends Readable { itemStorage.get('notifyForMentionsIfMuted') ?? null, notifyForRepliesIfMuted: itemStorage.get('notifyForRepliesIfMuted') ?? null, + notifyWhenContactJoins: + itemStorage.get('notifyWhenContactJoins') ?? null, + showUnreadReminders: itemStorage.get('showUnreadReminders') ?? null, unreadBadgeType, hasSetMyStoriesPrivacy: itemStorage.get('hasSetMyStoriesPrivacy') ?? null, diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index f697bdd3fe..30d350bdae 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -907,6 +907,14 @@ export class BackupImportStream extends Writable { 'notifyForRepliesIfMuted', accountSettings?.notifyForRepliesIfMuted ?? undefined ); + await itemStorage.put( + 'notifyWhenContactJoins', + accountSettings?.notifyWhenContactJoins ?? undefined + ); + await itemStorage.put( + 'showUnreadReminders', + accountSettings?.showUnreadReminders ?? undefined + ); let unreadCountBadgeType: UnreadCountBadgeType; switch (accountSettings?.unreadBadgeType) { @@ -1608,6 +1616,7 @@ export class BackupImportStream extends Writable { conversation.notifyForRepliesIfMuted = dropNull( chat.notifyForRepliesIfMuted ); + conversation.showUnreadReminders = dropNull(chat.showUnreadReminders); const chatStyle = this.#fromChatStyle(chat.style); diff --git a/ts/services/notifications.preload.ts b/ts/services/notifications.preload.ts index b2ee02b279..424e833742 100644 --- a/ts/services/notifications.preload.ts +++ b/ts/services/notifications.preload.ts @@ -19,33 +19,46 @@ import type { LocalizerType } from '../types/Util.std.ts'; import { NotificationType } from '../types/notifications.std.ts'; import { drop } from '../util/drop.std.ts'; import type { Emoji } from '../axo/emoji.std.ts'; +import { + getUnreadReminderNotificationContent, + type UnreadReminderSummary, +} from '../util/unreadReminders.std.ts'; const { debounce } = lodash; const log = createLogger('notifications'); -type QueuedNotificationData = Readonly<{ - type: NotificationType.Message | NotificationType.Reaction; - conversationId: string; - isExpiringMessage: boolean; - messageId: string; - message: string; - iconUrl: string | null; - iconAbsolutePath: string | null; - reaction?: { - emoji: Emoji.Variant; - targetAuthorAci: string; - targetTimestamp: number; - }; - pollVote?: { - voterConversationId: string; - targetAuthorAci: string; - targetTimestamp: number; - }; - senderTitle: string; - sentAt: number; - storyId?: string; -}>; +type QueuedNotificationData = Readonly< + | { + type: NotificationType.Message | NotificationType.Reaction; + conversationId: string; + isExpiringMessage: boolean; + messageId: string; + message: string; + iconUrl: string | null; + iconAbsolutePath: string | null; + reaction?: { + emoji: Emoji.Variant; + targetAuthorAci: string; + targetTimestamp: number; + }; + pollVote?: { + voterConversationId: string; + targetAuthorAci: string; + targetTimestamp: number; + }; + senderTitle: string; + sentAt: number; + storyId?: string; + } + | { + type: NotificationType.UnreadReminder; + conversationId: string; + summary: UnreadReminderSummary; + iconUrl: string | null; + iconAbsolutePath: string | null; + } +>; export type ProcessedNotificationData = Readonly< { @@ -128,6 +141,7 @@ function getSoundTypeFor(type: NotificationType): SoundType { switch (type) { case NotificationType.Message: case NotificationType.Reaction: + case NotificationType.UnreadReminder: return SoundType.Pop; case NotificationType.IncomingCall: case NotificationType.IncomingGroupCall: @@ -273,7 +287,8 @@ export class NotificationService extends EventEmitter { // Note: this maps to the xmlTemplate() function in app/WindowsNotifications.ts if ( type === NotificationType.Message || - type === NotificationType.Reaction + type === NotificationType.Reaction || + type === NotificationType.UnreadReminder ) { window.IPC.showWindow(); window.Events.showConversationViaNotification({ @@ -361,7 +376,8 @@ export class NotificationService extends EventEmitter { ): boolean { if ( data.type !== NotificationType.Message && - data.type !== NotificationType.Reaction + data.type !== NotificationType.Reaction && + data.type !== NotificationType.UnreadReminder ) { return false; } @@ -377,7 +393,8 @@ export class NotificationService extends EventEmitter { const matchesConversationId = conversationId != null && data.conversationId === conversationId; - const matchesMessageId = messageId != null && data.messageId === messageId; + const matchesMessageId = + messageId != null && 'messageId' in data && data.messageId === messageId; if (!matchesConversationId && !matchesMessageId) { return false; @@ -494,6 +511,33 @@ export class NotificationService extends EventEmitter { silent: !shouldPlayNotificationSound, }); } + case NotificationType.UnreadReminder: { + const contentSetting = + userSetting === NotificationSetting.NameAndMessage || + userSetting === NotificationSetting.NameOnly + ? 'full' + : 'countsOnly'; + const content = getUnreadReminderNotificationContent({ + summary: queuedNotificationData.summary, + contentSetting, + fallbackTitle: FALLBACK_NOTIFICATION_TITLE, + i18n, + }); + + return this.rawNotify({ + conversationId, + type, + title: content.title, + body: content.body, + silent: !shouldPlayNotificationSound, + iconUrl: + contentSetting === 'full' ? queuedNotificationData.iconUrl : null, + iconAbsolutePath: + contentSetting === 'full' + ? queuedNotificationData.iconAbsolutePath + : null, + }); + } default: throw missingCaseError(type); } diff --git a/ts/services/storageRecordOps.preload.ts b/ts/services/storageRecordOps.preload.ts index f1e068ee69..924b6441d4 100644 --- a/ts/services/storageRecordOps.preload.ts +++ b/ts/services/storageRecordOps.preload.ts @@ -406,6 +406,9 @@ export async function toContactRecord( notifyForCallsIfMuted: toOptionalBool( conversation.get('notifyForCallsIfMuted') ), + showUnreadReminders: toOptionalBool( + conversation.get('showUnreadReminders') + ), avatarColor: conversation.get('colorFromPrimary') ?? null, hideStory: hideStory ?? null, unregisteredAtTimestamp: getSafeLongFromTimestamp( @@ -668,6 +671,10 @@ export function toAccountRecord({ notifyForRepliesIfMuted: toOptionalBool( itemStorage.get('notifyForRepliesIfMuted') ), + notifyWhenContactJoins: toOptionalBool( + itemStorage.get('notifyWhenContactJoins') + ), + showUnreadReminders: toOptionalBool(itemStorage.get('showUnreadReminders')), hasSetMyStoriesPrivacy: itemStorage.get('hasSetMyStoriesPrivacy') ?? null, hasViewedOnboardingStory: @@ -773,6 +780,9 @@ export function toGroupV2Record( notifyForRepliesIfMuted: toOptionalBool( conversation.get('notifyForRepliesIfMuted') ), + showUnreadReminders: toOptionalBool( + conversation.get('showUnreadReminders') + ), hideStory: Boolean(conversation.get('hideStory')), avatarColor: avatarColor ?? null, storySendMode, @@ -1396,6 +1406,7 @@ export async function mergeGroupV2Record( notifyForRepliesIfMuted: fromOptionalBool( groupV2Record.notifyForRepliesIfMuted ), + showUnreadReminders: fromOptionalBool(groupV2Record.showUnreadReminders), storageID, storageVersion, storySendMode, @@ -1642,6 +1653,7 @@ export async function mergeContactRecord( notifyForCallsIfMuted: fromOptionalBool( contactRecord.notifyForCallsIfMuted ), + showUnreadReminders: fromOptionalBool(contactRecord.showUnreadReminders), storageID, storageVersion, needsStorageServiceSync: false, @@ -1720,6 +1732,8 @@ export async function mergeAccountRecord( notifyForCallsIfMuted, notifyForMentionsIfMuted, notifyForRepliesIfMuted, + notifyWhenContactJoins, + showUnreadReminders, unreadBadgeType, hasCompletedUsernameOnboarding, hasSeenGroupStoryEducationSheet, @@ -2013,6 +2027,14 @@ export async function mergeAccountRecord( 'notifyForRepliesIfMuted', fromOptionalBool(notifyForRepliesIfMuted) ?? undefined ); + await itemStorage.put( + 'notifyWhenContactJoins', + fromOptionalBool(notifyWhenContactJoins) ?? undefined + ); + await itemStorage.put( + 'showUnreadReminders', + fromOptionalBool(showUnreadReminders) ?? undefined + ); { let unreadCountBadgeType: UnreadCountBadgeType; switch (unreadBadgeType) { diff --git a/ts/services/unreadReminders.preload.ts b/ts/services/unreadReminders.preload.ts new file mode 100644 index 0000000000..57eca0b9ea --- /dev/null +++ b/ts/services/unreadReminders.preload.ts @@ -0,0 +1,268 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createLogger } from '../logging/log.std.ts'; +import type { ConversationModel } from '../models/conversations.preload.ts'; +import { DataReader, DataWriter } from '../sql/Client.preload.ts'; +import { getActiveProfile } from '../state/selectors/notificationProfiles.dom.ts'; +import { itemStorage } from '../textsecure/Storage.preload.ts'; +import * as Errors from '../types/errors.std.ts'; +import { shouldNotify as shouldNotifyDuringNotificationProfile } from '../types/NotificationProfile.std.ts'; +import { NotificationType } from '../types/notifications.std.ts'; +import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.ts'; +import { drop } from '../util/drop.std.ts'; +import { getTitle } from '../util/getTitle.preload.ts'; +import { isConversationMuted } from '../util/isConversationMuted.std.ts'; +import { getNotifyWhileMutedForConversation } from '../util/notifyWhileMuted.preload.ts'; +import * as Registration from '../util/registration.preload.ts'; +import { safeSetTimeout } from '../util/timeout.std.ts'; +import { + filterForConversationsDueReminder, + getShowUnreadRemindersForConversation, + UNREAD_REMINDER_MIN_INTERVAL_BETWEEN_REMINDERS, + UNREAD_REMINDER_POLL_INTERVAL, + type UnreadReminderParticipant, + type UnreadReminderSummary, +} from '../util/unreadReminders.std.ts'; +import { isGroup, isMe } from '../util/whatTypeOfConversation.dom.ts'; +import { notificationService } from './notifications.preload.ts'; +import { MAX_SAFE_DATE } from '../util/timestamp.std.ts'; + +const log = createLogger('unreadReminders'); + +function includeStoryReplies(conversation: ConversationModel): boolean { + return !isGroup(conversation.attributes); +} + +class UnreadReminderService { + #timeout: NodeJS.Timeout | null = null; + #isInitialized = false; + + init(): void { + if (this.#isInitialized) { + log.warn('init: already initialized'); + return; + } + this.#isInitialized = true; + + if (itemStorage.get('unreadRemindersEnabledAt') == null) { + const now = Date.now(); + log.info(`init: marking unread reminders enabled at ${now}`); + drop(itemStorage.put('unreadRemindersEnabledAt', now)); + } + + notificationService.onShown(data => { + if (data.type !== NotificationType.UnreadReminder) { + return; + } + this.#markReminded(data.conversationId); + }); + + drop(this.#refresh()); + } + + #markReminded(conversationId: string): void { + const conversation = window.ConversationController.get(conversationId); + if (conversation == null) { + return; + } + + conversation.set({ lastUnreadReminderAt: Date.now() }); + drop(DataWriter.updateConversation(conversation.attributes)); + } + + async #refresh(): Promise { + clearTimeoutIfNecessary(this.#timeout); + this.#timeout = null; + + try { + await this.#doRefresh(); + } catch (error) { + log.error('Refresh failed:', Errors.toLogFormat(error)); + } finally { + this.#timeout = safeSetTimeout( + () => drop(this.#refresh()), + UNREAD_REMINDER_POLL_INTERVAL + ); + } + } + + async #doRefresh() { + if (!Registration.isDone() || !notificationService.isEnabled) { + return; + } + + const eligible = window.ConversationController.getAll().filter( + conversation => this.#isEligible(conversation) + ); + + if (eligible.length === 0) { + return; + } + + const candidates = await this.#getUnreadTimeRanges(eligible); + const conversationIds = filterForConversationsDueReminder( + candidates, + Date.now() + ); + // We only show one notification at a time, so we only queue a single chat. + for (const conversationId of conversationIds) { + // oxlint-disable-next-line no-await-in-loop + if (await this.#maybeNotify(conversationId)) { + return; + } + } + } + + #isEligible(conversation: ConversationModel): boolean { + const attributes = conversation.attributes; + + if ( + isMe(attributes) || + attributes.left || + attributes.removalStage != null || + conversation.isBlocked() + ) { + return false; + } + + if (attributes.isArchived) { + return false; + } + + if (!isConversationMuted(attributes)) { + return false; + } + + if (!getShowUnreadRemindersForConversation(attributes, itemStorage)) { + return false; + } + + if ( + (conversation.attributes.lastUnreadReminderAt ?? 0) > + Date.now() - UNREAD_REMINDER_MIN_INTERVAL_BETWEEN_REMINDERS + ) { + return false; + } + + return (attributes.unreadCount ?? 0) > 0; + } + + async #getUnreadTimeRanges(conversations: ReadonlyArray) { + return DataReader.getUnremindedUnreadMessageTimeRanges( + conversations.map(conversation => ({ + conversationId: conversation.id, + lastRemindedAt: + conversation.attributes.lastUnreadReminderAt ?? + // only show unread reminders for conversations with unread messages received + // after feature launch + itemStorage.get('unreadRemindersEnabledAt', MAX_SAFE_DATE), + includeStoryReplies: includeStoryReplies(conversation), + })) + ); + } + + async #maybeNotify(conversationId: string): Promise { + const conversation = window.ConversationController.get(conversationId); + if (conversation == null) { + return false; + } + + if (!this.#isEligible(conversation)) { + return false; + } + + const summary = await this.#getSummary(conversation); + if (summary == null) { + return false; + } + + if ( + !shouldNotifyDuringNotificationProfile({ + activeProfile: getActiveProfile(window.reduxStore.getState()), + conversationId, + isCall: false, + isMentionOrReply: false, + }) + ) { + log.info('maybeNotify: suppressed by the active notification profile'); + return false; + } + + const { url, absolutePath } = await conversation.getAvatarOrIdenticon(); + + notificationService.add({ + conversationId, + type: NotificationType.UnreadReminder, + summary, + iconUrl: url ?? null, + iconAbsolutePath: absolutePath ?? null, + }); + + return true; + } + + async #getSummary( + conversation: ConversationModel + ): Promise { + const ourAci = itemStorage.user.getAci(); + if (ourAci == null) { + return undefined; + } + + const data = await DataReader.getUnreadReminderSummaryData( + conversation.id, + { + includeStoryReplies: includeStoryReplies(conversation), + ourAci, + } + ); + + if (data.unreadMessageCount <= 0) { + return undefined; + } + + const notifyWhileMuted = getNotifyWhileMutedForConversation( + conversation.attributes + ); + + const toParticipants = (rows: ReadonlyArray<{ sourceServiceId: string }>) => + rows.map(row => this.#toParticipant(row.sourceServiceId)); + + return { + conversationId: conversation.id, + conversationTitle: conversation.getTitle(), + unreadMessageCount: data.unreadMessageCount, + mentionCount: notifyWhileMuted.mentions ? data.mentionCount : 0, + replyCount: notifyWhileMuted.replies ? data.replyCount : 0, + senders: toParticipants(data.senders), + mentioners: toParticipants(data.mentioners), + repliers: toParticipants(data.repliers), + }; + } + + #toParticipant(serviceId: string): UnreadReminderParticipant { + const conversation = window.ConversationController.get(serviceId); + + if (!conversation) { + log.warn('Cannot find conversation for chat participant'); + } + + return { + title: + conversation != null + ? getTitle(conversation.attributes, { isShort: true }) + : window.SignalContext.i18n('icu:unknownContact'), + }; + } +} + +let instance: UnreadReminderService | undefined; + +export function initialize(): void { + if (instance) { + return; + } + instance = new UnreadReminderService(); + instance.init(); +} diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index 78e2568ea5..f3d49d0579 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -55,6 +55,11 @@ import type { GroupSendMemberEndorsementRecord, } from '../types/GroupSendEndorsements.std.ts'; import type { SyncTaskType } from '../util/syncTasks.preload.ts'; +import type { + UnreadMessageTimeRange, + UnreadReminderConversationCandidate, + UnreadReminderSummaryData, +} from './server/unreadReminders.std.ts'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup.std.ts'; import type { AttachmentType } from '../types/Attachment.std.ts'; import type { MediaItemMessageType } from '../types/MediaItem.std.ts'; @@ -928,6 +933,13 @@ type ReadableInterface = { includeStoryReplies: boolean; } ) => number; + getUnremindedUnreadMessageTimeRanges: ( + conversations: ReadonlyArray + ) => ReadonlyArray; + getUnreadReminderSummaryData: ( + conversationId: string, + options: { includeStoryReplies: boolean; ourAci: AciString } + ) => UnreadReminderSummaryData; getTotalUnreadMentionsOfMeForConversation: ( conversationId: string, options: { diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 7946639b2a..1c411a536d 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -295,6 +295,10 @@ import { setKTAccountData, removeAllKTAccountData, } from './server/keyTransparency.std.ts'; +import { + getUnremindedUnreadMessageTimeRanges, + getUnreadReminderSummaryData, +} from './server/unreadReminders.std.ts'; import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer.std.ts'; import type { GifType } from '../components/fun/panels/FunPanelGifs.dom.tsx'; import type { NotificationProfileType } from '../types/NotificationProfile.std.ts'; @@ -490,6 +494,8 @@ export const DataReader: ServerReadableInterface = { getOldestUnreadMentionOfMeForConversation, getTotalUnreadForConversation, getTotalUnreadMentionsOfMeForConversation, + getUnremindedUnreadMessageTimeRanges, + getUnreadReminderSummaryData, getMessageMetricsForConversation, getConversationRangeCenteredOnMessage, getConversationMessageStats, diff --git a/ts/sql/server/unreadReminders.std.ts b/ts/sql/server/unreadReminders.std.ts new file mode 100644 index 0000000000..1660a8f4dd --- /dev/null +++ b/ts/sql/server/unreadReminders.std.ts @@ -0,0 +1,190 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ReadStatus } from '../../messages/MessageReadStatus.std.ts'; +import type { AciString } from '../../types/ServiceId.std.ts'; +import type { ReadableDB } from '../Interface.std.ts'; +import type { QueryFragment, QueryTemplate } from '../util.std.ts'; +import { + batchMultiVarQuery, + sql, + sqlConstant, + sqlFragment, + sqlJoin, +} from '../util.std.ts'; + +const MAX_SENDERS_TO_SHOW = 5; + +function storyIdPredicate(includeStoryReplies: boolean): QueryFragment { + return includeStoryReplies + ? sqlFragment`NULL IS NULL` + : sqlFragment`storyId IS NULL`; +} + +export type UnreadReminderConversationCandidate = Readonly<{ + conversationId: string; + lastRemindedAt: number | null; + includeStoryReplies: boolean; +}>; + +export type UnreadMessageTimeRange = Readonly<{ + conversationId: string; + oldestUnremindedReceivedAtMs: number; + newestUnreadReceivedAtMs: number; +}>; + +function cutoffsCte( + batch: ReadonlyArray +): QueryFragment { + return sqlJoin( + batch.map( + ({ conversationId, lastRemindedAt, includeStoryReplies }) => + sqlFragment`(${conversationId}, ${lastRemindedAt}, ${includeStoryReplies ? 1 : 0})` + ) + ); +} + +export function getUnremindedUnreadMessageTimeRangesQuery( + batch: ReadonlyArray +): QueryTemplate { + return sql` + WITH cutoffs(conversationId, lastRemindedAt, includeStoryReplies) AS ( + VALUES ${cutoffsCte(batch)} + ) + SELECT + messages.conversationId AS conversationId, + MIN(messages.received_at_ms) AS oldestUnremindedReceivedAtMs, + MAX(messages.received_at_ms) AS newestUnreadReceivedAtMs + FROM messages + INNER JOIN cutoffs ON cutoffs.conversationId IS messages.conversationId + WHERE + messages.readStatus = ${sqlConstant(ReadStatus.Unread)} AND + messages.isStory IS 0 AND + (cutoffs.includeStoryReplies IS 1 OR messages.storyId IS NULL) AND + messages.received_at_ms > IFNULL(cutoffs.lastRemindedAt, 0) + GROUP BY messages.conversationId + `; +} + +export function getUnremindedUnreadMessageTimeRanges( + db: ReadableDB, + conversations: ReadonlyArray +): ReadonlyArray { + return batchMultiVarQuery(db, conversations, (batch, persistent) => { + const [query, params] = getUnremindedUnreadMessageTimeRangesQuery(batch); + + return db + .prepare(query, { persistent }) + .all(params); + }); +} + +export type UnreadReminderSummaryData = Readonly<{ + unreadMessageCount: number; + mentionCount: number; + replyCount: number; + senders: ReadonlyArray<{ sourceServiceId: string }>; + mentioners: ReadonlyArray<{ sourceServiceId: string }>; + repliers: ReadonlyArray<{ sourceServiceId: string }>; +}>; + +export type UnreadReminderSummaryOptions = Readonly<{ + includeStoryReplies: boolean; + ourAci: AciString; +}>; + +export type UnreadReminderFilter = 'all' | 'mentions' | 'replies'; + +function unreadPredicate( + conversationId: string, + filter: UnreadReminderFilter, + { includeStoryReplies, ourAci }: UnreadReminderSummaryOptions +): QueryFragment { + let filterPredicate: QueryFragment; + if (filter === 'mentions') { + filterPredicate = sqlFragment`mentionsMe IS 1`; + } else if (filter === 'replies') { + filterPredicate = sqlFragment`json ->> '$.quote.authorAci' IS ${ourAci}`; + } else { + filterPredicate = sqlFragment`TRUE`; + } + + return sqlFragment` + conversationId = ${conversationId} AND + readStatus = ${sqlConstant(ReadStatus.Unread)} AND + isStory IS 0 AND + (${storyIdPredicate(includeStoryReplies)}) AND + (${filterPredicate}) + `; +} + +export function getUnreadReminderCountQuery( + conversationId: string, + filter: UnreadReminderFilter, + options: UnreadReminderSummaryOptions +): QueryTemplate { + return sql` + SELECT count(1) FROM messages + WHERE ${unreadPredicate(conversationId, filter, options)} + `; +} + +export function getUnreadReminderSendersQuery( + conversationId: string, + filter: UnreadReminderFilter, + options: UnreadReminderSummaryOptions +): QueryTemplate { + return sql` + SELECT + sourceServiceId, + MAX(received_at_ms) AS latestReceivedAtMs + FROM messages + WHERE + sourceServiceId IS NOT NULL AND + ${unreadPredicate(conversationId, filter, options)} + GROUP BY sourceServiceId + ORDER BY latestReceivedAtMs DESC + LIMIT ${MAX_SENDERS_TO_SHOW} + `; +} + +export function getUnreadReminderSummaryData( + db: ReadableDB, + conversationId: string, + options: UnreadReminderSummaryOptions +): UnreadReminderSummaryData { + return db.transaction(() => { + const countUnread = (filter: UnreadReminderFilter) => { + const [query, params] = getUnreadReminderCountQuery( + conversationId, + filter, + options + ); + return db.prepare(query, { pluck: true }).get(params) ?? 0; + }; + + const getSenders = (filter: UnreadReminderFilter) => { + const [query, params] = getUnreadReminderSendersQuery( + conversationId, + filter, + options + ); + return db.prepare(query).all<{ + sourceServiceId: string; + }>(params); + }; + + const unreadMessageCount = countUnread('all'); + const mentionCount = countUnread('mentions'); + const replyCount = countUnread('replies'); + + return { + unreadMessageCount, + mentionCount, + replyCount, + senders: getSenders('all'), + mentioners: mentionCount > 0 ? getSenders('mentions') : [], + repliers: replyCount > 0 ? getSenders('replies') : [], + }; + })(); +} diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index 9913fe4d5e..464712f39a 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -416,6 +416,7 @@ export type ConversationType = ReadonlyDeep< notifyForCallsIfMuted?: boolean; notifyForMentionsIfMuted?: boolean; notifyForRepliesIfMuted?: boolean; + showUnreadReminders?: boolean; isMe: boolean; lastUpdated?: number; // This is used by the CompositionInput for @mentions @@ -1227,7 +1228,7 @@ export const actions = { repairOldestMessage, replaceAvatar, resetAllChatColors, - resetAllNotifyWhileMuted, + resetAllNotificationSettings, copyMessageText, retryDeleteForEveryone, retryMessageSend, @@ -1262,6 +1263,7 @@ export const actions = { setMessageToEdit, setMuteExpiration, setNotifyWhileMuted, + setShowUnreadReminders, setChatFolderMuteExpiration, setPinned, setPreJoinConversation, @@ -1784,6 +1786,20 @@ function setNotifyWhileMuted( return noopAction('setNotifyWhileMuted'); } +function setShowUnreadReminders( + conversationId: string, + newValue: boolean +): NoopActionType { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error('setShowUnreadReminders: No conversation found'); + } + + conversation.setShowUnreadReminders(newValue); + + return noopAction('setShowUnreadReminders'); +} + function setChatFolderMuteExpiration( chatFolderId: ChatFolderId, muteExpiration: MuteExpiration @@ -2397,12 +2413,12 @@ function resetAllChatColors(): ThunkAction< }; } -function resetAllNotifyWhileMuted(): NoopActionType { +function resetAllNotificationSettings(): NoopActionType { for (const conversation of window.ConversationController.getAll()) { - conversation.resetNotifyWhileMuted(); + conversation.resetNotificationSettings(); } - return noopAction('resetAllNotifyWhileMuted'); + return noopAction('resetAllNotificationSettings'); } function kickOffAttachmentDownload( diff --git a/ts/state/selectors/items.dom.ts b/ts/state/selectors/items.dom.ts index a8d34f6718..bbe928b51e 100644 --- a/ts/state/selectors/items.dom.ts +++ b/ts/state/selectors/items.dom.ts @@ -25,6 +25,7 @@ import { isNotUpdatable } from '../../util/version.std.ts'; import { BackupLevel } from '../../services/backups/types.std.ts'; import type { StateSelector } from '../types.std.ts'; import { Emoji } from '../../axo/emoji.std.ts'; +import { DEFAULT_SHOW_UNREAD_REMINDERS } from '../../util/unreadReminders.std.ts'; const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320; @@ -231,6 +232,12 @@ export const getGlobalNotifyWhileMuted = createSelector( }) ); +export const getGlobalShowUnreadReminders = createSelector( + getItems, + (state: ItemsStateType): boolean => + state.showUnreadReminders ?? DEFAULT_SHOW_UNREAD_REMINDERS +); + export const getUnreadCountBadgeType = createSelector( getItems, (state: ItemsStateType): UnreadCountBadgeType => { diff --git a/ts/state/smart/ConversationNotificationsSettings.preload.tsx b/ts/state/smart/ConversationNotificationsSettings.preload.tsx index 792f6a2c99..34b75def9c 100644 --- a/ts/state/smart/ConversationNotificationsSettings.preload.tsx +++ b/ts/state/smart/ConversationNotificationsSettings.preload.tsx @@ -11,7 +11,11 @@ import { useConversationsActions } from '../ducks/conversations.preload.ts'; import { useNavActions } from '../ducks/nav.std.ts'; import { PanelType } from '../../types/Panels.std.ts'; import { getNotifyWhileMuted } from '../../util/notifyWhileMuted.std.ts'; -import { getGlobalNotifyWhileMuted } from '../selectors/items.dom.ts'; +import { + getGlobalNotifyWhileMuted, + getGlobalShowUnreadReminders, +} from '../selectors/items.dom.ts'; +import { getShowUnreadReminders } from '../../util/unreadReminders.std.ts'; export type SmartConversationNotificationsSettingsProps = { conversationId: string; @@ -23,9 +27,11 @@ export const SmartConversationNotificationsSettings = memo( }: SmartConversationNotificationsSettingsProps) { const i18n = useSelector(getIntl); const conversationSelector = useSelector(getConversationByIdSelector); - const { setMuteExpiration } = useConversationsActions(); + const { setMuteExpiration, setShowUnreadReminders } = + useConversationsActions(); const { pushPanelForConversation } = useNavActions(); const globalNotifyWhileMuted = useSelector(getGlobalNotifyWhileMuted); + const globalShowUnreadReminders = useSelector(getGlobalShowUnreadReminders); const conversation = conversationSelector(conversationId); strictAssert(conversation, 'Expected a conversation to be found'); const { muteExpiresAt, type: conversationType } = conversation; @@ -35,6 +41,18 @@ export const SmartConversationNotificationsSettings = memo( [conversation, globalNotifyWhileMuted] ); + const showUnreadReminders = getShowUnreadReminders( + conversation, + globalShowUnreadReminders + ); + + const handleSetShowUnreadReminders = useCallback( + (value: boolean) => { + setShowUnreadReminders(conversationId, value); + }, + [conversationId, setShowUnreadReminders] + ); + const handleOpenWhileMutedSettings = useCallback(() => { pushPanelForConversation({ type: PanelType.WhileMuted }); }, [pushPanelForConversation]); @@ -48,6 +66,8 @@ export const SmartConversationNotificationsSettings = memo( notifyWhileMuted={notifyWhileMuted} onOpenWhileMutedSettings={handleOpenWhileMutedSettings} setMuteExpiration={setMuteExpiration} + setShowUnreadReminders={handleSetShowUnreadReminders} + showUnreadReminders={showUnreadReminders} /> ); } diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index 48e9697c93..94f88038fe 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -19,6 +19,7 @@ import { getBackupKey, getCustomColors, getGlobalNotifyWhileMuted, + getGlobalShowUnreadReminders, getItems, getNavTabsCollapsed, getPreferredLeftPaneWidth, @@ -131,6 +132,11 @@ import type { BlockedConversation } from '../../components/Preferences.dom.tsx'; import { pinReminderService } from '../../services/pinReminder.preload.ts'; import { useGlobalModalActions } from '../ducks/globalModals.preload.ts'; +const RESETTABLE_GLOBAL_NOTIFICATION_ITEMS = [ + ...Object.values(NOTIFY_WHILE_MUTED_FIELDS), + 'showUnreadReminders', +] as const; + function renderUpdateDialog( props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> ): JSX.Element { @@ -233,7 +239,7 @@ export function SmartPreferences(): JSX.Element | null { const { removeCustomColorOnConversations, resetAllChatColors, - resetAllNotifyWhileMuted: resetAllPerChatNotifyWhileMutedSettings, + resetAllNotificationSettings: resetAllPerChatNotificationSettings, } = useConversationsActions(); const { startUpdate } = useUpdatesActions(); const { changeLocation } = useNavActions(); @@ -819,6 +825,13 @@ export function SmartPreferences(): JSX.Element | null { account.captureChange(itemKey); }; + const hasUnreadReminders = useSelector(getGlobalShowUnreadReminders); + const onUnreadRemindersChange = (value: boolean) => { + putItem('showUnreadReminders', value); + const account = window.ConversationController.getOurConversationOrThrow(); + account.captureChange('showUnreadReminders'); + }; + const onResetNotificationSettings = () => { // Reset global settings onNotificationContentChange(STORAGE_KEY_DEFAULTS['notification-setting']); @@ -836,7 +849,7 @@ export function SmartPreferences(): JSX.Element | null { ); const account = window.ConversationController.getOurConversationOrThrow(); - for (const itemKey of Object.values(NOTIFY_WHILE_MUTED_FIELDS)) { + for (const itemKey of RESETTABLE_GLOBAL_NOTIFICATION_ITEMS) { if (itemStorage.get(itemKey) === STORAGE_KEY_DEFAULTS[itemKey]) { continue; } @@ -845,7 +858,7 @@ export function SmartPreferences(): JSX.Element | null { } // Reset per-chat settings - resetAllPerChatNotifyWhileMutedSettings(); + resetAllPerChatNotificationSettings(); }; const [hasPinReminders, onPinRemindersChange] = createItemsAccess( @@ -1091,6 +1104,7 @@ export function SmartPreferences(): JSX.Element | null { hasStoriesDisabled={hasStoriesDisabled} hasTextFormatting={hasTextFormatting} hasTypingIndicators={hasTypingIndicators} + hasUnreadReminders={hasUnreadReminders} i18n={i18n} initialSpellCheckSetting={initialSpellCheckSetting} isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported} @@ -1165,6 +1179,7 @@ export function SmartPreferences(): JSX.Element | null { onTypingIndicatorsChange={onTypingIndicatorsChange} onUniversalExpireTimerChange={onUniversalExpireTimerChange} onUnreadCountBadgeTypeChange={onUnreadCountBadgeTypeChange} + onUnreadRemindersChange={onUnreadRemindersChange} onWhoCanFindMeChange={onWhoCanFindMeChange} onWhoCanSeeMeChange={onWhoCanSeeMeChange} onZoomFactorChange={onZoomFactorChange} diff --git a/ts/test-electron/services/notifications_test.preload.ts b/ts/test-electron/services/notifications_test.preload.ts index 9685bb9532..eb2f7b8b9f 100644 --- a/ts/test-electron/services/notifications_test.preload.ts +++ b/ts/test-electron/services/notifications_test.preload.ts @@ -14,6 +14,7 @@ import { itemStorage } from '../../textsecure/Storage.preload.ts'; import { NotificationType } from '../../types/notifications.std.ts'; import OS from '../../util/os/osMain.node.ts'; import { Sound } from '../../util/Sound.std.ts'; +import type { UnreadReminderSummary } from '../../util/unreadReminders.std.ts'; const DEBOUNCE_INTERVAL = 1000; @@ -349,6 +350,105 @@ describe('NotificationService', () => { }); }); + describe('unread reminders', () => { + function queueReminder(): void { + const summary: UnreadReminderSummary = { + conversationId: 'chat', + conversationTitle: 'Friends', + unreadMessageCount: 7, + mentionCount: 0, + replyCount: 0, + senders: [{ title: 'Alice' }], + mentioners: [], + repliers: [], + }; + service.add({ + type: NotificationType.UnreadReminder, + conversationId: 'chat', + summary, + iconUrl: 'avatar-url', + iconAbsolutePath: 'avatar-path', + }); + } + it(`includes names and avatar with the NameAndMessage setting`, () => { + getNotificationSetting.returns(NotificationSetting.NameAndMessage); + const shown = sandbox.stub(); + service.onShown(shown); + queueReminder(); + clock.tick(DEBOUNCE_INTERVAL); + + sinon.assert.calledOnce(showNotification); + sinon.assert.calledOnceWithMatch(shown, { + type: NotificationType.UnreadReminder, + conversationId: 'chat', + title: 'Friends', + body: window.SignalContext.i18n( + 'icu:UnreadReminders__notification--messages--1', + { messageCount: 7, person1: 'Alice' } + ), + iconUrl: 'avatar-url', + iconAbsolutePath: 'avatar-path', + }); + }); + + it(`includes names and avatar with the NameOnly setting`, () => { + getNotificationSetting.returns(NotificationSetting.NameOnly); + const shown = sandbox.stub(); + service.onShown(shown); + queueReminder(); + clock.tick(DEBOUNCE_INTERVAL); + sinon.assert.calledOnceWithMatch(shown, { + type: NotificationType.UnreadReminder, + conversationId: 'chat', + title: 'Friends', + body: window.SignalContext.i18n( + 'icu:UnreadReminders__notification--messages--1', + { messageCount: 7, person1: 'Alice' } + ), + iconUrl: 'avatar-url', + iconAbsolutePath: 'avatar-path', + }); + }); + + it('redacts names and avatars with the NoNameOrMessage setting', () => { + getNotificationSetting.returns(NotificationSetting.NoNameOrMessage); + const shown = sandbox.stub(); + service.onShown(shown); + queueReminder(); + clock.tick(DEBOUNCE_INTERVAL); + + sinon.assert.calledOnceWithMatch(shown, { + title: 'Signal', + body: window.SignalContext.i18n( + 'icu:UnreadReminders__notification--counts--messages', + { messageCount: 7 } + ), + iconUrl: null, + iconAbsolutePath: null, + }); + }); + + it('does not report a reminder as shown when notifications are off', () => { + getNotificationSetting.returns(NotificationSetting.Off); + const shown = sandbox.stub(); + service.onShown(shown); + queueReminder(); + clock.tick(DEBOUNCE_INTERVAL); + sinon.assert.notCalled(showNotification); + sinon.assert.notCalled(shown); + }); + + it('does not report a canceled reminder as shown', () => { + const shown = sandbox.stub(); + service.onShown(shown); + queueReminder(); + service.removeBy({ conversationId: 'chat' }); + clock.tick(DEBOUNCE_INTERVAL); + sinon.assert.notCalled(showNotification); + sinon.assert.notCalled(shown); + }); + }); + if (platform === 'windows') { it('preserves the click target during focus-triggered clearing', () => { showMessage(); diff --git a/ts/test-helpers/generateBackup.node.ts b/ts/test-helpers/generateBackup.node.ts index 480d5c599f..975e264d91 100644 --- a/ts/test-helpers/generateBackup.node.ts +++ b/ts/test-helpers/generateBackup.node.ts @@ -129,6 +129,8 @@ function* createRecords({ notifyForCallsIfMuted: null, notifyForMentionsIfMuted: null, notifyForRepliesIfMuted: null, + notifyWhenContactJoins: null, + showUnreadReminders: null, hasSetMyStoriesPrivacy: true, hasViewedOnboardingStory: true, storiesDisabled: false, @@ -249,6 +251,7 @@ function* createRecords({ notifyForCallsIfMuted: null, notifyForRepliesIfMuted: null, notifyForMentionsIfMuted: null, + showUnreadReminders: null, style: { wallpaper: null, bubbleColor: { diff --git a/ts/test-node/sql/server/unreadReminders_test.node.ts b/ts/test-node/sql/server/unreadReminders_test.node.ts new file mode 100644 index 0000000000..54b19a1a8d --- /dev/null +++ b/ts/test-node/sql/server/unreadReminders_test.node.ts @@ -0,0 +1,375 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import assert from 'node:assert/strict'; +import { cwd } from 'node:process'; + +import { ReadStatus } from '../../../messages/MessageReadStatus.std.ts'; +import { SeenStatus } from '../../../MessageSeenStatus.std.ts'; +import type { WritableDB } from '../../../sql/Interface.std.ts'; +import { setupTests } from '../../../sql/Server.node.ts'; +import type { UnreadReminderFilter } from '../../../sql/server/unreadReminders.std.ts'; +import { + getUnremindedUnreadMessageTimeRanges, + getUnremindedUnreadMessageTimeRangesQuery, + getUnreadReminderCountQuery, + getUnreadReminderSendersQuery, + getUnreadReminderSummaryData, +} from '../../../sql/server/unreadReminders.std.ts'; +import { createDB, explain, insertData } from '../helpers.node.ts'; +import { generateAci } from '../../../test-helpers/serviceIdUtils.std.ts'; +import { DAY } from '../../../util/durations/constants.std.ts'; + +const OUR_ACI = generateAci(); +const ALICE_ACI = generateAci(); +const BOB_ACI = generateAci(); + +const yesterday = Date.now() - DAY; + +let messageCounter = 0; + +type MessageOverrides = { + conversationId: string; + receivedAtMs?: number | null; + readStatus?: ReadStatus; + sourceServiceId?: string; + mentionsMe?: 0 | 1; + quoteAuthorAci?: string; + storyId?: string | null; +}; + +function composeMessage(overrides: MessageOverrides) { + messageCounter += 1; + const { + conversationId, + receivedAtMs = yesterday, + readStatus = ReadStatus.Unread, + sourceServiceId = ALICE_ACI, + mentionsMe = 0, + quoteAuthorAci, + storyId = null, + } = overrides; + + const json: Record = { id: `m${messageCounter}` }; + if (quoteAuthorAci != null) { + json.quote = { authorAci: quoteAuthorAci }; + } + + return { + id: `m${messageCounter}`, + conversationId, + type: 'incoming', + readStatus, + seenStatus: SeenStatus.Unseen, + received_at: messageCounter, + received_at_ms: receivedAtMs, + sent_at: receivedAtMs ?? yesterday, + sourceServiceId, + mentionsMe, + storyId, + json: JSON.stringify(json), + }; +} + +describe('sql/server/unreadReminders', () => { + let db: WritableDB; + beforeEach(() => { + messageCounter = 0; + db = createDB(); + setupTests(db, { userDataPath: cwd() }); + insertData(db, 'conversations', [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]); + }); + afterEach(() => { + db.close(); + }); + + describe('getUnremindedUnreadMessageTimeRanges', () => { + it('returns the oldest and newest unread arrival for a conversation', () => { + insertData(db, 'messages', [ + composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }), + composeMessage({ + conversationId: 'c1', + receivedAtMs: yesterday + 5_000, + }), + composeMessage({ + conversationId: 'c1', + receivedAtMs: yesterday + 1_000, + }), + ]); + + assert.deepEqual( + getUnremindedUnreadMessageTimeRanges(db, [ + { + conversationId: 'c1', + lastRemindedAt: 0, + includeStoryReplies: true, + }, + ]), + [ + { + conversationId: 'c1', + oldestUnremindedReceivedAtMs: yesterday, + newestUnreadReceivedAtMs: yesterday + 5_000, + }, + ] + ); + }); + + it('excludes activity before lastRemindedAt', () => { + insertData(db, 'messages', [ + composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }), + composeMessage({ + conversationId: 'c1', + receivedAtMs: yesterday + 10_000, + }), + ]); + + const result = getUnremindedUnreadMessageTimeRanges(db, [ + { + conversationId: 'c1', + lastRemindedAt: yesterday + 5_000, + includeStoryReplies: true, + }, + ]); + + assert.deepEqual(result, [ + { + conversationId: 'c1', + oldestUnremindedReceivedAtMs: yesterday + 10_000, + newestUnreadReceivedAtMs: yesterday + 10_000, + }, + ]); + }); + + it('omits a conversation whose unread all predates the cutoff', () => { + insertData(db, 'messages', [ + composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }), + ]); + + assert.deepEqual( + getUnremindedUnreadMessageTimeRanges(db, [ + { + conversationId: 'c1', + lastRemindedAt: yesterday + 1, + includeStoryReplies: true, + }, + ]), + [] + ); + }); + + it('ignores read messages', () => { + insertData(db, 'messages', [ + composeMessage({ + conversationId: 'c1', + receivedAtMs: yesterday, + readStatus: ReadStatus.Read, + }), + ]); + + assert.deepEqual( + getUnremindedUnreadMessageTimeRanges(db, [ + { + conversationId: 'c1', + lastRemindedAt: 0, + includeStoryReplies: true, + }, + ]), + [] + ); + }); + + it('respects includeStoryReplies', () => { + insertData(db, 'messages', [ + composeMessage({ + conversationId: 'c1', + receivedAtMs: yesterday, + storyId: 'story1', + }), + ]); + + assert.deepEqual( + getUnremindedUnreadMessageTimeRanges(db, [ + { + conversationId: 'c1', + lastRemindedAt: 0, + includeStoryReplies: false, + }, + ]), + [] + ); + assert.equal( + getUnremindedUnreadMessageTimeRanges(db, [ + { + conversationId: 'c1', + lastRemindedAt: 0, + includeStoryReplies: true, + }, + ]).length, + 1 + ); + }); + }); + + describe('getUnreadReminderSummaryData', () => { + const options = { includeStoryReplies: true, ourAci: OUR_ACI }; + + it('counts unread messages and their senders, most recent first', () => { + insertData(db, 'messages', [ + composeMessage({ + conversationId: 'c1', + receivedAtMs: yesterday, + sourceServiceId: ALICE_ACI, + }), + composeMessage({ + conversationId: 'c1', + receivedAtMs: yesterday + 5_000, + sourceServiceId: BOB_ACI, + }), + composeMessage({ + conversationId: 'c1', + receivedAtMs: yesterday + 1_000, + sourceServiceId: ALICE_ACI, + }), + ]); + + const data = getUnreadReminderSummaryData(db, 'c1', options); + assert.equal(data.unreadMessageCount, 3); + assert.deepEqual( + data.senders.map(({ sourceServiceId }) => sourceServiceId), + [BOB_ACI, ALICE_ACI] + ); + }); + + it('counts mentions of us and who made them', () => { + insertData(db, 'messages', [ + composeMessage({ conversationId: 'c1', mentionsMe: 1 }), + composeMessage({ conversationId: 'c1', mentionsMe: 0 }), + ]); + + const data = getUnreadReminderSummaryData(db, 'c1', options); + assert.equal(data.mentionCount, 1); + assert.deepEqual( + data.mentioners.map(({ sourceServiceId }) => sourceServiceId), + [ALICE_ACI] + ); + }); + + it('counts only replies quoting our own ACI', () => { + insertData(db, 'messages', [ + composeMessage({ conversationId: 'c1', quoteAuthorAci: OUR_ACI }), + composeMessage({ conversationId: 'c1', quoteAuthorAci: BOB_ACI }), + composeMessage({ conversationId: 'c1' }), + ]); + + const data = getUnreadReminderSummaryData(db, 'c1', options); + assert.equal(data.replyCount, 1); + assert.deepEqual( + data.repliers.map(({ sourceServiceId }) => sourceServiceId), + [ALICE_ACI] + ); + }); + + it('does not count a reply that has already been read', () => { + insertData(db, 'messages', [ + composeMessage({ + conversationId: 'c1', + quoteAuthorAci: OUR_ACI, + readStatus: ReadStatus.Read, + }), + ]); + + assert.equal( + getUnreadReminderSummaryData(db, 'c1', options).replyCount, + 0 + ); + }); + + it('is scoped to a single conversation', () => { + insertData(db, 'messages', [ + composeMessage({ conversationId: 'c1' }), + composeMessage({ conversationId: 'c2' }), + composeMessage({ conversationId: 'c2' }), + ]); + + assert.equal( + getUnreadReminderSummaryData(db, 'c1', options).unreadMessageCount, + 1 + ); + }); + }); + + describe('query plans', () => { + it('getUnremindedUnreadMessageTimeRangesQuery uses messages_unread_no_story_id index', () => { + const details = explain( + db, + getUnremindedUnreadMessageTimeRangesQuery([ + { + conversationId: 'c1', + lastRemindedAt: 0, + includeStoryReplies: true, + }, + { + conversationId: 'c2', + lastRemindedAt: yesterday, + includeStoryReplies: false, + }, + ]) + ); + + assert.ok( + details.includes( + 'SEARCH messages USING INDEX messages_unread_no_story_id ' + + '(conversationId=? AND readStatus=? AND isStory=?)' + ), + details + ); + assert.ok(!details.includes('SCAN messages'), details); + }); + + describe('summaryData', () => { + for (const includeStoryReplies of [true, false]) { + for (const filter of ['all', 'mentions', 'replies']) { + // oxlint-disable-next-line no-loop-func + it(`getUnreadReminderSendersQuery: ${filter}, ${includeStoryReplies}`, () => { + const details = explain( + db, + getUnreadReminderSendersQuery( + 'c1', + filter as UnreadReminderFilter, + { + includeStoryReplies, + ourAci: OUR_ACI, + } + ) + ); + assert.ok( + details.includes(`SEARCH messages USING INDEX messages_unread`), + `${details} uses messages_unread* index` + ); + assert.ok(!details.includes('SCAN'), `${details} does not scan`); + }); + // oxlint-disable-next-line no-loop-func + it(`getUnreadReminderCountQuery: ${filter}, ${includeStoryReplies}`, () => { + const details = explain( + db, + getUnreadReminderCountQuery( + 'c1', + filter as UnreadReminderFilter, + { + includeStoryReplies, + ourAci: OUR_ACI, + } + ) + ); + + assert.ok( + details.includes(`SEARCH messages USING INDEX messages_unread`), + `${details} uses messages_unread* index` + ); + assert.ok(!details.includes('SCAN'), `${details} does not scan`); + }); + } + } + }); + }); +}); diff --git a/ts/test-node/util/unreadReminders_test.node.ts b/ts/test-node/util/unreadReminders_test.node.ts new file mode 100644 index 0000000000..79e75e3a31 --- /dev/null +++ b/ts/test-node/util/unreadReminders_test.node.ts @@ -0,0 +1,273 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import i18n from './i18n.node.ts'; + +import { DAY, HOUR, MINUTE } from '../../util/durations/index.std.ts'; +import type { + UnreadReminderCandidate, + UnreadReminderSummary, +} from '../../util/unreadReminders.std.ts'; +import { + UNREAD_REMINDER_MIN_UNREAD_AGE, + UNREAD_REMINDER_MAX_AGE, + filterForConversationsDueReminder, + getUnreadReminderNotificationContent, +} from '../../util/unreadReminders.std.ts'; + +const NOW = new Date('2026-09-08T12:00:00.000Z').getTime(); + +function candidate( + conversationId: string, + { + oldestUnremindedAgo, + newestUnreadAgo = oldestUnremindedAgo, + }: { oldestUnremindedAgo: number; newestUnreadAgo?: number } +): UnreadReminderCandidate { + return { + conversationId, + oldestUnremindedReceivedAtMs: NOW - oldestUnremindedAgo, + newestUnreadReceivedAtMs: NOW - newestUnreadAgo, + }; +} + +function idsFor(candidates: ReadonlyArray) { + return filterForConversationsDueReminder(candidates, NOW); +} +describe('unreadReminders', () => { + describe('getCandidateConversationIds', () => { + it('returns nothing when there are no candidates', () => { + assert.deepEqual(idsFor([]), []); + }); + + describe('the interval', () => { + it('does not return a chat before the interval has elapsed', () => { + assert.deepEqual( + idsFor([ + candidate('convoId', { + oldestUnremindedAgo: UNREAD_REMINDER_MIN_UNREAD_AGE - HOUR, + }), + ]), + [] + ); + }); + + it('returns a chat once the interval has elapsed', () => { + assert.deepEqual( + idsFor([ + candidate('convoId', { + oldestUnremindedAgo: UNREAD_REMINDER_MIN_UNREAD_AGE + HOUR, + }), + ]), + ['convoId'] + ); + }); + + it('anchors on the oldest unreminded message, not the newest unread', () => { + assert.deepEqual( + idsFor([ + candidate('convoId', { + oldestUnremindedAgo: UNREAD_REMINDER_MIN_UNREAD_AGE + DAY, + newestUnreadAgo: MINUTE, + }), + ]), + ['convoId'] + ); + }); + }); + + describe('staleness', () => { + it('drops a chat whose newest unread is past the max age', () => { + assert.deepEqual( + idsFor([ + candidate('convoId', { + oldestUnremindedAgo: UNREAD_REMINDER_MAX_AGE + DAY, + newestUnreadAgo: UNREAD_REMINDER_MAX_AGE + HOUR, + }), + ]), + [] + ); + }); + + it('keeps a chat with very old backlog but is still active', () => { + assert.deepEqual( + idsFor([ + candidate('convoId', { + oldestUnremindedAgo: UNREAD_REMINDER_MAX_AGE + 100 * DAY, + newestUnreadAgo: UNREAD_REMINDER_MAX_AGE - DAY, + }), + ]), + ['convoId'] + ); + }); + }); + + it('returns the chat waiting longest first', () => { + assert.deepEqual( + idsFor([ + candidate('recent', { + oldestUnremindedAgo: 4 * DAY, + }), + candidate('oldest', { oldestUnremindedAgo: 6 * DAY }), + candidate('middle', { oldestUnremindedAgo: 5 * DAY }), + ]), + ['oldest', 'middle', 'recent'] + ); + }); + + it('returns only the due, non-stale chats, oldest-due first', () => { + assert.deepEqual( + idsFor([ + candidate('not-yet', { oldestUnremindedAgo: HOUR }), + candidate('due-later', { oldestUnremindedAgo: 4 * DAY }), + candidate('stale', { + oldestUnremindedAgo: 30 * DAY, + newestUnreadAgo: 30 * DAY, + }), + candidate('due-first', { oldestUnremindedAgo: 12 * DAY }), + ]), + ['due-first', 'due-later'] + ); + }); + }); + + describe('getUnreadReminderNotificationContent', () => { + const FALLBACK_TITLE = 'Signal'; + const CHAT_TITLE = 'Book Club'; + + const ALICE = { title: 'Alice' }; + const BOB = { title: 'Bob' }; + const CAROL = { title: 'Carol' }; + + function getContent( + summaryOverrides: Partial, + contentSetting: 'full' | 'countsOnly' = 'full' + ) { + return getUnreadReminderNotificationContent({ + summary: { + conversationId: 'chat', + conversationTitle: CHAT_TITLE, + unreadMessageCount: 0, + mentionCount: 0, + replyCount: 0, + senders: [], + mentioners: [], + repliers: [], + ...summaryOverrides, + }, + contentSetting, + fallbackTitle: FALLBACK_TITLE, + i18n, + }); + } + + function getBody( + overrides: Partial, + contentSetting: 'full' | 'countsOnly' = 'full' + ): string { + return getContent(overrides, contentSetting).body; + } + + describe('unread messages', () => { + it('one sender', () => { + assert.strictEqual( + getBody({ unreadMessageCount: 3, senders: [ALICE] }), + 'You have 3 unread messages from Alice.' + ); + }); + + it('two senders', () => { + assert.strictEqual( + getBody({ unreadMessageCount: 5, senders: [ALICE, BOB] }), + 'You have 5 unread messages from Alice and Bob.' + ); + }); + + it('>2 senders', () => { + assert.strictEqual( + getBody({ unreadMessageCount: 9, senders: [ALICE, BOB, CAROL] }), + 'You have 9 unread messages from Alice, Bob, and others.' + ); + }); + }); + + describe('mentions', () => { + it('one person, one mention', () => { + assert.strictEqual( + getBody({ + unreadMessageCount: 6, + mentionCount: 1, + senders: [ALICE, BOB], + mentioners: [ALICE], + }), + 'You have 6 unread messages, including a mention of you by Alice.' + ); + }); + + it('one person, multiple mentions', () => { + assert.strictEqual( + getBody({ + unreadMessageCount: 6, + mentionCount: 4, + mentioners: [ALICE], + }), + 'You have 6 unread messages, including 4 mentions of you by Alice.' + ); + }); + + it('many mentioners', () => { + assert.strictEqual( + getBody({ + unreadMessageCount: 9, + mentionCount: 5, + mentioners: [ALICE, BOB, CAROL], + }), + 'You have 9 unread messages, including 5 mentions of you by Alice and others.' + ); + }); + }); + + describe('replies', () => { + it('one reply', () => { + assert.strictEqual( + getBody({ + unreadMessageCount: 3, + replyCount: 1, + repliers: [ALICE], + }), + 'You have 3 unread messages, including a reply from Alice.' + ); + }); + }); + + describe('privacy', () => { + it('names nobody and uses the generic title under countsOnly', () => { + const result = getContent( + { + unreadMessageCount: 4, + mentionCount: 1, + replyCount: 1, + senders: [ALICE], + mentioners: [ALICE], + repliers: [BOB], + }, + 'countsOnly' + ); + + assert.deepEqual(result, { + title: FALLBACK_TITLE, + body: 'You have 4 unread messages.', + }); + }); + + it('uses the chat title under full privacy', () => { + assert.strictEqual( + getContent({ unreadMessageCount: 1, senders: [ALICE] })?.title, + CHAT_TITLE + ); + }); + }); + }); +}); diff --git a/ts/types/StorageKeys.std.ts b/ts/types/StorageKeys.std.ts index 9ec2805c7d..2ecc334f8a 100644 --- a/ts/types/StorageKeys.std.ts +++ b/ts/types/StorageKeys.std.ts @@ -220,6 +220,9 @@ export type StorageAccessType = { notifyForCallsIfMuted: boolean | undefined; notifyForMentionsIfMuted: boolean | undefined; notifyForRepliesIfMuted: boolean | undefined; + notifyWhenContactJoins: boolean | undefined; + showUnreadReminders: boolean | undefined; + unreadRemindersEnabledAt: number; usernameLastIntegrityCheck: number; usernameCorrupted: boolean; usernameLinkCorrupted: boolean; @@ -438,6 +441,8 @@ export const STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK = [ 'notifyForCallsIfMuted', 'notifyForMentionsIfMuted', 'notifyForRepliesIfMuted', + 'notifyWhenContactJoins', + 'showUnreadReminders', 'hasSetMyStoriesPrivacy', 'hasViewedOnboardingStory', 'hasKeyTransparencyDisabled', @@ -485,6 +490,7 @@ export const STORAGE_KEYS_TO_PRESERVE_WHEN_PRIMARY = [ ] as const satisfies ReadonlyArray; const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [ + 'unreadRemindersEnabledAt', 'lastCallQualitySurveyTime', 'lastCallQualityFailureSurveyTime', 'cqsTestMode', @@ -662,5 +668,6 @@ export const STORAGE_KEY_DEFAULTS = { notifyForCallsIfMuted: undefined, notifyForMentionsIfMuted: undefined, notifyForRepliesIfMuted: undefined, + showUnreadReminders: undefined, unreadCountBadgeType: 'unread-messages', } as const satisfies Partial; diff --git a/ts/types/notifications.std.ts b/ts/types/notifications.std.ts index 5e0bf7d865..59c3f0af38 100644 --- a/ts/types/notifications.std.ts +++ b/ts/types/notifications.std.ts @@ -7,4 +7,5 @@ export enum NotificationType { IsPresenting = 'IsPresenting', Message = 'Message', Reaction = 'Reaction', + UnreadReminder = 'UnreadReminder', } diff --git a/ts/util/getConversation.preload.ts b/ts/util/getConversation.preload.ts index 4ebb4ede1e..7c56a260e8 100644 --- a/ts/util/getConversation.preload.ts +++ b/ts/util/getConversation.preload.ts @@ -233,6 +233,7 @@ export function getConversation(model: ConversationModel): ConversationType { notifyForCallsIfMuted: attributes.notifyForCallsIfMuted, notifyForMentionsIfMuted: attributes.notifyForMentionsIfMuted, notifyForRepliesIfMuted: attributes.notifyForRepliesIfMuted, + showUnreadReminders: attributes.showUnreadReminders, nicknameFamilyName: dropNull(attributes.nicknameFamilyName), nicknameGivenName: dropNull(attributes.nicknameGivenName), note: dropNull(attributes.note), diff --git a/ts/util/unreadReminders.std.ts b/ts/util/unreadReminders.std.ts new file mode 100644 index 0000000000..0f49f126ce --- /dev/null +++ b/ts/util/unreadReminders.std.ts @@ -0,0 +1,258 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createLogger } from '../logging/log.std.ts'; +import type { ConversationAttributesType } from '../model-types.d.ts'; +import type { StorageInterface } from '../types/Storage.d.ts'; +import type { LocalizerType } from '../types/Util.std.ts'; +import { DAY, HOUR } from './durations/index.std.ts'; + +const log = createLogger('unreadReminders'); + +/** How long a chat must sit on unreminded activity before it may be elibigle for a reminder */ +export const UNREAD_REMINDER_MIN_UNREAD_AGE = 3 * DAY; + +/** How long must pass before a subsequent unread reminder is sent for a conversation */ +export const UNREAD_REMINDER_MIN_INTERVAL_BETWEEN_REMINDERS = 3 * DAY; + +/** Never remind about a chat whose newest unread is older than this */ +export const UNREAD_REMINDER_MAX_AGE = 14 * DAY; + +/** How often the app will check for unread reminders */ +export const UNREAD_REMINDER_POLL_INTERVAL = HOUR; + +export const DEFAULT_SHOW_UNREAD_REMINDERS = true; + +export function getShowUnreadRemindersForConversation( + conversation: ConversationAttributesType, + itemStorage: StorageInterface +): boolean { + return getShowUnreadReminders( + conversation, + itemStorage.get('showUnreadReminders', DEFAULT_SHOW_UNREAD_REMINDERS) + ); +} + +export function getShowUnreadReminders( + conversation: { showUnreadReminders?: boolean | undefined }, + globalShowUnreadReminders: boolean +): boolean { + return conversation.showUnreadReminders ?? globalShowUnreadReminders; +} + +export type UnreadReminderCandidate = Readonly<{ + conversationId: string; + oldestUnremindedReceivedAtMs: number; + newestUnreadReceivedAtMs: number; +}>; + +export function filterForConversationsDueReminder( + candidates: ReadonlyArray, + now: number +): ReadonlyArray { + return candidates + .filter( + candidate => + candidate.newestUnreadReceivedAtMs > now - UNREAD_REMINDER_MAX_AGE + ) + + .filter( + ({ oldestUnremindedReceivedAtMs }) => + oldestUnremindedReceivedAtMs < now - UNREAD_REMINDER_MIN_UNREAD_AGE + ) + .sort( + (a, b) => a.oldestUnremindedReceivedAtMs - b.oldestUnremindedReceivedAtMs + ) + .map(({ conversationId }) => conversationId); +} + +export type UnreadReminderParticipant = Readonly<{ + title: string; +}>; + +export type UnreadReminderSummary = Readonly<{ + conversationId: string; + conversationTitle: string; + + /** Total unread incoming messages. Mentions and replies are subsets of this. */ + unreadMessageCount: number; + + /** Zeroed when the chat's "while muted" setting for mentions is off. */ + mentionCount: number; + /** Zeroed when the chat's "while muted" setting for replies is off. */ + replyCount: number; + + /** Most-recent-first, deduped */ + senders: ReadonlyArray; + /** Most-recent-first, deduped */ + mentioners: ReadonlyArray; + /** Most-recent-first, deduped*/ + repliers: ReadonlyArray; +}>; + +function getMentionSummary( + mentionCount: number, + mentioners: ReadonlyArray, + i18n: LocalizerType +): string | undefined { + const person1 = mentioners[0]?.title; + const person2 = mentioners[1]?.title; + const person3 = mentioners[2]?.title; + + if (person1 && person2 && person3) { + return i18n('icu:UnreadReminders__notification__mentionSummary--many', { + mentionCount, + person1, + }); + } + if (person1 && person2) { + return i18n('icu:UnreadReminders__notification__mentionSummary--2', { + mentionCount, + person1, + person2, + }); + } + if (person1) { + return i18n('icu:UnreadReminders__notification__mentionSummary--1', { + mentionCount, + person1, + }); + } + log.error('Mentioners had no names', { mentionerCount: mentioners.length }); + return undefined; +} + +function getReplySummary( + replyCount: number, + repliers: ReadonlyArray, + i18n: LocalizerType +): string | undefined { + const person1 = repliers[0]?.title; + const person2 = repliers[1]?.title; + const person3 = repliers[2]?.title; + + if (person1 && person2 && person3) { + return i18n('icu:UnreadReminders__notification__replySummary--many', { + replyCount, + person1, + }); + } + if (person1 && person2) { + return i18n('icu:UnreadReminders__notification__replySummary--2', { + replyCount, + person1, + person2, + }); + } + if (person1) { + return i18n('icu:UnreadReminders__notification__replySummary--1', { + replyCount, + person1, + }); + } + log.error('Repliers had no names', { replierCount: repliers.length }); + return undefined; +} + +function getCountsOnlyBody(messageCount: number, i18n: LocalizerType): string { + return i18n('icu:UnreadReminders__notification--counts--messages', { + messageCount, + }); +} + +function getSendersBody( + messageCount: number, + senders: ReadonlyArray, + i18n: LocalizerType +): string | undefined { + const person1 = senders[0]?.title; + const person2 = senders[1]?.title; + const person3 = senders[2]?.title; + + if (person1 && person2 && person3) { + return i18n('icu:UnreadReminders__notification--messages--many', { + messageCount, + person1, + person2, + }); + } + if (person1 && person2) { + return i18n('icu:UnreadReminders__notification--messages--2', { + messageCount, + person1, + person2, + }); + } + if (person1) { + return i18n('icu:UnreadReminders__notification--messages--1', { + messageCount, + person1, + }); + } + + log.error('No body generated', { senderCount: senders.length }); + return undefined; +} + +export function getUnreadReminderNotificationContent({ + summary, + contentSetting, + fallbackTitle, + i18n, +}: { + summary: UnreadReminderSummary; + contentSetting: 'countsOnly' | 'full'; + fallbackTitle: string; + i18n: LocalizerType; +}): { title: string; body: string } { + const { + conversationTitle, + unreadMessageCount, + mentionCount, + replyCount, + senders, + mentioners, + repliers, + } = summary; + + if (contentSetting === 'countsOnly') { + const body = getCountsOnlyBody(unreadMessageCount, i18n); + return { title: fallbackTitle, body }; + } + + const title = conversationTitle || fallbackTitle; + + const mentionSummary = + mentionCount > 0 + ? getMentionSummary(mentionCount, mentioners, i18n) + : undefined; + const replySummary = + replyCount > 0 ? getReplySummary(replyCount, repliers, i18n) : undefined; + + let body: string | undefined; + // We prefer the mentionSummary to the reply summary + if (mentionSummary != null) { + body = i18n( + 'icu:UnreadReminders__notification--messages-including-mentions', + { + messageCount: unreadMessageCount, + mentionSummary, + } + ); + } else if (replySummary != null) { + body = i18n( + 'icu:UnreadReminders__notification--messages-including-replies', + { + messageCount: unreadMessageCount, + replySummary, + } + ); + } else { + body = getSendersBody(unreadMessageCount, senders, i18n); + if (body == null) { + body = getCountsOnlyBody(unreadMessageCount, i18n); + } + } + + return { title, body }; +}