import { useCallback, useEffect } from 'react'; import { MatrixClient } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { createAccountDataListStore } from './createAccountDataListStore'; export type Bookmark = { roomId: string; eventId: string; savedAt: number; // Snapshot fields. Account data is stored UNENCRYPTED on the homeserver, so // for E2EE rooms none of these are persisted (see stripBookmarkText) — the // panel resolves the preview / room / author live from the local timeline // instead. They remain for unencrypted rooms (already server-visible) and for // entries saved before this policy, so older stored bookmarks still render. previewText?: string; // Kept as a (possibly empty) string in memory because the grouping helpers in // utils/bookmarks read it directly; the store omits it from the wire when empty. roomName: string; // Author display name, snapshotted at save time. Optional for backward // compatibility with bookmarks stored before attribution was added; the panel // re-resolves a live name when the room is joined and falls back to this // snapshot otherwise. (We deliberately don't store the sender MXID — it's never // read back and would only grow the 500-entry account-data blob.) senderName?: string; }; // Wire shape: identical to Bookmark except that `roomName` may be absent. type StoredBookmark = Omit & { roomName?: string }; const BOOKMARKS_KEY = 'io.lotus.bookmarks'; const MAX_BOOKMARKS = 500; type BookmarksContent = { bookmarks: StoredBookmark[]; }; /** True when the entry carries any of the text fields we refuse to upload for E2EE rooms. */ export const hasBookmarkText = (b: Bookmark): boolean => b.previewText !== undefined || b.roomName !== '' || b.senderName !== undefined; /** * Reduce a bookmark to its non-text fields ({roomId, eventId, savedAt}). Used for * messages in encrypted rooms so no decrypted plaintext (or who said it) ever * reaches the server's account-data store. */ export const stripBookmarkText = (b: Bookmark): Bookmark => ({ roomId: b.roomId, eventId: b.eventId, savedAt: b.savedAt, roomName: '', }); /** * Apply the E2EE storage policy to a bookmark about to be persisted. * `isEncryptedRoom` is injected so the policy is testable without a client. */ export const toStorableBookmark = ( b: Bookmark, isEncryptedRoom: (roomId: string) => boolean, ): Bookmark => (isEncryptedRoom(b.roomId) ? stripBookmarkText(b) : b); /** * One-time cleanup for entries persisted before the E2EE policy: returns the list * with text fields stripped for every bookmark whose room is currently encrypted, * or `undefined` when nothing needs to change (so callers can skip the write). */ export const cleanupEncryptedBookmarks = ( bookmarks: Bookmark[], isEncryptedRoom: (roomId: string) => boolean, ): Bookmark[] | undefined => { let changed = false; const next = bookmarks.map((b) => { if (hasBookmarkText(b) && isEncryptedRoom(b.roomId)) { changed = true; return stripBookmarkText(b); } return b; }); return changed ? next : undefined; }; const isEncryptedRoomFor = (mx: MatrixClient) => (roomId: string): boolean => mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false; // Shared, concurrency-safe store. See createAccountDataListStore for why the // snapshot + write queue must be module-scoped (writes are serialized to avoid // lost updates, since setAccountData replaces the whole content with no merge). const bookmarksStore = createAccountDataListStore({ eventType: BOOKMARKS_KEY, read: (content) => (content?.bookmarks ?? []).map((b) => ({ ...b, roomName: b.roomName ?? '' })), write: (bookmarks) => ({ // Drop the in-memory '' placeholder so stripped entries stay {roomId, eventId, savedAt}. bookmarks: bookmarks.map(({ roomName, ...rest }) => (roomName ? { ...rest, roomName } : rest)), }), }); // Guard so the load-time cleanup below runs once per client, not on every mount // (useBookmarks is mounted by every message row) and not again after its own // write echoes back. let cleanedUpFor: MatrixClient | null = null; export function useBookmarks(): { bookmarks: Bookmark[]; addBookmark: (b: Bookmark) => Promise; removeBookmark: (eventId: string) => Promise; isBookmarked: (eventId: string) => boolean; } { const mx = useMatrixClient(); const bookmarks = bookmarksStore.useValue(mx); // Strip text from entries saved (by older builds) for rooms that are encrypted, // writing the store back at most once per client. useEffect(() => { if (cleanedUpFor === mx) return; cleanedUpFor = mx; const cleaned = cleanupEncryptedBookmarks(bookmarksStore.getLatest(mx), isEncryptedRoomFor(mx)); if (cleaned) { bookmarksStore .enqueueWrite( mx, (current) => cleanupEncryptedBookmarks(current, isEncryptedRoomFor(mx)) ?? current, ) .catch(() => { // Retry on the next client (re)mount. if (cleanedUpFor === mx) cleanedUpFor = null; }); } }, [mx]); const addBookmark = useCallback( (b: Bookmark) => bookmarksStore.enqueueWrite(mx, (current) => { // Never upload plaintext for E2EE rooms — account data is server-readable. const stored = toStorableBookmark(b, isEncryptedRoomFor(mx)); // Avoid duplicates const filtered = current.filter((bk) => bk.eventId !== stored.eventId); let next = [stored, ...filtered]; if (next.length > MAX_BOOKMARKS) { next = next.slice(0, MAX_BOOKMARKS); } return next; }), [mx], ); const removeBookmark = useCallback( (eventId: string) => bookmarksStore.enqueueWrite(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 }; }