fix(wave-2): audit fixes — account-data races, search-cache wipe, export, media

Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO):
- F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED
  logout too (token expiry / remote sign-out) — only manual logout did before.
  F4: the delete no longer reports success while onblocked (waits, 3s cap).
- M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now
  serialized at MODULE scope (single queue + latestRef per client, echo-driven),
  fixing the cross-instance lost-update clobber (useBookmarks mounts per message
  row, so a per-instance queue was insufficient — caught in review).
- M6: room-history export gets a 200-page cap + Cancel + unmount-abort +
  correct date-range early-break (raw paginated ts). M4: image compression
  skips PNG (was flattening transparency to black), bakes EXIF orientation via
  createImageBitmap, .jpg-renames, and falls back to the original on decode
  failure instead of dropping the file. M5: MediaGallery lightbox opens the
  right item (shared thumb guard). M8: audio speed survives async decrypt.
- Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like
  the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5
  updater has a terminal state.

Reviewed; M7 reverted (past-time clamp is an intentional, tested contract).
tsc/eslint/prettier clean, build OK, 678 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 20:56:27 -04:00
parent ee6bdd8241
commit 668bdaad7d
15 changed files with 511 additions and 171 deletions
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -16,6 +16,12 @@ const FORMAT_LABELS: Record<ExportFormat, string> = {
html: 'HTML',
};
const PAGE_LIMIT = 100;
// Hard cap on back-pagination requests. Without a fromDate, "export all" would
// otherwise decrypt and hold every message in the room, hammering the server and
// risking an OOM/freeze with no way to stop. 200 pages × 100 ≈ 20,000 events.
const MAX_EXPORT_PAGES = 200;
type ExportRoomHistoryProps = {
requestClose: () => void;
};
@@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const [toDate, setToDate] = useState('');
const [exporting, setExporting] = useState(false);
const [exportCount, setExportCount] = useState(0);
const [notice, setNotice] = useState('');
const cancelledRef = useRef(false);
const handleCancel = useCallback(() => {
cancelledRef.current = true;
}, []);
// Stop an in-flight export if the panel unmounts (closing settings mid-export
// would otherwise keep paginating + decrypting in the background).
useEffect(
() => () => {
cancelledRef.current = true;
},
[],
);
const handleExport = useCallback(async () => {
if (exporting) return;
cancelledRef.current = false;
setExporting(true);
setExportCount(0);
setNotice('');
try {
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
@@ -55,6 +78,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const seen = new Set<string>();
const timeline = room.getLiveTimeline();
let canLoadMore = true;
// Track the oldest collected timestamp incrementally so the fromTs check
// doesn't rescan the whole `collected` array on every pagination step.
let oldestTs = Number.POSITIVE_INFINITY;
// Oldest RAW message ts paginated (tracked BEFORE the fromTs filter). The
// date-range early-break must use this — oldestTs only ever holds collected
// events (all >= fromTs), so it can never fall below fromTs and the export
// would over-paginate to the page cap and show a misleading "truncated".
let oldestRawTs = Number.POSITIVE_INFINITY;
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
for (const ev of events) {
@@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
if (ev.getType() !== EventType.RoomMessage) continue;
if (ev.isDecryptionFailure()) continue;
const ts = ev.getTs();
if (ts < oldestRawTs) oldestRawTs = ts;
if (fromTs !== null && ts < fromTs) continue;
if (toTs !== null && ts > toTs) continue;
const content = ev.getContent();
const body: string = content.body ?? '';
const msgtype: string = content.msgtype ?? '';
if (!body) continue;
if (ts < oldestTs) oldestTs = ts;
collected.push({
ts,
sender: ev.getSender() ?? '',
@@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
await addEvents(timeline.getEvents());
// Paginate backwards until start or date range exceeded
// Paginate backwards until start, date range exceeded, cap hit, or cancel
let pageCount = 0;
let truncated = false;
let cancelled = false;
while (canLoadMore) {
// If we have a fromTs, check whether the oldest collected event is already
// before it — if so we don't need to paginate further.
if (fromTs !== null && collected.length > 0) {
const oldestTs = Math.min(...collected.map((r) => r.ts));
if (oldestTs < fromTs) break;
if (cancelledRef.current) {
cancelled = true;
break;
}
// If we've paginated back past the fromTs boundary, there's nothing more
// in range to fetch (use the raw paginated ts, not the collected one).
if (fromTs !== null && oldestRawTs < fromTs) break;
// Hard cap so "export all" can't run away and OOM the tab.
if (pageCount >= MAX_EXPORT_PAGES) {
truncated = true;
break;
}
pageCount += 1;
// eslint-disable-next-line no-await-in-loop
canLoadMore = await mx.paginateEventTimeline(timeline, {
backwards: true,
limit: 100,
limit: PAGE_LIMIT,
});
// eslint-disable-next-line no-await-in-loop
await addEvents(timeline.getEvents());
}
if (cancelled) {
setNotice(`Export cancelled after ${collected.length} messages.`);
return;
}
// Sort chronologically (oldest first)
collected.sort((a, b) => a.ts - b.ts);
@@ -191,6 +239,12 @@ ${msgRows}
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
a.click();
URL.revokeObjectURL(url);
if (truncated) {
setNotice(
`Export truncated to ${collected.length} messages (reached the ${MAX_EXPORT_PAGES}-page limit). Narrow the date range to export older history.`,
);
}
} finally {
setExporting(false);
}
@@ -297,24 +351,35 @@ ${msgRows}
? `Exporting… ${exportCount} messages`
: 'Export will download automatically.'}
</Text>
<Button
size="400"
variant="Primary"
fill="Solid"
radii="300"
disabled={exporting}
onClick={handleExport}
before={
exporting ? (
<Spinner size="200" />
) : (
<Icon src={Icons.Download} size="100" />
)
}
>
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text>
</Button>
{exporting ? (
<Button
size="400"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleCancel}
before={<Icon src={Icons.Cross} size="100" />}
>
<Text size="B400">Cancel</Text>
</Button>
) : (
<Button
size="400"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleExport}
before={<Icon src={Icons.Download} size="100" />}
>
<Text size="B400">Export</Text>
</Button>
)}
</Box>
{notice && (
<Text size="T200" priority="400">
{notice}
</Text>
)}
</SequenceCard>
</Box>
</Box>