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
co-authored by Claude Opus 4.8
parent ee6bdd8241
commit 668bdaad7d
15 changed files with 511 additions and 171 deletions
+87 -54
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback, useEffect, useState } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export type Reminder = {
roomId: string;
@@ -23,6 +22,75 @@ function readReminders(mx: MatrixClient): Reminder[] {
);
}
// Module-scoped serialization state.
//
// The latest snapshot and the write queue must be shared across every hook
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
// (adds reminders) mount separate hooks, and a per-instance queue would let a
// remove and an add race across instances and clobber each other (setAccountData
// replaces the whole content, no server merge). We therefore keep a single
// shared queue + latest ref, keyed off the active MatrixClient.
type ReminderModuleState = {
mx: MatrixClient;
latest: Reminder[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Reminder[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: ReminderModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): ReminderModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: ReminderModuleState = {
mx,
latest: readReminders(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === REMINDERS_KEY) {
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueReminderWrite(
mx: MatrixClient,
compute: (current: Reminder[]) => Reminder[],
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useReminders(): {
reminders: Reminder[];
addReminder: (r: Reminder) => Promise<void>;
@@ -30,69 +98,34 @@ export function useReminders(): {
getReminders: () => Reminder[];
} {
const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
// Authoritative local snapshot used to compute mutations. Reading
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both
// read the same stale baseline and the second write clobbers the first
// (N113). We instead mutate from this ref, kept in sync with server echoes.
const latestRef = useRef<Reminder[]>(reminders);
// Serialize writes so overlapping setAccountData calls can't land out of
// order on the server (last-write-wins would otherwise drop data).
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const applyServerState = useCallback((list: Reminder[]) => {
latestRef.current = list;
setReminders(list);
}, []);
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === REMINDERS_KEY) {
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
}
},
[applyServerState],
),
);
// Re-read on mx change
// Subscribe to the shared module state. A single AccountData listener is
// installed per client (in ensureModuleState); each hook instance only
// registers a local setter and unregisters it on unmount / client change.
useEffect(() => {
applyServerState(readReminders(mx));
}, [mx, applyServerState]);
const enqueueWrite = useCallback(
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => {
const run = writeQueueRef.current.then(async () => {
const next = compute(latestRef.current);
latestRef.current = next;
setReminders(next);
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
writeQueueRef.current = run.catch(() => undefined);
return run;
},
[mx],
);
const state = ensureModuleState(mx);
setReminders(state.latest);
state.listeners.add(setReminders);
return () => {
state.listeners.delete(setReminders);
};
}, [mx]);
const addReminder = useCallback(
(r: Reminder) => enqueueWrite((current) => [...current, r]),
[enqueueWrite],
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
[mx],
);
const removeReminder = useCallback(
(eventId: string, timestamp: number) =>
enqueueWrite((current) =>
enqueueReminderWrite(mx, (current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
),
[enqueueWrite],
[mx],
);
const getReminders = useCallback(() => reminders, [reminders]);
const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
return { reminders, addReminder, removeReminder, getReminders };
}