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
This commit is contained in:
Lotus CI
2026-10-17 20:33:32 -04:00
co-authored by Claude Opus 5.5
parent 3878a85339
commit 69e05bbf8d
6 changed files with 131 additions and 45 deletions
+27 -21
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,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<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]);