fix(notifications): show the loudest mode per group and re-enable disabled rules so a server-disabled rule can be toggled

This commit is contained in:
heaven 2026-06-04 12:48:55 +03:00
parent e66d8cf7bf
commit d985b289c9
4 changed files with 70 additions and 32 deletions

View file

@ -6,9 +6,11 @@ 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 { getPushRule, PushRuleData, usePushRule } from '../../../hooks/usePushRule'; import { getPushRule, PushRuleData } from '../../../hooks/usePushRule';
import { import {
getNotificationModeActions, getNotificationModeActions,
getNotificationModeFromRule,
loudestNotificationMode,
NotificationMode, NotificationMode,
useNotificationModeActions, useNotificationModeActions,
} from '../../../hooks/useNotificationMode'; } from '../../../hooks/useNotificationMode';
@ -63,33 +65,42 @@ function AllMessagesModeSwitcher({ pushRules }: { pushRules: IPushRules }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const getModeActions = useNotificationModeActions(); const getModeActions = useNotificationModeActions();
// Use the DM rule as the representative selected state — all four are kept in // The displayed mode is the loudest across all four rules, with each rule's
// lockstep so any of them reflects the consolidated mode. // mode factoring in whether the server has it disabled (so a disabled rule
const representative = ALL_MESSAGE_RULES[0]; // reads OFF rather than echoing stale loud actions).
const defaultRepresentative = getAllMessageDefaultRule( const selectedMode = useMemo(
representative.ruleId, () =>
representative.encrypted, loudestNotificationMode(
representative.oneToOne ALL_MESSAGE_RULES.map(({ ruleId, encrypted, oneToOne }) => {
const { pushRule } =
getPushRule(pushRules, ruleId) ?? getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
return getNotificationModeFromRule(pushRule);
})
),
[pushRules]
); );
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);
// Fan the choice across ALL four underride rules so encrypted rooms never // Fan the choice across ALL four underride rules so encrypted rooms never
// silently fall out of sync. // silently fall out of sync. Also (re-)enable each rule: a server-disabled
// rule otherwise ignores the new actions and stays silent.
await Promise.all( await Promise.all(
ALL_MESSAGE_RULES.map(({ ruleId, encrypted, oneToOne }) => { ALL_MESSAGE_RULES.flatMap(({ ruleId, encrypted, oneToOne }) => {
const { kind } = const { kind } =
getPushRule(pushRules, ruleId) ?? getAllMessageDefaultRule(ruleId, encrypted, oneToOne); getPushRule(pushRules, ruleId) ?? getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
return mx.setPushRuleActions('global', kind, ruleId, actions); return [
mx.setPushRuleEnabled('global', kind, ruleId, true),
mx.setPushRuleActions('global', kind, ruleId, actions),
];
}) })
); );
}, },
[mx, getModeActions, pushRules] [mx, getModeActions, pushRules]
); );
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />; return <NotificationModeSwitcher selectedMode={selectedMode} onChange={handleChange} />;
} }
export function AllMessagesNotifications() { export function AllMessagesNotifications() {

View file

@ -1,8 +1,7 @@
import { Box, color, config, Icon, Icons, IconSrc, Spinner, toRem } from 'folds'; import { Box, color, config, Icon, Icons, IconSrc, Spinner, toRem } from 'folds';
import { IPushRule } from 'matrix-js-sdk';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode'; import { NotificationMode } from '../../../hooks/useNotificationMode';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
export const useNotificationModes = (): NotificationMode[] => export const useNotificationModes = (): NotificationMode[] =>
@ -33,14 +32,18 @@ const useNotificationModeStr = (): Record<NotificationMode, string> => {
}; };
type NotificationModeSwitcherProps = { type NotificationModeSwitcherProps = {
pushRule: IPushRule; // The group's consolidated mode (loudest member, factoring server-disabled
// rules). Controlled by the parent — the switcher no longer reads a rule.
selectedMode: NotificationMode;
onChange: (mode: NotificationMode) => Promise<void>; onChange: (mode: NotificationMode) => Promise<void>;
}; };
export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) { export function NotificationModeSwitcher({
selectedMode,
onChange,
}: NotificationModeSwitcherProps) {
const modes = useNotificationModes(); const modes = useNotificationModes();
const modeToIcon = useNotificationModeIcon(); const modeToIcon = useNotificationModeIcon();
const modeToStr = useNotificationModeStr(); const modeToStr = useNotificationModeStr();
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;

View file

@ -8,14 +8,11 @@ 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 { getMxIdLocalPart } from '../../../utils/matrix'; import { getMxIdLocalPart } from '../../../utils/matrix';
import { import { getPushRule, makePushRuleData, PushRuleData } from '../../../hooks/usePushRule';
getPushRule,
makePushRuleData,
PushRuleData,
usePushRule,
} from '../../../hooks/usePushRule';
import { import {
getNotificationModeActions, getNotificationModeActions,
getNotificationModeFromRule,
loudestNotificationMode,
NotificationMode, NotificationMode,
NotificationModeOptions, NotificationModeOptions,
useNotificationModeActions, useNotificationModeActions,
@ -110,25 +107,38 @@ function MentionGroupSwitcher({ pushRules, group }: MentionGroupSwitcherProps) {
const mx = useMatrixClient(); 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 // Displayed mode is the loudest across the group, each rule factoring in
// rules in the group are written in lockstep. // whether the server has it disabled.
const representative = group[0]; const selectedMode = useMemo(
const { pushRule } = usePushRule(pushRules, representative.ruleId) ?? representative.getDefault(); () =>
loudestNotificationMode(
group.map(({ ruleId, getDefault }) => {
const { pushRule } = getPushRule(pushRules, ruleId) ?? getDefault();
return getNotificationModeFromRule(pushRule);
})
),
[pushRules, group]
);
const handleChange = useCallback( const handleChange = useCallback(
async (mode: NotificationMode) => { async (mode: NotificationMode) => {
const actions = getModeActions(mode); const actions = getModeActions(mode);
// Write actions AND (re-)enable each rule in lockstep — a server-disabled
// mention rule otherwise ignores the new actions and stays silent.
await Promise.all( await Promise.all(
group.map(({ ruleId, getDefault }) => { group.flatMap(({ ruleId, getDefault }) => {
const { kind } = getPushRule(pushRules, ruleId) ?? getDefault(); const { kind } = getPushRule(pushRules, ruleId) ?? getDefault();
return mx.setPushRuleActions('global', kind, ruleId, actions); return [
mx.setPushRuleEnabled('global', kind, ruleId, true),
mx.setPushRuleActions('global', kind, ruleId, actions),
];
}) })
); );
}, },
[mx, getModeActions, pushRules, group] [mx, getModeActions, pushRules, group]
); );
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />; return <NotificationModeSwitcher selectedMode={selectedMode} onChange={handleChange} />;
} }
export function SpecialMessagesNotifications() { export function SpecialMessagesNotifications() {

View file

@ -1,4 +1,4 @@
import { PushRuleAction, PushRuleActionName, TweakName } from 'matrix-js-sdk'; import { IPushRule, PushRuleAction, PushRuleActionName, TweakName } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
export enum NotificationMode { export enum NotificationMode {
@ -66,3 +66,17 @@ export const useNotificationActionsMode = (actions: PushRuleAction[]): Notificat
return mode; return mode;
}; };
// A rule the server has disabled is effectively OFF regardless of its stored
// actions — without this the UI reads loud actions on a disabled rule and
// claims "On" while nothing actually fires.
export const getNotificationModeFromRule = (pushRule: IPushRule): NotificationMode =>
pushRule.enabled === false ? NotificationMode.OFF : getNotificationMode(pushRule.actions);
// The mode shown for a consolidated group of rules is its loudest member, so a
// partially-muted group doesn't misreport as fully off (mirrors Element).
export const loudestNotificationMode = (modes: NotificationMode[]): NotificationMode => {
if (modes.includes(NotificationMode.NotifyLoud)) return NotificationMode.NotifyLoud;
if (modes.includes(NotificationMode.Notify)) return NotificationMode.Notify;
return NotificationMode.OFF;
};