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:
@@ -6,6 +6,25 @@ import { settingsAtom } from '../state/settings';
|
||||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const ACTIVITY_THROTTLE_MS = 1000;
|
||||
|
||||
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
||||
export type PresenceState = 'online' | 'unavailable' | 'offline';
|
||||
|
||||
/**
|
||||
* Single source of truth for mapping the user's presence preference to the
|
||||
* Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable',
|
||||
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
|
||||
* status writer so setting/clearing a status message never overrides the user's
|
||||
* chosen presence (e.g. outing an Invisible user as online).
|
||||
*/
|
||||
export function presenceStateFromSetting(
|
||||
presenceStatus: PresenceSetting,
|
||||
hidePresence: boolean,
|
||||
): PresenceState {
|
||||
if (hidePresence || presenceStatus === 'invisible') return 'offline';
|
||||
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
|
||||
return 'online';
|
||||
}
|
||||
|
||||
export function usePresenceUpdater() {
|
||||
const mx = useMatrixClient();
|
||||
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
const readReducedMotion = (): boolean =>
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||
|
||||
/**
|
||||
* Reactively tracks the OS `prefers-reduced-motion: reduce` setting.
|
||||
*
|
||||
* Unlike a one-off `window.matchMedia(...).matches` read, this subscribes to the
|
||||
* media query's `change` event, so toggling the OS setting mid-session updates
|
||||
* the returned value (and any animation gated on it) without a page reload.
|
||||
* SSR/undefined-safe: returns `false` when `window`/`matchMedia` is unavailable.
|
||||
*/
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState<boolean>(readReducedMotion);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
const mql = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
|
||||
// Re-sync in case the setting changed between the initial render and this effect.
|
||||
setReduced(mql.matches);
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import {
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
MatrixEventHandlerMap,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
@@ -45,11 +52,20 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
|
||||
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
|
||||
setLatestEvent(getLatestEvent());
|
||||
};
|
||||
// An E2EE message often arrives as an undecrypted placeholder and is decrypted
|
||||
// shortly after — decryption does NOT re-fire RoomEvent.Timeline, so without this
|
||||
// the DM preview stays stale ("Encrypted message") until the next timeline event.
|
||||
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => {
|
||||
if (event.getRoomId() !== room.roomId) return;
|
||||
setLatestEvent(getLatestEvent());
|
||||
};
|
||||
setLatestEvent(getLatestEvent());
|
||||
|
||||
room.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
room.client.on(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||
return () => {
|
||||
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||
room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||
};
|
||||
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user