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:
@@ -35,6 +35,9 @@ import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
@@ -319,8 +322,8 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||
|
||||
const CLEAR_AFTER_OPTIONS = [
|
||||
{ label: 'Never', value: '0' },
|
||||
@@ -347,6 +350,8 @@ function ProfileStatus() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const presence = useUserPresence(userId);
|
||||
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||
|
||||
const [statusMsg, setStatusMsg] = useState<string>(
|
||||
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
||||
@@ -357,12 +362,6 @@ function ProfileStatus() {
|
||||
const [clearAfter, setClearAfter] = useState('0');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
|
||||
// Initialise expiry from localStorage so timer survives page reload
|
||||
const [expiryTs, setExpiryTs] = useState<number>(() => {
|
||||
const stored = localStorage.getItem(STATUS_EXPIRY_KEY(userId));
|
||||
return stored ? parseInt(stored, 10) : 0;
|
||||
});
|
||||
|
||||
// Sync input when another device changes the status.
|
||||
// Skipped while the user has unsaved local edits to avoid clobbering
|
||||
// mid-flight input (e.g. an emoji being inserted).
|
||||
@@ -373,32 +372,16 @@ function ProfileStatus() {
|
||||
}
|
||||
}, [presence?.status, userId]);
|
||||
|
||||
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
|
||||
useEffect(() => {
|
||||
if (!expiryTs) return undefined;
|
||||
const remaining = expiryTs - Date.now();
|
||||
const clearStatus = () => {
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
setExpiryTs(0);
|
||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
||||
};
|
||||
if (remaining <= 0) {
|
||||
clearStatus();
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(clearStatus, remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}, [expiryTs, userId, mx]);
|
||||
|
||||
const [saveState, saveStatus] = useAsyncCallback(
|
||||
useCallback(
|
||||
(msg: string) =>
|
||||
mx.setPresence({
|
||||
presence: 'online',
|
||||
// Derive presence from the user's chosen setting so writing a status
|
||||
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
status_msg: msg,
|
||||
}),
|
||||
[mx],
|
||||
[mx, presenceStatus, hidePresence],
|
||||
),
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
@@ -429,12 +412,12 @@ function ProfileStatus() {
|
||||
|
||||
const delayMs = getMsFromOption(clearAfter);
|
||||
if (msg && delayMs > 0) {
|
||||
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
||||
const ts = Date.now() + delayMs;
|
||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
|
||||
setExpiryTs(ts);
|
||||
} else {
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
setExpiryTs(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -443,8 +426,11 @@ function ProfileStatus() {
|
||||
setStatusMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
setExpiryTs(0);
|
||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
||||
// Preserve the user's chosen presence when clearing the status message.
|
||||
mx.setPresence({
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
status_msg: '',
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const hasChanges = statusMsg !== (presence?.status ?? '');
|
||||
@@ -751,10 +737,22 @@ function ProfileTimezone() {
|
||||
const [saveState, saveTimezone] = useAsyncCallback(
|
||||
useCallback(
|
||||
(value: string) =>
|
||||
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => {
|
||||
Promise.all([
|
||||
// Self-fallback: account data is readable by useExtendedProfile for the
|
||||
// own user even on servers without extended-profile (m.tz) support.
|
||||
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }),
|
||||
// Mirror the pronouns write path so OTHER users can read the timezone
|
||||
// via the m.tz profile field. Best-effort: standard Synapse rejects
|
||||
// unknown profile fields, so a failure here must not fail the save.
|
||||
mx.http
|
||||
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
|
||||
'm.tz': value,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
]).then(() => {
|
||||
setSavedTimezone(value);
|
||||
}),
|
||||
[mx],
|
||||
[mx, userId],
|
||||
),
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
@@ -50,7 +50,7 @@ function DecorationPreviewCell({
|
||||
<img
|
||||
src={`${DECORATION_CDN}/${slug}.png`}
|
||||
alt={name}
|
||||
loading="eager"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -105,6 +105,7 @@ import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||
import { DenoiseTester } from './DenoiseTester';
|
||||
@@ -2054,6 +2055,7 @@ function ChatBgGrid() {
|
||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||
const theme = useTheme();
|
||||
const isDark = theme.kind === ThemeKind.Dark;
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -2079,7 +2081,7 @@ function ChatBgGrid() {
|
||||
style={{
|
||||
width: toRem(76),
|
||||
height: toRem(50),
|
||||
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations),
|
||||
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations || reduced),
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
|
||||
Reference in New Issue
Block a user