Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 69e05bbf8d fix(notifications): timed mutes are lifted by any Lotus device, not just the one that set them (#333)
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
2026-10-17 20:33:32 -04:00
9 changed files with 138 additions and 145 deletions
+5 -4
View File
@@ -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<HTMLDivElement, RoomNavItemMenuProps>(
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();
},
+14 -1
View File
@@ -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 }), []);
});
+77 -16
View File
@@ -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<string, unknown>)
.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<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
@@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
RoomNotificationMode.Mute,
).catch(() => {});
}
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<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 }]);
}
@@ -67,7 +67,6 @@ import {
makePresetId,
normalizeLabel,
} from '../../../utils/statusPresets';
import { writeStatusExpiry } from '../../../utils/statusExpiry';
type ProfileProps = {
profile: UserProfile;
@@ -509,16 +508,12 @@ function ProfileStatus() {
if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
const expiresAt = Date.now() + delayMs;
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(expiresAt));
// [Gitea #300] …and on every other Lotus device, via account data.
writeStatusExpiry(mx, msg, expiresAt).catch(() => undefined);
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
writeStatusExpiry(mx, msg).catch(() => undefined);
}
},
[saveStatus, userId, mx],
[saveStatus, userId],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -558,7 +553,6 @@ function ProfileStatus() {
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
writeStatusExpiry(mx, '').catch(() => undefined);
// Preserve the user's chosen presence when clearing the status message.
mx.setPresence({
presence: presenceStateFromSetting(presenceStatus, hidePresence),
+33 -42
View File
@@ -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,7 +91,7 @@ import {
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
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.
@@ -370,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<typeof setTimeout>[] = [];
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]);
@@ -428,7 +433,12 @@ function StatusExpiryMonitor() {
// permanently and the status stayed set forever. Retry the write (honouring
// Retry-After), and only forget the status once the server has taken it.
let clearing = false;
const clearStatus = () => {
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
clearing = true;
setPresenceWithRetry(mx, {
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
@@ -438,31 +448,12 @@ function StatusExpiryMonitor() {
noteLocalStatusWrite();
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
writeStatusExpiry(mx, '').catch(() => undefined);
})
.catch(() => undefined)
.finally(() => {
clearing = false;
});
};
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
const ts = stored ? parseInt(stored, 10) : 0;
if (ts && Date.now() >= ts) {
clearStatus();
return;
}
// [Gitea #300] An expiry set on another device (account data): clear the
// status if it is still that one, otherwise just forget the expiry.
const action = statusExpiryAction(
readStatusExpiry(mx),
mx.getUser(userId)?.presenceStatusMsg,
Date.now(),
);
if (action === 'clear') clearStatus();
else if (action === 'forget') writeStatusExpiry(mx, '').catch(() => undefined);
};
check();
// Poll often enough that a rate-limited retry lands promptly, but not so
+4 -3
View File
@@ -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
-22
View File
@@ -1,22 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseStatusExpiry, statusExpiryAction } from './statusExpiry';
test('parseStatusExpiry (#300)', () => {
assert.deepEqual(parseStatusExpiry({ status_msg: '🍔 Lunch', expires_at: 5 }), {
statusMsg: '🍔 Lunch',
expiresAt: 5,
});
assert.equal(parseStatusExpiry({}), undefined);
assert.equal(parseStatusExpiry({ status_msg: '', expires_at: 5 }), undefined);
assert.equal(parseStatusExpiry({ status_msg: 'x', expires_at: '5' }), undefined);
});
test('statusExpiryAction: clears only the status that was set to expire (#300)', () => {
const e = { statusMsg: '🍔 Lunch', expiresAt: 1000 };
assert.equal(statusExpiryAction(e, '🍔 Lunch', 999), 'none');
assert.equal(statusExpiryAction(e, '🍔 Lunch', 1000), 'clear');
assert.equal(statusExpiryAction(e, '🎮 Gaming', 2000), 'forget');
assert.equal(statusExpiryAction(e, '', 2000), 'forget');
assert.equal(statusExpiryAction(undefined, 'x', 2000), 'none');
});
-46
View File
@@ -1,46 +0,0 @@
import { MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getAccountData } from './room';
/**
* [Gitea #300] The status message's auto-clear time, in account data so every
* Lotus device knows it. Before, only the device that set "clear after 1 hour"
* (localStorage) ever cleared it, and only while it was open.
*/
export type StatusExpiry = { statusMsg: string; expiresAt: number };
/** Pure: parse `io.lotus.status_expiry` content. */
export function parseStatusExpiry(content: unknown): StatusExpiry | undefined {
const c = content as { status_msg?: unknown; expires_at?: unknown } | undefined;
if (!c || typeof c.status_msg !== 'string' || typeof c.expires_at !== 'number') return undefined;
if (!c.status_msg || !Number.isFinite(c.expires_at)) return undefined;
return { statusMsg: c.status_msg, expiresAt: c.expires_at };
}
/**
* Pure: what to do with a due expiry. Clear the server status only if it is
* still the one that was set to expire; if it has since changed (e.g. on
* another device), just forget the expiry.
*/
export function statusExpiryAction(
entry: StatusExpiry | undefined,
currentStatusMsg: string | undefined,
now: number,
): 'none' | 'clear' | 'forget' {
if (!entry || now < entry.expiresAt) return 'none';
return (currentStatusMsg ?? '') === entry.statusMsg ? 'clear' : 'forget';
}
export const readStatusExpiry = (mx: MatrixClient): StatusExpiry | undefined =>
parseStatusExpiry(getAccountData(mx, AccountDataEvent.LotusStatusExpiry)?.getContent());
/** Record (or with no `expiresAt`, remove) the shared auto-clear time. */
export async function writeStatusExpiry(
mx: MatrixClient,
statusMsg: string,
expiresAt?: number,
): Promise<void> {
const content =
statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {};
await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never);
}
+3 -3
View File
@@ -26,9 +26,9 @@ export enum AccountDataEvent {
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
LotusSettings = 'io.lotus.settings',
// [Gitea #300] When the status message auto-clears: { status_msg, expires_at },
// so any Lotus device can clear it (not only the one that set it).
LotusStatusExpiry = 'io.lotus.status_expiry',
// [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',