CI / Build & Quality Checks (pull_request) Successful in 3m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m45s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
150 lines
5.9 KiB
TypeScript
150 lines
5.9 KiB
TypeScript
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<string, unknown>)
|
|
.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<void> {
|
|
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<void> {
|
|
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<IPushRules>();
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const unmuteAt = Date.now() + durationMs;
|
|
const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId);
|
|
await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]);
|
|
}
|