refactor(notifications): collapse the push-rule matrices into compact controls, preserving every rule id
This commit is contained in:
parent
5843d75d89
commit
083c8e7149
3 changed files with 160 additions and 189 deletions
|
|
@ -6,7 +6,7 @@ import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
|||
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SettingsSection } from '../SettingsSection';
|
||||
import { PushRuleData, usePushRule } from '../../../hooks/usePushRule';
|
||||
import { getPushRule, PushRuleData, usePushRule } from '../../../hooks/usePushRule';
|
||||
import {
|
||||
getNotificationModeActions,
|
||||
NotificationMode,
|
||||
|
|
@ -14,6 +14,22 @@ import {
|
|||
} from '../../../hooks/useNotificationMode';
|
||||
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 = (
|
||||
ruleId: RuleId,
|
||||
encrypted: boolean,
|
||||
|
|
@ -43,29 +59,34 @@ const getAllMessageDefaultRule = (
|
|||
};
|
||||
};
|
||||
|
||||
type PushRulesProps = {
|
||||
ruleId: RuleId.DM | RuleId.EncryptedDM | RuleId.Message | RuleId.EncryptedMessage;
|
||||
pushRules: IPushRules;
|
||||
encrypted?: boolean;
|
||||
oneToOne?: boolean;
|
||||
};
|
||||
function AllMessagesModeSwitcher({
|
||||
ruleId,
|
||||
pushRules,
|
||||
encrypted = false,
|
||||
oneToOne = false,
|
||||
}: PushRulesProps) {
|
||||
function AllMessagesModeSwitcher({ pushRules }: { pushRules: IPushRules }) {
|
||||
const mx = useMatrixClient();
|
||||
const defaultPushRuleData = getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
|
||||
const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
|
||||
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(
|
||||
async (mode: NotificationMode) => {
|
||||
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} />;
|
||||
|
|
@ -82,33 +103,9 @@ export function AllMessagesNotifications() {
|
|||
return (
|
||||
<SettingsSection label={t('Settings.all_messages')}>
|
||||
<SettingTile
|
||||
title={t('Settings.one_to_one')}
|
||||
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
|
||||
/>
|
||||
<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
|
||||
/>
|
||||
}
|
||||
title={t('Settings.default_for_new_chats')}
|
||||
description={t('Settings.default_for_new_chats_desc')}
|
||||
after={<AllMessagesModeSwitcher pushRules={pushRules} />}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,27 +1,25 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Box, color, config, Icon, Icons, IconSrc, Spinner, toRem } from 'folds';
|
||||
import { IPushRule } from 'matrix-js-sdk';
|
||||
import React, { MouseEventHandler, useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
|
||||
export const useNotificationModes = (): NotificationMode[] =>
|
||||
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 { t } = useTranslation();
|
||||
return useMemo(
|
||||
|
|
@ -40,81 +38,63 @@ type NotificationModeSwitcherProps = {
|
|||
};
|
||||
export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) {
|
||||
const modes = useNotificationModes();
|
||||
const modeToIcon = useNotificationModeIcon();
|
||||
const modeToStr = useNotificationModeStr();
|
||||
const selectedMode = useNotificationActionsMode(pushRule.actions);
|
||||
const [changeState, change] = useAsyncCallback(onChange);
|
||||
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 (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={
|
||||
changing ? (
|
||||
<Spinner variant="Secondary" size="300" />
|
||||
) : (
|
||||
<Icon size="300" src={Icons.ChevronBottom} />
|
||||
)
|
||||
}
|
||||
onClick={handleMenu}
|
||||
disabled={changing}
|
||||
>
|
||||
<Text size="T300">{modeToStr[selectedMode]}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
gap="0"
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: toRem(2),
|
||||
borderRadius: config.radii.Pill,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
opacity: changing ? 0.6 : undefined,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{modes.map((mode) => (
|
||||
<MenuItem
|
||||
{modes.map((mode) => {
|
||||
const selected = mode === selectedMode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
aria-selected={mode === selectedMode}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(mode)}
|
||||
type="button"
|
||||
aria-label={modeToStr[mode]}
|
||||
aria-pressed={selected}
|
||||
title={modeToStr[mode]}
|
||||
disabled={changing}
|
||||
onClick={() => change(mode)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(32),
|
||||
height: toRem(28),
|
||||
border: 'none',
|
||||
cursor: changing ? 'default' : 'pointer',
|
||||
borderRadius: config.radii.Pill,
|
||||
backgroundColor: selected ? color.Primary.Main : 'transparent',
|
||||
color: selected ? color.Primary.OnMain : color.SurfaceVariant.OnContainer,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T300">{modeToStr[mode]}</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
<Icon
|
||||
size="100"
|
||||
src={modeToIcon[mode]}
|
||||
filled={selected}
|
||||
style={{ color: 'inherit' }}
|
||||
/>
|
||||
</>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{changing && (
|
||||
<Box alignItems="Center" justifyContent="Center" style={{ position: 'absolute', inset: 0 }}>
|
||||
<Spinner variant="Secondary" size="300" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ import { SettingTile } from '../../../components/setting-tile';
|
|||
import { SettingsSection } from '../SettingsSection';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
|
||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule';
|
||||
import {
|
||||
getPushRule,
|
||||
makePushRuleData,
|
||||
PushRuleData,
|
||||
usePushRule,
|
||||
} from '../../../hooks/usePushRule';
|
||||
import {
|
||||
getNotificationModeActions,
|
||||
NotificationMode,
|
||||
|
|
@ -89,23 +93,39 @@ const DefaultAtRoomNotification = makePushRuleData(
|
|||
]
|
||||
);
|
||||
|
||||
type PushRulesProps = {
|
||||
type RuleGroupEntry = {
|
||||
ruleId: RuleId;
|
||||
pushRules: IPushRules;
|
||||
defaultPushRuleData: PushRuleData;
|
||||
getDefault: () => 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);
|
||||
|
||||
// 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(
|
||||
async (mode: NotificationMode) => {
|
||||
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} />;
|
||||
|
|
@ -114,68 +134,42 @@ function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRul
|
|||
export function SpecialMessagesNotifications() {
|
||||
const { t } = useTranslation();
|
||||
const userId = useAuthedUserId();
|
||||
const { displayName } = useUserProfile(userId);
|
||||
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
|
||||
const pushRules = useMemo(
|
||||
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
|
||||
[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 (
|
||||
<SettingsSection label={t('Settings.special_messages')}>
|
||||
<SettingTile
|
||||
title={t('Settings.mention_user_id', { userId })}
|
||||
after={
|
||||
<MentionModeSwitcher
|
||||
pushRules={pushRules}
|
||||
ruleId={RuleId.IsUserMention}
|
||||
defaultPushRuleData={getDefaultIsUserMention(userId)}
|
||||
/>
|
||||
}
|
||||
title={t('Settings.notify_on_mention')}
|
||||
description={t('Settings.notify_on_mention_desc')}
|
||||
after={<MentionGroupSwitcher pushRules={pushRules} group={mentionGroup} />}
|
||||
/>
|
||||
<SettingTile
|
||||
title={
|
||||
displayName
|
||||
? t('Settings.contains_displayname_value', { displayName })
|
||||
: 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}
|
||||
/>
|
||||
}
|
||||
title={t('Settings.room_announcements')}
|
||||
description={t('Settings.room_announcements_desc')}
|
||||
after={<MentionGroupSwitcher pushRules={pushRules} group={roomGroup} />}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue