Add global default settings for notifying while muted

This commit is contained in:
trevor-signal
2026-08-27 16:24:27 -04:00
committed by GitHub
parent 3e3dd402b6
commit 65915de92c
26 changed files with 345 additions and 111 deletions
+16
View File
@@ -6938,6 +6938,22 @@
"messageformat": "Calls, mentions, replies",
"description": "Summary shown on the 'While muted' row when calls, mentions, and replies all notify while the chat is muted"
},
"icu:Preferences__WhileMuted__description": {
"messageformat": "Choose notifications to show for muted chats.",
"description": "Sub-label of the row in notification settings that opens the global 'While muted' page"
},
"icu:Preferences__WhileMuted__calls__description": {
"messageformat": "Ring or notify when a call is started in muted chats.",
"description": "On the global 'While muted' settings page, sub-label for the switch that allows call notifications in muted chats"
},
"icu:Preferences__WhileMuted__mentions__description": {
"messageformat": "Notify when you are mentioned in muted chats.",
"description": "On the global 'While muted' settings page, sub-label for the switch that allows @mention notifications in muted chats"
},
"icu:Preferences__WhileMuted__replies__description": {
"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:ConversationNotificationsSettings__mentions__label": {
"messageformat": "Mentions",
"description": "In the conversation notifications settings, this is the label for the mentions option"
+4 -1
View File
@@ -138,6 +138,9 @@ message AccountData {
bool allowSealedSenderFromAnyone = 30;
bool allowAutomaticKeyVerification = 31;
bool hasSeenAdminDeleteEducationDialog = 32;
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
}
message SubscriberData {
@@ -1465,4 +1468,4 @@ message ChatFolder {
repeated uint64 includedRecipientIds = 7; // generated recipient id of groups, contacts, and/or note to self
repeated uint64 excludedRecipientIds = 8; // generated recipient id of groups, contacts, and/or note to self
bytes id = 9; // should be 16 bytes
}
}
+3 -1
View File
@@ -189,7 +189,6 @@ message Payments {
}
message AccountRecord {
enum PhoneNumberSharingMode {
UNKNOWN = 0;
EVERYBODY = 1;
@@ -313,6 +312,9 @@ message AccountRecord {
optional bool releaseNotesChatBlocked = 50;
optional bool releaseNotesChatMarkedUnread = 51;
optional uint64 releaseNotesChatBlockedAt = 52; // only set if known (>0)
OptionalBool notifyForCallsIfMuted = 56; // If unset, consider this off
OptionalBool notifyForMentionsIfMuted = 57; // If unset, consider this on
OptionalBool notifyForRepliesIfMuted = 58; // If unset, consider this on
}
message StoryDistributionListRecord {
+15
View File
@@ -1451,6 +1451,21 @@ async function startApp(): Promise<void> {
window.ConversationController.repairPinnedConversations();
}
// Existing accounts keep notifying for calls in muted chats; new installs
// leave this unset, which means calls will not ring in muted chats.
if (
window.isBeforeVersion(lastVersion, '8.29.0-alpha') &&
itemStorage.get('notifyForCallsIfMuted') == null
) {
log.info(
'Defaulting notifyForCallsIfMuted to true for existing account'
);
await itemStorage.put('notifyForCallsIfMuted', true);
window.ConversationController.getOurConversation()?.captureChange(
'notifyForCallsIfMuted'
);
}
if (!itemStorage.get('avatarsHaveBeenMigrated', false)) {
window.ConversationController.migrateAvatarsForNonAcceptedConversations();
}
@@ -515,6 +515,7 @@ export default {
me,
navTabsCollapsed: false,
notificationContent: 'name',
notifyWhileMuted: { calls: false, mentions: true, replies: true },
osName: 'windows',
otherTabsUnreadStats: {
unreadCount: 0,
@@ -626,6 +627,7 @@ export default {
onNotificationAttentionChange: action('onNotificationAttentionChange'),
onNotificationContentChange: action('onNotificationContentChange'),
onNotificationsChange: action('onNotificationsChange'),
onNotifyWhileMutedChange: action('onNotifyWhileMutedChange'),
onPreferContactAvatarsChange: action('onPreferContactAvatarsChange'),
onReactionNotificationsChange: action('onReactionNotificationsChange'),
onReadReceiptsChange: action('onReadReceiptsChange'),
@@ -775,6 +777,10 @@ export const Notifications = Template.bind({});
Notifications.args = {
settingsLocation: { page: SettingsPage.Notifications },
};
export const NotificationsWhileMuted = Template.bind({});
NotificationsWhileMuted.args = {
settingsLocation: { page: SettingsPage.WhileMuted },
};
export const Privacy = Template.bind({});
Privacy.args = {
settingsLocation: { page: SettingsPage.Privacy },
+84 -1
View File
@@ -54,6 +54,11 @@ import type {
ZoomFactorType,
StorageAccessType,
} from '../types/StorageKeys.std.ts';
import type {
NotifyWhileMuted,
NotifyWhileMutedKey,
} from '../util/notifyWhileMuted.std.ts';
import { getNotifyWhileMutedSummary } from '../util/notifyWhileMuted.std.ts';
import type { ThemeSettingType } from '../util/theme.std.ts';
import type { AnyToast } from '../types/Toast.dom.tsx';
import { ToastType } from '../types/Toast.dom.tsx';
@@ -171,6 +176,7 @@ export type PropsDataType = {
settingsLocation: SettingsLocation;
lastSyncTime?: number;
notificationContent: NotificationSettingType;
notifyWhileMuted: NotifyWhileMuted;
osName: 'linux' | 'macos' | 'windows' | undefined;
phoneNumber: string | undefined;
selectedCamera?: string;
@@ -349,6 +355,10 @@ type PropsFunctionType = {
onNotificationAttentionChange: CheckboxChangeHandlerType;
onNotificationContentChange: SelectChangeHandlerType<NotificationSettingType>;
onNotificationsChange: CheckboxChangeHandlerType;
onNotifyWhileMutedChange: (
key: NotifyWhileMutedKey,
value: boolean
) => unknown;
onPinRemindersChange: CheckboxChangeHandlerType;
onPreferContactAvatarsChange: CheckboxChangeHandlerType;
onReactionNotificationsChange: CheckboxChangeHandlerType;
@@ -517,6 +527,7 @@ export function Preferences({
me,
navTabsCollapsed,
notificationContent,
notifyWhileMuted,
onAudioNotificationsChange,
onAutoConvertEmojiChange,
onAutoDownloadAttachmentChange,
@@ -544,6 +555,7 @@ export function Preferences({
onNotificationAttentionChange,
onNotificationContentChange,
onNotificationsChange,
onNotifyWhileMutedChange,
onPinRemindersChange,
onPreferContactAvatarsChange,
onReactionNotificationsChange,
@@ -1546,6 +1558,16 @@ export function Preferences({
},
]}
/>
<ClickableItem
title={i18n('icu:WhileMuted__title')}
value={getNotifyWhileMutedSummary(notifyWhileMuted, i18n)}
description={i18n('icu:Preferences__WhileMuted__description')}
arrow
disabled={!hasNotifications}
onClick={() =>
setSettingsLocation({ page: SettingsPage.WhileMuted })
}
/>
</List>
<List
@@ -2236,6 +2258,64 @@ export function Preferences({
title={pageTitle}
/>
);
} else if (settingsLocation.page === SettingsPage.WhileMuted) {
const backButton = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={() =>
setSettingsLocation({ page: SettingsPage.Notifications })
}
type="button"
/>
);
const pageContents = (
<ListGroup>
<List>
<SwitchItem
symbol="phone"
title={i18n('icu:WhileMuted__calls__title')}
description={i18n(
'icu:Preferences__WhileMuted__calls__description'
)}
checked={notifyWhileMuted.calls}
onCheckedChange={checked =>
onNotifyWhileMutedChange('calls', checked)
}
/>
<SwitchItem
symbol="at"
title={i18n('icu:WhileMuted__mentions__title')}
description={i18n(
'icu:Preferences__WhileMuted__mentions__description'
)}
checked={notifyWhileMuted.mentions}
onCheckedChange={checked =>
onNotifyWhileMutedChange('mentions', checked)
}
/>
<SwitchItem
symbol="reply"
title={i18n('icu:WhileMuted__replies__title')}
description={i18n(
'icu:Preferences__WhileMuted__replies__description'
)}
checked={notifyWhileMuted.replies}
onCheckedChange={checked =>
onNotifyWhileMutedChange('replies', checked)
}
/>
</List>
</ListGroup>
);
content = (
<PreferencesContent
backButton={backButton}
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:WhileMuted__title')}
/>
);
} else if (settingsLocation.page === SettingsPage.NotificationProfilesHome) {
content = renderNotificationProfilesHome({
setSettingsLocation,
@@ -2451,7 +2531,8 @@ export function Preferences({
Preferences__button: true,
'Preferences__button--notifications': true,
'Preferences__button--selected':
settingsLocation.page === SettingsPage.Notifications,
settingsLocation.page === SettingsPage.Notifications ||
settingsLocation.page === SettingsPage.WhileMuted,
})}
onClick={() =>
setSettingsLocation({ page: SettingsPage.Notifications })
@@ -2625,6 +2706,7 @@ function List(props: ListProps): ReactNode {
}
type SwitchItemProps = Readonly<{
symbol?: AxoSymbol.Name;
title: ReactNode;
description?: ReactNode;
disabled?: boolean;
@@ -2635,6 +2717,7 @@ type SwitchItemProps = Readonly<{
function SwitchItem(props: SwitchItemProps): ReactNode {
return (
<AxoItem.Root>
{props.symbol != null && <AxoItem.Icon symbol={props.symbol} />}
<AxoItem.Content>
<AxoItem.Body>
<AxoItem.Title>{props.title}</AxoItem.Title>
@@ -8,7 +8,6 @@ import type { Meta } from '@storybook/react';
import type { PropsType } from './ConversationNotificationsSettings.dom.tsx';
import { ConversationNotificationsSettings } from './ConversationNotificationsSettings.dom.tsx';
import { MuteExpiration } from '@signalapp/types';
import { DEFAULT_NOTIFY_WHILE_MUTED } from '../../../util/notifyWhileMuted.std.ts';
const { i18n } = window.SignalContext;
@@ -21,7 +20,7 @@ const getCommonProps = () => ({
id: 'conversation-id',
isGroup: true,
muteExpiresAt: undefined,
notifyWhileMuted: DEFAULT_NOTIFY_WHILE_MUTED,
notifyWhileMuted: { calls: false, mentions: true, replies: true },
i18n,
onOpenWhileMutedSettings: action('onOpenWhileMutedSettings'),
setMuteExpiration: action('setMuteExpiration'),
@@ -13,6 +13,7 @@ import { isConversationMuted } from '../../../util/isConversationMuted.std.ts';
import { getMutedUntilText } from '../../../util/getMutedUntilText.std.ts';
import { getConversationMuteMenu } from '../../../util/getMuteOptions.std.ts';
import type { NotifyWhileMuted } from '../../../util/notifyWhileMuted.std.ts';
import { getNotifyWhileMutedSummary } from '../../../util/notifyWhileMuted.std.ts';
export type PropsType = {
id: string;
@@ -43,6 +44,16 @@ export function ConversationNotificationsSettings({
? getMutedUntilText(muteExpiresAt, i18n)
: null;
const whileMutedSummary = useMemo(() => {
// Mentions and replies only apply to groups, and the "While muted" panel
// hides those rows for 1:1 chats, so don't summarize them here either.
const summarized = isGroup
? notifyWhileMuted
: { ...notifyWhileMuted, mentions: false, replies: false };
return getNotifyWhileMutedSummary(summarized, i18n);
}, [i18n, isGroup, notifyWhileMuted]);
const muteMenu = useMemo(
() => getConversationMuteMenu(muteExpiresAt, i18n),
[i18n, muteExpiresAt]
@@ -107,9 +118,7 @@ export function ConversationNotificationsSettings({
<AxoItem.Title id={whileMutedTitleId}>
{i18n('icu:WhileMuted__title')}
</AxoItem.Title>
<AxoItem.Value>
{getNotifyWhileMutedText(notifyWhileMuted, isGroup, i18n)}
</AxoItem.Value>
<AxoItem.Value>{whileMutedSummary}</AxoItem.Value>
<AxoItem.Description>
{i18n('icu:WhileMuted__description')}
</AxoItem.Description>
@@ -127,39 +136,3 @@ export function ConversationNotificationsSettings({
</div>
);
}
function getNotifyWhileMutedText(
notifyWhileMuted: NotifyWhileMuted,
isGroup: boolean,
i18n: LocalizerType
): string {
const { calls } = notifyWhileMuted;
// Mentions and replies only apply to groups, and the "While muted" panel
// hides those rows for 1:1 chats, so don't summarize them here either.
const mentions = isGroup && notifyWhileMuted.mentions;
const replies = isGroup && notifyWhileMuted.replies;
if (calls && mentions && replies) {
return i18n('icu:WhileMuted__value--calls-mentions-replies');
}
if (calls && mentions) {
return i18n('icu:WhileMuted__value--calls-mentions');
}
if (calls && replies) {
return i18n('icu:WhileMuted__value--calls-replies');
}
if (mentions && replies) {
return i18n('icu:WhileMuted__value--mentions-replies');
}
if (calls) {
return i18n('icu:WhileMuted__value--calls');
}
if (mentions) {
return i18n('icu:WhileMuted__value--mentions');
}
if (replies) {
return i18n('icu:WhileMuted__value--replies');
}
return i18n('icu:WhileMuted__value--none');
}
@@ -7,7 +7,6 @@ import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './WhileMutedSettings.dom.tsx';
import { WhileMutedSettings } from './WhileMutedSettings.dom.tsx';
import { DEFAULT_NOTIFY_WHILE_MUTED } from '../../../util/notifyWhileMuted.std.ts';
const { i18n } = window.SignalContext;
@@ -18,7 +17,7 @@ export default {
const getCommonProps = () => ({
i18n,
isGroup: true,
notifyWhileMuted: DEFAULT_NOTIFY_WHILE_MUTED,
notifyWhileMuted: { calls: false, mentions: true, replies: true },
setNotifyWhileMuted: action('setNotifyWhileMuted'),
});
+4 -2
View File
@@ -11,7 +11,7 @@ import { getActiveProfile } from '../state/selectors/notificationProfiles.dom.ts
import { shouldNotify as shouldNotifyDuringNotificationProfile } from '../types/NotificationProfile.std.ts';
import { NotificationType } from '../types/notifications.std.ts';
import { isMessageUnread } from '../util/isMessageUnread.std.ts';
import { getNotifyWhileMuted } from '../util/notifyWhileMuted.std.ts';
import { getNotifyWhileMutedForConversation } from '../util/notifyWhileMuted.preload.ts';
import { isDirectConversation } from '../util/whatTypeOfConversation.dom.ts';
import { isExpiringMessage } from '../types/Message2.preload.ts';
import { notificationService } from '../services/notifications.preload.ts';
@@ -299,7 +299,9 @@ function isAllowedByConversation(args: MaybeNotifyArgs): boolean {
return false;
}
const notifyWhileMuted = getNotifyWhileMuted(conversation.attributes);
const notifyWhileMuted = getNotifyWhileMutedForConversation(
conversation.attributes
);
if (notifyWhileMuted.mentions && isMention(args)) {
return true;
+1 -1
View File
@@ -413,7 +413,7 @@ export type ConversationAttributesType = {
messageRequestResponseType?: number;
messagesDeleted?: boolean;
muteExpiresAt?: MuteExpiration;
// Ternary: undefined means unset, see DEFAULT_NOTIFY_WHILE_MUTED
// Ternary: undefined means unset, so the chat follows the global setting
notifyForCallsIfMuted?: boolean;
notifyForMentionsIfMuted?: boolean;
notifyForRepliesIfMuted?: boolean;
+2 -14
View File
@@ -33,6 +33,7 @@ import {
import { drop } from '../util/drop.std.ts';
import { isShallowEqual } from '../util/isShallowEqual.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';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp.std.ts';
@@ -6112,20 +6113,7 @@ export class ConversationModel {
}
setNotifyWhileMuted(key: NotifyWhileMutedKey, newValue: boolean): void {
let attributeName: keyof ConversationAttributesType;
switch (key) {
case 'calls':
attributeName = 'notifyForCallsIfMuted';
break;
case 'mentions':
attributeName = 'notifyForMentionsIfMuted';
break;
case 'replies':
attributeName = 'notifyForRepliesIfMuted';
break;
default:
throw missingCaseError(key);
}
const attributeName = NOTIFY_WHILE_MUTED_FIELDS[key];
if (this.get(attributeName) === newValue) {
return;
+5
View File
@@ -1080,6 +1080,11 @@ export class BackupExportStream extends Readable {
itemStorage.get('displayBadgesOnProfile') ?? null,
keepMutedChatsArchived:
itemStorage.get('keepMutedChatsArchived') ?? null,
notifyForCallsIfMuted: itemStorage.get('notifyForCallsIfMuted') ?? null,
notifyForMentionsIfMuted:
itemStorage.get('notifyForMentionsIfMuted') ?? null,
notifyForRepliesIfMuted:
itemStorage.get('notifyForRepliesIfMuted') ?? null,
hasSetMyStoriesPrivacy:
itemStorage.get('hasSetMyStoriesPrivacy') ?? null,
hasViewedOnboardingStory:
+13
View File
@@ -890,6 +890,19 @@ export class BackupImportStream extends Writable {
'keepMutedChatsArchived',
accountSettings?.keepMutedChatsArchived === true
);
await itemStorage.put(
'notifyForCallsIfMuted',
accountSettings?.notifyForCallsIfMuted ?? undefined
);
await itemStorage.put(
'notifyForMentionsIfMuted',
accountSettings?.notifyForMentionsIfMuted ?? undefined
);
await itemStorage.put(
'notifyForRepliesIfMuted',
accountSettings?.notifyForRepliesIfMuted ?? undefined
);
await itemStorage.put(
'hasSetMyStoriesPrivacy',
accountSettings?.hasSetMyStoriesPrivacy === true
+4 -4
View File
@@ -110,7 +110,7 @@ import {
makeSfuRequest,
} from '../textsecure/WebAPI.preload.ts';
import { missingCaseError } from '../util/missingCaseError.std.ts';
import { getNotifyWhileMuted } from '../util/notifyWhileMuted.std.ts';
import { getNotifyWhileMutedForConversation } from '../util/notifyWhileMuted.preload.ts';
import { normalizeGroupCallTimestamp } from '../util/ringrtc/normalizeGroupCallTimestamp.std.ts';
import { requestCameraPermissions } from '../util/callingPermissions.dom.ts';
import {
@@ -3492,7 +3492,7 @@ class CallingClass {
RingRTC.cancelGroupRing(groupIdBytes, ringId, RingCancelReason.Busy);
} else if (
conversation.isMuted() &&
!getNotifyWhileMuted(conversation.attributes).calls
!getNotifyWhileMutedForConversation(conversation.attributes).calls
) {
log.info(
`${logId}: not notifying for calls while muted. Ignoring ring request`
@@ -3633,7 +3633,7 @@ class CallingClass {
if (
conversation.isMuted() &&
!getNotifyWhileMuted(conversation.attributes).calls
!getNotifyWhileMutedForConversation(conversation.attributes).calls
) {
log.info(`${logId}: not notifying for calls while muted, ignoring`);
@@ -4387,7 +4387,7 @@ class CallingClass {
const isAllowedWhileMuted =
!conversation.isMuted() ||
getNotifyWhileMuted(conversation.attributes).calls;
getNotifyWhileMutedForConversation(conversation.attributes).calls;
if (
isNewCall &&
+25
View File
@@ -638,6 +638,16 @@ export function toAccountRecord({
displayBadgesOnProfile: itemStorage.get('displayBadgesOnProfile') ?? null,
keepMutedChatsArchived: itemStorage.get('keepMutedChatsArchived') ?? null,
notifyForCallsIfMuted: toOptionalBool(
itemStorage.get('notifyForCallsIfMuted')
),
notifyForMentionsIfMuted: toOptionalBool(
itemStorage.get('notifyForMentionsIfMuted')
),
notifyForRepliesIfMuted: toOptionalBool(
itemStorage.get('notifyForRepliesIfMuted')
),
hasSetMyStoriesPrivacy: itemStorage.get('hasSetMyStoriesPrivacy') ?? null,
hasViewedOnboardingStory:
itemStorage.get('hasViewedOnboardingStory') ?? null,
@@ -1666,6 +1676,9 @@ export async function mergeAccountRecord(
backupTier,
displayBadgesOnProfile,
keepMutedChatsArchived,
notifyForCallsIfMuted,
notifyForMentionsIfMuted,
notifyForRepliesIfMuted,
hasCompletedUsernameOnboarding,
hasSeenGroupStoryEducationSheet,
hasSeenAdminDeleteEducationDialog,
@@ -1944,6 +1957,18 @@ export async function mergeAccountRecord(
await itemStorage.put('displayBadgesOnProfile', displayBadgesOnProfile);
await itemStorage.put('keepMutedChatsArchived', keepMutedChatsArchived);
await itemStorage.put(
'notifyForCallsIfMuted',
fromOptionalBool(notifyForCallsIfMuted) ?? undefined
);
await itemStorage.put(
'notifyForMentionsIfMuted',
fromOptionalBool(notifyForMentionsIfMuted) ?? undefined
);
await itemStorage.put(
'notifyForRepliesIfMuted',
fromOptionalBool(notifyForRepliesIfMuted) ?? undefined
);
await itemStorage.put('hasSetMyStoriesPrivacy', hasSetMyStoriesPrivacy);
{
await itemStorage.put('hasViewedOnboardingStory', hasViewedOnboardingStory);
+12
View File
@@ -14,6 +14,8 @@ import type {
import type { AciString } from '../../types/ServiceId.std.ts';
import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors.std.ts';
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji.std.ts';
import type { NotifyWhileMuted } from '../../util/notifyWhileMuted.std.ts';
import { DEFAULT_NOTIFY_IF_MUTED } from '../../util/notifyWhileMuted.std.ts';
import { DurationInSeconds } from '../../util/durations/index.std.ts';
import * as Bytes from '../../Bytes.std.ts';
import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes.std.ts';
@@ -217,6 +219,16 @@ export const getBadgeCountMutedConversations = createSelector(
}
);
export const getGlobalNotifyWhileMuted = createSelector(
getItems,
(state: ItemsStateType): NotifyWhileMuted => ({
calls: state.notifyForCallsIfMuted ?? DEFAULT_NOTIFY_IF_MUTED.calls,
mentions:
state.notifyForMentionsIfMuted ?? DEFAULT_NOTIFY_IF_MUTED.mentions,
replies: state.notifyForRepliesIfMuted ?? DEFAULT_NOTIFY_IF_MUTED.replies,
})
);
export const getTextFormattingEnabled = createSelector(
getItems,
(state: ItemsStateType): boolean => state.textFormatting ?? true
@@ -11,6 +11,7 @@ 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';
export type SmartConversationNotificationsSettingsProps = {
conversationId: string;
@@ -24,13 +25,14 @@ export const SmartConversationNotificationsSettings = memo(
const conversationSelector = useSelector(getConversationByIdSelector);
const { setMuteExpiration } = useConversationsActions();
const { pushPanelForConversation } = useNavActions();
const globalNotifyWhileMuted = useSelector(getGlobalNotifyWhileMuted);
const conversation = conversationSelector(conversationId);
strictAssert(conversation, 'Expected a conversation to be found');
const { muteExpiresAt, type: conversationType } = conversation;
const notifyWhileMuted = useMemo(
() => getNotifyWhileMuted(conversation),
[conversation]
() => getNotifyWhileMuted(conversation, globalNotifyWhileMuted),
[conversation, globalNotifyWhileMuted]
);
const handleOpenWhileMutedSettings = useCallback(() => {
+16
View File
@@ -18,6 +18,7 @@ import {
import {
getBackupKey,
getCustomColors,
getGlobalNotifyWhileMuted,
getItems,
getNavTabsCollapsed,
getPreferredLeftPaneWidth,
@@ -113,6 +114,8 @@ import {
import type { SettingsLocation } from '../../types/Nav.std.ts';
import type { StorageAccessType } from '../../types/Storage.d.ts';
import type { NotifyWhileMutedKey } from '../../util/notifyWhileMuted.std.ts';
import { NOTIFY_WHILE_MUTED_FIELDS } from '../../util/notifyWhileMuted.std.ts';
import type { ThemeType } from '../../util/preload.preload.ts';
import type { WidthBreakpoint } from '../../components/_util.std.ts';
import type { StateType } from '../reducer.preload.ts';
@@ -774,6 +777,17 @@ export function SmartPreferences(): JSX.Element | null {
);
};
const notifyWhileMuted = useSelector(getGlobalNotifyWhileMuted);
const onNotifyWhileMutedChange = (
key: NotifyWhileMutedKey,
value: boolean
) => {
const itemKey = NOTIFY_WHILE_MUTED_FIELDS[key];
putItem(itemKey, value);
const account = window.ConversationController.getOurConversationOrThrow();
account.captureChange(itemKey);
};
const [hasPinReminders, onPinRemindersChange] = createItemsAccess(
'pinReminders',
true,
@@ -1029,6 +1043,7 @@ export function SmartPreferences(): JSX.Element | null {
me={me}
navTabsCollapsed={navTabsCollapsed}
notificationContent={notificationContent}
notifyWhileMuted={notifyWhileMuted}
onAudioNotificationsChange={onAudioNotificationsChange}
onAutoConvertEmojiChange={onAutoConvertEmojiChange}
onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange}
@@ -1061,6 +1076,7 @@ export function SmartPreferences(): JSX.Element | null {
onNotificationAttentionChange={onNotificationAttentionChange}
onNotificationContentChange={onNotificationContentChange}
onNotificationsChange={onNotificationsChange}
onNotifyWhileMutedChange={onNotifyWhileMutedChange}
onStartUpdate={startUpdate}
onPreferContactAvatarsChange={onPreferContactAvatarsChange}
onReactionNotificationsChange={onReactionNotificationsChange}
@@ -6,6 +6,7 @@ import { memo, useCallback, useMemo } from 'react';
import { WhileMutedSettings } from '../../components/conversation/conversation-details/WhileMutedSettings.dom.tsx';
import { getIntl } from '../selectors/user.std.ts';
import { getConversationByIdSelector } from '../selectors/conversations.dom.ts';
import { getGlobalNotifyWhileMuted } from '../selectors/items.dom.ts';
import { strictAssert } from '../../util/assert.std.ts';
import { useConversationsActions } from '../ducks/conversations.preload.ts';
import {
@@ -23,12 +24,13 @@ export const SmartWhileMutedSettings = memo(function SmartWhileMutedSettings({
const i18n = useSelector(getIntl);
const conversationSelector = useSelector(getConversationByIdSelector);
const { setNotifyWhileMuted } = useConversationsActions();
const globalNotifyWhileMuted = useSelector(getGlobalNotifyWhileMuted);
const conversation = conversationSelector(conversationId);
strictAssert(conversation, 'Expected a conversation to be found');
const notifyWhileMuted = useMemo(
() => getNotifyWhileMuted(conversation),
[conversation]
() => getNotifyWhileMuted(conversation, globalNotifyWhileMuted),
[conversation, globalNotifyWhileMuted]
);
const setNotifyWhileMutedForConversation = useCallback(
(key: NotifyWhileMutedKey, value: boolean) =>
+3
View File
@@ -126,6 +126,9 @@ function* createRecords({
preferredReactionEmoji: [],
displayBadgesOnProfile: true,
keepMutedChatsArchived: false,
notifyForCallsIfMuted: null,
notifyForMentionsIfMuted: null,
notifyForRepliesIfMuted: null,
hasSetMyStoriesPrivacy: true,
hasViewedOnboardingStory: true,
storiesDisabled: false,
+19 -25
View File
@@ -9,37 +9,31 @@ import {
} from '../../util/notifyWhileMuted.std.ts';
describe('getNotifyWhileMuted', () => {
it('falls back to the defaults when nothing is set', () => {
assert.deepEqual(getNotifyWhileMuted({}), {
calls: true,
mentions: true,
replies: true,
});
const GLOBAL = { calls: true, mentions: true, replies: true };
it('follows the global setting when the chat has no settings', () => {
assert.deepEqual(getNotifyWhileMuted({}, GLOBAL), GLOBAL);
});
it('honors each setting once it is set', () => {
it('lets the chat override the global setting', () => {
assert.deepEqual(
getNotifyWhileMuted({
notifyForCallsIfMuted: true,
notifyForMentionsIfMuted: false,
notifyForRepliesIfMuted: false,
}),
{ calls: true, mentions: false, replies: false }
getNotifyWhileMuted(
{
notifyForCallsIfMuted: false,
notifyForMentionsIfMuted: false,
notifyForRepliesIfMuted: false,
},
GLOBAL
),
{ calls: false, mentions: false, replies: false }
);
});
it('defaults each setting independently', () => {
assert.deepEqual(getNotifyWhileMuted({ notifyForMentionsIfMuted: false }), {
calls: true,
mentions: false,
replies: true,
});
assert.deepEqual(getNotifyWhileMuted({ notifyForCallsIfMuted: true }), {
calls: true,
mentions: true,
replies: true,
});
it('mixes chat overrides with the global setting', () => {
assert.deepEqual(
getNotifyWhileMuted({ notifyForCallsIfMuted: false }, GLOBAL),
{ calls: false, mentions: true, replies: true }
);
});
});
+1
View File
@@ -87,6 +87,7 @@ export enum SettingsPage {
EditChatFolder = 'EditChatFolder',
NotificationProfilesHome = 'NotificationProfilesHome',
NotificationProfilesCreateFlow = 'NotificationProfilesCreateFlow',
WhileMuted = 'WhileMuted',
PNP = 'PNP',
BackupsDetails = 'BackupsDetails',
LocalBackups = 'LocalBackups',
+6
View File
@@ -215,6 +215,9 @@ export type StorageAccessType = {
backupsSubscriberOriginalTransactionId: string;
displayBadgesOnProfile: boolean;
keepMutedChatsArchived: boolean;
notifyForCallsIfMuted: boolean | undefined;
notifyForMentionsIfMuted: boolean | undefined;
notifyForRepliesIfMuted: boolean | undefined;
usernameLastIntegrityCheck: number;
usernameCorrupted: boolean;
usernameLinkCorrupted: boolean;
@@ -429,6 +432,9 @@ export const STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK = [
'universalExpireTimer',
'displayBadgesOnProfile',
'keepMutedChatsArchived',
'notifyForCallsIfMuted',
'notifyForMentionsIfMuted',
'notifyForRepliesIfMuted',
'hasSetMyStoriesPrivacy',
'hasViewedOnboardingStory',
'hasKeyTransparencyDisabled',
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d.ts';
import { itemStorage } from '../textsecure/Storage.preload.ts';
import type { NotifyWhileMuted } from './notifyWhileMuted.std.ts';
import {
DEFAULT_NOTIFY_IF_MUTED,
getNotifyWhileMuted,
} from './notifyWhileMuted.std.ts';
function getGlobalNotifyWhileMutedFromStorage(): NotifyWhileMuted {
return {
calls: itemStorage.get(
'notifyForCallsIfMuted',
DEFAULT_NOTIFY_IF_MUTED.calls
),
mentions: itemStorage.get(
'notifyForMentionsIfMuted',
DEFAULT_NOTIFY_IF_MUTED.mentions
),
replies: itemStorage.get(
'notifyForRepliesIfMuted',
DEFAULT_NOTIFY_IF_MUTED.replies
),
};
}
export function getNotifyWhileMutedForConversation(
attributes: ConversationAttributesType
): NotifyWhileMuted {
return getNotifyWhileMuted(
attributes,
getGlobalNotifyWhileMutedFromStorage()
);
}
+48 -15
View File
@@ -1,7 +1,7 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d.ts';
import type { LocalizerType } from '../types/Util.std.ts';
/**
* Which notifications still come through while a chat is muted.
@@ -14,20 +14,23 @@ export type NotifyWhileMuted = Readonly<{
export type NotifyWhileMutedKey = keyof NotifyWhileMuted;
export const DEFAULT_NOTIFY_WHILE_MUTED: NotifyWhileMuted = {
calls: true,
export const DEFAULT_NOTIFY_IF_MUTED: NotifyWhileMuted = {
calls: false,
mentions: true,
replies: true,
};
type NotifyWhileMutedFields = Readonly<
Pick<
ConversationAttributesType,
| 'notifyForCallsIfMuted'
| 'notifyForMentionsIfMuted'
| 'notifyForRepliesIfMuted'
>
>;
export type NotifyWhileMutedFields = Readonly<{
notifyForCallsIfMuted?: boolean | undefined;
notifyForMentionsIfMuted?: boolean | undefined;
notifyForRepliesIfMuted?: boolean | undefined;
}>;
export const NOTIFY_WHILE_MUTED_FIELDS = {
calls: 'notifyForCallsIfMuted',
mentions: 'notifyForMentionsIfMuted',
replies: 'notifyForRepliesIfMuted',
} as const satisfies Record<NotifyWhileMutedKey, keyof NotifyWhileMutedFields>;
/**
* Reconciles an incoming record's deprecated `dontNotifyForMentionsIfMuted`
@@ -55,13 +58,43 @@ export function resolveLegacyNotifyForMentionsIfMuted(
}
export function getNotifyWhileMuted(
fields: NotifyWhileMutedFields
conversation: NotifyWhileMutedFields,
globalNotifyWhileMuted: NotifyWhileMuted
): NotifyWhileMuted {
return {
calls: fields.notifyForCallsIfMuted ?? DEFAULT_NOTIFY_WHILE_MUTED.calls,
calls: conversation.notifyForCallsIfMuted ?? globalNotifyWhileMuted.calls,
mentions:
fields.notifyForMentionsIfMuted ?? DEFAULT_NOTIFY_WHILE_MUTED.mentions,
conversation.notifyForMentionsIfMuted ?? globalNotifyWhileMuted.mentions,
replies:
fields.notifyForRepliesIfMuted ?? DEFAULT_NOTIFY_WHILE_MUTED.replies,
conversation.notifyForRepliesIfMuted ?? globalNotifyWhileMuted.replies,
};
}
export function getNotifyWhileMutedSummary(
{ calls, mentions, replies }: NotifyWhileMuted,
i18n: LocalizerType
): string {
if (calls && mentions && replies) {
return i18n('icu:WhileMuted__value--calls-mentions-replies');
}
if (calls && mentions) {
return i18n('icu:WhileMuted__value--calls-mentions');
}
if (calls && replies) {
return i18n('icu:WhileMuted__value--calls-replies');
}
if (mentions && replies) {
return i18n('icu:WhileMuted__value--mentions-replies');
}
if (calls) {
return i18n('icu:WhileMuted__value--calls');
}
if (mentions) {
return i18n('icu:WhileMuted__value--mentions');
}
if (replies) {
return i18n('icu:WhileMuted__value--replies');
}
return i18n('icu:WhileMuted__value--none');
}