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'; // 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 // 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 }; /** 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 })); } /** 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 // 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(() => {}); } await clearMuteTimer(mx, roomId); } /** 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 }]); }