mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-09-28 07:57:21 +01:00
Support unread reminders
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
co-authored by
trevor-signal
parent
d5bcb1ba30
commit
4d737568c3
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')}
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+35
@@ -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>
|
||||
);
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') : [],
|
||||
};
|
||||
})();
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -7,4 +7,5 @@ export enum NotificationType {
|
||||
IsPresenting = 'IsPresenting',
|
||||
Message = 'Message',
|
||||
Reaction = 'Reaction',
|
||||
UnreadReminder = 'UnreadReminder',
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user