668bdaad7d
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>
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
|
import { useMatrixClient } from './useMatrixClient';
|
|
|
|
export type Bookmark = {
|
|
roomId: string;
|
|
eventId: string;
|
|
savedAt: number;
|
|
previewText: string;
|
|
roomName: string;
|
|
};
|
|
|
|
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
|
|
const MAX_BOOKMARKS = 500;
|
|
|
|
type BookmarksContent = {
|
|
bookmarks: Bookmark[];
|
|
};
|
|
|
|
function readBookmarks(mx: MatrixClient): Bookmark[] {
|
|
return (
|
|
(mx.getAccountData(BOOKMARKS_KEY as any)?.getContent() as BookmarksContent | undefined)
|
|
?.bookmarks ?? []
|
|
);
|
|
}
|
|
|
|
// Module-scoped serialization state.
|
|
//
|
|
// useBookmarks() is mounted once per message row (dozens of live instances), so
|
|
// a per-instance latest/queue would only serialize writes within a single row —
|
|
// bookmarking message A then message B from different rows (before the server
|
|
// echo lands) would let B compute from a stale snapshot and clobber A
|
|
// (setAccountData replaces the whole content, no server merge). We therefore
|
|
// keep a single shared latest ref + write queue, keyed off the active client.
|
|
type BookmarksModuleState = {
|
|
mx: MatrixClient;
|
|
latest: Bookmark[];
|
|
writeQueue: Promise<unknown>;
|
|
listeners: Set<(list: Bookmark[]) => void>;
|
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
|
};
|
|
|
|
let moduleState: BookmarksModuleState | 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): BookmarksModuleState {
|
|
if (moduleState && moduleState.mx === mx) {
|
|
return moduleState;
|
|
}
|
|
|
|
if (moduleState) {
|
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
|
}
|
|
|
|
const state: BookmarksModuleState = {
|
|
mx,
|
|
latest: readBookmarks(mx),
|
|
writeQueue: Promise.resolve(),
|
|
listeners: new Set(),
|
|
// Reassigned below once `state` is captured.
|
|
onAccountData: () => undefined,
|
|
};
|
|
|
|
state.onAccountData = (evt) => {
|
|
if (evt.getType() === BOOKMARKS_KEY) {
|
|
const list = evt.getContent<BookmarksContent>()?.bookmarks ?? [];
|
|
state.latest = list;
|
|
state.listeners.forEach((listener) => listener(list));
|
|
}
|
|
};
|
|
|
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
|
moduleState = state;
|
|
return state;
|
|
}
|
|
|
|
function enqueueBookmarkWrite(
|
|
mx: MatrixClient,
|
|
compute: (current: Bookmark[]) => Bookmark[],
|
|
): 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(BOOKMARKS_KEY, { bookmarks: 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 useBookmarks(): {
|
|
bookmarks: Bookmark[];
|
|
addBookmark: (b: Bookmark) => Promise<void>;
|
|
removeBookmark: (eventId: string) => Promise<void>;
|
|
isBookmarked: (eventId: string) => boolean;
|
|
} {
|
|
const mx = useMatrixClient();
|
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
|
|
|
|
// 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(() => {
|
|
const state = ensureModuleState(mx);
|
|
setBookmarks(state.latest);
|
|
state.listeners.add(setBookmarks);
|
|
return () => {
|
|
state.listeners.delete(setBookmarks);
|
|
};
|
|
}, [mx]);
|
|
|
|
const addBookmark = useCallback(
|
|
(b: Bookmark) =>
|
|
enqueueBookmarkWrite(mx, (current) => {
|
|
// Avoid duplicates
|
|
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
|
let next = [b, ...filtered];
|
|
if (next.length > MAX_BOOKMARKS) {
|
|
next = next.slice(0, MAX_BOOKMARKS);
|
|
}
|
|
return next;
|
|
}),
|
|
[mx],
|
|
);
|
|
|
|
const removeBookmark = useCallback(
|
|
(eventId: string) =>
|
|
enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
|
|
[mx],
|
|
);
|
|
|
|
const isBookmarked = useCallback(
|
|
(eventId: string) => bookmarks.some((bk) => bk.eventId === eventId),
|
|
[bookmarks],
|
|
);
|
|
|
|
return { bookmarks, addBookmark, removeBookmark, isBookmarked };
|
|
}
|