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,
+ }}
+ >
+
+
+
+
+ )}
);
}
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({
+ 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