Support unread reminders

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-09-23 22:20:17 +00:00
committed by GitHub
co-authored by trevor-signal
parent d5bcb1ba30
commit 4d737568c3
31 changed files with 1841 additions and 44 deletions
+60
View File
@@ -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"
+5 -1
View File
@@ -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,
});
+3
View File
@@ -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
}
/**
+4
View File
@@ -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 {
+13
View File
@@ -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<void> {
);
}
// 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<void> {
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(
@@ -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'),
+11
View File
@@ -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<number>;
onUnreadCountBadgeTypeChange: SelectChangeHandlerType<UnreadCountBadgeType>;
onUnreadRemindersChange: CheckboxChangeHandlerType;
onWhoCanFindMeChange: SelectChangeHandlerType<PhoneNumberDiscoverability>;
onWhoCanSeeMeChange: SelectChangeHandlerType<PhoneNumberSharingMode>;
onZoomFactorChange: SelectChangeHandlerType<ZoomFactorType>;
@@ -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}
/>
<AxoSwitchItem.Root
label={i18n('icu:UnreadReminders__title')}
description={i18n('icu:Preferences__UnreadReminders__description')}
disabled={!hasNotifications}
checked={hasUnreadReminders}
onCheckedChange={onUnreadRemindersChange}
/>
{isNotificationAttentionSupported && (
<AxoSwitchItem.Root
label={i18n('icu:notificationDrawAttention')}
@@ -24,6 +24,8 @@ const getCommonProps = () => ({
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 (
<ConversationNotificationsSettings
{...getCommonProps()}
showUnreadReminders={false}
/>
);
}
export function DirectConversation(): JSX.Element {
return (
<ConversationNotificationsSettings
@@ -7,6 +7,8 @@ import { MuteExpiration } from '@signalapp/types';
import type { LocalizerType } from '../../../types/Util.std.ts';
import { AxoItem } from '../../../axo/items/AxoItem.dom.tsx';
import { AxoList } from '../../../axo/items/AxoList.dom.tsx';
import { AxoSwitch } from '../../../axo/AxoSwitch.dom.tsx';
import { tw } from '../../../axo/tw.dom.tsx';
import { MuteNotificationsDropdownMenu } from '../../MuteNotificationsMenu.dom.tsx';
import { isConversationMuted } from '../../../util/isConversationMuted.std.ts';
import { getMutedUntilText } from '../../../util/getMutedUntilText.std.ts';
@@ -26,6 +28,8 @@ export type PropsType = {
conversationId: string,
muteExpiresAt: undefined | MuteExpiration
) => 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({
</AxoItem.Group>
</AxoList.Body>
</AxoList.Root>
<AxoList.Root accessibilityLabel={unreadRemindersLabel}>
<AxoList.Body>
<AxoItem.Group>
<AxoItem.Root>
<AxoItem.Leading>
<AxoItem.Icon symbol="arrow-clockwise" />
</AxoItem.Leading>
<AxoItem.Content>
<AxoItem.Label>{unreadRemindersLabel}</AxoItem.Label>
<AxoItem.Description>
{i18n('icu:UnreadReminders__description')}
</AxoItem.Description>
<AxoItem.Accessory>
<label>
<span className={tw('sr-only')}>
{unreadRemindersLabel}
</span>
<AxoSwitch.Root
checked={showUnreadReminders}
onCheckedChange={setShowUnreadReminders}
/>
</label>
</AxoItem.Accessory>
</AxoItem.Content>
</AxoItem.Root>
</AxoItem.Group>
</AxoList.Body>
</AxoList.Root>
</AxoList.Group>
</AxoContainer.Root>
);
+2
View File
@@ -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;
+30 -9
View File
@@ -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<keyof NotifyWhileMutedFields, undefined>);
showUnreadReminders: undefined,
} satisfies Record<
(typeof ConversationModel.RESETTABLE_NOTIFICATION_ITEMS)[number],
undefined
>);
drop(DataWriter.updateConversation(this.attributes));
this.captureChange('resetNotifyWhileMuted');
this.captureChange('resetNotificationSettings');
}
acknowledgeGroupMemberNameCollisions(
+4
View File
@@ -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,
+9
View File
@@ -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);
+69 -25
View File
@@ -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);
}
+22
View File
@@ -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) {
+268
View File
@@ -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<void> {
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<ConversationModel>) {
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<boolean> {
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<UnreadReminderSummary | undefined> {
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();
}
+12
View File
@@ -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<UnreadReminderConversationCandidate>
) => ReadonlyArray<UnreadMessageTimeRange>;
getUnreadReminderSummaryData: (
conversationId: string,
options: { includeStoryReplies: boolean; ourAci: AciString }
) => UnreadReminderSummaryData;
getTotalUnreadMentionsOfMeForConversation: (
conversationId: string,
options: {
+6
View File
@@ -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,
+190
View File
@@ -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<UnreadReminderConversationCandidate>
): QueryFragment {
return sqlJoin(
batch.map(
({ conversationId, lastRemindedAt, includeStoryReplies }) =>
sqlFragment`(${conversationId}, ${lastRemindedAt}, ${includeStoryReplies ? 1 : 0})`
)
);
}
export function getUnremindedUnreadMessageTimeRangesQuery(
batch: ReadonlyArray<UnreadReminderConversationCandidate>
): 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<UnreadReminderConversationCandidate>
): ReadonlyArray<UnreadMessageTimeRange> {
return batchMultiVarQuery(db, conversations, (batch, persistent) => {
const [query, params] = getUnremindedUnreadMessageTimeRangesQuery(batch);
return db
.prepare(query, { persistent })
.all<UnreadMessageTimeRange>(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<number>(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') : [],
};
})();
}
+20 -4
View File
@@ -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(
+7
View File
@@ -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 => {
@@ -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}
/>
);
}
+18 -3
View File
@@ -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}
@@ -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();
+3
View File
@@ -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: {
@@ -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<string, unknown> = { 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`);
});
}
}
});
});
});
@@ -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<UnreadReminderCandidate>) {
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<UnreadReminderSummary>,
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<UnreadReminderSummary>,
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
);
});
});
});
});
+7
View File
@@ -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<keyof StorageAccessType>;
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<StorageAccessType>;
+1
View File
@@ -7,4 +7,5 @@ export enum NotificationType {
IsPresenting = 'IsPresenting',
Message = 'Message',
Reaction = 'Reaction',
UnreadReminder = 'UnreadReminder',
}
+1
View File
@@ -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),
+258
View File
@@ -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<UnreadReminderCandidate>,
now: number
): ReadonlyArray<string> {
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<UnreadReminderParticipant>;
/** Most-recent-first, deduped */
mentioners: ReadonlyArray<UnreadReminderParticipant>;
/** Most-recent-first, deduped*/
repliers: ReadonlyArray<UnreadReminderParticipant>;
}>;
function getMentionSummary(
mentionCount: number,
mentioners: ReadonlyArray<UnreadReminderParticipant>,
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<UnreadReminderParticipant>,
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<UnreadReminderParticipant>,
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 };
}