For encrypted rooms, bookmarks persist only {roomId, eventId, savedAt}
and reminders only their non-text fields; the preview, room name and
sender resolve locally at render/fire time from the timeline (with a
"Message unavailable" fallback). A one-time, loop-guarded cleanup strips
text from existing entries in currently-encrypted rooms. Unencrypted
rooms are unchanged. Unit-tested.
Fixes #10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
161 lines
6.0 KiB
TypeScript
161 lines
6.0 KiB
TypeScript
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<Bookmark, 'roomName'> & { 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<Bookmark[], BookmarksContent>({
|
|
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<void>;
|
|
removeBookmark: (eventId: string) => Promise<void>;
|
|
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 };
|
|
}
|