diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 00da31e2d..af96816df 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -96,6 +96,31 @@ Tier-2 bug-hunt (desktop/native, crypto/session/infra, messaging data) by 3 para --- +## πŸ”Ž Audit findings β€” Wave 3 (2026-07) + +Tier-3 bug-hunt (theming/visual, presence/UX/composer, rooms-customization/moderation) by 3 parallel agents. Higher-severity than expected in the non-theming areas. `[P#]`=presence/UX, `[H#]`=rooms/moderation, `[T#]`=theming. + +**βœ… FIXED (2026-07), reviewed + gate-green (677 tests):** the ACL cluster [H1–H4] (empty-allow block, self-ban warning w/ case-insensitive glob match, glob validation, confirm dialog), [P1] wrong-room menu, [P2] presence override, [H6] insights overflow, [H7/H8] mod-log labels, [P3/P4/P5] mute-restore + status-expiry + timezone-`m.tz`, [P6–P9] favorites/charCount/DM-preview, and theming [T-P1/P2/P4/P5]. **DEFERRED:** [H5] invite-QR local generation (needs a bundled QR lib β€” not added). + +**πŸ”΄ High (fixing/fixed this pass):** + +- **[H1–H4] Server ACL editor can brick a room's federation in one click** β€” no guard against saving an **empty allow-list** (denies every server β†’ room partitioned, unrecoverable), no warning on **denying/omitting your own homeserver**, glob validation **rejects valid patterns** (`1.2.3.*`, `*.evil.*`), and a single Save writes `m.room.server_acl` with no confirmation. β†’ adding empty-allow block, self-ban warning (`mx.getDomain()`), glob validation, and a confirm dialog. +- **[P1] Room context menu acts on the WRONG room after a live reorder** β€” `RoomNavItem` keyed by list `index`, so an open menu rebinds to a different room on activity-sort reorder β†’ Leave/Mute/Favorite hits the wrong room. β†’ key by `roomId`. +- **[P2] Setting a status message force-flips presence to `online`** β€” overrides Invisible/DND/Idle (an Invisible user is outed as online). β†’ derive presence from the `presenceStatus` setting. +- **[H5] Invite QR leaks room identity to a third party** β€” the QR is fetched from `api.qrserver.com` (`RoomShareInvite.tsx`). **DEFERRED β€” needs a bundled QR lib** (none in deps); generate locally instead of a remote call. + +**🟠 Medium (fixing/fixed):** + +- **[H6] RoomInsights `Math.min(...allTs)`** spread overflowed the call stack on a large timeline β†’ **FIXED** (single-pass min/max). [H7] policy-list mislabels `org.matrix.mjolnir.ban` + empty recommendation badge; [H8] activity-log mislabels knockβ†’join and invite-retraction. +- **[P3] Timed-mute timers never restored on startup** β†’ a mute set before a reload stays stuck forever; re-arm/expire persisted timers on client init. **[P4]** custom-status auto-clear **never fires** (timer lives in the Settings modal) β†’ move to an always-mounted watcher. **[P5]** timezone written to `im.lotus.timezone` but read from the `m.tz` profile field β†’ invisible to other users despite the "visible to others" copy; also PUT `m.tz`. +- **[T-P1] Decoration picker eager-loads ~100 animated PNGs** (jank/CPU) β†’ `loading="lazy"`. **[T-P2]** a redundant always-on animated `` compositor layer when glassmorphism is off β†’ gate on `glassmorphismSidebar`. **[T-P4]** `prefers-reduced-motion` sampled once, never re-subscribed β†’ a `useReducedMotion()` hook. + +**🟑 Low (fixing/open):** [P6/P7] favorites collapse chevron doesn't hide + filter ignores favorites; [P8] `charCount` not reset on send; [P9] encrypted DM preview stale until next event (listen for `Decrypted`); [P10] presence badge not seeded when the User appears late; [T-P5] decoration `` stuck hidden on a recycled node; [H10] room-name setter fire-and-forget/silent length reject; theming [T-P3/P6/P7/P8] preview-grid perf + seasonal-swatch viewport-units + mutual-exclusion UX asymmetry (mostly acceptable); `App.tsx` mention-color assumes 6-digit hex. + +**Verified sound (spot-checks):** NO theming leaks (all backgrounds/overlays pure-CSS; `lotus-boot`/`LotusDecorationPusher` timers self-clean; NightLight unmounts + `pointerEvents:none`; reduced-motion honored on load); favorites use per-room `m.tag` (**no** account-data race); bookmarks serialization intact; toast queue self-dismiss + dedup; composer-toolbar config; CollapsibleBody ResizeObserver; syntax highlighter renders React children (**no XSS**); Report Room endpoint (MSC4151); knock badge gated on PL; ACL event wire shape. + +--- + ## βœ… Done β€” Awaiting Verification Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Open bugs + the verification backlog now live in this file and LOTUS_TESTING.md.) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 65e559c75..f0387a779 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -56,6 +56,7 @@ import { useRoomNavigate } from '../hooks/useRoomNavigate'; import { getChatBg } from '../features/lotus/chatBackground'; import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls'; import { useTheme, ThemeKind } from '../hooks/useTheme'; +import { useReducedMotion } from '../hooks/useReducedMotion'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room'; @@ -719,9 +720,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const theme = useTheme(); const isDark = theme.kind === ThemeKind.Dark; const [chatBackground] = useSetting(settingsAtom, 'chatBackground'); + const reduced = useReducedMotion(); const wallpaperStyle = React.useMemo( - () => getChatBg(chatBackground, isDark), - [chatBackground, isDark], + () => getChatBg(chatBackground, isDark, reduced), + [chatBackground, isDark, reduced], ); const [pipIsFullscreen, setPipIsFullscreen] = useState(false); diff --git a/src/app/components/avatar-decoration/AvatarDecoration.tsx b/src/app/components/avatar-decoration/AvatarDecoration.tsx index 3f179a985..26666f610 100644 --- a/src/app/components/avatar-decoration/AvatarDecoration.tsx +++ b/src/app/components/avatar-decoration/AvatarDecoration.tsx @@ -31,6 +31,10 @@ export function AvatarDecoration({ > {children} { + (e.currentTarget as HTMLImageElement).style.removeProperty('display'); + }} onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx index a49c76278..b56569eab 100644 --- a/src/app/components/seasonal/SeasonalEffect.tsx +++ b/src/app/components/seasonal/SeasonalEffect.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { settingsAtom } from '../../state/settings'; +import { useReducedMotion } from '../../hooks/useReducedMotion'; import { zIndices } from '../../styles/zIndex'; import { SeasonTheme } from './types'; import { getActiveSeason } from './seasonSchedule'; @@ -94,8 +95,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) { export function SeasonalEffect() { const settings = useAtomValue(settingsAtom); - const reduced = - typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const reduced = useReducedMotion(); const theme = useMemo(() => { const override = settings.seasonalThemeOverride ?? 'auto'; diff --git a/src/app/features/lotus/chatBackground.ts b/src/app/features/lotus/chatBackground.ts index d5b35e7c8..8309a0d24 100644 --- a/src/app/features/lotus/chatBackground.ts +++ b/src/app/features/lotus/chatBackground.ts @@ -137,12 +137,13 @@ const LIGHT: Record = { export const getChatBg = ( bg: ChatBackground, isDark: boolean, - pauseAnimations?: boolean, + // Whether to strip animation (user "pause animations" setting OR OS + // prefers-reduced-motion). Supplied by the caller β€” e.g. via useReducedMotion β€” + // so this function stays pure and SSR-safe (no matchMedia read at call time). + suppressAnimation?: boolean, ): CSSProperties => { const style = isDark ? DARK[bg] : LIGHT[bg]; - const reducedMotion = - typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - if ((pauseAnimations || reducedMotion) && style.animation) { + if (suppressAnimation && style.animation) { const { animation: _anim, ...rest } = style; return rest; } diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index d3f50c8f6..4b72999e3 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -1,5 +1,5 @@ import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react'; -import { Room } from 'matrix-js-sdk'; +import { MatrixClient, Room } from 'matrix-js-sdk'; import { Avatar, Box, @@ -263,27 +263,46 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) { } // localStorage key for timed mute timers -const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; +export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; -type MuteTimerEntry = { roomId: string; unmuteAt: number }; +// 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; -function loadMuteTimers(): MuteTimerEntry[] { +export type MuteTimerEntry = { roomId: string; unmuteAt: number }; + +export function loadMuteTimers(): MuteTimerEntry[] { try { - return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); + const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); + return Array.isArray(parsed) ? parsed : []; } catch { return []; } } -function saveMuteTimers(timers: MuteTimerEntry[]): void { +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 { + const { setRoomNotificationPreference } = + await import('../../hooks/useRoomsNotificationPreferences'); + 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, durationMs); + setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS)); } type RoomNavItemMenuProps = { @@ -338,13 +357,7 @@ const RoomNavItemMenu = forwardRef( ).catch(() => {}); if (durationMs !== null) { scheduleMuteTimer(room.roomId, durationMs, () => { - setRoomNotificationPreference( - mx, - room.roomId, - RoomNotificationMode.Unset, - RoomNotificationMode.Mute, - ).catch(() => {}); - saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== room.roomId)); + unmuteRoom(mx, room.roomId); }); } requestClose(); diff --git a/src/app/features/room-settings/PolicyListViewer.tsx b/src/app/features/room-settings/PolicyListViewer.tsx index 82b352737..97394da2d 100644 --- a/src/app/features/room-settings/PolicyListViewer.tsx +++ b/src/app/features/room-settings/PolicyListViewer.tsx @@ -46,7 +46,9 @@ function isGlob(entity: string): boolean { } function recommendationLabel(rec: string): string { - if (rec === 'm.ban') return 'Ban'; + // `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy + // (pre-stabilization) recommendation still emitted by older bots. + if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban'; return rec; } @@ -103,9 +105,11 @@ function PolicyEntryRow({ entry }: { entry: PolicyEntry }) { glob )} - - {recommendationLabel(entry.recommendation)} - + {entry.recommendation && ( + + {recommendationLabel(entry.recommendation)} + + )} {entry.reason && ( diff --git a/src/app/features/room-settings/RoomActivityLog.tsx b/src/app/features/room-settings/RoomActivityLog.tsx index 161e4f4cb..09489ebdc 100644 --- a/src/app/features/room-settings/RoomActivityLog.tsx +++ b/src/app/features/room-settings/RoomActivityLog.tsx @@ -67,6 +67,7 @@ function describeEvent(mx: ReturnType, ev: MatrixEvent): if (membership === 'join') { if ( prevMembership === 'invite' || + prevMembership === 'knock' || prevMembership === undefined || prevMembership === null ) { @@ -115,6 +116,19 @@ function describeEvent(mx: ReturnType, ev: MatrixEvent): filter: 'members', }; } + // sender !== stateKey and the target was only invited β†’ the inviter (or a + // moderator) retracted the invite; this is not a kick. + if (prevMembership === 'invite') { + return { + text: ( + <> + {senderName} withdrew the invite to {targetName} + + ), + iconSrc: Icons.User, + filter: 'members', + }; + } return { text: ( <> diff --git a/src/app/features/room-settings/RoomInsights.tsx b/src/app/features/room-settings/RoomInsights.tsx index 2e9ace52a..f2303f7bc 100644 --- a/src/app/features/room-settings/RoomInsights.tsx +++ b/src/app/features/room-settings/RoomInsights.tsx @@ -115,10 +115,16 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) { const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0); const uniqueParticipants = msgCounts.size; - const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage); - const allTs = msgEvents.map((ev) => ev.getTs()); - const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null; - const newestTs = allTs.length > 0 ? Math.max(...allTs) : null; + // Single-pass min/max β€” `Math.min(...allTs)` spreads one arg per message and + // overflows the call stack (RangeError) on a large paginated timeline. + let oldestTs: number | null = null; + let newestTs: number | null = null; + for (const ev of events) { + if (ev.getType() !== EventType.RoomMessage) continue; + const ts = ev.getTs(); + if (oldestTs === null || ts < oldestTs) oldestTs = ts; + if (newestTs === null || ts > newestTs) newestTs = ts; + } return { top5, diff --git a/src/app/features/room-settings/RoomServerACL.tsx b/src/app/features/room-settings/RoomServerACL.tsx index b8fb55566..70b3dba14 100644 --- a/src/app/features/room-settings/RoomServerACL.tsx +++ b/src/app/features/room-settings/RoomServerACL.tsx @@ -3,16 +3,22 @@ import { Box, Button, Checkbox, + Dialog, + Header, Icon, IconButton, Icons, Input, + Overlay, + OverlayBackdrop, + OverlayCenter, Scroll, Spinner, Text, color, config, } from 'folds'; +import FocusTrap from 'focus-trap-react'; import { Page, PageContent, PageHeader } from '../../components/page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoom } from '../../hooks/useRoom'; @@ -24,6 +30,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { SequenceCard } from '../../components/sequence-card'; import { SequenceCardStyle } from '../common-settings/styles.css'; +import { stopPropagation } from '../../utils/keyboard'; +import { useModalStyle } from '../../hooks/useModalStyle'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -42,20 +50,52 @@ const DEFAULT_ACL: ServerAclContent = { // ── Validation ──────────────────────────────────────────────────────────────── /** - * Validate a server name or wildcard pattern. - * Allowed forms: - * - plain hostname / IP: letters, digits, hyphens, dots - * - wildcard prefix: *.example.com (asterisk only at the very start) - * The Matrix spec allows `*` on its own (match-all wildcard). + * Validate a server-name glob for an ACL entry. + * + * Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and + * `?` (single char) may appear ANYWHERE β€” e.g. `*`, `*.example.com`, + * `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob* + * rather than a concrete hostname: + * - reject empty / whitespace-only + * - allow only hostname/IP chars plus the wildcards `*` and `?` + * (letters, digits, dots, hyphens, colons for ports/IPv6 β€” NO underscore) + * - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`) + * - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`) */ function isValidServerPattern(value: string): boolean { - if (value === '*') return true; - // Strip leading wildcard - const rest = value.startsWith('*.') ? value.slice(2) : value; - // Must not be empty after stripping wildcard - if (!rest) return false; - // Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports) - return /^[A-Za-z0-9.:_-]+$/.test(rest); + const v = value.trim(); + if (!v) return false; + // Only hostname/IP glob chars β€” wildcards may appear at any position. + if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false; + // Structural rules for the dotted parts. + if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false; + // Must carry actual signal β€” reject pure punctuation like `-`, `:` or `-.-`. + if (!/[A-Za-z0-9*?]/.test(v)) return false; + return true; +} + +/** + * Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp, + * escaping every other regex metacharacter. Used only for local self-ban + * detection β€” never sent to the server. + */ +function globToRegExp(glob: string): RegExp { + const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); + // Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are + // case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as + // self-banning `matrix.foo.org` (otherwise the warning is a false negative). + return new RegExp(`^${pattern}$`, 'i'); +} + +function matchesAnyGlob(domain: string, globs: string[]): boolean { + return globs.some((glob) => { + try { + return globToRegExp(glob).test(domain); + } catch { + return false; + } + }); } // ── Server list sub-component ───────────────────────────────────────────────── @@ -78,7 +118,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp if (!value) return; if (!isValidServerPattern(value)) { - setError('Invalid server pattern. Use a hostname or *.example.com'); + setError('Invalid pattern. Use a hostname, IP, or glob (e.g. *.evil.com, 1.2.3.*, 10.0.0.?)'); return; } setError(undefined); @@ -181,6 +221,7 @@ type RoomServerACLProps = { export function RoomServerACL({ requestClose }: RoomServerACLProps) { const mx = useMatrixClient(); const room = useRoom(); + const modalStyle = useModalStyle(480); // Power level checks const powerLevels = usePowerLevels(room); @@ -221,6 +262,26 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) { const saveError = saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined; + // ── Save guards ─────────────────────────────────────────────────────────── + // #1 Empty allow list denies EVERY server (allow: [] is not "allow all") and + // partitions the room from all federation irreversibly β€” block the save. + const emptyAllow = allowList.length === 0; + + // #2 Self-ban: the local homeserver must match at least one allow glob and no + // deny glob, otherwise applying this ACL removes our own server from the room. + const localDomain = mx.getDomain() ?? ''; + const selfBanned = + localDomain.length > 0 && + (!matchesAnyGlob(localDomain, allowList) || matchesAnyGlob(localDomain, denyList)); + + // #4 Gate the destructive write behind a confirmation dialog. + const [prompt, setPrompt] = useState(false); + + const handleConfirmSave = () => { + setPrompt(false); + save(); + }; + // Required power level for this state event const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl); const myPL = readPowerLevel.user(powerLevels, myUserId); @@ -242,8 +303,8 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) { variant="Primary" fill="Solid" radii="300" - disabled={saving || !isDirty} - onClick={() => save()} + disabled={saving || !isDirty || emptyAllow} + onClick={() => setPrompt(true)} before={saving ? : } > {saving ? 'Saving…' : 'Save Changes'} @@ -290,6 +351,24 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) { )} + {/* #1 Empty allow list guard β€” blocks save */} + {canEdit && emptyAllow && ( + + The allow list is empty. An empty allow list denies every server and partitions + this room from all federation permanently. Add at least one entry (use + "*" to allow all servers). + + )} + + {/* #2 Self-ban warning β€” save allowed but confirmation required */} + {canEdit && !emptyAllow && selfBanned && ( + + Warning: your own homeserver ({localDomain}) is not permitted by this ACL. + Applying it will remove your server from the room and you may lose the ability to + moderate it. + + )} + {/* Allow IP literals toggle */} IP Address Access @@ -352,6 +431,82 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) { + + {/* #4 Confirmation dialog β€” surfaces the empty-allow (#1) and self-ban (#2) + warnings and keeps a safe save one extra click. */} + {prompt && ( + }> + + setPrompt(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + + Apply Server ACL + + + setPrompt(false)} + radii="300" + aria-label="Cancel" + > + + +
+ + + + Server ACL changes take effect immediately and control which servers can + participate in this room. This cannot be undone by other servers once they are + removed. + + {emptyAllow && ( + + The allow list is empty β€” this would deny every server and partition the + room from all federation permanently. + + )} + {!emptyAllow && selfBanned && ( + + Warning: your own homeserver ({localDomain}) is not permitted by this ACL. + Applying it will remove your server from the room and you may lose the + ability to moderate it. + + )} + + + +
+
+
+
+ )} ); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index a487a8787..c13a3e8cc 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -542,6 +542,7 @@ export const RoomInput = forwardRef( } resetEditor(editor); resetEditorHistory(editor); + setCharCount(0); sendTypingStatus(false); return; } @@ -583,6 +584,7 @@ export const RoomInput = forwardRef( mx.sendMessage(roomId, threadRootId ?? null, content as any); resetEditor(editor); resetEditorHistory(editor); + setCharCount(0); localStorage.removeItem(`draft-msg-${draftKey}`); setReplyDraft(undefined); sendTypingStatus(false); diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 3b452dff4..c5f4c5ecd 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -19,6 +19,7 @@ import { Page } from '../../components/page'; 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 '../lotus/chatBackground'; import { useKeyDown } from '../../hooks/useKeyDown'; import { editableActiveElement } from '../../utils/dom'; @@ -65,6 +66,7 @@ export function RoomView({ eventId }: { eventId?: string }) { const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const theme = useTheme(); const isDark = theme.kind === ThemeKind.Dark; + const reduced = useReducedMotion(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -102,10 +104,11 @@ export function RoomView({ eventId }: { eventId?: string }) { // Background.Container color. SidebarNav mirrors it onto document.body separately // so the glassmorphism sidebar can blur through it. const chatBgStyle = useMemo(() => { - if (chatBackground !== 'none') return getChatBg(chatBackground, isDark, pauseAnimations); - if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations); + if (chatBackground !== 'none') + return getChatBg(chatBackground, isDark, pauseAnimations || reduced); + if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations || reduced); return {}; - }, [chatBackground, lotusTerminal, isDark, pauseAnimations]); + }, [chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]); return ( diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 22d897126..a72b22154 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -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( presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '', @@ -357,12 +362,6 @@ function ProfileStatus() { const [clearAfter, setClearAfter] = useState('0'); const [emojiAnchor, setEmojiAnchor] = useState(); - // Initialise expiry from localStorage so timer survives page reload - const [expiryTs, setExpiryTs] = useState(() => { - 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; diff --git a/src/app/features/settings/account/ProfileDecoration.tsx b/src/app/features/settings/account/ProfileDecoration.tsx index dd2d83d7f..101f9da5c 100644 --- a/src/app/features/settings/account/ProfileDecoration.tsx +++ b/src/app/features/settings/account/ProfileDecoration.tsx @@ -50,7 +50,7 @@ function DecorationPreviewCell({ {name} + 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(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; +} diff --git a/src/app/hooks/useRoomLatestRenderedEvent.ts b/src/app/hooks/useRoomLatestRenderedEvent.ts index 4fb085fc7..d46b90a75 100644 --- a/src/app/hooks/useRoomLatestRenderedEvent.ts +++ b/src/app/hooks/useRoomLatestRenderedEvent.ts @@ -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]); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 4e4692fc3..7d72433ca 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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(null); const lastNotifiedEventRef = useRef>(new Map()); @@ -675,6 +768,8 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + + diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index fa8230319..ab59d8cc1 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -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 ( diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index cd1c8f49f..8a4dbd318 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -321,11 +321,7 @@ export function Direct() { const selected = selectedRoomId === roomId; return ( - + - 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() { /> - {sortedFavoriteRooms.length > 0 && ( + {favoriteRooms.length > 0 && (
{favVirtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedFavoriteRooms[vItem.index]; + const roomId = filteredFavoriteRooms[vItem.index]; const room = mx.getRoom(roomId); if (!room) return null; return ( +