From 2b66dcc08c8e1509841e50ddf48aa3e51f3b4965 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 02:08:03 -0400 Subject: [PATCH] fix(room-nav): expiring timed mute no longer clobbers a manual mode change unmuteRoom unconditionally reset the room to Unset when a timed mute expired (in-session timer and boot-time restore alike), silently reverting a mode the user had changed by hand during the window. Mute-timer helpers move to muteTimers.ts; unmuteRoom now reads the live push-rule mode and only resets when it is still Mute, always dropping the persisted timer. Unit-tested. Fixes #21 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room-nav/RoomNavItem.tsx | 44 +--------- src/app/features/room-nav/muteTimers.test.ts | 20 +++++ src/app/features/room-nav/muteTimers.ts | 88 ++++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- 4 files changed, 111 insertions(+), 43 deletions(-) create mode 100644 src/app/features/room-nav/muteTimers.test.ts create mode 100644 src/app/features/room-nav/muteTimers.ts diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index dd299ee1b..a4746c417 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -6,7 +6,7 @@ import React, { useRef, useState, } from 'react'; -import { MatrixClient, Room } from 'matrix-js-sdk'; +import { Room } from 'matrix-js-sdk'; import { Avatar, Box, @@ -69,6 +69,7 @@ import { setRoomNotificationPreference, } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; +import { scheduleMuteTimer, unmuteRoom } from './muteTimers'; import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; @@ -274,47 +275,6 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) { ); } -// localStorage key for timed mute timers -export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; - -// setTimeout's delay is a signed 32-bit int; larger values overflow and fire -// immediately. Clamp long delays to this max (~24.8 days). -export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647; - -export type MuteTimerEntry = { roomId: string; unmuteAt: number }; - -export function loadMuteTimers(): MuteTimerEntry[] { - try { - const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -export function saveMuteTimers(timers: MuteTimerEntry[]): void { - localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers)); -} - -// Reverse a timed mute: restore the room's notification mode to Unset and drop -// its persisted timer. Shared by the in-session timer and the boot-time restore. -export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise { - await setRoomNotificationPreference( - mx, - roomId, - RoomNotificationMode.Unset, - RoomNotificationMode.Mute, - ).catch(() => {}); - saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId)); -} - -function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void { - const unmuteAt = Date.now() + durationMs; - const existing = loadMuteTimers().filter((e) => e.roomId !== roomId); - saveMuteTimers([...existing, { roomId, unmuteAt }]); - setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS)); -} - type RoomNavItemMenuProps = { room: Room; requestClose: () => void; diff --git a/src/app/features/room-nav/muteTimers.test.ts b/src/app/features/room-nav/muteTimers.test.ts new file mode 100644 index 000000000..809ee152c --- /dev/null +++ b/src/app/features/room-nav/muteTimers.test.ts @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences'; +import { shouldResetMuteOnUnmute } from './muteTimers'; + +test('resets to Unset when the room is still Mute at expiry', () => { + assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true); +}); + +test('does not reset when the user switched to All messages during the mute window', () => { + assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.AllMessages), false); +}); + +test('does not reset when the user switched to Special messages during the mute window', () => { + assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.SpecialMessages), false); +}); + +test('does not reset when the mode is already Unset', () => { + assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false); +}); diff --git a/src/app/features/room-nav/muteTimers.ts b/src/app/features/room-nav/muteTimers.ts new file mode 100644 index 000000000..d1eb3e66f --- /dev/null +++ b/src/app/features/room-nav/muteTimers.ts @@ -0,0 +1,88 @@ +import { IPushRule, IPushRules, MatrixClient } from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../../types/matrix/accountData'; +import { getAccountData } from '../../utils/room'; +import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode'; +import { + RoomNotificationMode, + setRoomNotificationPreference, +} from '../../hooks/useRoomsNotificationPreferences'; + +// localStorage key for timed mute timers +export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; + +// setTimeout's delay is a signed 32-bit int; larger values overflow and fire +// immediately. Clamp long delays to this max (~24.8 days). +export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647; + +export type MuteTimerEntry = { roomId: string; unmuteAt: number }; + +export function loadMuteTimers(): MuteTimerEntry[] { + try { + const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function saveMuteTimers(timers: MuteTimerEntry[]): void { + localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers)); +} + +// Pure decision for the unmute guard: a timed mute should only be reset back to +// Unset if the room's notification mode is still Mute at expiry time. If the user +// manually changed it (e.g. to All messages) while the timer was pending, leave +// their choice alone — just let the stale timer entry get dropped. +export function shouldResetMuteOnUnmute(currentMode: RoomNotificationMode): boolean { + return currentMode === RoomNotificationMode.Mute; +} + +// Reads the room's live notification mode straight from account data push rules, +// mirroring useRoomsNotificationPreferences' per-room derivation, without needing +// the React hook (this runs from plain timers/effects, not components). +export function getLiveRoomNotificationMode( + mx: MatrixClient, + roomId: string, +): RoomNotificationMode { + const pushRules = getAccountData(mx, AccountDataEvent.PushRules)?.getContent(); + const global = pushRules?.global; + + const overrideRule = global?.override?.find((rule: IPushRule) => rule.rule_id === roomId); + if (overrideRule && getNotificationMode(overrideRule.actions) === NotificationMode.OFF) { + return RoomNotificationMode.Mute; + } + + const roomRule = global?.room?.find((rule: IPushRule) => rule.rule_id === roomId); + if (roomRule) { + return getNotificationMode(roomRule.actions) === NotificationMode.OFF + ? RoomNotificationMode.SpecialMessages + : RoomNotificationMode.AllMessages; + } + + return RoomNotificationMode.Unset; +} + +// Reverse a timed mute: restore the room's notification mode to Unset and drop +// its persisted timer. Shared by the in-session timer and the boot-time restore. +// Only resets the mode if it is still Mute — otherwise a manual change made +// during the mute window (e.g. switching to "All messages") would silently get +// reverted when the stale timer fires. +export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise { + const currentMode = getLiveRoomNotificationMode(mx, roomId); + if (shouldResetMuteOnUnmute(currentMode)) { + await setRoomNotificationPreference( + mx, + roomId, + RoomNotificationMode.Unset, + RoomNotificationMode.Mute, + ).catch(() => {}); + } + saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId)); +} + +export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void { + const unmuteAt = Date.now() + durationMs; + const existing = loadMuteTimers().filter((e) => e.roomId !== roomId); + saveMuteTimers([...existing, { roomId, unmuteAt }]); + setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS)); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 3a8437752..d7944424c 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -51,7 +51,7 @@ import { MuteTimerEntry, loadMuteTimers, unmuteRoom, -} from '../../features/room-nav/RoomNavItem'; +} from '../../features/room-nav/muteTimers'; import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { toastQueueAtom } from '../../state/toast';