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:
@@ -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 />
|
||||
|
||||
@@ -24,6 +24,7 @@ import { CreateTab } from './sidebar/CreateTab';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { getChatBg } from '../../features/lotus/chatBackground';
|
||||
|
||||
export function SidebarNav() {
|
||||
@@ -34,6 +35,7 @@ export function SidebarNav() {
|
||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||
const theme = useTheme();
|
||||
const isDark = theme.kind === ThemeKind.Dark;
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
// backdrop-filter only blurs content directly behind the element in the z-axis.
|
||||
// The sidebar is a flex sibling of the room view, so nothing sits behind it by default.
|
||||
@@ -53,17 +55,26 @@ export function SidebarNav() {
|
||||
}
|
||||
|
||||
const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical';
|
||||
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations);
|
||||
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations || reduced);
|
||||
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
|
||||
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
|
||||
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
|
||||
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
|
||||
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
||||
// Promote animated backgrounds to their own compositor layer so the browser
|
||||
// doesn't repaint the overlaid text/UI content on every animation frame.
|
||||
if (bgStyle.animation) {
|
||||
style.willChange = 'background-position, background-size';
|
||||
// The animated body mirror (animation + will-change) exists solely so the
|
||||
// glassmorphism sidebar can blur through document.body. When glass is OFF nothing
|
||||
// samples this layer, yet SidebarNav is always mounted, so writing an animated bg +
|
||||
// will-change here would leave a permanent invisible animated compositor layer
|
||||
// app-wide. Only mirror the animation when glass is on; the static background above
|
||||
// (needed by lotusTerminal / non-animated cases) is still written regardless.
|
||||
if (glassmorphismSidebar) {
|
||||
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
||||
if (bgStyle.animation) {
|
||||
style.willChange = 'background-position, background-size';
|
||||
} else {
|
||||
style.removeProperty('will-change');
|
||||
}
|
||||
} else {
|
||||
style.removeProperty('animation');
|
||||
style.removeProperty('will-change');
|
||||
}
|
||||
|
||||
@@ -75,7 +86,7 @@ export function SidebarNav() {
|
||||
style.removeProperty('animation');
|
||||
style.removeProperty('will-change');
|
||||
};
|
||||
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]);
|
||||
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
|
||||
|
||||
return (
|
||||
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
|
||||
|
||||
@@ -321,11 +321,7 @@ export function Direct() {
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
|
||||
@@ -275,15 +275,27 @@ export function Home() {
|
||||
return { favoriteRooms: favs, otherRooms: others };
|
||||
}, [mx, rooms]);
|
||||
|
||||
const sortedFavoriteRooms = useMemo(
|
||||
() =>
|
||||
Array.from(favoriteRooms).sort(
|
||||
closedCategories.has(FAVORITES_CATEGORY_ID)
|
||||
? factoryRoomIdByActivity(mx)
|
||||
: factoryRoomIdByAtoZ(mx),
|
||||
),
|
||||
[mx, favoriteRooms, closedCategories],
|
||||
);
|
||||
const sortedFavoriteRooms = useMemo(() => {
|
||||
const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID);
|
||||
const items = Array.from(favoriteRooms).sort(
|
||||
isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
|
||||
);
|
||||
if (isClosed) {
|
||||
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||
}
|
||||
return items;
|
||||
}, [mx, favoriteRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||
|
||||
const filteredFavoriteRooms = useMemo(() => {
|
||||
if (!filterQuery.trim()) return sortedFavoriteRooms;
|
||||
const query = filterQuery.toLowerCase();
|
||||
const localNames = getLocalRoomNamesContent(mx);
|
||||
return sortedFavoriteRooms.filter((rId) => {
|
||||
const localName = localNames.rooms[rId];
|
||||
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||
});
|
||||
}, [mx, sortedFavoriteRooms, filterQuery]);
|
||||
|
||||
const sortedRooms = useMemo(() => {
|
||||
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
||||
@@ -324,7 +336,7 @@ export function Home() {
|
||||
}, [mx, sortedRooms, filterQuery]);
|
||||
|
||||
const favVirtualizer = useVirtualizer({
|
||||
count: sortedFavoriteRooms.length,
|
||||
count: filteredFavoriteRooms.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 38,
|
||||
overscan: 10,
|
||||
@@ -453,7 +465,7 @@ export function Home() {
|
||||
/>
|
||||
</Box>
|
||||
</NavCategory>
|
||||
{sortedFavoriteRooms.length > 0 && (
|
||||
{favoriteRooms.length > 0 && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
@@ -466,13 +478,13 @@ export function Home() {
|
||||
</NavCategoryHeader>
|
||||
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
|
||||
{favVirtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = sortedFavoriteRooms[vItem.index];
|
||||
const roomId = filteredFavoriteRooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
key={roomId}
|
||||
ref={favVirtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
@@ -611,11 +623,7 @@ export function Home() {
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
|
||||
Reference in New Issue
Block a user