fix(room-nav): expiring timed mute no longer clobbers a manual mode change
unmuteRoom unconditionally reset the room to Unset when a timed mute expired (in-session timer and boot-time restore alike), silently reverting a mode the user had changed by hand during the window. Mute-timer helpers move to muteTimers.ts; unmuteRoom now reads the live push-rule mode and only resets when it is still Mute, always dropping the persisted timer. Unit-tested. Fixes #21 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -6,7 +6,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
setRoomNotificationPreference,
|
setRoomNotificationPreference,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
|
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
|
||||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
@@ -274,47 +275,6 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// localStorage key for timed mute timers
|
|
||||||
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 };
|
|
||||||
|
|
||||||
export function loadMuteTimers(): MuteTimerEntry[] {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
|
||||||
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
|
|
||||||
await setRoomNotificationPreference(
|
|
||||||
mx,
|
|
||||||
roomId,
|
|
||||||
RoomNotificationMode.Unset,
|
|
||||||
RoomNotificationMode.Mute,
|
|
||||||
).catch(() => {});
|
|
||||||
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { shouldResetMuteOnUnmute } from './muteTimers';
|
||||||
|
|
||||||
|
test('resets to Unset when the room is still Mute at expiry', () => {
|
||||||
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not reset when the user switched to All messages during the mute window', () => {
|
||||||
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.AllMessages), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not reset when the user switched to Special messages during the mute window', () => {
|
||||||
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.SpecialMessages), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not reset when the mode is already Unset', () => {
|
||||||
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// localStorage key for timed mute timers
|
||||||
|
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 };
|
||||||
|
|
||||||
|
export function loadMuteTimers(): MuteTimerEntry[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
||||||
|
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(() => {});
|
||||||
|
}
|
||||||
|
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== 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));
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
MuteTimerEntry,
|
MuteTimerEntry,
|
||||||
loadMuteTimers,
|
loadMuteTimers,
|
||||||
unmuteRoom,
|
unmuteRoom,
|
||||||
} from '../../features/room-nav/RoomNavItem';
|
} from '../../features/room-nav/muteTimers';
|
||||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { toastQueueAtom } from '../../state/toast';
|
import { toastQueueAtom } from '../../state/toast';
|
||||||
|
|||||||
Reference in New Issue
Block a user