fix(ui): settings modal sizing regression + 17 more folds audit findings
Fix settings modal regression: Modal500 was wrapped in useModalStyle(560), forcing maxWidth 560px and squishing the two-pane Settings layout (folds size="500" is ~50rem). Restore desktop width to the folds recipe while keeping mobile fullscreen. N-series fixes: - N13 ScheduledMessagesTray header: <Box as="button"> -> folds <Button> - N28 composer char counter: drop undefined --tc-surface-low + opacity, use priority="300" and config.space token - N31 collapsible "Read more" toggle: padded <Button> -> flush inline-button pattern matching (edited) link - N41 UserPrivateNotes "Saving..." now shows a folds <Spinner> - N43 Night Light slider: add accentColor; label opacity -> priority - N44 mention-highlight Reset: bare <button> -> folds <Button> (drops undefined --border-interactive-normal); Boot button kept (TDS-only) - N45 SelectTheme trigger variant -> Secondary to match SettingsSelect - N49 RoomInsights StatTile emoji -> folds <Icon> (Photo/VideoCamera/ Headphone/File) - N54/N57 PiP overlay badges + fullscreen button: token discipline (config.radii/space, folds Text); dark scrim kept for video legibility - N60 knock badge: match Pinned Messages pattern (no wrapper div, toRem insets, no hardcoded size overrides) - N62 unverified-device banner: 3px left-accent -> standard border via color.Warning.ContainerLine; drop opacity hacks - N65 Edit History: real "Load more" pagination (accumulate next_batch, de-dupe by id, re-sort by ts) replacing passive text - N66 search date fields: raw <input type="date"> -> folds <Input> - N67 SeasonalEffect z-index 9999 -> 9997 (below Night Light + modals) - N73 Pending Requests header uses css.MembersGroupLabel - N74 remove raw em-sized emoji <span> in RoomNavItem name - N85/N86 RemindMeDialog: <Box role="dialog"> -> folds <Dialog>; preset MenuItems -> Buttons (fixes invalid menuitem-in-dialog ARIA) Document deliberate WON'T FIX rationale for N9, N51, N61, N71, N75, N77. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import React, { ReactNode, useCallback, useEffect } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import parse from 'html-react-parser';
|
||||
import Linkify from 'linkify-react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -40,11 +41,6 @@ type EditHistoryResponse = {
|
||||
next_batch?: string;
|
||||
};
|
||||
|
||||
type EditHistoryData = {
|
||||
events: MatrixEvent[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
type EditHistoryModalProps = {
|
||||
room: Room;
|
||||
mEvent: MatrixEvent;
|
||||
@@ -100,48 +96,71 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
const eventId = mEvent.getId();
|
||||
const roomId = room.roomId;
|
||||
|
||||
const [historyState, fetchHistory] = useAsyncCallback<EditHistoryData, unknown, []>(
|
||||
useCallback(async () => {
|
||||
if (!eventId) return { events: [], hasMore: false };
|
||||
// Accumulated, de-duplicated edits across paginated fetches.
|
||||
const [edits, setEdits] = useState<MatrixEvent[]>([]);
|
||||
const [nextBatch, setNextBatch] = useState<string | undefined>(undefined);
|
||||
|
||||
// Relations API lives at /_matrix/client/v1/ (not v3); use raw fetch to avoid SDK prefix
|
||||
const token = mx.getAccessToken();
|
||||
const baseUrl = mx.getHomeserverUrl();
|
||||
const url = `${baseUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/m.replace?limit=50`;
|
||||
const fetchRes = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
|
||||
const res = (await fetchRes.json()) as EditHistoryResponse;
|
||||
const rawEvents = res.chunk ?? [];
|
||||
const events = await Promise.all(
|
||||
rawEvents
|
||||
.filter(isRawEditEvent)
|
||||
.sort((a, b) => a.origin_server_ts - b.origin_server_ts)
|
||||
.map(async (raw) => {
|
||||
const existing = room.findEventById(raw.event_id);
|
||||
if (existing) return existing;
|
||||
const evt = new MatrixEvent({
|
||||
type: raw.type,
|
||||
content: raw.content,
|
||||
origin_server_ts: raw.origin_server_ts,
|
||||
event_id: raw.event_id,
|
||||
room_id: roomId,
|
||||
sender: mEvent.getSender() ?? '',
|
||||
});
|
||||
if (evt.isEncrypted()) {
|
||||
await mx.decryptEventIfNeeded(evt);
|
||||
}
|
||||
return evt;
|
||||
}),
|
||||
);
|
||||
const parseRawEvents = useCallback(
|
||||
(rawEvents: Array<Record<string, unknown>>): Promise<MatrixEvent[]> =>
|
||||
Promise.all(
|
||||
rawEvents.filter(isRawEditEvent).map(async (raw) => {
|
||||
const existing = room.findEventById(raw.event_id);
|
||||
if (existing) return existing;
|
||||
const evt = new MatrixEvent({
|
||||
type: raw.type,
|
||||
content: raw.content,
|
||||
origin_server_ts: raw.origin_server_ts,
|
||||
event_id: raw.event_id,
|
||||
room_id: roomId,
|
||||
sender: mEvent.getSender() ?? '',
|
||||
});
|
||||
if (evt.isEncrypted()) {
|
||||
await mx.decryptEventIfNeeded(evt);
|
||||
}
|
||||
return evt;
|
||||
}),
|
||||
),
|
||||
[room, roomId, mEvent, mx],
|
||||
);
|
||||
|
||||
return { events, hasMore: !!res.next_batch };
|
||||
}, [mx, roomId, eventId, room, mEvent]),
|
||||
const [historyState, fetchHistory] = useAsyncCallback<void, unknown, [string | undefined]>(
|
||||
useCallback(
|
||||
async (from?: string) => {
|
||||
if (!eventId) return;
|
||||
|
||||
// Relations API lives at /_matrix/client/v1/ (not v3); use raw fetch to avoid SDK prefix
|
||||
const token = mx.getAccessToken();
|
||||
const baseUrl = mx.getHomeserverUrl();
|
||||
const fromParam = from ? `&from=${encodeURIComponent(from)}` : '';
|
||||
const url = `${baseUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/m.replace?limit=50${fromParam}`;
|
||||
const fetchRes = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
|
||||
const res = (await fetchRes.json()) as EditHistoryResponse;
|
||||
const newEvents = await parseRawEvents(res.chunk ?? []);
|
||||
|
||||
// Merge with prior pages, de-dupe by event id, sort chronologically so
|
||||
// page ordering across batches is always correct.
|
||||
setEdits((prev) => {
|
||||
const byId = new Map<string, MatrixEvent>();
|
||||
[...prev, ...newEvents].forEach((evt) => {
|
||||
const id = evt.getId();
|
||||
if (id) byId.set(id, evt);
|
||||
});
|
||||
return Array.from(byId.values()).sort((a, b) => a.getTs() - b.getTs());
|
||||
});
|
||||
setNextBatch(res.next_batch);
|
||||
},
|
||||
[mx, roomId, eventId, parseRawEvents],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory().catch(() => undefined);
|
||||
fetchHistory(undefined).catch(() => undefined);
|
||||
}, [fetchHistory]);
|
||||
|
||||
const initialLoading = historyState.status === AsyncStatus.Loading && edits.length === 0;
|
||||
const loadingMore = historyState.status === AsyncStatus.Loading && edits.length > 0;
|
||||
|
||||
const formatTs = (ts: number): string => {
|
||||
const time = timeHourMinute(ts, hour24Clock);
|
||||
const date = timeDayMonYear(ts, dateFormatString);
|
||||
@@ -195,7 +214,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
paddingBottom: config.space.S700,
|
||||
}}
|
||||
>
|
||||
{historyState.status === AsyncStatus.Loading && (
|
||||
{initialLoading && (
|
||||
<Box
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
@@ -204,12 +223,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
<Spinner size="200" />
|
||||
</Box>
|
||||
)}
|
||||
{historyState.status === AsyncStatus.Error && (
|
||||
{historyState.status === AsyncStatus.Error && edits.length === 0 && (
|
||||
<Text size="T300" priority="300">
|
||||
Failed to load edit history.
|
||||
</Text>
|
||||
)}
|
||||
{historyState.status === AsyncStatus.Success && (
|
||||
{!initialLoading && historyState.status !== AsyncStatus.Error && (
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="100">
|
||||
<Box gap="200" alignItems="Center">
|
||||
@@ -223,11 +242,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{historyState.data.events.map((editEvt, index) => (
|
||||
{edits.map((editEvt, index) => (
|
||||
<Box key={editEvt.getId() ?? index} direction="Column" gap="100">
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="L400">
|
||||
{index === historyState.data.events.length - 1
|
||||
{index === edits.length - 1
|
||||
? `Edit ${index + 1} (current)`
|
||||
: `Edit ${index + 1}`}
|
||||
</Text>
|
||||
@@ -244,17 +263,27 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{historyState.data.events.length === 0 && (
|
||||
{edits.length === 0 && (
|
||||
<Text size="T300" priority="300">
|
||||
No edit history found.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{historyState.data.hasMore && (
|
||||
{nextBatch && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||
<Text size="T200" priority="300">
|
||||
Showing the 50 most recent edits
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={loadingMore}
|
||||
before={
|
||||
loadingMore ? <Spinner size="100" variant="Secondary" /> : undefined
|
||||
}
|
||||
onClick={() => fetchHistory(nextBatch).catch(() => undefined)}
|
||||
>
|
||||
<Text size="B300">Load more</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -2,14 +2,14 @@ import React, { useMemo } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
color,
|
||||
Button,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
MenuItem,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
@@ -42,7 +42,6 @@ function getPresets(): Array<{ label: string; ms: number }> {
|
||||
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder } = useReminders();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
|
||||
const handlePick = async (ms: number) => {
|
||||
@@ -66,18 +65,12 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="remind-me-title"
|
||||
direction="Column"
|
||||
style={{
|
||||
background: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: color.Other.Shadow,
|
||||
...modalStyle,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
variant="Surface"
|
||||
@@ -109,14 +102,21 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
)}
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
{presets.map((p) => (
|
||||
<MenuItem key={p.label} size="300" radii="300" onClick={() => handlePick(p.ms)}>
|
||||
<Text size="T300" truncate>
|
||||
<Button
|
||||
key={p.label}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => handlePick(p.ms)}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{p.label}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
|
||||
Reference in New Issue
Block a user