From d985b289c957d75362f743b263344a3a14e7c9a7 Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 4 Jun 2026 12:48:55 +0300 Subject: [PATCH] fix(notifications): show the loudest mode per group and re-enable disabled rules so a server-disabled rule can be toggled --- .../settings/notifications/AllMessages.tsx | 37 ++++++++++++------- .../NotificationModeSwitcher.tsx | 13 ++++--- .../notifications/SpecialMessages.tsx | 36 +++++++++++------- src/app/hooks/useNotificationMode.ts | 16 +++++++- 4 files changed, 70 insertions(+), 32 deletions(-) diff --git a/src/app/features/settings/notifications/AllMessages.tsx b/src/app/features/settings/notifications/AllMessages.tsx index da80cc80..f12d3ece 100644 --- a/src/app/features/settings/notifications/AllMessages.tsx +++ b/src/app/features/settings/notifications/AllMessages.tsx @@ -6,9 +6,11 @@ import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { SettingTile } from '../../../components/setting-tile'; import { SettingsSection } from '../SettingsSection'; -import { getPushRule, PushRuleData, usePushRule } from '../../../hooks/usePushRule'; +import { getPushRule, PushRuleData } from '../../../hooks/usePushRule'; import { getNotificationModeActions, + getNotificationModeFromRule, + loudestNotificationMode, NotificationMode, useNotificationModeActions, } from '../../../hooks/useNotificationMode'; @@ -63,33 +65,42 @@ function AllMessagesModeSwitcher({ pushRules }: { pushRules: IPushRules }) { const mx = useMatrixClient(); 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 + // The displayed mode is the loudest across all four rules, with each rule's + // mode factoring in whether the server has it disabled (so a disabled rule + // reads OFF rather than echoing stale loud actions). + const selectedMode = useMemo( + () => + loudestNotificationMode( + 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( async (mode: NotificationMode) => { const actions = getModeActions(mode); // 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( - ALL_MESSAGE_RULES.map(({ ruleId, encrypted, oneToOne }) => { + ALL_MESSAGE_RULES.flatMap(({ ruleId, encrypted, oneToOne }) => { const { kind } = 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] ); - return ; + return ; } export function AllMessagesNotifications() { diff --git a/src/app/features/settings/notifications/NotificationModeSwitcher.tsx b/src/app/features/settings/notifications/NotificationModeSwitcher.tsx index 3b10509a..69ba081e 100644 --- a/src/app/features/settings/notifications/NotificationModeSwitcher.tsx +++ b/src/app/features/settings/notifications/NotificationModeSwitcher.tsx @@ -1,8 +1,7 @@ import { Box, color, config, Icon, Icons, IconSrc, Spinner, toRem } from 'folds'; -import { IPushRule } from 'matrix-js-sdk'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode'; +import { NotificationMode } from '../../../hooks/useNotificationMode'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; export const useNotificationModes = (): NotificationMode[] => @@ -33,14 +32,18 @@ const useNotificationModeStr = (): Record => { }; 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; }; -export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) { +export function NotificationModeSwitcher({ + selectedMode, + 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; diff --git a/src/app/features/settings/notifications/SpecialMessages.tsx b/src/app/features/settings/notifications/SpecialMessages.tsx index 631e02d7..dcdea0cc 100644 --- a/src/app/features/settings/notifications/SpecialMessages.tsx +++ b/src/app/features/settings/notifications/SpecialMessages.tsx @@ -8,14 +8,11 @@ import { SettingsSection } from '../SettingsSection'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useAuthedUserId } from '../../../hooks/useAuthedUserId'; import { getMxIdLocalPart } from '../../../utils/matrix'; -import { - getPushRule, - makePushRuleData, - PushRuleData, - usePushRule, -} from '../../../hooks/usePushRule'; +import { getPushRule, makePushRuleData, PushRuleData } from '../../../hooks/usePushRule'; import { getNotificationModeActions, + getNotificationModeFromRule, + loudestNotificationMode, NotificationMode, NotificationModeOptions, useNotificationModeActions, @@ -110,25 +107,38 @@ 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(); + // Displayed mode is the loudest across the group, each rule factoring in + // whether the server has it disabled. + const selectedMode = useMemo( + () => + loudestNotificationMode( + group.map(({ ruleId, getDefault }) => { + const { pushRule } = getPushRule(pushRules, ruleId) ?? getDefault(); + return getNotificationModeFromRule(pushRule); + }) + ), + [pushRules, group] + ); const handleChange = useCallback( async (mode: NotificationMode) => { 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( - group.map(({ ruleId, getDefault }) => { + group.flatMap(({ 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] ); - return ; + return ; } export function SpecialMessagesNotifications() { diff --git a/src/app/hooks/useNotificationMode.ts b/src/app/hooks/useNotificationMode.ts index df90f647..b2f9adb6 100644 --- a/src/app/hooks/useNotificationMode.ts +++ b/src/app/hooks/useNotificationMode.ts @@ -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'; export enum NotificationMode { @@ -66,3 +66,17 @@ export const useNotificationActionsMode = (actions: PushRuleAction[]): Notificat 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; +};