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:
@@ -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);
|
||||
|
||||
@@ -31,6 +31,10 @@ export function AvatarDecoration({
|
||||
>
|
||||
{children}
|
||||
<img
|
||||
// Force a fresh element per slug so a recycled node whose previous slug
|
||||
// 404'd (and was hidden in onError) can't leak `display:none` onto a
|
||||
// valid decoration.
|
||||
key={slug}
|
||||
src={decorationUrl(slug)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -48,6 +52,9 @@ export function AvatarDecoration({
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.removeProperty('display');
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
|
||||
@@ -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<SeasonTheme | null>(() => {
|
||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||
|
||||
@@ -137,12 +137,13 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
).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();
|
||||
|
||||
@@ -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 }) {
|
||||
<Text size="T200">glob</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="Critical" fill="Soft" radii="Pill">
|
||||
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
||||
</Badge>
|
||||
{entry.recommendation && (
|
||||
<Badge variant="Critical" fill="Soft" radii="Pill">
|
||||
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{entry.reason && (
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
|
||||
@@ -67,6 +67,7 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
|
||||
if (membership === 'join') {
|
||||
if (
|
||||
prevMembership === 'invite' ||
|
||||
prevMembership === 'knock' ||
|
||||
prevMembership === undefined ||
|
||||
prevMembership === null
|
||||
) {
|
||||
@@ -115,6 +116,19 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, 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: (
|
||||
<>
|
||||
<strong>{senderName}</strong> withdrew the invite to <strong>{targetName}</strong>
|
||||
</>
|
||||
),
|
||||
iconSrc: Icons.User,
|
||||
filter: 'members',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
|
||||
>
|
||||
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
|
||||
@@ -290,6 +351,24 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* #1 Empty allow list guard — blocks save */}
|
||||
{canEdit && emptyAllow && (
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
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).
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* #2 Self-ban warning — save allowed but confirmation required */}
|
||||
{canEdit && !emptyAllow && selfBanned && (
|
||||
<Text size="T300" style={{ color: color.Warning.Main }}>
|
||||
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.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Allow IP literals toggle */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">IP Address Access</Text>
|
||||
@@ -352,6 +431,82 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
|
||||
{/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2)
|
||||
warnings and keeps a safe save one extra click. */}
|
||||
{prompt && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setPrompt(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
aria-labelledby="server-acl-confirm-title"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text as="h2" size="H4" id="server-acl-confirm-title">
|
||||
Apply Server ACL
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="300"
|
||||
onClick={() => setPrompt(false)}
|
||||
radii="300"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text priority="400">
|
||||
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.
|
||||
</Text>
|
||||
{emptyAllow && (
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
The allow list is empty — this would deny every server and partition the
|
||||
room from all federation permanently.
|
||||
</Text>
|
||||
)}
|
||||
{!emptyAllow && selfBanned && (
|
||||
<Text size="T300" style={{ color: color.Warning.Main }}>
|
||||
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.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={selfBanned ? 'Critical' : 'Primary'}
|
||||
onClick={handleConfirmSave}
|
||||
disabled={emptyAllow}
|
||||
>
|
||||
<Text size="B400">Apply ACL</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,6 +542,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setCharCount(0);
|
||||
sendTypingStatus(false);
|
||||
return;
|
||||
}
|
||||
@@ -583,6 +584,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setCharCount(0);
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
|
||||
@@ -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 (
|
||||
<Page ref={roomViewRef} style={chatBgStyle}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -37,7 +37,14 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||
import {
|
||||
MAX_MUTE_TIMEOUT_MS,
|
||||
MuteTimerEntry,
|
||||
loadMuteTimers,
|
||||
unmuteRoom,
|
||||
} from '../../features/room-nav/RoomNavItem';
|
||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
import { toastQueueAtom } from '../../state/toast';
|
||||
import { useReminders } from '../../hooks/useReminders';
|
||||
@@ -235,6 +242,92 @@ function PresenceUpdater() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Restores timed-mute timers persisted by RoomNavItem across reloads. Bare
|
||||
// setTimeouts don't survive a page reload, so without this a scheduled unmute is
|
||||
// lost and the room stays muted forever. On boot: unmute anything already
|
||||
// past-due and re-arm a timer for each future entry (clamped to setTimeout's max).
|
||||
function MuteTimerRestore() {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
const timers = loadMuteTimers();
|
||||
if (timers.length === 0) return undefined;
|
||||
|
||||
const now = Date.now();
|
||||
const pastDue: MuteTimerEntry[] = [];
|
||||
const future: MuteTimerEntry[] = [];
|
||||
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
|
||||
|
||||
pastDue.forEach((entry) => {
|
||||
unmuteRoom(mx, entry.roomId);
|
||||
});
|
||||
|
||||
const handles = future.map((entry) =>
|
||||
setTimeout(
|
||||
() => {
|
||||
unmuteRoom(mx, entry.roomId);
|
||||
},
|
||||
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
|
||||
),
|
||||
);
|
||||
|
||||
return () => {
|
||||
handles.forEach(clearTimeout);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fires the custom-status auto-clear even when Settings→Profile is closed. The
|
||||
// expiry setTimeout used to live in ProfileStatus, which unmounts on close, so
|
||||
// the status never cleared. This always-mounted watcher polls the persisted
|
||||
// expiry key and clears (preserving the user's chosen presence) when due.
|
||||
function StatusExpiryMonitor() {
|
||||
const mx = useMatrixClient();
|
||||
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||
// Read latest settings via refs so the poll interval isn't torn down/restarted
|
||||
// (resetting its countdown) whenever the presence setting changes.
|
||||
const presenceStatusRef = useRef(presenceStatus);
|
||||
presenceStatusRef.current = presenceStatus;
|
||||
const hidePresenceRef = useRef(hidePresence);
|
||||
hidePresenceRef.current = hidePresence;
|
||||
|
||||
useEffect(() => {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) return undefined;
|
||||
const expiryKey = STATUS_EXPIRY_KEY(userId);
|
||||
const msgKey = STATUS_MSG_KEY(userId);
|
||||
|
||||
const check = () => {
|
||||
const stored = localStorage.getItem(expiryKey);
|
||||
if (!stored) return;
|
||||
const ts = parseInt(stored, 10);
|
||||
if (!ts || Date.now() < ts) return;
|
||||
localStorage.removeItem(msgKey);
|
||||
localStorage.removeItem(expiryKey);
|
||||
mx.setPresence({
|
||||
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
||||
status_msg: '',
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
check();
|
||||
const interval = setInterval(check, 30_000);
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') check();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MessageNotifications() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -675,6 +768,8 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<PageZoomFeature />
|
||||
<FaviconUpdater />
|
||||
<PresenceUpdater />
|
||||
<MuteTimerRestore />
|
||||
<StatusExpiryMonitor />
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<ReminderMonitor />
|
||||
|
||||
@@ -24,6 +24,7 @@ import { CreateTab } from './sidebar/CreateTab';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { getChatBg } from '../../features/lotus/chatBackground';
|
||||
|
||||
export function SidebarNav() {
|
||||
@@ -34,6 +35,7 @@ export function SidebarNav() {
|
||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||
const theme = useTheme();
|
||||
const isDark = theme.kind === ThemeKind.Dark;
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
// backdrop-filter only blurs content directly behind the element in the z-axis.
|
||||
// The sidebar is a flex sibling of the room view, so nothing sits behind it by default.
|
||||
@@ -53,17 +55,26 @@ export function SidebarNav() {
|
||||
}
|
||||
|
||||
const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical';
|
||||
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations);
|
||||
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations || reduced);
|
||||
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
|
||||
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
|
||||
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
|
||||
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
|
||||
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
||||
// Promote animated backgrounds to their own compositor layer so the browser
|
||||
// doesn't repaint the overlaid text/UI content on every animation frame.
|
||||
if (bgStyle.animation) {
|
||||
style.willChange = 'background-position, background-size';
|
||||
// The animated body mirror (animation + will-change) exists solely so the
|
||||
// glassmorphism sidebar can blur through document.body. When glass is OFF nothing
|
||||
// samples this layer, yet SidebarNav is always mounted, so writing an animated bg +
|
||||
// will-change here would leave a permanent invisible animated compositor layer
|
||||
// app-wide. Only mirror the animation when glass is on; the static background above
|
||||
// (needed by lotusTerminal / non-animated cases) is still written regardless.
|
||||
if (glassmorphismSidebar) {
|
||||
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
||||
if (bgStyle.animation) {
|
||||
style.willChange = 'background-position, background-size';
|
||||
} else {
|
||||
style.removeProperty('will-change');
|
||||
}
|
||||
} else {
|
||||
style.removeProperty('animation');
|
||||
style.removeProperty('will-change');
|
||||
}
|
||||
|
||||
@@ -75,7 +86,7 @@ export function SidebarNav() {
|
||||
style.removeProperty('animation');
|
||||
style.removeProperty('will-change');
|
||||
};
|
||||
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]);
|
||||
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
|
||||
|
||||
return (
|
||||
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
|
||||
|
||||
@@ -321,11 +321,7 @@ export function Direct() {
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
|
||||
@@ -275,15 +275,27 @@ export function Home() {
|
||||
return { favoriteRooms: favs, otherRooms: others };
|
||||
}, [mx, rooms]);
|
||||
|
||||
const sortedFavoriteRooms = useMemo(
|
||||
() =>
|
||||
Array.from(favoriteRooms).sort(
|
||||
closedCategories.has(FAVORITES_CATEGORY_ID)
|
||||
? factoryRoomIdByActivity(mx)
|
||||
: factoryRoomIdByAtoZ(mx),
|
||||
),
|
||||
[mx, favoriteRooms, closedCategories],
|
||||
);
|
||||
const sortedFavoriteRooms = useMemo(() => {
|
||||
const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID);
|
||||
const items = Array.from(favoriteRooms).sort(
|
||||
isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
|
||||
);
|
||||
if (isClosed) {
|
||||
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||
}
|
||||
return items;
|
||||
}, [mx, favoriteRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||
|
||||
const filteredFavoriteRooms = useMemo(() => {
|
||||
if (!filterQuery.trim()) return sortedFavoriteRooms;
|
||||
const query = filterQuery.toLowerCase();
|
||||
const localNames = getLocalRoomNamesContent(mx);
|
||||
return sortedFavoriteRooms.filter((rId) => {
|
||||
const localName = localNames.rooms[rId];
|
||||
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||
});
|
||||
}, [mx, sortedFavoriteRooms, filterQuery]);
|
||||
|
||||
const sortedRooms = useMemo(() => {
|
||||
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
||||
@@ -324,7 +336,7 @@ export function Home() {
|
||||
}, [mx, sortedRooms, filterQuery]);
|
||||
|
||||
const favVirtualizer = useVirtualizer({
|
||||
count: sortedFavoriteRooms.length,
|
||||
count: filteredFavoriteRooms.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 38,
|
||||
overscan: 10,
|
||||
@@ -453,7 +465,7 @@ export function Home() {
|
||||
/>
|
||||
</Box>
|
||||
</NavCategory>
|
||||
{sortedFavoriteRooms.length > 0 && (
|
||||
{favoriteRooms.length > 0 && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
@@ -466,13 +478,13 @@ export function Home() {
|
||||
</NavCategoryHeader>
|
||||
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
|
||||
{favVirtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = sortedFavoriteRooms[vItem.index];
|
||||
const roomId = filteredFavoriteRooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
key={roomId}
|
||||
ref={favVirtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
@@ -611,11 +623,7 @@ export function Home() {
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
|
||||
Reference in New Issue
Block a user