refactor(notifications): collapse the push-rule matrices into compact controls, preserving every rule id

This commit is contained in:
heaven 2026-06-04 01:00:55 +03:00
parent 5843d75d89
commit 083c8e7149
3 changed files with 160 additions and 189 deletions

View file

@ -6,7 +6,7 @@ import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { NotificationModeSwitcher } from './NotificationModeSwitcher';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection'; import { SettingsSection } from '../SettingsSection';
import { PushRuleData, usePushRule } from '../../../hooks/usePushRule'; import { getPushRule, PushRuleData, usePushRule } from '../../../hooks/usePushRule';
import { import {
getNotificationModeActions, getNotificationModeActions,
NotificationMode, NotificationMode,
@ -14,6 +14,22 @@ import {
} from '../../../hooks/useNotificationMode'; } from '../../../hooks/useNotificationMode';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
// The four global underride rules that govern "all messages" notifications.
// They are written in lockstep behind one consolidated control so a single
// "Default for new chats" choice fans out to DM / encrypted DM / rooms /
// encrypted rooms. NEVER drop a ruleId here — a missing one silently stops
// notifying (e.g. encrypted rooms) for that surface.
const ALL_MESSAGE_RULES: {
ruleId: RuleId.DM | RuleId.EncryptedDM | RuleId.Message | RuleId.EncryptedMessage;
encrypted: boolean;
oneToOne: boolean;
}[] = [
{ ruleId: RuleId.DM, encrypted: false, oneToOne: true },
{ ruleId: RuleId.EncryptedDM, encrypted: true, oneToOne: true },
{ ruleId: RuleId.Message, encrypted: false, oneToOne: false },
{ ruleId: RuleId.EncryptedMessage, encrypted: true, oneToOne: false },
];
const getAllMessageDefaultRule = ( const getAllMessageDefaultRule = (
ruleId: RuleId, ruleId: RuleId,
encrypted: boolean, encrypted: boolean,
@ -43,29 +59,34 @@ const getAllMessageDefaultRule = (
}; };
}; };
type PushRulesProps = { function AllMessagesModeSwitcher({ pushRules }: { pushRules: IPushRules }) {
ruleId: RuleId.DM | RuleId.EncryptedDM | RuleId.Message | RuleId.EncryptedMessage;
pushRules: IPushRules;
encrypted?: boolean;
oneToOne?: boolean;
};
function AllMessagesModeSwitcher({
ruleId,
pushRules,
encrypted = false,
oneToOne = false,
}: PushRulesProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const defaultPushRuleData = getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
const getModeActions = useNotificationModeActions(); const getModeActions = useNotificationModeActions();
// Use the DM rule as the representative selected state — all four are kept in
// lockstep so any of them reflects the consolidated mode.
const representative = ALL_MESSAGE_RULES[0];
const defaultRepresentative = getAllMessageDefaultRule(
representative.ruleId,
representative.encrypted,
representative.oneToOne
);
const { pushRule } = usePushRule(pushRules, representative.ruleId) ?? defaultRepresentative;
const handleChange = useCallback( const handleChange = useCallback(
async (mode: NotificationMode) => { async (mode: NotificationMode) => {
const actions = getModeActions(mode); const actions = getModeActions(mode);
await mx.setPushRuleActions('global', kind, ruleId, actions); // Fan the choice across ALL four underride rules so encrypted rooms never
// silently fall out of sync.
await Promise.all(
ALL_MESSAGE_RULES.map(({ ruleId, encrypted, oneToOne }) => {
const { kind } =
getPushRule(pushRules, ruleId) ?? getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
return mx.setPushRuleActions('global', kind, ruleId, actions);
})
);
}, },
[mx, getModeActions, kind, ruleId] [mx, getModeActions, pushRules]
); );
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />; return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
@ -82,33 +103,9 @@ export function AllMessagesNotifications() {
return ( return (
<SettingsSection label={t('Settings.all_messages')}> <SettingsSection label={t('Settings.all_messages')}>
<SettingTile <SettingTile
title={t('Settings.one_to_one')} title={t('Settings.default_for_new_chats')}
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />} description={t('Settings.default_for_new_chats_desc')}
/> after={<AllMessagesModeSwitcher pushRules={pushRules} />}
<SettingTile
title={t('Settings.one_to_one_encrypted')}
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
ruleId={RuleId.EncryptedDM}
encrypted
oneToOne
/>
}
/>
<SettingTile
title={t('Settings.rooms')}
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.Message} />}
/>
<SettingTile
title={t('Settings.rooms_encrypted')}
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
ruleId={RuleId.EncryptedMessage}
encrypted
/>
}
/> />
</SettingsSection> </SettingsSection>
); );

View file

@ -1,27 +1,25 @@
import { import { Box, color, config, Icon, Icons, IconSrc, Spinner, toRem } from 'folds';
Box,
Button,
config,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
} from 'folds';
import { IPushRule } from 'matrix-js-sdk'; import { IPushRule } from 'matrix-js-sdk';
import React, { MouseEventHandler, useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FocusTrap from 'focus-trap-react';
import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode'; import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode';
import { stopPropagation } from '../../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
export const useNotificationModes = (): NotificationMode[] => export const useNotificationModes = (): NotificationMode[] =>
useMemo(() => [NotificationMode.NotifyLoud, NotificationMode.Notify, NotificationMode.OFF], []); useMemo(() => [NotificationMode.NotifyLoud, NotificationMode.Notify, NotificationMode.OFF], []);
// Mirror the per-room bell language (getRoomNotificationModeIcon): loud = ring,
// silent = ping, off = mute.
const useNotificationModeIcon = (): Record<NotificationMode, IconSrc> =>
useMemo(
() => ({
[NotificationMode.NotifyLoud]: Icons.BellRing,
[NotificationMode.Notify]: Icons.BellPing,
[NotificationMode.OFF]: Icons.BellMute,
}),
[]
);
const useNotificationModeStr = (): Record<NotificationMode, string> => { const useNotificationModeStr = (): Record<NotificationMode, string> => {
const { t } = useTranslation(); const { t } = useTranslation();
return useMemo( return useMemo(
@ -40,81 +38,63 @@ type NotificationModeSwitcherProps = {
}; };
export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) { export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) {
const modes = useNotificationModes(); const modes = useNotificationModes();
const modeToIcon = useNotificationModeIcon();
const modeToStr = useNotificationModeStr(); const modeToStr = useNotificationModeStr();
const selectedMode = useNotificationActionsMode(pushRule.actions); const selectedMode = useNotificationActionsMode(pushRule.actions);
const [changeState, change] = useAsyncCallback(onChange); const [changeState, change] = useAsyncCallback(onChange);
const changing = changeState.status === AsyncStatus.Loading; const changing = changeState.status === AsyncStatus.Loading;
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (mode: NotificationMode) => {
setMenuCords(undefined);
change(mode);
};
return ( return (
<> <Box
<Button shrink="No"
size="300" alignItems="Center"
variant="Secondary" gap="0"
outlined style={{
fill="Soft" position: 'relative',
radii="300" padding: toRem(2),
after={ borderRadius: config.radii.Pill,
changing ? ( backgroundColor: color.SurfaceVariant.Container,
<Spinner variant="Secondary" size="300" /> opacity: changing ? 0.6 : undefined,
) : ( }}
<Icon size="300" src={Icons.ChevronBottom} /> >
) {modes.map((mode) => {
} const selected = mode === selectedMode;
onClick={handleMenu} return (
disabled={changing} <button
> key={mode}
<Text size="T300">{modeToStr[selectedMode]}</Text> type="button"
</Button> aria-label={modeToStr[mode]}
<PopOut aria-pressed={selected}
anchor={menuCords} title={modeToStr[mode]}
offset={5} disabled={changing}
position="Bottom" onClick={() => change(mode)}
align="End" style={{
content={ display: 'flex',
<FocusTrap alignItems: 'center',
focusTrapOptions={{ justifyContent: 'center',
initialFocus: false, width: toRem(32),
onDeactivate: () => setMenuCords(undefined), height: toRem(28),
clickOutsideDeactivates: true, border: 'none',
isKeyForward: (evt: KeyboardEvent) => cursor: changing ? 'default' : 'pointer',
evt.key === 'ArrowDown' || evt.key === 'ArrowRight', borderRadius: config.radii.Pill,
isKeyBackward: (evt: KeyboardEvent) => backgroundColor: selected ? color.Primary.Main : 'transparent',
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', color: selected ? color.Primary.OnMain : color.SurfaceVariant.OnContainer,
escapeDeactivates: stopPropagation,
}} }}
> >
<Menu> <Icon
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> size="100"
{modes.map((mode) => ( src={modeToIcon[mode]}
<MenuItem filled={selected}
key={mode} style={{ color: 'inherit' }}
size="300" />
variant="Surface" </button>
aria-selected={mode === selectedMode} );
radii="300" })}
onClick={() => handleSelect(mode)} {changing && (
> <Box alignItems="Center" justifyContent="Center" style={{ position: 'absolute', inset: 0 }}>
<Box grow="Yes"> <Spinner variant="Secondary" size="300" />
<Text size="T300">{modeToStr[mode]}</Text> </Box>
</Box> )}
</MenuItem> </Box>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
); );
} }

View file

@ -7,9 +7,13 @@ import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection'; import { SettingsSection } from '../SettingsSection';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAuthedUserId } from '../../../hooks/useAuthedUserId'; import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart } from '../../../utils/matrix'; import { getMxIdLocalPart } from '../../../utils/matrix';
import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule'; import {
getPushRule,
makePushRuleData,
PushRuleData,
usePushRule,
} from '../../../hooks/usePushRule';
import { import {
getNotificationModeActions, getNotificationModeActions,
NotificationMode, NotificationMode,
@ -89,23 +93,39 @@ const DefaultAtRoomNotification = makePushRuleData(
] ]
); );
type PushRulesProps = { type RuleGroupEntry = {
ruleId: RuleId; ruleId: RuleId;
pushRules: IPushRules; getDefault: () => PushRuleData;
defaultPushRuleData: PushRuleData;
}; };
function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRulesProps) {
const mx = useMatrixClient();
const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData; // One consolidated "Mentions" control fans across the three personal-mention
// rules; one "@room" control fans across the two room-wide announcement rules.
// Every ruleId is still written behind the grouped control — a dropped ruleId
// silently breaks mention notifications.
type MentionGroupSwitcherProps = {
pushRules: IPushRules;
group: RuleGroupEntry[];
};
function MentionGroupSwitcher({ pushRules, group }: MentionGroupSwitcherProps) {
const mx = useMatrixClient();
const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS); const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
// First rule in the group is the representative for the displayed mode; all
// rules in the group are written in lockstep.
const representative = group[0];
const { pushRule } = usePushRule(pushRules, representative.ruleId) ?? representative.getDefault();
const handleChange = useCallback( const handleChange = useCallback(
async (mode: NotificationMode) => { async (mode: NotificationMode) => {
const actions = getModeActions(mode); const actions = getModeActions(mode);
await mx.setPushRuleActions('global', kind, ruleId, actions); await Promise.all(
group.map(({ ruleId, getDefault }) => {
const { kind } = getPushRule(pushRules, ruleId) ?? getDefault();
return mx.setPushRuleActions('global', kind, ruleId, actions);
})
);
}, },
[mx, getModeActions, kind, ruleId] [mx, getModeActions, pushRules, group]
); );
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />; return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
@ -114,68 +134,42 @@ function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRul
export function SpecialMessagesNotifications() { export function SpecialMessagesNotifications() {
const { t } = useTranslation(); const { t } = useTranslation();
const userId = useAuthedUserId(); const userId = useAuthedUserId();
const { displayName } = useUserProfile(userId);
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules); const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
const pushRules = useMemo( const pushRules = useMemo(
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} }, () => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
[pushRulesEvt] [pushRulesEvt]
); );
const username = getMxIdLocalPart(userId) ?? userId;
const mentionGroup = useMemo<RuleGroupEntry[]>(
() => [
{ ruleId: RuleId.IsUserMention, getDefault: () => getDefaultIsUserMention(userId) },
{ ruleId: RuleId.ContainsDisplayName, getDefault: () => DefaultContainsDisplayName },
{ ruleId: RuleId.ContainsUserName, getDefault: () => getDefaultContainsUsername(username) },
],
[userId, username]
);
const roomGroup = useMemo<RuleGroupEntry[]>(
() => [
{ ruleId: RuleId.IsRoomMention, getDefault: () => DefaultIsRoomMention },
{ ruleId: RuleId.AtRoomNotification, getDefault: () => DefaultAtRoomNotification },
],
[]
);
return ( return (
<SettingsSection label={t('Settings.special_messages')}> <SettingsSection label={t('Settings.special_messages')}>
<SettingTile <SettingTile
title={t('Settings.mention_user_id', { userId })} title={t('Settings.notify_on_mention')}
after={ description={t('Settings.notify_on_mention_desc')}
<MentionModeSwitcher after={<MentionGroupSwitcher pushRules={pushRules} group={mentionGroup} />}
pushRules={pushRules}
ruleId={RuleId.IsUserMention}
defaultPushRuleData={getDefaultIsUserMention(userId)}
/>
}
/> />
<SettingTile <SettingTile
title={ title={t('Settings.room_announcements')}
displayName description={t('Settings.room_announcements_desc')}
? t('Settings.contains_displayname_value', { displayName }) after={<MentionGroupSwitcher pushRules={pushRules} group={roomGroup} />}
: t('Settings.contains_displayname')
}
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.ContainsDisplayName}
defaultPushRuleData={DefaultContainsDisplayName}
/>
}
/>
<SettingTile
title={t('Settings.contains_username', { username: getMxIdLocalPart(userId) })}
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.ContainsUserName}
defaultPushRuleData={getDefaultContainsUsername(getMxIdLocalPart(userId) ?? userId)}
/>
}
/>
<SettingTile
title={t('Settings.mention_room')}
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.IsRoomMention}
defaultPushRuleData={DefaultIsRoomMention}
/>
}
/>
<SettingTile
title={t('Settings.contains_room')}
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.AtRoomNotification}
defaultPushRuleData={DefaultAtRoomNotification}
/>
}
/> />
</SettingsSection> </SettingsSection>
); );