fix(security): bookmarks/reminders stop storing decrypted text server-side

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
This commit is contained in:
2026-09-12 19:46:05 -04:00
co-authored by Claude Opus 5
parent 23ee156f2f
commit 9bdf4ff1fd
5 changed files with 272 additions and 21 deletions
+22 -11
View File
@@ -103,7 +103,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
const displayRoomName = room?.name ?? bookmark.roomName;
// E2EE-room bookmarks store no roomName; fall back past the '' placeholder.
const displayRoomName = room?.name || bookmark.roomName || 'Unknown room';
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
@@ -162,7 +163,7 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{preview ?? (bookmark.previewText || '(no preview)')}
{preview ?? (bookmark.previewText || 'Message unavailable')}
</Text>
</Button>
</Box>
@@ -173,13 +174,16 @@ type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
// Renders the same layout as BookmarkItem, but resolves the message body live so
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
// stored snapshot (previewText) remains the fallback for loading/failed/empty
// states; bookmarks from E2EE rooms have no snapshot at all (account data is
// server-readable), so the live event is their only source of text.
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
room.findEventById(bookmark.eventId),
);
const snapshot = bookmark.previewText || '(no preview)';
const snapshot =
bookmark.previewText || (liveEvent === undefined ? 'Loading…' : 'Message unavailable');
let preview: ReactNode = snapshot;
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
@@ -234,7 +238,7 @@ function RoomGroupHeader({
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(roomId) ?? undefined;
const displayRoomName = room?.name ?? roomName;
const displayRoomName = room?.name || roomName || 'Unknown room';
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
@@ -327,13 +331,20 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
() =>
query.length === 0
? bookmarks
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
: bookmarks.filter((bk) => {
// E2EE-room bookmarks have no stored text: match against the locally
// cached event body / live room name instead (nothing is fetched here).
const room = mx.getRoom(bk.roomId);
const localBody = room?.findEventById(bk.eventId)?.getContent()?.body;
return (
(bk.previewText?.toLowerCase().includes(query) ?? false) ||
(typeof localBody === 'string' && localBody.toLowerCase().includes(query)) ||
bk.roomName.toLowerCase().includes(query) ||
(bk.senderName?.toLowerCase().includes(query) ?? false),
),
[bookmarks, query],
(room?.name.toLowerCase().includes(query) ?? false) ||
(bk.senderName?.toLowerCase().includes(query) ?? false)
);
}),
[mx, bookmarks, query],
);
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
@@ -1273,6 +1273,9 @@ export const Message = React.memo(
const content = mEvent.getContent();
const body: string =
(content?.body as string | undefined) ?? '';
// For E2EE rooms useBookmarks strips the text
// fields before persisting (account data is
// server-readable); the panel resolves them live.
addBookmark({
roomId: room.roomId,
eventId,