fix(wave-3): audit fixes — ACL guards, presence, moderation, theming perf

Wave-3 bug-hunt fixes (findings in LOTUS_TODO), reviewed + gate-green:
- 🔴 ACL editor [H1–H4]: block saving an empty allow-list (was a one-click
  federation brick), warn on self-ban (case-insensitive glob match of
  mx.getDomain() vs allow/deny), accept real globs (1.2.3.*, *.evil.*), and
  gate Save behind a confirm dialog.
- 🔴 [P1] room context menu no longer acts on the wrong room after a live
  reorder (key by roomId, not list index). 🔴 [P2] status writes no longer
  force presence to online over Invisible/DND (shared presenceStateFromSetting).
- 🟠 [P3] timed mutes restored on boot; [P4] custom-status auto-clear now fires
  (always-mounted StatusExpiryMonitor); [P5] timezone also PUT to the m.tz
  profile field so it's visible to others; [H6] RoomInsights single-pass
  min/max (was Math.min(...spread) stack overflow); [H7/H8] mod-log labels.
- 🟡 [P6/P7] favorites collapse+filter, [P8] charCount reset, [P9] DM preview
  refresh on decrypt; theming [T-P1] lazy decorations, [T-P2] drop the redundant
  always-on body animation, [T-P4] live useReducedMotion, [T-P5] decoration key.
- NATIVE-CINNY LAW: notification presets + Powers permissions use folds icons.

DEFERRED: [H5] invite-QR is fetched from api.qrserver.com (third-party leak);
local generation needs a bundled QR lib (not added). tsc/eslint/prettier clean,
build OK, 677 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:40:07 -04:00
co-authored by Claude Opus 4.8
parent 41149db685
commit dcd8201e16
22 changed files with 527 additions and 116 deletions
+96 -1
View File
@@ -37,7 +37,14 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import {
MAX_MUTE_TIMEOUT_MS,
MuteTimerEntry,
loadMuteTimers,
unmuteRoom,
} from '../../features/room-nav/RoomNavItem';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
@@ -235,6 +242,92 @@ function PresenceUpdater() {
return null;
}
// Restores timed-mute timers persisted by RoomNavItem across reloads. Bare
// setTimeouts don't survive a page reload, so without this a scheduled unmute is
// lost and the room stays muted forever. On boot: unmute anything already
// past-due and re-arm a timer for each future entry (clamped to setTimeout's max).
function MuteTimerRestore() {
const mx = useMatrixClient();
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),
),
);
return () => {
handles.forEach(clearTimeout);
};
}, [mx]);
return null;
}
// Fires the custom-status auto-clear even when Settings→Profile is closed. The
// expiry setTimeout used to live in ProfileStatus, which unmounts on close, so
// the status never cleared. This always-mounted watcher polls the persisted
// expiry key and clears (preserving the user's chosen presence) when due.
function StatusExpiryMonitor() {
const mx = useMatrixClient();
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
// Read latest settings via refs so the poll interval isn't torn down/restarted
// (resetting its countdown) whenever the presence setting changes.
const presenceStatusRef = useRef(presenceStatus);
presenceStatusRef.current = presenceStatus;
const hidePresenceRef = useRef(hidePresence);
hidePresenceRef.current = hidePresence;
useEffect(() => {
const userId = mx.getUserId();
if (!userId) return undefined;
const expiryKey = STATUS_EXPIRY_KEY(userId);
const msgKey = STATUS_MSG_KEY(userId);
const check = () => {
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
mx.setPresence({
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
status_msg: '',
}).catch(() => undefined);
};
check();
const interval = setInterval(check, 30_000);
const onVisible = () => {
if (document.visibilityState === 'visible') check();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
};
}, [mx]);
return null;
}
function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
@@ -675,6 +768,8 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PageZoomFeature />
<FaviconUpdater />
<PresenceUpdater />
<MuteTimerRestore />
<StatusExpiryMonitor />
<InviteNotifications />
<MessageNotifications />
<ReminderMonitor />