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; 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()?.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 { 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; removeBookmark: (eventId: string) => Promise; isBookmarked: (eventId: string) => boolean; } { const mx = useMatrixClient(); const [bookmarks, setBookmarks] = useState(() => 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 }; }