From 69e05bbf8de96587f57c1b1597c7640a912f9b22 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:33:32 -0400 Subject: [PATCH] fix(notifications): timed mutes are lifted by any Lotus device, not just the one that set them (#333) A timed mute writes a server-side push rule (muted everywhere), but the unmute timer lived in one browser's localStorage: close that laptop and the room stayed muted on the phone until it was reopened, and forever after a logout there. Timers now live in account data (io.lotus.mute_timers: { timers: { [roomId]: unmuteAt } }). Every client schedules from it, reschedules when it changes, and the first one online after expiry unmutes. An indefinite mute clears a pending timer. Local timers from before are migrated once. Fixes #333 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/room-nav/RoomNavItem.tsx | 9 +- src/app/features/room-nav/muteTimers.test.ts | 15 +++- src/app/features/room-nav/muteTimers.ts | 93 ++++++++++++++++---- src/app/pages/client/ClientNonUIFeatures.tsx | 48 +++++----- src/app/state/plaintextCaches.ts | 7 +- src/types/matrix/accountData.ts | 4 + 6 files changed, 131 insertions(+), 45 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 711dd0122..a3f4da929 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -80,7 +80,7 @@ import { setRoomNotificationPreference, } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; -import { scheduleMuteTimer, unmuteRoom } from './muteTimers'; +import { clearMuteTimer, scheduleMuteTimer } from './muteTimers'; import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; @@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef( prevMode, ).catch(() => {}); if (durationMs !== null) { - scheduleMuteTimer(room.roomId, durationMs, () => { - unmuteRoom(mx, room.roomId); - }); + await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {}); + } else { + // An indefinite mute replaces any timed one. + await clearMuteTimer(mx, room.roomId); } requestClose(); }, diff --git a/src/app/features/room-nav/muteTimers.test.ts b/src/app/features/room-nav/muteTimers.test.ts index 809ee152c..094c9b01c 100644 --- a/src/app/features/room-nav/muteTimers.test.ts +++ b/src/app/features/room-nav/muteTimers.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences'; -import { shouldResetMuteOnUnmute } from './muteTimers'; +import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers'; test('resets to Unset when the room is still Mute at expiry', () => { assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true); @@ -18,3 +18,16 @@ test('does not reset when the user switched to Special messages during the mute test('does not reset when the mode is already Unset', () => { assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false); }); + +test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => { + const entries = [ + { roomId: '!a:x', unmuteAt: 1000 }, + { roomId: '!b:x', unmuteAt: 2000 }, + ]; + assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries); + assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [ + { roomId: '!b:x', unmuteAt: 5 }, + ]); + assert.deepEqual(parseMuteTimers(undefined), []); + assert.deepEqual(parseMuteTimers({ timers: null }), []); +}); diff --git a/src/app/features/room-nav/muteTimers.ts b/src/app/features/room-nav/muteTimers.ts index d1eb3e66f..06286e64d 100644 --- a/src/app/features/room-nav/muteTimers.ts +++ b/src/app/features/room-nav/muteTimers.ts @@ -7,7 +7,7 @@ import { setRoomNotificationPreference, } from '../../hooks/useRoomsNotificationPreferences'; -// localStorage key for timed mute timers +// Legacy localStorage key (before #333); migrated into account data once. export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; // setTimeout's delay is a signed 32-bit int; larger values overflow and fire @@ -16,17 +16,60 @@ 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 []; - } +/** Pure: the timers in an `io.lotus.mute_timers` account data content. */ +export function parseMuteTimers(content: unknown): MuteTimerEntry[] { + const timers = (content as { timers?: unknown } | undefined)?.timers; + if (!timers || typeof timers !== 'object') return []; + return Object.entries(timers as Record) + .filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1])) + .map(([roomId, unmuteAt]) => ({ roomId, unmuteAt })); } -export function saveMuteTimers(timers: MuteTimerEntry[]): void { - localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers)); +/** Pure: account data content for `entries`. */ +export const muteTimersContent = (entries: MuteTimerEntry[]) => ({ + timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])), +}); + +/** + * [Gitea #333] Timed mutes live in account data: the mute itself is a + * server-side push rule on every device, so the timer must be too — kept in one + * browser's localStorage, only that device ever unmuted (and never after a + * logout there). + */ +export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] { + return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent()); +} + +async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise { + await mx.setAccountData( + AccountDataEvent.LotusMuteTimers as never, + muteTimersContent(entries) as never, + ); +} + +/** Move timers kept in this browser's localStorage (pre-#333) into account data. */ +export async function migrateLocalMuteTimers(mx: MatrixClient): Promise { + let legacy: MuteTimerEntry[] = []; + try { + const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); + legacy = Array.isArray(parsed) + ? parsed.filter( + (e): e is MuteTimerEntry => + !!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number', + ) + : []; + } catch { + legacy = []; + } + if (legacy.length === 0) return; + const current = loadMuteTimers(mx); + const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))]; + await saveMuteTimers(mx, merged); + try { + localStorage.removeItem(MUTE_TIMERS_KEY); + } catch { + /* ignore */ + } } // Pure decision for the unmute guard: a timed mute should only be reset back to @@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise {}); } - saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId)); + await clearMuteTimer(mx, 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)); +/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */ +export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise { + const timers = loadMuteTimers(mx); + if (!timers.some((e) => e.roomId === roomId)) return; + await saveMuteTimers( + mx, + timers.filter((e) => e.roomId !== roomId), + ).catch(() => {}); +} + +/** + * Record when a timed mute ends. MuteTimerRestore (on every Lotus device) + * schedules the unmute from account data, so whichever is online at expiry + * lifts it. + */ +export async function scheduleMuteTimer( + mx: MatrixClient, + roomId: string, + durationMs: number, +): Promise { + const unmuteAt = Date.now() + durationMs; + const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId); + await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]); } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6bf40cbe2..aaeaf6812 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -58,8 +58,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater'; import { MAX_MUTE_TIMEOUT_MS, - MuteTimerEntry, loadMuteTimers, + migrateLocalMuteTimers, unmuteRoom, } from '../../features/room-nav/muteTimers'; import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile'; @@ -91,6 +91,7 @@ import { shouldNotifyThreadReply, THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, } from '../../utils/threadNotifications'; +import { AccountDataEvent } from '../../../types/matrix/accountData'; // Grace period after the initial sync settles before invite notifications arm, so // the async invite-atom population lands first and isn't mistaken for new invites. @@ -369,30 +370,35 @@ function PresenceUpdater() { function MuteTimerRestore() { const mx = useMatrixClient(); + // [Gitea #333] Timers are in account data, so a mute set on another device + // is lifted here too, and changes there (a new mute, a manual unmute) + // reschedule. Moves any timers this browser kept locally (pre-#333) first. useEffect(() => { - const timers = loadMuteTimers(); - if (timers.length === 0) return undefined; - - const now = Date.now(); - const pastDue: MuteTimerEntry[] = []; - const future: MuteTimerEntry[] = []; - timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry)); - - pastDue.forEach((entry) => { - unmuteRoom(mx, entry.roomId); - }); - - const handles = future.map((entry) => - setTimeout( - () => { - unmuteRoom(mx, entry.roomId); - }, - Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS), - ), - ); + migrateLocalMuteTimers(mx).catch(() => {}); + }, [mx]); + useEffect(() => { + let handles: ReturnType[] = []; + const schedule = () => { + handles.forEach(clearTimeout); + const now = Date.now(); + handles = loadMuteTimers(mx).map((entry) => + setTimeout( + () => { + unmuteRoom(mx, entry.roomId); + }, + Math.max(0, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS)), + ), + ); + }; + const onAccountData = (event: MatrixEvent) => { + if (event.getType() === AccountDataEvent.LotusMuteTimers) schedule(); + }; + schedule(); + mx.on(ClientEvent.AccountData, onAccountData); return () => { handles.forEach(clearTimeout); + mx.removeListener(ClientEvent.AccountData, onAccountData); }; }, [mx]); diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index 6e66bc864..ca4c9e8a0 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -64,9 +64,10 @@ const clearMsgDrafts = (): void => { * `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are * swept with the rest — the server-side presence status survives, so a * re-login loses nothing) - * - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed - * nav/space categories, `cinny_oidc_dynamic_clients`) is treated as - * preferences, not swept here + * - low-sensitivity UI/metadata residue (collapsed nav/space categories, + * `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here. + * Timed mutes moved to account data in [Gitea #333]; a leftover local + * `io.lotus.mute_timers` is migrated there on the next start. */ /** * [Gitea #204] The local mirror of the user's status message (+ its expiry) is diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 472d0bd40..74e8dee4a 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -26,6 +26,10 @@ export enum AccountDataEvent { // [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync). LotusSettings = 'io.lotus.settings', + // [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus + // device can lift a mute set on another. + LotusMuteTimers = 'io.lotus.mute_timers', + SecretStorageDefaultKey = 'm.secret_storage.default_key', CrossSigningMaster = 'm.cross_signing.master',