From 0d5e1abf0aeabdf35d54e8e3050fbc81293375af Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 7 May 2026 21:19:52 -0400
Subject: [PATCH 1/9] feat(bookmarks): add message bookmarks (MSC4438) with
reminder infrastructure
---
.changeset/message-bookmarks.md | 5 +
knip.json | 2 +-
src/app/features/bookmarks/BookmarksList.tsx | 304 +++++++++
src/app/features/bookmarks/BookmarksPanel.tsx | 48 ++
.../features/bookmarks/bookmarkDomain.test.ts | 375 +++++++++++
src/app/features/bookmarks/bookmarkDomain.ts | 171 +++++
.../bookmarks/bookmarkRepository.test.ts | 494 ++++++++++++++
.../features/bookmarks/bookmarkRepository.ts | 204 ++++++
.../features/bookmarks/useBookmarks.test.tsx | 136 ++++
src/app/features/bookmarks/useBookmarks.ts | 119 ++++
.../bookmarks/useInitBookmarks.test.tsx | 154 +++++
.../features/bookmarks/useInitBookmarks.ts | 79 +++
src/app/features/bookmarks/useReminderSync.ts | 73 ++
src/app/features/room/message/Message.tsx | 53 ++
.../settings/experimental/Experimental.tsx | 2 +
.../experimental/MSC4438MessageBookmarks.tsx | 57 ++
src/app/hooks/router/useHomeSelected.ts | 11 +
src/app/hooks/router/useInbox.ts | 17 +-
src/app/hooks/useBookmarks.ts | 197 ++++++
src/app/pages/Router.tsx | 4 +
src/app/pages/client/ClientNonUIFeatures.tsx | 20 +
src/app/pages/client/SidebarNav.tsx | 2 +
.../pages/client/bookmarks/BookmarksList.tsx | 628 ++++++++++++++++++
src/app/pages/client/bookmarks/index.ts | 1 +
src/app/pages/client/inbox/Bookmarks.tsx | 48 ++
src/app/pages/client/inbox/Inbox.tsx | 43 +-
src/app/pages/client/inbox/index.ts | 1 +
src/app/pages/client/sidebar/BookmarksTab.tsx | 39 ++
src/app/pages/client/sidebar/index.ts | 1 +
src/app/pages/pathUtils.ts | 4 +
src/app/pages/paths.ts | 3 +
src/app/state/bookmarks.test.ts | 69 ++
src/app/state/bookmarks.ts | 27 +
src/app/state/settings.ts | 6 +
src/sw.ts | 99 +++
src/types/matrix/accountData.ts | 64 +-
src/unstable/prefixes/sable/accountdata.ts | 3 +
37 files changed, 3544 insertions(+), 19 deletions(-)
create mode 100644 .changeset/message-bookmarks.md
create mode 100644 src/app/features/bookmarks/BookmarksList.tsx
create mode 100644 src/app/features/bookmarks/BookmarksPanel.tsx
create mode 100644 src/app/features/bookmarks/bookmarkDomain.test.ts
create mode 100644 src/app/features/bookmarks/bookmarkDomain.ts
create mode 100644 src/app/features/bookmarks/bookmarkRepository.test.ts
create mode 100644 src/app/features/bookmarks/bookmarkRepository.ts
create mode 100644 src/app/features/bookmarks/useBookmarks.test.tsx
create mode 100644 src/app/features/bookmarks/useBookmarks.ts
create mode 100644 src/app/features/bookmarks/useInitBookmarks.test.tsx
create mode 100644 src/app/features/bookmarks/useInitBookmarks.ts
create mode 100644 src/app/features/bookmarks/useReminderSync.ts
create mode 100644 src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
create mode 100644 src/app/hooks/useBookmarks.ts
create mode 100644 src/app/pages/client/bookmarks/BookmarksList.tsx
create mode 100644 src/app/pages/client/bookmarks/index.ts
create mode 100644 src/app/pages/client/inbox/Bookmarks.tsx
create mode 100644 src/app/pages/client/sidebar/BookmarksTab.tsx
create mode 100644 src/app/state/bookmarks.test.ts
create mode 100644 src/app/state/bookmarks.ts
diff --git a/.changeset/message-bookmarks.md b/.changeset/message-bookmarks.md
new file mode 100644
index 000000000..9ca1cf6c3
--- /dev/null
+++ b/.changeset/message-bookmarks.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+Add message bookmarks (MSC4438). Users can bookmark messages for easy retrieval via a new Bookmarks section in the home sidebar. Gated by an operator `config.json` experiment flag (`experiments.messageBookmarks`) and a per-user experimental settings toggle.
diff --git a/knip.json b/knip.json
index 6cc8c8581..a2c24c8cb 100644
--- a/knip.json
+++ b/knip.json
@@ -1,7 +1,7 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/sw.ts", "scripts/normalize-imports.js"],
- "ignore": ["oxlint.config.ts", "oxfmt.config.ts"],
+ "ignore": ["oxlint.config.ts", "oxfmt.config.ts", "src/app/features/bookmarks/BookmarksPanel.tsx"],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
diff --git a/src/app/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx
new file mode 100644
index 000000000..d73649b07
--- /dev/null
+++ b/src/app/features/bookmarks/BookmarksList.tsx
@@ -0,0 +1,304 @@
+import { Avatar, Box, Chip, Icon, IconButton, Icons, Line, Text, config } from 'folds';
+import { useAtomValue } from 'jotai';
+import {
+ useBookmarks,
+ useArchivedBookmarks,
+ toggleBookmark,
+ restoreBookmark,
+ permanentlyDeleteBookmark,
+} from '$hooks/useBookmarks';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { useRoomNavigate } from '$hooks/useRoomNavigate';
+import { useGetRoom, useAllJoinedRoomsSet } from '$hooks/useGetRoom';
+import { getMemberAvatarMxc, getMemberDisplayName } from '$utils/room';
+import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
+import { UserAvatar } from '$components/user-avatar';
+import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { SequenceCard } from '$components/sequence-card';
+import { AvatarBase, ModernLayout, Time, Username, UsernameBold } from '$components/message';
+import { ContainerColor } from '$styles/ContainerColor.css';
+import { EncryptedContent } from '$features/room/message';
+import { nicknamesAtom } from '$state/nicknames';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+
+type BookmarksListProps = {
+ onNavigate?: () => void;
+};
+
+export function BookmarksList({ onNavigate }: BookmarksListProps) {
+ const mx = useMatrixClient();
+ const bookmarks = useBookmarks();
+ const archived = useArchivedBookmarks();
+ const { navigateRoom } = useRoomNavigate();
+ const useAuthentication = useMediaAuthentication();
+ const allRoomsSet = useAllJoinedRoomsSet();
+ const getRoom = useGetRoom(allRoomsSet);
+ const nicknames = useAtomValue(nicknamesAtom);
+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
+
+ const handleOpen = (roomId: string, eventId: string) => {
+ navigateRoom(roomId, eventId);
+ onNavigate?.();
+ };
+
+ const handleRemove = (roomId: string, eventId: string) => {
+ toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {});
+ };
+
+ const handleRestore = (entry: (typeof archived)[number]) => {
+ restoreBookmark(mx, entry).catch(() => {});
+ };
+
+ const handlePermanentDelete = (entry: (typeof archived)[number]) => {
+ const allIds = [...bookmarks.map((b) => b.id), ...archived.map((b) => b.id)];
+ permanentlyDeleteBookmark(mx, entry, allIds).catch(() => {});
+ };
+
+ if (bookmarks.length === 0 && archived.length === 0) {
+ return (
+
+ No Bookmarks
+ Bookmark messages from the message menu to save them here.
+
+ );
+ }
+
+ return (
+
+ {bookmarks.map((bookmark) => {
+ const room = getRoom(bookmark.room_id);
+ const event = room
+ ?.getTimelineForEvent(bookmark.event_id)
+ ?.getEvents()
+ .find((e) => e.getId() === bookmark.event_id);
+
+ const senderId = event?.getSender() ?? '';
+ const displayName =
+ (room && senderId ? getMemberDisplayName(room, senderId, nicknames) : undefined) ??
+ getMxIdLocalPart(senderId) ??
+ senderId;
+ const senderAvatarMxc = room && senderId ? getMemberAvatarMxc(room, senderId) : undefined;
+ const senderAvatarUrl = senderAvatarMxc
+ ? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
+ : undefined;
+
+ return (
+
+
+
+ }
+ />
+
+
+ }
+ >
+
+
+
+
+
+ {displayName || 'Unknown'}
+
+
+
+ {event && (
+
+ )}
+
+
+ handleOpen(bookmark.room_id, bookmark.event_id)}
+ variant="Secondary"
+ radii="400"
+ >
+ Open
+
+ handleRemove(bookmark.room_id, bookmark.event_id)}
+ aria-label="Remove bookmark"
+ >
+
+
+
+
+
+ in {room?.name ?? bookmark.room_id}
+
+ {event ? (
+
+ {() => {
+ const content = event.getContent<{ body?: string }>();
+ return (
+
+ {content.body ?? 'Unknown content'}
+
+ );
+ }}
+
+ ) : (
+
+ Event not in local timeline
+
+ )}
+
+
+ );
+ })}
+ {archived.length > 0 && (
+ <>
+
+
+
+
+
+ Archived
+
+
+
+
+ {archived.map((entry) => {
+ const room = getRoom(entry.room_id);
+ const event = room
+ ?.getTimelineForEvent(entry.event_id)
+ ?.getEvents()
+ .find((e) => e.getId() === entry.event_id);
+
+ const senderId = event?.getSender() ?? '';
+ const displayName =
+ (room && senderId ? getMemberDisplayName(room, senderId, nicknames) : undefined) ??
+ getMxIdLocalPart(senderId) ??
+ senderId;
+ const senderAvatarMxc =
+ room && senderId ? getMemberAvatarMxc(room, senderId) : undefined;
+ const senderAvatarUrl = senderAvatarMxc
+ ? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
+ : undefined;
+
+ return (
+
+
+
+ }
+ />
+
+
+ }
+ >
+
+
+
+
+
+ {displayName || 'Unknown'}
+
+
+
+ {event && (
+
+ )}
+
+
+ handleOpen(entry.room_id, entry.event_id)}
+ variant="Secondary"
+ radii="400"
+ >
+ Open
+
+ handleRestore(entry)}
+ aria-label="Restore bookmark"
+ title="Restore"
+ >
+
+
+ handlePermanentDelete(entry)}
+ aria-label="Permanently delete bookmark"
+ title="Delete permanently"
+ >
+
+
+
+
+
+ in {room?.name ?? entry.room_id}
+
+ {event ? (
+
+ {() => {
+ const content = event.getContent<{ body?: string }>();
+ return (
+
+ {content.body ?? 'Unknown content'}
+
+ );
+ }}
+
+ ) : (
+
+ Event not in local timeline
+
+ )}
+
+
+ );
+ })}
+ >
+ )}
+
+ );
+}
diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx
new file mode 100644
index 000000000..b38ba0bc8
--- /dev/null
+++ b/src/app/features/bookmarks/BookmarksPanel.tsx
@@ -0,0 +1,48 @@
+import { Box, color, Dialog, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
+import { BookmarksList } from './BookmarksList';
+
+export { BookmarksList } from './BookmarksList';
+
+type BookmarksPanelProps = {
+ requestClose: () => void;
+};
+
+export function BookmarksPanel({ requestClose }: BookmarksPanelProps) {
+ return (
+
+ );
+}
diff --git a/src/app/features/bookmarks/bookmarkDomain.test.ts b/src/app/features/bookmarks/bookmarkDomain.test.ts
new file mode 100644
index 000000000..2f70879da
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkDomain.test.ts
@@ -0,0 +1,375 @@
+/**
+ * Unit tests for MSC4438 bookmark domain logic.
+ * All functions in bookmarkDomain.ts are pure / side-effect-free.
+ */
+import { describe, it, expect } from 'vitest';
+import type { MatrixEvent, Room } from '$types/matrix-sdk';
+import { AccountDataEvent } from '$types/matrix/accountData';
+import {
+ bookmarkItemEventType,
+ buildMatrixURI,
+ computeBookmarkId,
+ createBookmarkItem,
+ emptyIndex,
+ extractBodyPreview,
+ isValidBookmarkItem,
+ isValidIndexContent,
+} from './bookmarkDomain';
+
+// ---------------------------------------------------------------------------
+// Helpers: minimal Matrix object stubs
+// ---------------------------------------------------------------------------
+
+function makeEvent(
+ opts: {
+ id?: string | null;
+ body?: unknown;
+ msgtype?: string;
+ sender?: string;
+ ts?: number;
+ } = {}
+): MatrixEvent {
+ return {
+ getId: () => (opts.id === null ? undefined : (opts.id ?? '$event:server.tld')),
+ getTs: () => opts.ts ?? 1_000_000,
+ getSender: () => opts.sender ?? '@alice:server.tld',
+ getContent: () => ({
+ body: opts.body,
+ msgtype: opts.msgtype ?? 'm.text',
+ }),
+ } as unknown as MatrixEvent;
+}
+
+function makeRoom(opts: { roomId?: string; name?: string } = {}): Room {
+ return {
+ roomId: opts.roomId ?? '!room:server.tld',
+ name: opts.name ?? 'Test Room',
+ } as unknown as Room;
+}
+
+// ---------------------------------------------------------------------------
+// computeBookmarkId
+// ---------------------------------------------------------------------------
+
+describe('computeBookmarkId', () => {
+ it('returns a string prefixed with "bmk_"', () => {
+ expect(computeBookmarkId('!room:s', '$event:s')).toMatch(/^bmk_/);
+ });
+
+ it('is exactly 12 characters long ("bmk_" + 8 hex digits)', () => {
+ expect(computeBookmarkId('!room:s', '$event:s')).toHaveLength(12);
+ });
+
+ it('only contains hex digits after the prefix', () => {
+ const id = computeBookmarkId('!room:server.tld', '$event:server.tld');
+ expect(id.slice(4)).toMatch(/^[0-9a-f]{8}$/);
+ });
+
+ it('is deterministic — same inputs always yield the same ID', () => {
+ const a = computeBookmarkId('!room:server.tld', '$event:server.tld');
+ const b = computeBookmarkId('!room:server.tld', '$event:server.tld');
+ expect(a).toBe(b);
+ });
+
+ it('differs when roomId changes', () => {
+ const a = computeBookmarkId('!roomA:s', '$event:s');
+ const b = computeBookmarkId('!roomB:s', '$event:s');
+ expect(a).not.toBe(b);
+ });
+
+ it('differs when eventId changes', () => {
+ const a = computeBookmarkId('!room:s', '$eventA:s');
+ const b = computeBookmarkId('!room:s', '$eventB:s');
+ expect(a).not.toBe(b);
+ });
+
+ it('separator prevents (roomId + eventId) collisions', () => {
+ // Without "|" separator, ("ab", "c") and ("a", "bc") would hash the same
+ const a = computeBookmarkId('ab', 'c');
+ const b = computeBookmarkId('a', 'bc');
+ expect(a).not.toBe(b);
+ });
+
+ // Known vector — computed from the reference djb2-like algorithm:
+ // input = "a|b", each char's code units: 97, 124, 98
+ // hash trace: 0 → 97 → 3131 → 97159 (0x17b87)
+ it('produces the known reference vector for ("a", "b")', () => {
+ expect(computeBookmarkId('a', 'b')).toBe('bmk_00017b87');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// bookmarkItemEventType
+// ---------------------------------------------------------------------------
+
+describe('bookmarkItemEventType', () => {
+ it('returns the MSC4438 unstable event type for a given bookmark ID', () => {
+ expect(bookmarkItemEventType('bmk_abcd1234')).toBe(
+ `${AccountDataEvent.BookmarkItemPrefix}bmk_abcd1234`
+ );
+ });
+
+ it('uses BookmarkItemPrefix as the base', () => {
+ const id = 'bmk_00000001';
+ expect(bookmarkItemEventType(id)).toContain(AccountDataEvent.BookmarkItemPrefix);
+ });
+
+ it('has BookmarksIndex enum value defined correctly', () => {
+ expect(AccountDataEvent.BookmarksIndex).toBe('org.matrix.msc4438.bookmarks.index');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildMatrixURI
+// ---------------------------------------------------------------------------
+
+describe('buildMatrixURI', () => {
+ it.each([
+ [
+ '!room:server.tld',
+ '$event:server.tld',
+ // encodeURIComponent does not encode '!' — only ':' and '$' are encoded here
+ 'matrix:roomid/!room%3Aserver.tld/e/%24event%3Aserver.tld',
+ ],
+ ['simple', 'id', 'matrix:roomid/simple/e/id'],
+ ['a b', 'c d', 'matrix:roomid/a%20b/e/c%20d'],
+ ])('buildMatrixURI(%s, %s) → %s', (roomId, eventId, expected) => {
+ expect(buildMatrixURI(roomId, eventId)).toBe(expected);
+ });
+
+ it('starts with "matrix:roomid/"', () => {
+ expect(buildMatrixURI('!r:s', '$e:s')).toMatch(/^matrix:roomid\//);
+ });
+
+ it('contains "/e/" separator between roomId and eventId', () => {
+ expect(buildMatrixURI('!r:s', '$e:s')).toContain('/e/');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// extractBodyPreview
+// ---------------------------------------------------------------------------
+
+describe('extractBodyPreview', () => {
+ it('returns the body unchanged when it is within the default limit', () => {
+ const event = makeEvent({ body: 'Hello, world!' });
+ expect(extractBodyPreview(event)).toBe('Hello, world!');
+ });
+
+ it('returns an empty string when body is undefined', () => {
+ const event = makeEvent({ body: undefined });
+ expect(extractBodyPreview(event)).toBe('');
+ });
+
+ it('returns an empty string when body is a non-string type', () => {
+ const event = makeEvent({ body: 42 });
+ expect(extractBodyPreview(event)).toBe('');
+ });
+
+ it('returns an empty string when body is an empty string', () => {
+ const event = makeEvent({ body: '' });
+ expect(extractBodyPreview(event)).toBe('');
+ });
+
+ it('truncates to 120 chars and appends "…" when body exceeds the default limit', () => {
+ const long = 'x'.repeat(200);
+ const result = extractBodyPreview(makeEvent({ body: long }));
+ expect(result).toHaveLength(121); // 120 + ellipsis char
+ expect(result.endsWith('\u2026')).toBe(true);
+ expect(result.slice(0, 120)).toBe('x'.repeat(120));
+ });
+
+ it('does not truncate when body is exactly 120 chars', () => {
+ const exact = 'y'.repeat(120);
+ expect(extractBodyPreview(makeEvent({ body: exact }))).toBe(exact);
+ });
+
+ it('respects a custom maxLength', () => {
+ const event = makeEvent({ body: 'abcdefghij' });
+ const result = extractBodyPreview(event, 5);
+ expect(result).toBe('abcde\u2026');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isValidIndexContent
+// ---------------------------------------------------------------------------
+
+describe('isValidIndexContent', () => {
+ const valid = {
+ version: 1 as const,
+ revision: 0,
+ updated_ts: Date.now(),
+ bookmark_ids: [],
+ };
+
+ it('accepts a well-formed index', () => {
+ expect(isValidIndexContent(valid)).toBe(true);
+ });
+
+ it('accepts an index with string IDs in bookmark_ids', () => {
+ expect(isValidIndexContent({ ...valid, bookmark_ids: ['bmk_aabbccdd'] })).toBe(true);
+ });
+
+ it('rejects null', () => {
+ expect(isValidIndexContent(null)).toBe(false);
+ });
+
+ it('rejects a non-object', () => {
+ expect(isValidIndexContent('string')).toBe(false);
+ expect(isValidIndexContent(42)).toBe(false);
+ });
+
+ it('rejects version !== 1', () => {
+ expect(isValidIndexContent({ ...valid, version: 2 })).toBe(false);
+ });
+
+ it('rejects missing revision', () => {
+ const { revision, ...rest } = valid;
+ expect(isValidIndexContent(rest)).toBe(false);
+ });
+
+ it('rejects missing updated_ts', () => {
+ const { updated_ts: updatedTs, ...rest } = valid;
+ expect(isValidIndexContent(rest)).toBe(false);
+ });
+
+ it('rejects missing bookmark_ids', () => {
+ const { bookmark_ids: bookmarkIds, ...rest } = valid;
+ expect(isValidIndexContent(rest)).toBe(false);
+ });
+
+ it('rejects bookmark_ids containing a non-string', () => {
+ expect(isValidIndexContent({ ...valid, bookmark_ids: [1, 2, 3] })).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isValidBookmarkItem
+// ---------------------------------------------------------------------------
+
+describe('isValidBookmarkItem', () => {
+ const valid = {
+ version: 1 as const,
+ bookmark_id: 'bmk_abcd1234',
+ uri: 'matrix:roomid/foo/e/bar',
+ room_id: '!room:s',
+ event_id: '$event:s',
+ event_ts: 1_000_000,
+ bookmarked_ts: 2_000_000,
+ };
+
+ it('accepts a complete, well-formed item', () => {
+ expect(isValidBookmarkItem(valid)).toBe(true);
+ });
+
+ it('accepts an item with optional fields set', () => {
+ expect(
+ isValidBookmarkItem({ ...valid, sender: '@alice:s', room_name: 'Room', deleted: false })
+ ).toBe(true);
+ });
+
+ it('rejects null', () => {
+ expect(isValidBookmarkItem(null)).toBe(false);
+ });
+
+ it('rejects a non-object', () => {
+ expect(isValidBookmarkItem('string')).toBe(false);
+ });
+
+ it('rejects version !== 1', () => {
+ expect(isValidBookmarkItem({ ...valid, version: 2 })).toBe(false);
+ });
+
+ it.each(['bookmark_id', 'uri', 'room_id', 'event_id'] as const)(
+ 'rejects item missing string field "%s"',
+ (field) => {
+ const copy = { ...valid } as Record;
+ delete copy[field];
+ expect(isValidBookmarkItem(copy)).toBe(false);
+ }
+ );
+
+ it.each(['event_ts', 'bookmarked_ts'] as const)(
+ 'rejects item missing numeric field "%s"',
+ (field) => {
+ const copy = { ...valid } as Record;
+ delete copy[field];
+ expect(isValidBookmarkItem(copy)).toBe(false);
+ }
+ );
+});
+
+// ---------------------------------------------------------------------------
+// createBookmarkItem
+// ---------------------------------------------------------------------------
+
+describe('createBookmarkItem', () => {
+ it('returns undefined when the event has no ID', () => {
+ const room = makeRoom();
+ const event = makeEvent({ id: null });
+ expect(createBookmarkItem(room, event)).toBeUndefined();
+ });
+
+ it('returns a valid BookmarkItemContent for a normal event', () => {
+ const room = makeRoom({ roomId: '!r:s', name: 'My Room' });
+ const event = makeEvent({
+ id: '$e:s',
+ body: 'Hello',
+ msgtype: 'm.text',
+ sender: '@bob:s',
+ ts: 123456,
+ });
+ const item = createBookmarkItem(room, event);
+ expect(item).toBeDefined();
+ expect(item!.version).toBe(1);
+ expect(item!.room_id).toBe('!r:s');
+ expect(item!.event_id).toBe('$e:s');
+ expect(item!.bookmark_id).toBe(computeBookmarkId('!r:s', '$e:s'));
+ expect(item!.uri).toBe(buildMatrixURI('!r:s', '$e:s'));
+ expect(item!.event_ts).toBe(123456);
+ expect(item!.sender).toBe('@bob:s');
+ expect(item!.room_name).toBe('My Room');
+ expect(item!.body_preview).toBe('Hello');
+ expect(item!.msgtype).toBe('m.text');
+ });
+
+ it('omits body_preview when body is missing', () => {
+ const room = makeRoom();
+ const event = makeEvent({ body: undefined });
+ const item = createBookmarkItem(room, event);
+ expect(item!.body_preview).toBe('');
+ });
+
+ it('passes isValidBookmarkItem on the returned content', () => {
+ const room = makeRoom();
+ const event = makeEvent();
+ const item = createBookmarkItem(room, event);
+ expect(isValidBookmarkItem(item)).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// emptyIndex
+// ---------------------------------------------------------------------------
+
+describe('emptyIndex', () => {
+ it('returns a valid index with version 1', () => {
+ const idx = emptyIndex();
+ expect(isValidIndexContent(idx)).toBe(true);
+ expect(idx.version).toBe(1);
+ });
+
+ it('starts with revision 0 and empty bookmark_ids', () => {
+ const idx = emptyIndex();
+ expect(idx.revision).toBe(0);
+ expect(idx.bookmark_ids).toEqual([]);
+ });
+
+ it('returns a fresh object on each call (no shared reference)', () => {
+ const a = emptyIndex();
+ const b = emptyIndex();
+ a.bookmark_ids.push('bmk_aabbccdd');
+ expect(b.bookmark_ids).toHaveLength(0);
+ });
+});
diff --git a/src/app/features/bookmarks/bookmarkDomain.ts b/src/app/features/bookmarks/bookmarkDomain.ts
new file mode 100644
index 000000000..ab36ba95e
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkDomain.ts
@@ -0,0 +1,171 @@
+/**
+ * MSC4438: Message bookmarks via account data
+ * https://github.com/matrix-org/matrix-spec-proposals/pull/4438
+ *
+ * Unstable event type names in use (will migrate to stable names once MSC is accepted):
+ * m.bookmarks.index → org.matrix.msc4438.bookmarks.index
+ * m.bookmark. → org.matrix.msc4438.bookmark.
+ *
+ * Bookmark ID algorithm: djb2-like 32-bit hash over "|", prefixed with "bmk_".
+ * This matches the reference implementation in smokku/cinny commit 6363e441 and is used here for
+ * cross-client interoperability. If the algorithm ever changes, a migration must be provided so
+ * that existing bookmarks can have their IDs recomputed (the ID is stored in the item event, so
+ * old items remain accessible).
+ */
+
+import type { MatrixEvent, Room } from '$types/matrix-sdk';
+import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
+
+export type BookmarkIndexContent = {
+ version: 1;
+ revision: number;
+ updated_ts: number;
+ bookmark_ids: string[];
+};
+
+export type BookmarkItemContent = {
+ version: 1;
+ bookmark_id: string;
+ uri: string;
+ room_id: string;
+ event_id: string;
+ event_ts: number;
+ bookmarked_ts: number;
+ sender?: string;
+ room_name?: string;
+ body_preview?: string;
+ msgtype?: string;
+ deleted?: boolean;
+};
+
+/**
+ * Compute a bookmark ID for a (roomId, eventId) pair using the reference
+ * djb2-style algorithm agreed upon with the Cinny proof-of-concept.
+ *
+ * Input string: "|"
+ * Algorithm: For each UTF-16 code unit ch, hash = ((hash << 5) - hash + ch) | 0
+ * Output: "bmk_" + unsigned 32-bit hex, zero-padded to 8 chars
+ *
+ * NOTE: If this algorithm is ever changed, a migration helper must be written
+ * so that existing bookmarked items (whose IDs are stored on the server as
+ * account data event-type suffixes) can still be resolved. The bookmark_id
+ * field inside each item event is the canonical reference.
+ */
+export function computeBookmarkId(roomId: string, eventId: string): string {
+ const input = `${roomId}|${eventId}`;
+ let hash = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ const ch = input.charCodeAt(i);
+ // eslint-disable-next-line no-bitwise
+ hash = ((hash << 5) - hash + ch) | 0;
+ }
+ // Convert to unsigned 32-bit integer and encode as 8-char lowercase hex
+ // eslint-disable-next-line no-bitwise
+ const hex = (hash >>> 0).toString(16).padStart(8, '0');
+ return `bmk_${hex}`;
+}
+
+/** Construct the account data event type for a bookmark item. */
+export function bookmarkItemEventType(bookmarkId: string): string {
+ return `${AccountDataEvent.BookmarkItemPrefix}${bookmarkId}`;
+}
+
+/**
+ * Build a matrix: URI for a room event.
+ * Canonical form: matrix:roomid//e/
+ * (MSC4438 §Matrix URI)
+ */
+export function buildMatrixURI(roomId: string, eventId: string): string {
+ return `matrix:roomid/${encodeURIComponent(roomId)}/e/${encodeURIComponent(eventId)}`;
+}
+
+const BODY_PREVIEW_MAX_LENGTH = 120;
+
+/**
+ * Extract a short preview of the event body for display in the bookmark list.
+ * Truncated to 120 chars with an ellipsis (MSC4438 §Body preview).
+ *
+ * Security: preview is only used as plain text in the UI, never parsed as HTML.
+ * Encrypted-room callers may choose to pass an empty string to avoid leaking
+ * plaintext into unencrypted account data (MSC4438 §Security considerations).
+ */
+export function extractBodyPreview(
+ mEvent: MatrixEvent,
+ maxLength = BODY_PREVIEW_MAX_LENGTH
+): string {
+ const content = mEvent.getContent();
+ const body = content?.body;
+ if (typeof body !== 'string' || body.length === 0) return '';
+ if (body.length <= maxLength) return body;
+ return `${body.slice(0, maxLength)}\u2026`;
+}
+
+/**
+ * Build a BookmarkItemContent from a room and event.
+ *
+ * Security: optional metadata (sender, room_name, body_preview) is copied into
+ * unencrypted account data. For encrypted rooms the caller may choose to omit
+ * these fields, storing only the required fields (room_id, event_id, uri).
+ * Currently we always populate them for usability; future work could honour a
+ * "privacy mode" setting.
+ */
+export function createBookmarkItem(
+ room: Room,
+ mEvent: MatrixEvent
+): BookmarkItemContent | undefined {
+ const eventId = mEvent.getId();
+ const { roomId } = room;
+ if (!eventId) return undefined;
+
+ const bookmarkId = computeBookmarkId(roomId, eventId);
+
+ return {
+ version: 1,
+ bookmark_id: bookmarkId,
+ uri: buildMatrixURI(roomId, eventId),
+ room_id: roomId,
+ event_id: eventId,
+ event_ts: mEvent.getTs(),
+ bookmarked_ts: Date.now(),
+ sender: mEvent.getSender() ?? undefined,
+ room_name: room.name,
+ body_preview: extractBodyPreview(mEvent),
+ msgtype: mEvent.getContent()?.msgtype,
+ };
+}
+
+// Validators (MSC4438: clients must validate before use)
+export function isValidIndexContent(content: unknown): content is BookmarkIndexContent {
+ if (typeof content !== 'object' || content === null) return false;
+ const c = content as Record;
+ return (
+ c.version === 1 &&
+ typeof c.revision === 'number' &&
+ typeof c.updated_ts === 'number' &&
+ Array.isArray(c.bookmark_ids) &&
+ (c.bookmark_ids as unknown[]).every((id) => typeof id === 'string')
+ );
+}
+
+export function isValidBookmarkItem(content: unknown): content is BookmarkItemContent {
+ if (typeof content !== 'object' || content === null) return false;
+ const c = content as Record;
+ return (
+ c.version === 1 &&
+ typeof c.bookmark_id === 'string' &&
+ typeof c.uri === 'string' &&
+ typeof c.room_id === 'string' &&
+ typeof c.event_id === 'string' &&
+ typeof c.event_ts === 'number' &&
+ typeof c.bookmarked_ts === 'number'
+ );
+}
+
+export function emptyIndex(): BookmarkIndexContent {
+ return {
+ version: 1,
+ revision: 0,
+ updated_ts: Date.now(),
+ bookmark_ids: [],
+ };
+}
diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts
new file mode 100644
index 000000000..2ffe4260e
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkRepository.test.ts
@@ -0,0 +1,494 @@
+/**
+ * Unit tests for MSC4438 bookmark repository layer.
+ *
+ * The repository functions are pure in the sense that they read and write
+ * synchronously from a MatrixClient mock that returns predictable account data.
+ * No network calls are made.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import type { MatrixClient } from '$types/matrix-sdk';
+import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
+import {
+ addBookmark,
+ removeBookmark,
+ listBookmarks,
+ listDeletedBookmarks,
+ isBookmarked,
+} from './bookmarkRepository';
+import {
+ bookmarkItemEventType,
+ emptyIndex,
+ type BookmarkIndexContent,
+ type BookmarkItemContent,
+} from './bookmarkDomain';
+
+/** Typed test-stub accessor — exposes the private `_store` used for assertions. */
+interface StubClient {
+ _store: Record;
+}
+
+// ---------------------------------------------------------------------------
+// Stub MatrixClient
+// ---------------------------------------------------------------------------
+
+/**
+ * Build a minimal MatrixClient stub backed by an in-memory store.
+ * `getAccountData` returns a fake MatrixEvent whose `getContent()` reads
+ * from the store; `setAccountData` writes to the store.
+ */
+function makeClient(initialData: Record = {}): MatrixClient {
+ const store: Record = { ...initialData };
+ const accountData = new Map(Object.entries(store));
+
+ return {
+ getAccountData: vi.fn<(eventType: string) => { getContent: () => unknown } | undefined>(
+ (eventType: string) => {
+ const content = store[eventType];
+ if (content === undefined) return undefined;
+ return { getContent: () => content };
+ }
+ ),
+ setAccountData: vi.fn<(eventType: string, content: unknown) => Promise>(
+ async (eventType: string, content: unknown) => {
+ store[eventType] = content;
+ accountData.set(eventType, content);
+ }
+ ),
+ store: { accountData },
+ _store: store, // exposed for inspection in tests
+ } as unknown as MatrixClient;
+}
+
+// ---------------------------------------------------------------------------
+// Test data helpers
+// ---------------------------------------------------------------------------
+
+function makeItem(overrides: Partial = {}): BookmarkItemContent {
+ return {
+ version: 1,
+ bookmark_id: 'bmk_aabbccdd',
+ uri: 'matrix:roomid/foo/e/bar',
+ room_id: '!room:s',
+ event_id: '$event:s',
+ event_ts: 1_000_000,
+ bookmarked_ts: 2_000_000,
+ ...overrides,
+ };
+}
+
+function makeIndex(overrides: Partial = {}): BookmarkIndexContent {
+ return {
+ ...emptyIndex(),
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// addBookmark
+// ---------------------------------------------------------------------------
+
+describe('addBookmark', () => {
+ let mx: MatrixClient;
+
+ beforeEach(() => {
+ mx = makeClient();
+ });
+
+ it('writes the item event before writing the index', async () => {
+ const item = makeItem();
+ const callOrder: string[] = [];
+
+ (mx.setAccountData as ReturnType).mockImplementation(
+ async (type: string, content: unknown) => {
+ callOrder.push(type);
+ // keep default in-memory behaviour
+ (mx as unknown as StubClient)._store[type] = content;
+ }
+ );
+
+ await addBookmark(mx, item);
+
+ expect(callOrder[0]).toBe(bookmarkItemEventType(item.bookmark_id));
+ expect(callOrder[1]).toBe(AccountDataEvent.BookmarksIndex);
+ });
+
+ it('prepends the bookmark ID to bookmark_ids in the index', async () => {
+ const existing = makeItem({ bookmark_id: 'bmk_11111111' });
+ const mx2 = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [existing.bookmark_id] }),
+ [bookmarkItemEventType(existing.bookmark_id)]: existing,
+ });
+
+ const newItem = makeItem({ bookmark_id: 'bmk_22222222' });
+ await addBookmark(mx2, newItem);
+
+ const store = (mx2 as unknown as StubClient)._store;
+ const idx = store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.bookmark_ids[0]).toBe('bmk_22222222');
+ expect(idx.bookmark_ids[1]).toBe('bmk_11111111');
+ });
+
+ it('does not duplicate an ID already in the index', async () => {
+ const item = makeItem();
+ const mx2 = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await addBookmark(mx2, item);
+
+ const idx = (mx2 as unknown as StubClient)._store[
+ AccountDataEvent.BookmarksIndex
+ ] as BookmarkIndexContent;
+ expect(idx.bookmark_ids.filter((id) => id === item.bookmark_id)).toHaveLength(1);
+ });
+
+ it('increments the index revision', async () => {
+ const item = makeItem();
+ await addBookmark(mx, item);
+
+ const idx = (mx as unknown as StubClient)._store[
+ AccountDataEvent.BookmarksIndex
+ ] as BookmarkIndexContent;
+ expect(idx.revision).toBe(1);
+ });
+
+ it('works when no index exists yet (creates an empty one)', async () => {
+ const item = makeItem();
+ await addBookmark(mx, item);
+
+ const idx = (mx as unknown as StubClient)._store[
+ AccountDataEvent.BookmarksIndex
+ ] as BookmarkIndexContent;
+ expect(idx.bookmark_ids).toContain(item.bookmark_id);
+ });
+
+ it('re-activates a tombstoned bookmark (strips deleted: true)', async () => {
+ const tombstoned = makeItem({ deleted: true });
+ const mx2 = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }),
+ [bookmarkItemEventType(tombstoned.bookmark_id)]: tombstoned,
+ });
+
+ // Re-add with a fresh item (same bookmark_id, no deleted flag)
+ const freshItem = makeItem();
+ await addBookmark(mx2, freshItem);
+
+ const stored = (mx2 as unknown as StubClient)._store[
+ bookmarkItemEventType(freshItem.bookmark_id)
+ ] as BookmarkItemContent;
+ expect(stored.deleted).toBeUndefined();
+ const idx = (mx2 as unknown as StubClient)._store[
+ AccountDataEvent.BookmarksIndex
+ ] as BookmarkIndexContent;
+ expect(idx.bookmark_ids).toContain(freshItem.bookmark_id);
+ });
+
+ it('strips deleted: true even when the item passed in carries the flag', async () => {
+ const item = makeItem({ deleted: true });
+ await addBookmark(mx, item);
+
+ const stored = (mx as unknown as StubClient)._store[
+ bookmarkItemEventType(item.bookmark_id)
+ ] as BookmarkItemContent;
+ expect(stored.deleted).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// removeBookmark
+// ---------------------------------------------------------------------------
+
+describe('removeBookmark', () => {
+ it('removes the bookmark ID from the index', async () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await removeBookmark(mx, item.bookmark_id);
+
+ const idx = (mx as unknown as StubClient)._store[
+ AccountDataEvent.BookmarksIndex
+ ] as BookmarkIndexContent;
+ expect(idx.bookmark_ids).not.toContain(item.bookmark_id);
+ });
+
+ it('soft-deletes the item event (sets deleted: true)', async () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await removeBookmark(mx, item.bookmark_id);
+
+ const stored = (mx as unknown as StubClient)._store[
+ bookmarkItemEventType(item.bookmark_id)
+ ] as BookmarkItemContent;
+ expect(stored.deleted).toBe(true);
+ });
+
+ it('increments the index revision', async () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({
+ bookmark_ids: [item.bookmark_id],
+ revision: 3,
+ }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await removeBookmark(mx, item.bookmark_id);
+
+ const idx = (mx as unknown as StubClient)._store[
+ AccountDataEvent.BookmarksIndex
+ ] as BookmarkIndexContent;
+ expect(idx.revision).toBe(4);
+ });
+
+ it('succeeds without error when the item event does not exist', async () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ // No item event stored
+ });
+
+ await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow();
+ });
+
+ it('tombstones a malformed item event (sets deleted: true even when validation fails)', async () => {
+ // A malformed item exists in account data (e.g. written by a buggy client).
+ // removeBookmark must still tombstone it so orphan recovery does not resurrect it.
+ const badContent = { not_a_valid: 'item' };
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_bad'] }),
+ [bookmarkItemEventType('bmk_bad')]: badContent,
+ });
+
+ await removeBookmark(mx, 'bmk_bad');
+
+ const stored = (mx as unknown as StubClient)._store[bookmarkItemEventType('bmk_bad')] as {
+ deleted?: boolean;
+ };
+ expect(stored.deleted).toBe(true);
+ });
+
+ it('tombstones an already-deleted item event (idempotent)', async () => {
+ // If for any reason the same bookmark is removed twice, the tombstone write
+ // should still succeed and the item should remain deleted.
+ const item = makeItem({ deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow();
+ const stored = (mx as unknown as StubClient)._store[
+ bookmarkItemEventType(item.bookmark_id)
+ ] as BookmarkItemContent;
+ expect(stored.deleted).toBe(true);
+ });
+
+ it('leaves the index unchanged when the ID was not present', async () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aaaabbbb'] }),
+ });
+
+ await removeBookmark(mx, 'bmk_nonexistent');
+
+ const idx = (mx as unknown as StubClient)._store[
+ AccountDataEvent.BookmarksIndex
+ ] as BookmarkIndexContent;
+ expect(idx.bookmark_ids).toEqual(['bmk_aaaabbbb']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// listBookmarks
+// ---------------------------------------------------------------------------
+
+describe('listBookmarks', () => {
+ it('returns an empty array when there is no index', () => {
+ const mx = makeClient();
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+
+ it('returns active items in index order', () => {
+ const a = makeItem({ bookmark_id: 'bmk_aaaaaaaa' });
+ const b = makeItem({ bookmark_id: 'bmk_bbbbbbbb' });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({
+ bookmark_ids: [a.bookmark_id, b.bookmark_id],
+ }),
+ [bookmarkItemEventType(a.bookmark_id)]: a,
+ [bookmarkItemEventType(b.bookmark_id)]: b,
+ });
+
+ const result = listBookmarks(mx);
+ expect(result.map((i) => i.bookmark_id)).toEqual([a.bookmark_id, b.bookmark_id]);
+ });
+
+ it('skips items that are soft-deleted (deleted: true)', () => {
+ const item = makeItem({ deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+
+ it('skips item IDs whose event is missing from account data', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_orphaned'] }),
+ // No item event
+ });
+
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+
+ it('deduplicates IDs that appear more than once in bookmark_ids', () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({
+ bookmark_ids: [item.bookmark_id, item.bookmark_id],
+ }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ expect(listBookmarks(mx)).toHaveLength(1);
+ });
+
+ it('skips malformed item events', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_bad'] }),
+ [bookmarkItemEventType('bmk_bad')]: { not_a_valid: 'item' },
+ });
+
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+
+ it('recovers orphaned items whose event exists but ID is absent from the index', () => {
+ // Simulate a concurrent-write race: device A's bookmark_id was dropped from the
+ // index by a last-write-wins overwrite, but the item event still exists.
+ const orphan = makeItem({ bookmark_id: 'bmk_orphan1' });
+ const indexed = makeItem({ bookmark_id: 'bmk_indexed' });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [indexed.bookmark_id] }),
+ [bookmarkItemEventType(indexed.bookmark_id)]: indexed,
+ [bookmarkItemEventType(orphan.bookmark_id)]: orphan,
+ });
+
+ const result = listBookmarks(mx);
+ expect(result.map((i) => i.bookmark_id)).toContain(orphan.bookmark_id);
+ expect(result.map((i) => i.bookmark_id)).toContain(indexed.bookmark_id);
+ // Indexed item should appear before the orphan
+ expect(result[0]!.bookmark_id).toBe(indexed.bookmark_id);
+ });
+
+ it('does not return soft-deleted orphaned items', () => {
+ const orphan = makeItem({ bookmark_id: 'bmk_orphan2', deleted: true });
+ const mx = makeClient({
+ // No index entry for the orphan — deleted orphan should still be skipped
+ [bookmarkItemEventType(orphan.bookmark_id)]: orphan,
+ });
+
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// listDeletedBookmarks
+// ---------------------------------------------------------------------------
+
+describe('listDeletedBookmarks', () => {
+ it('returns an empty array when there are no tombstoned items', () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+ expect(listDeletedBookmarks(mx)).toEqual([]);
+ });
+
+ it('returns index-referenced items that are tombstoned (partial remove failure)', () => {
+ const item = makeItem({ deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+ const result = listDeletedBookmarks(mx);
+ expect(result).toHaveLength(1);
+ expect(result[0]!.bookmark_id).toBe(item.bookmark_id);
+ });
+
+ it('returns orphan tombstones not in the index (normal remove path)', () => {
+ const item = makeItem({ bookmark_id: 'bmk_orphan99', deleted: true });
+ const mx = makeClient({
+ // ID intentionally absent from the index
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+ const result = listDeletedBookmarks(mx);
+ expect(result).toHaveLength(1);
+ expect(result[0]!.bookmark_id).toBe(item.bookmark_id);
+ });
+
+ it('does not return active (non-deleted) items', () => {
+ const active = makeItem();
+ const deleted = makeItem({ bookmark_id: 'bmk_deleted1', deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [active.bookmark_id] }),
+ [bookmarkItemEventType(active.bookmark_id)]: active,
+ [bookmarkItemEventType(deleted.bookmark_id)]: deleted,
+ });
+ const result = listDeletedBookmarks(mx);
+ expect(result.map((i) => i.bookmark_id)).not.toContain(active.bookmark_id);
+ expect(result.map((i) => i.bookmark_id)).toContain(deleted.bookmark_id);
+ });
+
+ it('deduplicates when the same ID appears in both index and orphan scan', () => {
+ const item = makeItem({ deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+ const result = listDeletedBookmarks(mx);
+ expect(result).toHaveLength(1);
+ });
+
+ it('skips malformed item events even if deleted: true', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }),
+ [bookmarkItemEventType('bmk_bad')]: { deleted: true, not_valid: 'junk' },
+ });
+ expect(listDeletedBookmarks(mx)).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isBookmarked
+// ---------------------------------------------------------------------------
+
+describe('isBookmarked', () => {
+ it('returns true when the ID is in the index', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aabbccdd'] }),
+ });
+ expect(isBookmarked(mx, 'bmk_aabbccdd')).toBe(true);
+ });
+
+ it('returns false when the ID is not in the index', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aabbccdd'] }),
+ });
+ expect(isBookmarked(mx, 'bmk_ffffffff')).toBe(false);
+ });
+
+ it('returns false when there is no index', () => {
+ const mx = makeClient();
+ expect(isBookmarked(mx, 'bmk_aabbccdd')).toBe(false);
+ });
+});
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
new file mode 100644
index 000000000..78493d016
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -0,0 +1,204 @@
+/**
+ * Bookmark repository: low-level read/write operations against Matrix account data.
+ *
+ * All writes follow the MSC4438 ordering guarantee:
+ * item is written first → index is updated second
+ * This ensures that when other devices receive the updated index via /sync, the
+ * referenced item event is already available.
+ */
+/* oxlint-disable typescript/no-explicit-any -- MatrixClient.getAccountData/setAccountData only accept
+ SDK-known event types (keyof AccountDataEvents); custom MSC4438 account data event types require
+ `as any` to bypass the constraint without a full SDK fork. */
+
+import type { MatrixClient } from '$types/matrix-sdk';
+import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
+import type { BookmarkIndexContent, BookmarkItemContent } from './bookmarkDomain';
+import {
+ bookmarkItemEventType,
+ emptyIndex,
+ isValidBookmarkItem,
+ isValidIndexContent,
+} from './bookmarkDomain';
+
+// Internal helpers
+function readIndex(mx: MatrixClient): BookmarkIndexContent {
+ const evt = mx.getAccountData(AccountDataEvent.BookmarksIndex as any);
+ const content = evt?.getContent();
+ if (isValidIndexContent(content)) return content;
+ return emptyIndex();
+}
+
+function readItem(mx: MatrixClient, bookmarkId: string): BookmarkItemContent | undefined {
+ const evt = mx.getAccountData(bookmarkItemEventType(bookmarkId) as any);
+ const content = evt?.getContent();
+ // Must be valid and not tombstoned (MSC4438 §Listing bookmarks)
+ if (isValidBookmarkItem(content) && !content.deleted) return content;
+ return undefined;
+}
+
+async function writeIndex(mx: MatrixClient, index: BookmarkIndexContent): Promise {
+ await mx.setAccountData(AccountDataEvent.BookmarksIndex as any, index as any);
+}
+
+async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise {
+ await mx.setAccountData(bookmarkItemEventType(item.bookmark_id) as any, item as any);
+}
+
+// Public API
+/**
+ * Add a bookmark. Also handles re-activation: if the same (roomId, eventId) was
+ * previously removed (tombstoned), calling addBookmark again clears the tombstone
+ * and restores it to the active list.
+ *
+ * MSC4438 §Adding a bookmark:
+ * 1. Write the item event first (strips any deleted flag to guarantee re-activation).
+ * 2. Prepend the ID to bookmark_ids (if not already present).
+ * 3. Increment revision and update timestamp.
+ * 4. Write the updated index.
+ */
+export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): Promise {
+ // Strip deleted so that re-bookmarking a previously removed message always
+ // produces an active item, even if a stale tombstoned item is passed in.
+ const { deleted, ...activeItem } = item;
+ // Write item before updating index (cross-device consistency)
+ await writeItem(mx, activeItem as BookmarkItemContent);
+
+ const index = readIndex(mx);
+ if (!index.bookmark_ids.includes(item.bookmark_id)) {
+ index.bookmark_ids.unshift(item.bookmark_id);
+ }
+ index.revision += 1;
+ index.updated_ts = Date.now();
+ await writeIndex(mx, index);
+}
+
+/**
+ * Remove a bookmark.
+ *
+ * MSC4438 §Removing a bookmark:
+ * 1. Soft-delete the item first (set deleted: true).
+ * 2. Remove the ID from the index.
+ * 3. Increment revision and update timestamp.
+ * 4. Write the updated index.
+ *
+ * Account data events cannot be deleted from the server, so soft-deletion is
+ * used. This implementation intentionally tombstones the item before updating
+ * the index to mirror addBookmark()'s item-first ordering and avoid transient
+ * orphan recovery/resurrection if a removal only partially completes.
+ */
+export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
+ // Tombstone the item event directly — bypass readItem()'s validation so that
+ // malformed or already-deleted items still get marked deleted: true. Without
+ // this, orphan recovery can resurrect items whose deletion write failed halfway.
+ const evt = mx.getAccountData(bookmarkItemEventType(bookmarkId) as any);
+ const raw = evt?.getContent();
+ if (raw != null) {
+ // Write using the bookmarkId param as the canonical type key, not item.bookmark_id,
+ // so malformed items (missing bookmark_id field) still get the right event type.
+ await mx.setAccountData(
+ bookmarkItemEventType(bookmarkId) as any,
+ { ...(raw as object), deleted: true } as any
+ );
+ }
+
+ const index = readIndex(mx);
+ index.bookmark_ids = index.bookmark_ids.filter((id) => id !== bookmarkId);
+ index.revision += 1;
+ index.updated_ts = Date.now();
+ await writeIndex(mx, index);
+}
+
+/**
+ * List all active bookmarks in index order, with orphan recovery.
+ *
+ * MSC4438 §Listing bookmarks:
+ * - Iterates bookmark_ids in order.
+ * - Skips missing, malformed, or tombstoned items.
+ * - Deduplicates by first occurrence.
+ *
+ * Orphan recovery: also scans the in-memory account data store for bookmark
+ * item events that exist but are absent from the index. These arise when two
+ * devices concurrently write the index (last-write-wins drops the other
+ * device's new bookmark_id while the item event itself persists). Orphaned
+ * items are appended after the index-ordered items.
+ */
+export function listBookmarks(mx: MatrixClient): BookmarkItemContent[] {
+ const index = readIndex(mx);
+ const seen = new Set();
+
+ const items = index.bookmark_ids
+ .filter((id) => {
+ if (seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ })
+ .map((id) => readItem(mx, id))
+ .filter((item): item is BookmarkItemContent => item != null);
+
+ // Walk the in-memory account data store for orphaned item events.
+ const prefix = AccountDataEvent.BookmarkItemPrefix as string;
+ Array.from(mx.store.accountData.keys()).forEach((key) => {
+ if (!key.startsWith(prefix)) return;
+ const bookmarkId = key.slice(prefix.length);
+ if (seen.has(bookmarkId)) return;
+ const item = readItem(mx, bookmarkId);
+ if (item) {
+ seen.add(bookmarkId);
+ items.push(item);
+ }
+ });
+
+ return items;
+}
+
+/**
+ * List all deleted (tombstoned) bookmark items.
+ *
+ * Includes both:
+ * - Items still referenced in the index whose item event carries deleted: true
+ * (arises when the index write fails after a soft-delete).
+ * - Orphaned tombstones whose ID has already been removed from the index
+ * (the normal case after a successful remove).
+ *
+ * Results are deduplicated and include only items that pass isValidBookmarkItem
+ * (ensuring enough stored metadata is available to display and restore them).
+ */
+export function listDeletedBookmarks(mx: MatrixClient): BookmarkItemContent[] {
+ const index = readIndex(mx);
+ const results: BookmarkItemContent[] = [];
+ const seen = new Set();
+
+ // 1. Index-referenced items that are tombstoned (partial remove failure)
+ index.bookmark_ids.forEach((id) => {
+ if (seen.has(id)) return;
+ seen.add(id);
+ const content = mx.getAccountData(bookmarkItemEventType(id) as any)?.getContent();
+ if (isValidBookmarkItem(content) && content.deleted === true) results.push(content);
+ });
+
+ // 2. Orphan tombstones (properly removed from index but item event persists)
+ const prefix = AccountDataEvent.BookmarkItemPrefix as string;
+ Array.from(mx.store.accountData.keys()).forEach((key) => {
+ if (!key.startsWith(prefix)) return;
+ const bookmarkId = key.slice(prefix.length);
+ if (seen.has(bookmarkId)) return;
+ seen.add(bookmarkId);
+ const content = mx.getAccountData(key as any)?.getContent();
+ if (isValidBookmarkItem(content) && content.deleted === true) results.push(content);
+ });
+
+ return results;
+}
+
+/**
+ * Check whether a specific bookmark ID is in the index.
+ *
+ * NOTE: Do not rely on the bookmark ID being deterministically derivable from
+ * (roomId, eventId) for this check — different clients may use different
+ * algorithms. Use the bookmarkIdSet atom (derived from the live list) for
+ * O(1) per-message checks instead.
+ */
+export function isBookmarked(mx: MatrixClient, bookmarkId: string): boolean {
+ const index = readIndex(mx);
+ return index.bookmark_ids.includes(bookmarkId);
+}
diff --git a/src/app/features/bookmarks/useBookmarks.test.tsx b/src/app/features/bookmarks/useBookmarks.test.tsx
new file mode 100644
index 000000000..9b399ba38
--- /dev/null
+++ b/src/app/features/bookmarks/useBookmarks.test.tsx
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+import { createElement, type ReactNode } from 'react';
+import { bookmarkListAtom, bookmarkDeletedListAtom } from '$state/bookmarks';
+import { useBookmarkActions } from './useBookmarks';
+import type { BookmarkItemContent } from './bookmarkDomain';
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const { mockMx } = vi.hoisted(() => {
+ const store: Record = {};
+ return {
+ mockMx: {
+ getAccountData: vi.fn<(type: string) => { getContent: () => unknown } | undefined>(
+ (type: string) => {
+ const content = store[type];
+ if (!content) return undefined;
+ return { getContent: () => content };
+ }
+ ),
+ setAccountData: vi.fn<(type: string, content: unknown) => Promise>(
+ async (type: string, content: unknown) => {
+ store[type] = content;
+ }
+ ),
+ store: { accountData: new Map() },
+ },
+ };
+});
+
+vi.mock('$hooks/useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
+// Mock the repository so removeBookmark doesn't try to read real account data
+vi.mock('./bookmarkRepository', async (importOriginal) => {
+ const orig = await importOriginal>();
+ return {
+ ...orig,
+ removeBookmark: vi.fn<() => Promise>(async () => {}),
+ addBookmark: vi.fn<() => Promise>(async () => {}),
+ };
+});
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeItem(id: string): BookmarkItemContent {
+ return {
+ version: 1,
+ bookmark_id: id,
+ uri: `matrix:roomid/foo/e/${id}`,
+ room_id: '!room:s',
+ event_id: `$${id}:s`,
+ event_ts: 1_000,
+ bookmarked_ts: 2_000,
+ };
+}
+
+function makeWrapper(store: ReturnType) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return createElement(Provider, { store }, children);
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('useBookmarkActions.remove', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ it('moves item from active list to deleted list optimistically', async () => {
+ const item = makeItem('bmk_1111');
+ store.set(bookmarkListAtom, [item]);
+ store.set(bookmarkDeletedListAtom, []);
+
+ const { result } = renderHook(() => useBookmarkActions(), {
+ wrapper: makeWrapper(store),
+ });
+
+ await act(async () => {
+ await result.current.remove('bmk_1111');
+ });
+
+ expect(store.get(bookmarkListAtom)).toHaveLength(0);
+
+ const deleted = store.get(bookmarkDeletedListAtom);
+ expect(deleted).toHaveLength(1);
+ expect(deleted[0]!.bookmark_id).toBe('bmk_1111');
+ expect(deleted[0]!.deleted).toBe(true);
+ });
+
+ it('does not duplicate item in deleted list if already present', async () => {
+ const item = makeItem('bmk_2222');
+ const deletedItem = { ...item, deleted: true as const };
+ store.set(bookmarkListAtom, [item]);
+ store.set(bookmarkDeletedListAtom, [deletedItem]);
+
+ const { result } = renderHook(() => useBookmarkActions(), {
+ wrapper: makeWrapper(store),
+ });
+
+ await act(async () => {
+ await result.current.remove('bmk_2222');
+ });
+
+ expect(store.get(bookmarkDeletedListAtom)).toHaveLength(1);
+ });
+
+ it('handles removing a non-existent item gracefully', async () => {
+ store.set(bookmarkListAtom, [makeItem('bmk_3333')]);
+ store.set(bookmarkDeletedListAtom, []);
+
+ const { result } = renderHook(() => useBookmarkActions(), {
+ wrapper: makeWrapper(store),
+ });
+
+ await act(async () => {
+ await result.current.remove('bmk_nonexistent');
+ });
+
+ // Original item untouched
+ expect(store.get(bookmarkListAtom)).toHaveLength(1);
+ // Nothing added to deleted list since the item wasn't found
+ expect(store.get(bookmarkDeletedListAtom)).toHaveLength(0);
+ });
+});
diff --git a/src/app/features/bookmarks/useBookmarks.ts b/src/app/features/bookmarks/useBookmarks.ts
new file mode 100644
index 000000000..7a53dce43
--- /dev/null
+++ b/src/app/features/bookmarks/useBookmarks.ts
@@ -0,0 +1,119 @@
+import { useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import {
+ bookmarkDeletedListAtom,
+ bookmarkIdSetAtom,
+ bookmarkListAtom,
+ bookmarkLoadingAtom,
+} from '$state/bookmarks';
+import type { BookmarkItemContent } from './bookmarkDomain';
+import { computeBookmarkId } from './bookmarkDomain';
+import {
+ addBookmark,
+ listBookmarks,
+ listDeletedBookmarks,
+ removeBookmark,
+ isBookmarked,
+} from './bookmarkRepository';
+
+/** Returns the current ordered bookmark list. */
+export function useBookmarkList(): BookmarkItemContent[] {
+ return useAtomValue(bookmarkListAtom);
+}
+
+/** Returns deleted (tombstoned) bookmarks that can be restored. */
+export function useBookmarkDeletedList(): BookmarkItemContent[] {
+ return useAtomValue(bookmarkDeletedListAtom);
+}
+
+/** Returns true while a bookmark refresh is in progress. */
+export function useBookmarkLoading(): boolean {
+ return useAtomValue(bookmarkLoadingAtom);
+}
+
+/**
+ * Returns true if the given (roomId, eventId) is currently bookmarked.
+ *
+ * Uses the locally cached bookmarkIdSetAtom for O(1) lookup.
+ * MSC4438 §Checking if a message is bookmarked.
+ */
+export function useIsBookmarked(roomId: string, eventId: string): boolean {
+ const idSet = useAtomValue(bookmarkIdSetAtom);
+ return idSet.has(computeBookmarkId(roomId, eventId));
+}
+
+/**
+ * Returns bookmark action callbacks: refresh, add, remove, checkIsBookmarked.
+ *
+ * `refresh` re-reads all bookmark items from the locally cached account data.
+ * `add` / `remove` optimistically update the local atom before writing to the server.
+ */
+export function useBookmarkActions() {
+ const mx = useMatrixClient();
+ const setList = useSetAtom(bookmarkListAtom);
+ const setDeletedList = useSetAtom(bookmarkDeletedListAtom);
+ const setLoading = useSetAtom(bookmarkLoadingAtom);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ setList(listBookmarks(mx));
+ setDeletedList(listDeletedBookmarks(mx));
+ } finally {
+ setLoading(false);
+ }
+ }, [mx, setList, setDeletedList, setLoading]);
+
+ const add = useCallback(
+ async (item: BookmarkItemContent) => {
+ // Optimistic update: add to active list, remove from deleted list
+ setList((prev) => {
+ if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev;
+ return [item, ...prev];
+ });
+ setDeletedList((prev) => prev.filter((b) => b.bookmark_id !== item.bookmark_id));
+ await addBookmark(mx, item);
+ },
+ [mx, setList, setDeletedList]
+ );
+
+ const remove = useCallback(
+ async (bookmarkId: string) => {
+ // Optimistic update: move from active list to deleted list
+ setList((prev) => {
+ const removed = prev.find((b) => b.bookmark_id === bookmarkId);
+ if (removed) {
+ setDeletedList((del) => {
+ if (del.some((b) => b.bookmark_id === bookmarkId)) return del;
+ return [{ ...removed, deleted: true }, ...del];
+ });
+ }
+ return prev.filter((b) => b.bookmark_id !== bookmarkId);
+ });
+ await removeBookmark(mx, bookmarkId);
+ },
+ [mx, setList, setDeletedList]
+ );
+
+ const restore = useCallback(
+ async (item: BookmarkItemContent) => {
+ // Optimistic update: move from deleted list to active list
+ setDeletedList((prev) => prev.filter((b) => b.bookmark_id !== item.bookmark_id));
+ setList((prev) => {
+ if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev;
+ return [item, ...prev];
+ });
+ await addBookmark(mx, item); // strips deleted flag
+ },
+ [mx, setList, setDeletedList]
+ );
+
+ const checkIsBookmarked = useCallback(
+ (roomId: string, eventId: string): boolean =>
+ isBookmarked(mx, computeBookmarkId(roomId, eventId)),
+ [mx]
+ );
+
+ return { refresh, add, remove, restore, checkIsBookmarked };
+}
diff --git a/src/app/features/bookmarks/useInitBookmarks.test.tsx b/src/app/features/bookmarks/useInitBookmarks.test.tsx
new file mode 100644
index 000000000..582ed669e
--- /dev/null
+++ b/src/app/features/bookmarks/useInitBookmarks.test.tsx
@@ -0,0 +1,154 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+import { createElement, type ReactNode } from 'react';
+import { bookmarkListAtom, bookmarkDeletedListAtom } from '$state/bookmarks';
+import { useInitBookmarks } from './useInitBookmarks';
+import type { BookmarkItemContent, BookmarkIndexContent } from './bookmarkDomain';
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const BOOKMARKS_INDEX = 'org.matrix.msc4438.bookmarks.index';
+const BOOKMARK_PREFIX = 'org.matrix.msc4438.bookmark.';
+
+const { accountDataCB, syncStateCB, mockMx } = vi.hoisted(() => {
+ const adCB: { current: ((event: { getType: () => string }) => void) | null } = { current: null };
+ const ssCB: { current: ((state: string, prev: string) => void) | null } = { current: null };
+
+ const item: BookmarkItemContent = {
+ version: 1,
+ bookmark_id: 'bmk_aabb',
+ uri: 'matrix:roomid/foo/e/bar',
+ room_id: '!room:s',
+ event_id: '$ev:s',
+ event_ts: 1_000,
+ bookmarked_ts: 2_000,
+ };
+ const deletedItem: BookmarkItemContent = {
+ version: 1,
+ bookmark_id: 'bmk_ccdd',
+ uri: 'matrix:roomid/baz/e/qux',
+ room_id: '!room2:s',
+ event_id: '$ev2:s',
+ event_ts: 3_000,
+ bookmarked_ts: 4_000,
+ deleted: true,
+ };
+ const index: BookmarkIndexContent = {
+ version: 1,
+ revision: 1,
+ updated_ts: 5_000,
+ bookmark_ids: ['bmk_aabb', 'bmk_ccdd'],
+ };
+
+ const store: Record = {
+ 'org.matrix.msc4438.bookmarks.index': index,
+ 'org.matrix.msc4438.bookmark.bmk_aabb': item,
+ 'org.matrix.msc4438.bookmark.bmk_ccdd': deletedItem,
+ };
+
+ const mx = {
+ getAccountData: vi.fn<(type: string) => { getContent: () => unknown } | undefined>(
+ (type: string) => {
+ const content = store[type];
+ if (!content) return undefined;
+ return { getContent: () => content };
+ }
+ ),
+ setAccountData: vi.fn<() => void>(),
+ store: { accountData: new Map(Object.entries(store)) },
+ };
+
+ return { accountDataCB: adCB, syncStateCB: ssCB, mockMx: mx };
+});
+
+vi.mock('$hooks/useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
+vi.mock('$hooks/useAccountDataCallback', () => ({
+ useAccountDataCallback: (_mx: unknown, cb: (event: { getType: () => string }) => void) => {
+ accountDataCB.current = cb;
+ },
+}));
+
+vi.mock('$hooks/useSyncState', () => ({
+ useSyncState: (_mx: unknown, cb: (state: string, prev: string) => void) => {
+ syncStateCB.current = cb;
+ },
+}));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeStore() {
+ return createStore();
+}
+
+function makeWrapper(store: ReturnType) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return createElement(Provider, { store }, children);
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('useInitBookmarks', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ store = makeStore();
+ accountDataCB.current = null;
+ syncStateCB.current = null;
+ });
+
+ it('loads bookmarks on mount', () => {
+ renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) });
+
+ const list = store.get(bookmarkListAtom);
+ const deleted = store.get(bookmarkDeletedListAtom);
+ expect(list).toHaveLength(1);
+ expect(list[0]!.bookmark_id).toBe('bmk_aabb');
+ expect(deleted).toHaveLength(1);
+ expect(deleted[0]!.bookmark_id).toBe('bmk_ccdd');
+ });
+
+ it('reloads when BookmarksIndex account data event fires', () => {
+ renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) });
+
+ // Clear the atom to prove the callback re-populates it
+ store.set(bookmarkListAtom, []);
+
+ accountDataCB.current!({ getType: () => BOOKMARKS_INDEX });
+
+ expect(store.get(bookmarkListAtom)).toHaveLength(1);
+ });
+
+ it('reloads when a bookmark item account data event fires', () => {
+ renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) });
+
+ store.set(bookmarkListAtom, []);
+
+ accountDataCB.current!({
+ getType: () => `${BOOKMARK_PREFIX}bmk_aabb`,
+ });
+
+ expect(store.get(bookmarkListAtom)).toHaveLength(1);
+ });
+
+ it('ignores unrelated account data events', () => {
+ renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) });
+
+ store.set(bookmarkListAtom, []);
+
+ accountDataCB.current!({ getType: () => 'm.room.message' });
+
+ // Should still be empty — callback should not have triggered a reload
+ expect(store.get(bookmarkListAtom)).toHaveLength(0);
+ });
+});
diff --git a/src/app/features/bookmarks/useInitBookmarks.ts b/src/app/features/bookmarks/useInitBookmarks.ts
new file mode 100644
index 000000000..d2956d7e4
--- /dev/null
+++ b/src/app/features/bookmarks/useInitBookmarks.ts
@@ -0,0 +1,79 @@
+import type { MatrixEvent } from '$types/matrix-sdk';
+import { SyncState } from '$types/matrix-sdk';
+import { useCallback, useEffect } from 'react';
+import { useSetAtom } from 'jotai';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { useSyncState } from '$hooks/useSyncState';
+import { useAccountDataCallback } from '$hooks/useAccountDataCallback';
+import { bookmarkDeletedListAtom, bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks';
+import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
+import { listBookmarks, listDeletedBookmarks } from './bookmarkRepository';
+
+/**
+ * Top-level hook that keeps `bookmarkListAtom` in sync with account data.
+ *
+ * Must be called from an always-mounted component (e.g. ClientNonUIFeatures),
+ * NOT from a page component. Page components should simply read from the atom.
+ *
+ * Three triggers keep the atom current:
+ * 1. `useEffect` on mount — covers the case where `ClientNonUIFeatures` mounts
+ * after the initial sync transition has already fired (the common case).
+ * 2. `SyncState.Syncing` transition — refreshes on every reconnect.
+ * 3. `ClientEvent.AccountData` for the index event type — reacts immediately
+ * to index updates pushed by other devices mid-session.
+ */
+export function useInitBookmarks(): void {
+ const mx = useMatrixClient();
+ const setList = useSetAtom(bookmarkListAtom);
+ const setDeletedList = useSetAtom(bookmarkDeletedListAtom);
+ const setLoading = useSetAtom(bookmarkLoadingAtom);
+
+ const loadBookmarks = useCallback(() => {
+ setLoading(true);
+ try {
+ setList(listBookmarks(mx));
+ setDeletedList(listDeletedBookmarks(mx));
+ } finally {
+ setLoading(false);
+ }
+ }, [mx, setList, setDeletedList, setLoading]);
+
+ // Immediate load: fires once on mount to cover the case where ClientNonUIFeatures
+ // mounts after the initial SyncState.Syncing transition has already fired.
+ // loadBookmarks is stable (memoized with stable deps), so this fires exactly once.
+ useEffect(() => {
+ loadBookmarks();
+ }, [loadBookmarks]);
+
+ // Trigger on reconnect (SyncState.Syncing transition after a disconnect).
+ useSyncState(
+ mx,
+ useCallback(
+ (state, prevState) => {
+ if (state === SyncState.Syncing && prevState !== SyncState.Syncing) {
+ loadBookmarks();
+ }
+ },
+ [loadBookmarks]
+ )
+ );
+
+ // React to bookmark account data changes pushed by other devices mid-session.
+ // The index event fires when the bookmark list changes; individual item events
+ // fire when a bookmark is added, removed, or soft-deleted.
+ useAccountDataCallback(
+ mx,
+ useCallback(
+ (event: MatrixEvent) => {
+ const type = event.getType();
+ if (
+ type === (AccountDataEvent.BookmarksIndex as string) ||
+ type.startsWith(AccountDataEvent.BookmarkItemPrefix as string)
+ ) {
+ loadBookmarks();
+ }
+ },
+ [loadBookmarks]
+ )
+ );
+}
diff --git a/src/app/features/bookmarks/useReminderSync.ts b/src/app/features/bookmarks/useReminderSync.ts
new file mode 100644
index 000000000..1a23a6116
--- /dev/null
+++ b/src/app/features/bookmarks/useReminderSync.ts
@@ -0,0 +1,73 @@
+import { useCallback, useEffect } from 'react';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { useAccountDataCallback } from '$hooks/useAccountDataCallback';
+import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
+import type { BookmarkReminder, BookmarksRemindersContent } from '$types/matrix/accountData';
+import type { MatrixEvent } from '$types/matrix-sdk';
+
+function postRemindersToSW(reminders: BookmarkReminder[]): void {
+ if (!('serviceWorker' in navigator)) return;
+ const payload = { type: 'updateReminders', reminders };
+ navigator.serviceWorker.ready
+ .then((reg) => {
+ reg.active?.postMessage(payload);
+ })
+ .catch(() => undefined);
+}
+
+async function tryRegisterPeriodicSync(): Promise {
+ if (!('serviceWorker' in navigator)) return;
+ try {
+ const reg = await navigator.serviceWorker.ready;
+ if (!('periodicSync' in reg)) return;
+ await (
+ reg as ServiceWorkerRegistration & {
+ periodicSync: { register(tag: string, opts: { minInterval: number }): Promise };
+ }
+ ).periodicSync.register('check-reminders', {
+ minInterval: 60 * 60 * 1000, // 1 hour — browser controls actual frequency
+ });
+ } catch {
+ // periodicSync unavailable or site engagement too low — SW interval is the fallback.
+ }
+}
+
+/**
+ * Reads bookmark reminders from Matrix account data and pushes them to the
+ * service worker cache whenever they change. The SW uses this cache to fire
+ * reminder notifications while the app is in a background tab (setInterval)
+ * or fully closed (periodicSync on Chromium).
+ *
+ * Must be called from an always-mounted component (e.g. ClientNonUIFeatures).
+ */
+export function useReminderSync(): void {
+ const mx = useMatrixClient();
+
+ const syncReminders = useCallback(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const accountDataEvent = mx.getAccountData(AccountDataEvent.SableBookmarksReminders as any);
+ const content = accountDataEvent?.getContent();
+ const reminders = content?.reminders ?? [];
+ postRemindersToSW(reminders);
+ }, [mx]);
+
+ // Initial sync on mount — covers the common case where ClientNonUIFeatures
+ // mounts after the initial sync has already fired.
+ useEffect(() => {
+ syncReminders();
+ tryRegisterPeriodicSync().catch(() => undefined);
+ }, [syncReminders]);
+
+ // React to account data changes pushed by other devices mid-session.
+ useAccountDataCallback(
+ mx,
+ useCallback(
+ (mxEvent: MatrixEvent) => {
+ if (mxEvent.getType() === (AccountDataEvent.SableBookmarksReminders as string)) {
+ syncReminders();
+ }
+ },
+ [syncReminders]
+ )
+ );
+}
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index ca1517de8..3ebc1dc5d 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -71,6 +71,8 @@ import { MessageEditHistoryItem } from '$components/message/modals/MessageEditHi
import { MessageSourceCodeItem } from '$components/message/modals/MessageSource';
import { MessageForwardItem } from '$components/message/modals/MessageForward';
import { MessageDeleteItem } from '$components/message/modals/MessageDelete';
+import { computeBookmarkId, createBookmarkItem } from '$features/bookmarks/bookmarkDomain';
+import { useIsBookmarked, useBookmarkActions } from '$features/bookmarks/useBookmarks';
import { MessageReportItem } from '$components/message/modals/MessageReport';
import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns';
import type { PronounSet } from '$utils/pronouns';
@@ -199,6 +201,49 @@ export const MessagePinItem = as<
);
});
+// message bookmarking
+export const MessageBookmarkItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+ const eventId = mEvent.getId();
+ const isBookmarked = useIsBookmarked(room.roomId, eventId ?? '');
+ const { add, remove } = useBookmarkActions();
+
+ if (!eventId) return null;
+ if (!enableMessageBookmarks) return null;
+
+ const handleClick = async () => {
+ if (isBookmarked) {
+ await remove(computeBookmarkId(room.roomId, eventId));
+ } else {
+ const item = createBookmarkItem(room, mEvent);
+ if (item) await add(item);
+ }
+ onClose?.();
+ };
+
+ return (
+ }
+ radii="300"
+ onClick={handleClick}
+ {...props}
+ ref={ref}
+ >
+
+ {isBookmarked ? 'Remove Bookmark' : 'Bookmark Message'}
+
+
+ );
+});
+
export type ForwardedMessageProps = {
originalTimestamp: number;
isForwarded: boolean;
@@ -1116,6 +1161,7 @@ function MessageInternal(
)}
+
{canPinEvent && (
)}
@@ -1454,6 +1500,13 @@ export const Event = as<'div', EventProps>(
)}
+ {!stateEvent && (
+
+ )}
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx
index 330412185..6e1d592d5 100644
--- a/src/app/features/settings/experimental/Experimental.tsx
+++ b/src/app/features/settings/experimental/Experimental.tsx
@@ -10,6 +10,7 @@ import { Sync } from '../general';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { BandwidthSavingEmojis } from './BandwithSavingEmojis';
import { MSC4268HistoryShare } from './MSC4268HistoryShare';
+import { MSC4438MessageBookmarks } from './MSC4438MessageBookmarks';
function PersonaToggle() {
const [showPersonaSetting, setShowPersonaSetting] = useSetting(
@@ -62,6 +63,7 @@ export function Experimental({ requestBack, requestClose }: Readonly
+
diff --git a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
new file mode 100644
index 000000000..0751a5578
--- /dev/null
+++ b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
@@ -0,0 +1,57 @@
+import { SequenceCard } from '$components/sequence-card';
+import { SettingTile } from '$components/setting-tile';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { Box, Switch, Text } from 'folds';
+import { SequenceCardStyle } from '../styles.css';
+
+export function MSC4438MessageBookmarks() {
+ const [enableMessageBookmarks, setEnableMessageBookmarks] = useSetting(
+ settingsAtom,
+ 'enableMessageBookmarks'
+ );
+
+ return (
+
+ Message Bookmarks
+
+
+ Save individual messages for later. Bookmarks are synced across all your devices via
+ account data.{' '}
+
+ MSC4438
+
+ .{' '}
+
+ Known issues (Sable GitHub)
+
+ .
+ >
+ }
+ after={
+
+ }
+ />
+
+
+ );
+}
diff --git a/src/app/hooks/router/useHomeSelected.ts b/src/app/hooks/router/useHomeSelected.ts
index 2a16511aa..fcc439196 100644
--- a/src/app/hooks/router/useHomeSelected.ts
+++ b/src/app/hooks/router/useHomeSelected.ts
@@ -4,6 +4,7 @@ import {
getHomeJoinPath,
getHomePath,
getHomeSearchPath,
+ getHomeBookmarksPath,
} from '$pages/pathUtils';
export const useHomeSelected = (): boolean => {
@@ -45,3 +46,13 @@ export const useHomeSearchSelected = (): boolean => {
return !!match;
};
+
+export const useHomeBookmarksSelected = (): boolean => {
+ const match = useMatch({
+ path: getHomeBookmarksPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
diff --git a/src/app/hooks/router/useInbox.ts b/src/app/hooks/router/useInbox.ts
index 639e16dd4..c19c0cc4b 100644
--- a/src/app/hooks/router/useInbox.ts
+++ b/src/app/hooks/router/useInbox.ts
@@ -1,5 +1,10 @@
import { useMatch } from 'react-router-dom';
-import { getInboxInvitesPath, getInboxNotificationsPath, getInboxPath } from '$pages/pathUtils';
+import {
+ getInboxBookmarksPath,
+ getInboxInvitesPath,
+ getInboxNotificationsPath,
+ getInboxPath,
+} from '$pages/pathUtils';
export const useInboxSelected = (): boolean => {
const match = useMatch({
@@ -30,3 +35,13 @@ export const useInboxInvitesSelected = (): boolean => {
return !!match;
};
+
+export const useInboxBookmarksSelected = (): boolean => {
+ const match = useMatch({
+ path: getInboxBookmarksPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts
new file mode 100644
index 000000000..cc1f59bdf
--- /dev/null
+++ b/src/app/hooks/useBookmarks.ts
@@ -0,0 +1,197 @@
+/* eslint-disable typescript/no-explicit-any -- MatrixClient.setAccountData only accepts
+ SDK-known event types (keyof AccountDataEvents); custom MSC4438 account data event types
+ require `as any` to bypass the constraint without a full SDK fork. */
+import { useCallback, useEffect, useState } from 'react';
+import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk';
+import { ClientEvent } from '$types/matrix-sdk';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { CustomAccountDataEvent } from '$types/matrix/accountData';
+
+export type BookmarkEntry = {
+ event_id: string;
+ room_id: string;
+ /** MSC4438 bookmark key suffix, e.g. "bmk_a1b2c3d4" */
+ id: string;
+};
+
+// ---------------------------------------------------------------------------
+// MSC4438 helpers
+// ---------------------------------------------------------------------------
+
+const BOOKMARK_PREFIX = CustomAccountDataEvent.BookmarkItemPrefix; // 'org.matrix.msc4438.bookmark.'
+const INDEX_KEY = CustomAccountDataEvent.BookmarksIndex; // 'org.matrix.msc4438.bookmarks.index'
+
+function generateBookmarkId(): string {
+ // 8 random hex chars, prefixed with "bmk_"
+ const bytes = new Uint8Array(4);
+ crypto.getRandomValues(bytes);
+ return `bmk_${Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('')}`;
+}
+
+function getIndexIds(mx: MatrixClient): string[] {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
+ const ev = mx.getAccountData(INDEX_KEY as any);
+ if (!ev) return [];
+ const content = ev.getContent<{ bookmark_ids?: string[] }>();
+ return Array.isArray(content.bookmark_ids) ? content.bookmark_ids : [];
+}
+
+export function readBookmarks(mx: MatrixClient): BookmarkEntry[] {
+ const ids = getIndexIds(mx);
+ const entries: BookmarkEntry[] = [];
+ for (const id of ids) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
+ const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}` as any);
+ if (!ev) continue;
+ const c = ev.getContent<{ room_id?: string; event_id?: string; deleted?: boolean }>();
+ if (!c.deleted && c.room_id && c.event_id) {
+ entries.push({ id, room_id: c.room_id, event_id: c.event_id });
+ }
+ }
+ return entries;
+}
+
+export function readArchivedBookmarks(mx: MatrixClient): BookmarkEntry[] {
+ const ids = getIndexIds(mx);
+ const entries: BookmarkEntry[] = [];
+ for (const id of ids) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
+ const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}` as any);
+ if (!ev) continue;
+ const c = ev.getContent<{ room_id?: string; event_id?: string; deleted?: boolean }>();
+ if (c.deleted && c.room_id && c.event_id) {
+ entries.push({ id, room_id: c.room_id, event_id: c.event_id });
+ }
+ }
+ return entries;
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+export function useBookmarks(): BookmarkEntry[] {
+ const mx = useMatrixClient();
+ const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx));
+
+ const refresh = useCallback(() => setBookmarks(readBookmarks(mx)), [mx]);
+
+ useEffect(() => {
+ refresh();
+ const handler = (event: MatrixEvent) => {
+ const type = event.getType();
+ if (type === INDEX_KEY || type.startsWith(BOOKMARK_PREFIX)) {
+ refresh();
+ }
+ };
+ mx.on(ClientEvent.AccountData, handler);
+ return () => {
+ mx.off(ClientEvent.AccountData, handler);
+ };
+ }, [mx, refresh]);
+
+ return bookmarks;
+}
+
+export function useArchivedBookmarks(): BookmarkEntry[] {
+ const mx = useMatrixClient();
+ const [archived, setArchived] = useState(() => readArchivedBookmarks(mx));
+
+ const refresh = useCallback(() => setArchived(readArchivedBookmarks(mx)), [mx]);
+
+ useEffect(() => {
+ refresh();
+ const handler = (event: MatrixEvent) => {
+ const type = event.getType();
+ if (type === INDEX_KEY || type.startsWith(BOOKMARK_PREFIX)) {
+ refresh();
+ }
+ };
+ mx.on(ClientEvent.AccountData, handler);
+ return () => {
+ mx.off(ClientEvent.AccountData, handler);
+ };
+ }, [mx, refresh]);
+
+ return archived;
+}
+
+// ---------------------------------------------------------------------------
+// Utilities
+// ---------------------------------------------------------------------------
+
+export function isBookmarked(bookmarks: BookmarkEntry[], eventId: string): boolean {
+ return bookmarks.some((b) => b.event_id === eventId);
+}
+
+export async function toggleBookmark(
+ mx: MatrixClient,
+ roomId: string,
+ eventId: string,
+ currentBookmarks: BookmarkEntry[]
+): Promise {
+ const existing = currentBookmarks.find((b) => b.event_id === eventId);
+ if (existing) {
+ // Archive: keep the id in the index so the archive section can find it,
+ // mark as deleted but retain room_id + event_id so readArchivedBookmarks
+ // can reconstruct the entry.
+ await mx.setAccountData(
+ `${BOOKMARK_PREFIX}${existing.id}` as any,
+ {
+ deleted: true,
+ bookmark_id: existing.id,
+ room_id: existing.room_id,
+ event_id: existing.event_id,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any
+ );
+ } else {
+ // Add: write individual event, then update index
+ const id = generateBookmarkId();
+ await mx.setAccountData(
+ `${BOOKMARK_PREFIX}${id}` as any,
+ {
+ room_id: roomId,
+ event_id: eventId,
+ bookmark_id: id,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any
+ );
+ const newIds = [...currentBookmarks.map((b) => b.id), id];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
+ await (mx.setAccountData as any)(INDEX_KEY, { bookmark_ids: newIds });
+ }
+}
+
+/** Restore an archived bookmark back to the active list. */
+export async function restoreBookmark(mx: MatrixClient, entry: BookmarkEntry): Promise {
+ await mx.setAccountData(
+ `${BOOKMARK_PREFIX}${entry.id}` as any,
+ {
+ room_id: entry.room_id,
+ event_id: entry.event_id,
+ bookmark_id: entry.id,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any
+ );
+}
+
+/**
+ * Permanently remove a bookmark: strip it from the index and clear its
+ * account data entry so it no longer consumes account data space.
+ */
+export async function permanentlyDeleteBookmark(
+ mx: MatrixClient,
+ entry: BookmarkEntry,
+ allIds: string[]
+): Promise {
+ const newIds = allIds.filter((id) => id !== entry.id);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
+ await (mx.setAccountData as any)(INDEX_KEY, { bookmark_ids: newIds });
+ // Clear the individual event data — write a minimal tombstone so syncing
+ // clients discard the entry rather than seeing a stale object.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (mx.setAccountData as any)(`${BOOKMARK_PREFIX}${entry.id}`, { deleted: true });
+}
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index aec62cc86..a6914028b 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -49,6 +49,7 @@ import {
NOTIFICATIONS_PATH_SEGMENT,
ROOM_PATH_SEGMENT,
SEARCH_PATH_SEGMENT,
+ BOOKMARKS_PATH_SEGMENT,
SERVER_PATH_SEGMENT,
CREATE_PATH,
TO_ROOM_EVENT_PATH,
@@ -66,6 +67,7 @@ import {
import { ClientBindAtoms, ClientLayout, ClientRoot, ClientRouteOutlet } from './client';
import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
+import { BookmarksList } from './client/bookmarks';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
@@ -243,6 +245,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
} />
join
} />
} />
+ } />
} />
} />
+ } />
} />
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index f847e0856..0628d4b83 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -60,6 +60,8 @@ import { useCallSignaling } from '$hooks/useCallSignaling';
import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
+import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
+import { useReminderSync } from '$features/bookmarks/useReminderSync';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -861,11 +863,29 @@ function SettingsSyncFeature() {
return null;
}
+function BookmarksFeature() {
+ useInitBookmarks();
+ return null;
+}
+
+function ReminderSync() {
+ useReminderSync();
+ return null;
+}
+
+function RemindersFeature() {
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+ if (!enableMessageBookmarks) return null;
+ return ;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useCallSignaling();
return (
<>
+
+
diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx
index 6cf397807..e8be0f616 100644
--- a/src/app/pages/client/SidebarNav.tsx
+++ b/src/app/pages/client/SidebarNav.tsx
@@ -16,6 +16,7 @@ import {
UnverifiedTab,
SearchTab,
AccountSwitcherTab,
+ BookmarksTab,
} from './sidebar';
import { CreateTab } from './sidebar/CreateTab';
@@ -133,6 +134,7 @@ export function SidebarNav() {
sticky={
+
diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx
new file mode 100644
index 000000000..0a40384f6
--- /dev/null
+++ b/src/app/pages/client/bookmarks/BookmarksList.tsx
@@ -0,0 +1,628 @@
+import type { FormEventHandler } from 'react';
+import { Fragment, useCallback, useMemo, useRef, useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Line,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Spinner,
+ Text,
+ Chip,
+ config,
+ color,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { JoinRule } from '$types/matrix-sdk';
+import {
+ Page,
+ PageContent,
+ PageContentCenter,
+ PageHeader,
+ PageHero,
+ PageHeroSection,
+} from '$components/page';
+import { SequenceCard } from '$components/sequence-card';
+import { AvatarBase, ModernLayout, Time, Username, UsernameBold } from '$components/message';
+import { RoomAvatar, RoomIcon } from '$components/room-avatar';
+import { UserAvatar } from '$components/user-avatar';
+import { BackRouteHandler } from '$components/BackRouteHandler';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useRoomNavigate } from '$hooks/useRoomNavigate';
+import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '$utils/room';
+import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
+import colorMXID from '$utils/colorMXID';
+import { stopPropagation } from '$utils/keyboard';
+import type { BookmarkItemContent } from '$features/bookmarks/bookmarkDomain';
+import {
+ useBookmarkActions,
+ useBookmarkDeletedList,
+ useBookmarkList,
+ useBookmarkLoading,
+} from '$features/bookmarks/useBookmarks';
+
+// ---------------------------------------------------------------------------
+// RemoveBookmarkDialog
+// ---------------------------------------------------------------------------
+
+type RemoveBookmarkDialogProps = {
+ item: BookmarkItemContent;
+ onConfirm: () => void;
+ onClose: () => void;
+};
+
+function RemoveBookmarkDialog({ item, onConfirm, onClose }: RemoveBookmarkDialogProps) {
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// BookmarkItemRow
+// ---------------------------------------------------------------------------
+
+type BookmarkItemRowProps = {
+ item: BookmarkItemContent;
+ highlight?: string;
+ onJump: (roomId: string, eventId: string) => void;
+ onRemove: (item: BookmarkItemContent) => void;
+ hour24Clock: boolean;
+ dateFormatString: string;
+};
+
+function BookmarkItemRow({
+ item,
+ highlight,
+ onJump,
+ onRemove,
+ hour24Clock,
+ dateFormatString,
+}: BookmarkItemRowProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+
+ // Try to resolve live room/member data; fall back to stored metadata
+ const room = mx.getRoom(item.room_id) ?? undefined;
+ const senderId = item.sender ?? '';
+
+ const displayName = room
+ ? (getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId)
+ : (getMxIdLocalPart(senderId) ?? senderId);
+
+ const senderAvatarMxc = room ? getMemberAvatarMxc(room, senderId) : undefined;
+ const avatarUrl = senderAvatarMxc
+ ? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
+ : undefined;
+
+ const usernameColor = colorMXID(senderId);
+
+ // Highlight matching substring in body_preview
+ const preview = item.body_preview ?? '';
+ const highlightedPreview = useMemo(() => {
+ if (!highlight || !preview) return <>{preview}>;
+ const idx = preview.toLowerCase().indexOf(highlight.toLowerCase());
+ if (idx === -1) return <>{preview}>;
+ return (
+ <>
+ {preview.slice(0, idx)}
+
+ {preview.slice(idx, idx + highlight.length)}
+
+ {preview.slice(idx + highlight.length)}
+ >
+ );
+ }, [preview, highlight]);
+
+ return (
+
+
+
+ }
+ />
+
+
+ }
+ >
+
+
+
+
+ {displayName}
+
+
+
+
+
+ onJump(item.room_id, item.event_id)}
+ variant="Secondary"
+ radii="400"
+ >
+ Jump
+
+ onRemove(item)}
+ aria-label="Remove bookmark"
+ >
+
+
+
+
+ {preview && (
+
+ {highlightedPreview}
+
+ )}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// BookmarkResultGroup
+// ---------------------------------------------------------------------------
+
+type BookmarkResultGroupProps = {
+ roomId: string;
+ roomName: string;
+ items: BookmarkItemContent[];
+ highlight?: string;
+ onJump: (roomId: string, eventId: string) => void;
+ onRemove: (item: BookmarkItemContent) => void;
+ hour24Clock: boolean;
+ dateFormatString: string;
+};
+
+function BookmarkResultGroup({
+ roomId,
+ roomName,
+ items,
+ highlight,
+ onJump,
+ onRemove,
+ hour24Clock,
+ dateFormatString,
+}: BookmarkResultGroupProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = mx.getRoom(roomId) ?? undefined;
+ const avatarUrl = room ? getRoomAvatarUrl(mx, room, 96, useAuthentication) : undefined;
+ const displayRoomName = room?.name ?? roomName;
+
+ return (
+
+
+
+
+ (
+
+ )}
+ />
+
+
+ {displayRoomName}
+
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// RemovedBookmarkRow
+// ---------------------------------------------------------------------------
+
+type RemovedBookmarkRowProps = {
+ item: BookmarkItemContent;
+ onRestore: (item: BookmarkItemContent) => void;
+};
+
+function RemovedBookmarkRow({ item, onRestore }: RemovedBookmarkRowProps) {
+ const mx = useMatrixClient();
+ const room = mx.getRoom(item.room_id) ?? undefined;
+ const roomName = room?.name ?? item.room_name ?? item.room_id;
+
+ return (
+
+
+
+
+ {roomName}
+
+ {item.body_preview && (
+
+ {item.body_preview}
+
+ )}
+
+ onRestore(item)} variant="Secondary" radii="400">
+ Restore
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// BookmarkFilterInput
+// ---------------------------------------------------------------------------
+
+type BookmarkFilterInputProps = {
+ inputRef: React.RefObject;
+ active?: boolean;
+ loading?: boolean;
+ onFilter: (term: string) => void;
+ onReset: () => void;
+};
+
+function BookmarkFilterInput({
+ inputRef,
+ active,
+ loading,
+ onFilter,
+ onReset,
+}: BookmarkFilterInputProps) {
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ const { filterInput } = evt.target as HTMLFormElement & {
+ filterInput: HTMLInputElement;
+ };
+ const term = filterInput.value.trim();
+ if (term) onFilter(term);
+ };
+
+ return (
+
+
+ Filter
+
+ ) : (
+
+ {active && (
+
+
+ Clear
+
+ )}
+
+ Filter
+
+
+ )
+ }
+ />
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// BookmarksList (main export)
+// ---------------------------------------------------------------------------
+
+export function BookmarksList() {
+ const mx = useMatrixClient();
+ const screenSize = useScreenSizeContext();
+ const scrollRef = useRef(null);
+ const filterInputRef = useRef(null);
+ const { navigateRoom } = useRoomNavigate();
+
+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
+
+ const bookmarks = useBookmarkList();
+ const deletedBookmarks = useBookmarkDeletedList();
+ const loading = useBookmarkLoading();
+ const { remove, restore } = useBookmarkActions();
+
+ const [filterTerm, setFilterTerm] = useState();
+ const [removingItem, setRemovingItem] = useState();
+
+ // Filter and group bookmarks
+ const filteredBookmarks = useMemo(() => {
+ if (!filterTerm) return bookmarks;
+ const lower = filterTerm.toLowerCase();
+ return bookmarks.filter(
+ (b) =>
+ b.body_preview?.toLowerCase().includes(lower) ||
+ b.room_name?.toLowerCase().includes(lower) ||
+ (b.sender && getMxIdLocalPart(b.sender)?.toLowerCase().includes(lower))
+ );
+ }, [bookmarks, filterTerm]);
+
+ // Group by room_id, preserving order
+ const groupedByRoom = useMemo(() => {
+ const map = new Map<
+ string,
+ { roomId: string; roomName: string; items: BookmarkItemContent[] }
+ >();
+ filteredBookmarks.forEach((item) => {
+ let group = map.get(item.room_id);
+ if (!group) {
+ const room = mx.getRoom(item.room_id);
+ group = {
+ roomId: item.room_id,
+ roomName: room?.name ?? item.room_name ?? item.room_id,
+ items: [],
+ };
+ map.set(item.room_id, group);
+ }
+ group.items.push(item);
+ });
+ return Array.from(map.values());
+ }, [filteredBookmarks, mx]);
+
+ const handleJump = useCallback(
+ (roomId: string, eventId: string) => {
+ navigateRoom(roomId, eventId);
+ },
+ [navigateRoom]
+ );
+
+ const handleRemoveConfirm = useCallback(async () => {
+ if (!removingItem) return;
+ await remove(removingItem.bookmark_id);
+ setRemovingItem(undefined);
+ }, [removingItem, remove]);
+
+ const handleRestore = useCallback(
+ async (item: BookmarkItemContent) => {
+ await restore(item);
+ },
+ [restore]
+ );
+
+ const handleFilter = useCallback((term: string) => {
+ setFilterTerm(term);
+ }, []);
+
+ const handleReset = useCallback(() => {
+ setFilterTerm(undefined);
+ if (filterInputRef.current) {
+ filterInputRef.current.value = '';
+ }
+ }, []);
+
+ return (
+
+
+
+
+ {screenSize === ScreenSize.Mobile && (
+
+ {(onBack) => (
+
+
+
+ )}
+
+ )}
+
+
+ {screenSize !== ScreenSize.Mobile && }
+
+ Bookmarks
+
+
+
+
+
+
+
+
+
+
+
+
+ {loading && bookmarks.length === 0 && (
+
+
+
+ )}
+
+ {!loading && bookmarks.length === 0 && (
+
+ }
+ title="No Bookmarks Yet"
+ subTitle="Bookmark messages to find them again easily. Right-click a message and choose Bookmark."
+ />
+
+ )}
+
+ {!loading && bookmarks.length > 0 && filteredBookmarks.length === 0 && (
+
+
+
+ No bookmarks match your filter.
+
+
+ )}
+
+ {groupedByRoom.length > 0 && (
+
+ {groupedByRoom.map((group, i) => (
+
+ {i > 0 && }
+
+
+ ))}
+
+ )}
+
+ {deletedBookmarks.length > 0 && !filterTerm && (
+
+
+
+
+ {deletedBookmarks.map((item) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {removingItem && (
+ }>
+
+ setRemovingItem(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ setRemovingItem(undefined)}
+ />
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/pages/client/bookmarks/index.ts b/src/app/pages/client/bookmarks/index.ts
new file mode 100644
index 000000000..cdd211f71
--- /dev/null
+++ b/src/app/pages/client/bookmarks/index.ts
@@ -0,0 +1 @@
+export * from './BookmarksList';
diff --git a/src/app/pages/client/inbox/Bookmarks.tsx b/src/app/pages/client/inbox/Bookmarks.tsx
new file mode 100644
index 000000000..c243eeadd
--- /dev/null
+++ b/src/app/pages/client/inbox/Bookmarks.tsx
@@ -0,0 +1,48 @@
+import { useRef } from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Page, PageContent, PageContentCenter, PageHeader } from '$components/page';
+import { BookmarksList } from '$features/bookmarks/BookmarksList';
+import { BackRouteHandler } from '$components/BackRouteHandler';
+import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
+
+export function Bookmarks() {
+ const scrollRef = useRef(null);
+ const screenSize = useScreenSizeContext();
+
+ return (
+
+
+
+
+ {screenSize === ScreenSize.Mobile && (
+
+ {(onBack) => (
+
+
+
+ )}
+
+ )}
+
+
+ {screenSize !== ScreenSize.Mobile && }
+
+ Bookmarks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
index 5e3926cce..1c4d7ea3e 100644
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ b/src/app/pages/client/inbox/Inbox.tsx
@@ -1,8 +1,16 @@
import { Avatar, Box, Icon, Icons, Text, toRem } from 'folds';
import { useAtomValue } from 'jotai';
import { NavCategory, NavItem, NavItemContent, NavLink } from '$components/nav';
-import { getInboxInvitesPath, getInboxNotificationsPath } from '$pages/pathUtils';
-import { useInboxInvitesSelected, useInboxNotificationsSelected } from '$hooks/router/useInbox';
+import {
+ getInboxBookmarksPath,
+ getInboxInvitesPath,
+ getInboxNotificationsPath,
+} from '$pages/pathUtils';
+import {
+ useInboxBookmarksSelected,
+ useInboxInvitesSelected,
+ useInboxNotificationsSelected,
+} from '$hooks/router/useInbox';
import { UnreadBadge } from '$components/unread-badge';
import { allInvitesAtom } from '$state/room-list/inviteList';
import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper';
@@ -51,9 +59,39 @@ function InvitesNavItem({ hideText }: { hideText?: boolean }) {
);
}
+function BookmarksNavItem({ hideText }: { hideText?: boolean }) {
+ const bookmarksSelected = useInboxBookmarksSelected();
+
+ return (
+
+
+
+
+
+
+
+ {!hideText && (
+
+
+ Bookmarks
+
+
+ )}
+
+
+
+
+ );
+}
+
export function Inbox() {
useNavToActivePathMapper('inbox');
const notificationsSelected = useInboxNotificationsSelected();
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
const [curWidth, setCurWidth] = useState(roomSidebarWidth);
@@ -114,6 +152,7 @@ export function Inbox() {
+ {enableMessageBookmarks && }
diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts
index c8036b471..dc02ccee6 100644
--- a/src/app/pages/client/inbox/index.ts
+++ b/src/app/pages/client/inbox/index.ts
@@ -1,3 +1,4 @@
export * from './Inbox';
export * from './Notifications';
export * from './Invites';
+export * from './Bookmarks';
diff --git a/src/app/pages/client/sidebar/BookmarksTab.tsx b/src/app/pages/client/sidebar/BookmarksTab.tsx
new file mode 100644
index 000000000..0b0170b8a
--- /dev/null
+++ b/src/app/pages/client/sidebar/BookmarksTab.tsx
@@ -0,0 +1,39 @@
+import { useNavigate } from 'react-router-dom';
+import { Icon, Icons } from 'folds';
+import { useAtomValue } from 'jotai';
+import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar';
+import { getInboxBookmarksPath, joinPathComponent } from '$pages/pathUtils';
+import { useInboxBookmarksSelected } from '$hooks/router/useInbox';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
+
+export function BookmarksTab() {
+ const navigate = useNavigate();
+ const navToActivePath = useAtomValue(useNavToActivePathAtom());
+ const bookmarksSelected = useInboxBookmarksSelected();
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+
+ if (!enableMessageBookmarks) return null;
+
+ const handleClick = () => {
+ const activePath = navToActivePath.get('inbox');
+ if (activePath) {
+ navigate(joinPathComponent(activePath));
+ return;
+ }
+ navigate(getInboxBookmarksPath());
+ };
+
+ return (
+
+
+ {(triggerRef) => (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts
index 08a9099c0..27bbfef75 100644
--- a/src/app/pages/client/sidebar/index.ts
+++ b/src/app/pages/client/sidebar/index.ts
@@ -7,3 +7,4 @@ export * from './ExploreTab';
export * from './UnverifiedTab';
export * from './SearchTab';
export * from './AccountSwitcherTab';
+export * from './BookmarksTab';
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index 4a95f47fc..2d2d63219 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -15,7 +15,9 @@ import {
HOME_PATH,
HOME_ROOM_PATH,
HOME_SEARCH_PATH,
+ HOME_BOOKMARKS_PATH,
LOGIN_PATH,
+ INBOX_BOOKMARKS_PATH,
INBOX_INVITES_PATH,
INBOX_NOTIFICATIONS_PATH,
INBOX_PATH,
@@ -91,6 +93,7 @@ export const getHomePath = (): string => HOME_PATH;
export const getHomeCreatePath = (): string => HOME_CREATE_PATH;
export const getHomeJoinPath = (): string => HOME_JOIN_PATH;
export const getHomeSearchPath = (): string => HOME_SEARCH_PATH;
+export const getHomeBookmarksPath = (): string => HOME_BOOKMARKS_PATH;
export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
const params = {
roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
@@ -158,6 +161,7 @@ export const getCreatePath = (): string => CREATE_PATH;
export const getInboxPath = (): string => INBOX_PATH;
export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
+export const getInboxBookmarksPath = (): string => INBOX_BOOKMARKS_PATH;
export const getSettingsPath = (section?: string, focus?: string): string => {
const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null }));
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
index 1ac57b756..2e686d109 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -39,6 +39,7 @@ export type SearchPathSearchParams = {
senders?: string;
};
export const SEARCH_PATH_SEGMENT = 'search/';
+export const BOOKMARKS_PATH_SEGMENT = 'bookmarks/';
export type RoomSearchParams = {
/* comma separated string of servers */
@@ -50,6 +51,7 @@ export const HOME_PATH = '/home/';
export const HOME_CREATE_PATH = `/home/${CREATE_PATH_SEGMENT}`;
export const HOME_JOIN_PATH = `/home/${JOIN_PATH_SEGMENT}`;
export const HOME_SEARCH_PATH = `/home/${SEARCH_PATH_SEGMENT}`;
+export const HOME_BOOKMARKS_PATH = `/home/${BOOKMARKS_PATH_SEGMENT}`;
export const HOME_ROOM_PATH = `/home/${ROOM_PATH_SEGMENT}`;
export const DIRECT_PATH = '/direct/';
@@ -88,6 +90,7 @@ export type InboxNotificationsPathSearchParams = {
};
export const INBOX_NOTIFICATIONS_PATH = `/inbox/${NOTIFICATIONS_PATH_SEGMENT}`;
export const INBOX_INVITES_PATH = `/inbox/${INVITES_PATH_SEGMENT}`;
+export const INBOX_BOOKMARKS_PATH = `/inbox/${BOOKMARKS_PATH_SEGMENT}`;
export const TO_PATH = '/to';
// Deep-link route used by push notification click-back URLs.
diff --git a/src/app/state/bookmarks.test.ts b/src/app/state/bookmarks.test.ts
new file mode 100644
index 000000000..1145b9d1e
--- /dev/null
+++ b/src/app/state/bookmarks.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Unit tests for the Jotai bookmark atoms in src/app/state/bookmarks.ts.
+ *
+ * The derived `bookmarkIdSetAtom` is the only atom with non-trivial logic —
+ * it builds an O(1) lookup Set from the bookmark list. The primitive atoms
+ * (`bookmarkListAtom`, `bookmarkLoadingAtom`) are default Jotai atoms whose
+ * read/write semantics are provided by the library itself and do not need
+ * additional testing.
+ */
+import { describe, it, expect } from 'vitest';
+import { createStore } from 'jotai';
+import { bookmarkIdSetAtom, bookmarkListAtom } from './bookmarks';
+import type { BookmarkItemContent } from '../features/bookmarks/bookmarkDomain';
+
+// Helper: minimal valid bookmark item
+function makeItem(id: string): BookmarkItemContent {
+ return {
+ version: 1,
+ bookmark_id: id,
+ uri: `matrix:roomid/foo/e/${id}`,
+ room_id: '!room:s',
+ event_id: `$${id}:s`,
+ event_ts: 1_000_000,
+ bookmarked_ts: 2_000_000,
+ };
+}
+
+describe('bookmarkIdSetAtom (derived)', () => {
+ it('returns an empty Set when the list is empty', () => {
+ const store = createStore();
+ const set = store.get(bookmarkIdSetAtom);
+ expect(set.size).toBe(0);
+ });
+
+ it('contains the IDs of every item in bookmarkListAtom', () => {
+ const store = createStore();
+ store.set(bookmarkListAtom, [makeItem('bmk_aaaaaaaa'), makeItem('bmk_bbbbbbbb')]);
+
+ const set = store.get(bookmarkIdSetAtom);
+ expect(set.has('bmk_aaaaaaaa')).toBe(true);
+ expect(set.has('bmk_bbbbbbbb')).toBe(true);
+ });
+
+ it('does not contain IDs not in the list', () => {
+ const store = createStore();
+ store.set(bookmarkListAtom, [makeItem('bmk_aaaaaaaa')]);
+
+ const set = store.get(bookmarkIdSetAtom);
+ expect(set.has('bmk_ffffffff')).toBe(false);
+ });
+
+ it('updates reactively when the list changes', () => {
+ const store = createStore();
+ store.set(bookmarkListAtom, [makeItem('bmk_11111111')]);
+
+ expect(store.get(bookmarkIdSetAtom).has('bmk_11111111')).toBe(true);
+
+ store.set(bookmarkListAtom, []);
+ expect(store.get(bookmarkIdSetAtom).has('bmk_11111111')).toBe(false);
+ });
+
+ it('returns a Set whose size equals the number of unique items', () => {
+ const store = createStore();
+ const items = [makeItem('bmk_aaaaaaaa'), makeItem('bmk_bbbbbbbb'), makeItem('bmk_cccccccc')];
+ store.set(bookmarkListAtom, items);
+
+ expect(store.get(bookmarkIdSetAtom).size).toBe(3);
+ });
+});
diff --git a/src/app/state/bookmarks.ts b/src/app/state/bookmarks.ts
new file mode 100644
index 000000000..533021435
--- /dev/null
+++ b/src/app/state/bookmarks.ts
@@ -0,0 +1,27 @@
+import { atom } from 'jotai';
+import type { BookmarkItemContent } from '../features/bookmarks/bookmarkDomain';
+
+/** Ordered list of active bookmark items (mirrors the server index order). */
+export const bookmarkListAtom = atom([]);
+
+/**
+ * Ordered list of deleted (tombstoned) bookmark items that are recoverable.
+ * Populated alongside bookmarkListAtom so the UI can show a "Recently Removed"
+ * section with a Restore button for each entry.
+ */
+export const bookmarkDeletedListAtom = atom([]);
+
+/** True while a refresh from account data is in progress. */
+export const bookmarkLoadingAtom = atom(false);
+
+/**
+ * Derived set of active bookmark IDs — used for O(1) per-message
+ * "is this message bookmarked?" checks.
+ *
+ * MSC4438 §Checking if a message is bookmarked: use a local reverse lookup
+ * rather than issuing server requests.
+ */
+export const bookmarkIdSetAtom = atom>((get) => {
+ const list = get(bookmarkListAtom);
+ return new Set(list.map((b) => b.bookmark_id));
+});
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index b1b744c1f..8c3b025c8 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -170,6 +170,9 @@ export interface Settings {
vcmsgSidebarWidth: number;
widgetSidebarWidth: number;
+ // experimental
+ enableMessageBookmarks: boolean;
+
// furry stuff
renderAnimals: boolean;
@@ -320,6 +323,9 @@ export const defaultSettings: Settings = {
themeMigrationDismissed: false,
themeRemoteTweakFavorites: [],
themeRemoteEnabledTweakFullUrls: [],
+
+ // experimental
+ enableMessageBookmarks: false,
};
function cloneDefaultSettings(): Settings {
diff --git a/src/sw.ts b/src/sw.ts
index 78255b701..1b93e78f1 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -28,6 +28,21 @@ const SW_SETTINGS_URL = '/sw-settings-meta';
const SW_SESSION_CACHE = 'sable-sw-session-v1';
const SW_SESSION_URL = '/sw-session-meta';
+// Inline type — mirrors BookmarkReminder in src/types/matrix/accountData.ts.
+// Defined here to avoid importing from the app bundle into the service worker.
+type BookmarkReminder = {
+ bookmarkId: string;
+ eventId: string;
+ roomId: string;
+ remindAt: number;
+ userId: string;
+ note?: string;
+};
+
+/** Cache key used to persist bookmark reminders so the SW can fire them after a restart. */
+const SW_REMINDERS_CACHE = 'sable-reminders-v1';
+const SW_REMINDERS_URL = '/sw-reminders-meta';
+
async function persistSettings() {
try {
const cache = await self.caches.open(SW_SETTINGS_CACHE);
@@ -108,6 +123,58 @@ async function loadPersistedSession(): Promise {
}
}
+async function persistReminders(reminders: BookmarkReminder[]): Promise {
+ try {
+ const cache = await self.caches.open(SW_REMINDERS_CACHE);
+ await cache.put(
+ SW_REMINDERS_URL,
+ new Response(JSON.stringify(reminders), { headers: { 'Content-Type': 'application/json' } })
+ );
+ } catch {
+ // best-effort
+ }
+}
+
+async function loadPersistedReminders(): Promise {
+ try {
+ const cache = await self.caches.open(SW_REMINDERS_CACHE);
+ const response = await cache.match(SW_REMINDERS_URL);
+ if (!response) return [];
+ const data = await response.json();
+ if (Array.isArray(data)) return data as BookmarkReminder[];
+ return [];
+ } catch {
+ return [];
+ }
+}
+
+async function checkDueReminders(): Promise {
+ const reminders = await loadPersistedReminders();
+ if (reminders.length === 0) return;
+
+ const now = Date.now();
+ const due = reminders.filter((r) => r.remindAt <= now);
+ const remaining = reminders.filter((r) => r.remindAt > now);
+
+ await Promise.all(
+ due.map((r) =>
+ self.registration.showNotification('Bookmark Reminder', {
+ body: r.note ?? 'You have a bookmark reminder.',
+ tag: `reminder-${r.bookmarkId}`,
+ data: { isReminder: true, roomId: r.roomId, eventId: r.eventId },
+ icon: '/res/ic_launcher-192.png',
+ })
+ )
+ );
+
+ if (due.length > 0) {
+ await persistReminders(remaining);
+ }
+}
+
+// Check for due reminders every minute.
+setInterval(() => checkDueReminders().catch(() => undefined), 60_000);
+
type SessionInfo = {
accessToken: string;
baseUrl: string;
@@ -549,6 +616,22 @@ self.addEventListener('activate', (event: ExtendableEvent) => {
// media fetch to trigger requestSessionWithTimeout.
const windowClients = await self.clients.matchAll({ type: 'window' });
windowClients.forEach((client) => client.postMessage({ type: 'requestSession' }));
+ // Fire any bookmark reminders that became due while the SW was inactive.
+ checkDueReminders().catch(() => undefined);
+ // Register for a periodic background sync to check reminders (1-hour minimum interval).
+ if ('periodicSync' in self.registration) {
+ try {
+ await (
+ self.registration as ServiceWorkerRegistration & {
+ periodicSync: {
+ register(tag: string, options?: { minInterval: number }): Promise;
+ };
+ }
+ ).periodicSync.register('check-reminders', { minInterval: 3_600_000 });
+ } catch {
+ // periodicSync not granted — the setInterval fallback above covers this.
+ }
+ }
})()
);
});
@@ -610,6 +693,17 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
// Persist so settings survive SW restart (iOS kills the SW aggressively).
event.waitUntil(persistSettings());
}
+ if (data.type === 'updateReminders') {
+ const reminders = (data as { type: string; reminders: BookmarkReminder[] }).reminders;
+ event.waitUntil(persistReminders(reminders));
+ }
+});
+
+self.addEventListener('periodicsync', (event: Event) => {
+ const syncEvent = event as Event & { tag: string; waitUntil: (p: Promise) => void };
+ if (syncEvent.tag === 'check-reminders') {
+ syncEvent.waitUntil(checkDueReminders());
+ }
});
const MEDIA_PATHS = [
@@ -848,6 +942,7 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
});
const isCall = data?.isCall === true;
+ const isReminder = data?.isReminder === true;
// Build a canonical deep-link URL.
//
@@ -869,6 +964,10 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
? `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${encodeURIComponent(pushEventId)}/${callParam}`
: `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${callParam}`;
targetUrl = new URL(segments, scope).href;
+ } else if (isReminder && data?.roomId && data?.eventId) {
+ // Reminder notifications carry roomId/eventId (no userId), so navigate directly.
+ const segments = `to/${encodeURIComponent(data.roomId as string)}/${encodeURIComponent(data.eventId as string)}/`;
+ targetUrl = new URL(segments, scope).href;
} else {
// Fallback: no room ID or no user ID in payload.
targetUrl = new URL('inbox/notifications/', scope).href;
diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts
index 670effb19..2626fd444 100644
--- a/src/types/matrix/accountData.ts
+++ b/src/types/matrix/accountData.ts
@@ -1,18 +1,32 @@
-import * as prefix from '$unstable/prefixes';
-
-export const CustomAccountDataEvent = {
- CinnySpaces: prefix.MATRIX_CINNY_UNSTABLE_ACCOUNT_SPACES_PROPERTY_NAME,
- ElementRecentEmoji: prefix.MATRIX_ELEMENT_UNSTABLE_ACCOUNT_RECENT_EMOJIS_PROPERTY_NAME,
- PoniesUserEmotes: prefix.MATRIX_UNSTABLE_ACCOUNT_USER_EMOTES_PROPERTY_NAME,
- PoniesEmoteRooms: prefix.MATRIX_UNSTABLE_ACCOUNT_EMOTE_ROOMS_PROPERTY_NAME,
- SableNicknames: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_NICKNAMES_PROPERTY_NAME,
- SablePinStatus: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_PIN_STATUS_PROPERTY_NAME,
- SablePerProfileMessageProfiles:
- prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME,
- SableSettings: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME,
-} as const;
-export type CustomAccountDataEvent =
- (typeof CustomAccountDataEvent)[keyof typeof CustomAccountDataEvent];
+export enum CustomAccountDataEvent {
+ CinnySpaces = 'in.cinny.spaces',
+
+ ElementRecentEmoji = 'io.element.recent_emoji',
+
+ PoniesUserEmotes = 'im.ponies.user_emotes',
+ PoniesEmoteRooms = 'im.ponies.emote_rooms',
+
+ SecretStorageDefaultKey = 'm.secret_storage.default_key',
+
+ CrossSigningMaster = 'm.cross_signing.master',
+ CrossSigningSelf = 'm.cross_signing.self',
+ CrossSigningUser = 'm.cross_signing.user',
+ MegolmBackupV1 = 'm.megolm_backup.v1',
+
+ // MSC4438 Message Bookmarks (unstable prefix)
+ BookmarksIndex = 'org.matrix.msc4438.bookmarks.index',
+ /** Prefix for per-bookmark item events; append the bookmark ID to get the full event type. */
+ BookmarkItemPrefix = 'org.matrix.msc4438.bookmark.',
+
+ // Sable account data
+ SableNicknames = 'moe.sable.app.nicknames',
+ SablePinStatus = 'moe.sable.app.pins_read_marker',
+ SableBookmarksReminders = 'moe.sable.bookmarks.reminders',
+
+ // because of a mistake hasn't been renamed in time
+ SablePerProfileMessageProfiles = 'fyi.cisnt.permessageprofile',
+ SableSettings = 'moe.sable.app.settings',
+}
export type MDirectContent = Record;
@@ -44,6 +58,26 @@ export type SecretContent = {
/**
* type to save compatibility information
*/
+/** A single bookmark reminder stored in account data. */
+export type BookmarkReminder = {
+ /** Matches the key used in the MSC4438 bookmarks index. */
+ bookmarkId: string;
+ /** Matrix event ID of the bookmarked message. */
+ eventId: string;
+ /** Matrix room ID where the bookmarked message lives. */
+ roomId: string;
+ /** Unix timestamp (ms) when the reminder should fire. */
+ remindAt: number;
+ /** Matrix user ID who set the reminder — used for notification routing. */
+ userId: string;
+ /** Optional note shown in the notification body. */
+ note?: string;
+};
+
+export type BookmarksRemindersContent = {
+ reminders: BookmarkReminder[];
+};
+
export type AccountDataCompatVersion = {
/**
* a simple version number, for example 1
diff --git a/src/unstable/prefixes/sable/accountdata.ts b/src/unstable/prefixes/sable/accountdata.ts
index 9179d491a..f7ecb3d12 100644
--- a/src/unstable/prefixes/sable/accountdata.ts
+++ b/src/unstable/prefixes/sable/accountdata.ts
@@ -11,3 +11,6 @@ export const MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME = 'moe.sable.a
*/
export const MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME =
'fyi.cisnt.permessageprofile';
+
+export const MATRIX_SABLE_UNSTABLE_ACCOUNT_BOOKMARKS_REMINDERS_PROPERTY_NAME =
+ 'moe.sable.bookmarks.reminders';
From 0a740c8461684aa75c776617508735418d076052 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 7 May 2026 22:20:35 -0400
Subject: [PATCH 2/9] chore: replace silent catch with console.warn in
BookmarksList
---
src/app/features/bookmarks/BookmarksList.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/app/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx
index d73649b07..e476fea93 100644
--- a/src/app/features/bookmarks/BookmarksList.tsx
+++ b/src/app/features/bookmarks/BookmarksList.tsx
@@ -44,16 +44,16 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
};
const handleRemove = (roomId: string, eventId: string) => {
- toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {});
+ toggleBookmark(mx, roomId, eventId, bookmarks).catch(console.warn);
};
const handleRestore = (entry: (typeof archived)[number]) => {
- restoreBookmark(mx, entry).catch(() => {});
+ restoreBookmark(mx, entry).catch(console.warn);
};
const handlePermanentDelete = (entry: (typeof archived)[number]) => {
const allIds = [...bookmarks.map((b) => b.id), ...archived.map((b) => b.id)];
- permanentlyDeleteBookmark(mx, entry, allIds).catch(() => {});
+ permanentlyDeleteBookmark(mx, entry, allIds).catch(console.warn);
};
if (bookmarks.length === 0 && archived.length === 0) {
From bb2574a887aa1d774068de22c534d9afeef520d3 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 11 May 2026 09:05:42 -0400
Subject: [PATCH 3/9] fix(bookmarks): use cached account-data fields as
fallback when event not in local timeline
---
src/app/features/bookmarks/BookmarksList.tsx | 180 ++++++++++++++----
src/app/features/bookmarks/bookmarkDomain.ts | 6 +
.../features/bookmarks/bookmarkRepository.ts | 25 ++-
src/app/features/bookmarks/useBookmarks.ts | 71 ++++++-
4 files changed, 239 insertions(+), 43 deletions(-)
diff --git a/src/app/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx
index e476fea93..533cdbff7 100644
--- a/src/app/features/bookmarks/BookmarksList.tsx
+++ b/src/app/features/bookmarks/BookmarksList.tsx
@@ -1,12 +1,13 @@
-import { Avatar, Box, Chip, Icon, IconButton, Icons, Line, Text, config } from 'folds';
+import { Avatar, Box, Chip, Icon, IconButton, Icons, Input, Line, Text, config } from 'folds';
import { useAtomValue } from 'jotai';
+import { useCallback, useState } from 'react';
import {
- useBookmarks,
- useArchivedBookmarks,
- toggleBookmark,
- restoreBookmark,
- permanentlyDeleteBookmark,
-} from '$hooks/useBookmarks';
+ useBookmarkList,
+ useBookmarkDeletedList,
+ useBookmarkActions,
+ useBookmarkReminders,
+ useBookmarkReminderActions,
+} from '$features/bookmarks/useBookmarks';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
import { useGetRoom, useAllJoinedRoomsSet } from '$hooks/useGetRoom';
@@ -21,6 +22,7 @@ import { EncryptedContent } from '$features/room/message';
import { nicknamesAtom } from '$state/nicknames';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
+import type { BookmarkItemContent } from '$features/bookmarks/bookmarkDomain';
type BookmarksListProps = {
onNavigate?: () => void;
@@ -28,8 +30,46 @@ type BookmarksListProps = {
export function BookmarksList({ onNavigate }: BookmarksListProps) {
const mx = useMatrixClient();
- const bookmarks = useBookmarks();
- const archived = useArchivedBookmarks();
+ const bookmarks = useBookmarkList();
+ const archived = useBookmarkDeletedList();
+ const { remove, restore, purge } = useBookmarkActions();
+ const reminders = useBookmarkReminders();
+ const { setReminder, clearReminder } = useBookmarkReminderActions();
+ const [enableBookmarkReminders] = useSetting(settingsAtom, 'enableBookmarkReminders');
+ // Track which bookmark has the reminder picker open, and the current input value
+ const [reminderOpenId, setReminderOpenId] = useState(null);
+ const [reminderInputValue, setReminderInputValue] = useState('');
+
+ const getReminderForBookmark = useCallback(
+ (bookmarkId: string) => reminders.find((r) => r.bookmarkId === bookmarkId),
+ [reminders]
+ );
+
+ const handleOpenReminderPicker = (bookmark: BookmarkItemContent) => {
+ const existing = getReminderForBookmark(bookmark.bookmark_id);
+ const defaultValue = existing ? new Date(existing.remindAt).toISOString().slice(0, 16) : '';
+ setReminderInputValue(defaultValue);
+ setReminderOpenId((prev) => (prev === bookmark.bookmark_id ? null : bookmark.bookmark_id));
+ };
+
+ const handleSaveReminder = async (bookmark: BookmarkItemContent) => {
+ if (!reminderInputValue) return;
+ const remindAt = new Date(reminderInputValue).getTime();
+ if (Number.isNaN(remindAt)) return;
+ await setReminder({
+ bookmarkId: bookmark.bookmark_id,
+ eventId: bookmark.event_id,
+ roomId: bookmark.room_id,
+ remindAt,
+ userId: mx.getUserId() ?? '',
+ });
+ setReminderOpenId(null);
+ };
+
+ const handleClearReminder = async (bookmarkId: string) => {
+ await clearReminder(bookmarkId);
+ setReminderOpenId(null);
+ };
const { navigateRoom } = useRoomNavigate();
const useAuthentication = useMediaAuthentication();
const allRoomsSet = useAllJoinedRoomsSet();
@@ -43,17 +83,16 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
onNavigate?.();
};
- const handleRemove = (roomId: string, eventId: string) => {
- toggleBookmark(mx, roomId, eventId, bookmarks).catch(console.warn);
+ const handleRemove = (bookmark: BookmarkItemContent) => {
+ remove(bookmark.bookmark_id).catch(console.warn);
};
- const handleRestore = (entry: (typeof archived)[number]) => {
- restoreBookmark(mx, entry).catch(console.warn);
+ const handleRestore = (entry: BookmarkItemContent) => {
+ restore(entry).catch(console.warn);
};
- const handlePermanentDelete = (entry: (typeof archived)[number]) => {
- const allIds = [...bookmarks.map((b) => b.id), ...archived.map((b) => b.id)];
- permanentlyDeleteBookmark(mx, entry, allIds).catch(console.warn);
+ const handlePermanentDelete = (entry: BookmarkItemContent) => {
+ purge(entry.bookmark_id).catch(console.warn);
};
if (bookmarks.length === 0 && archived.length === 0) {
@@ -82,7 +121,8 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
?.getEvents()
.find((e) => e.getId() === bookmark.event_id);
- const senderId = event?.getSender() ?? '';
+ // Fall back to cached account-data fields when event isn't in the local timeline
+ const senderId = event?.getSender() ?? bookmark.sender ?? '';
const displayName =
(room && senderId ? getMemberDisplayName(room, senderId, nicknames) : undefined) ??
getMxIdLocalPart(senderId) ??
@@ -91,6 +131,8 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
const senderAvatarUrl = senderAvatarMxc
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
+ const displayTs = event?.getTs() ?? bookmark.event_ts;
+ const roomName = room?.name ?? bookmark.room_name ?? bookmark.room_id;
return (
- {event && (
-
- )}
+
Open
+ {enableBookmarkReminders && (
+ handleOpenReminderPicker(bookmark)}
+ aria-label={
+ getReminderForBookmark(bookmark.bookmark_id)
+ ? 'Edit reminder'
+ : 'Set reminder'
+ }
+ title={
+ getReminderForBookmark(bookmark.bookmark_id)
+ ? 'Edit reminder'
+ : 'Set reminder'
+ }
+ >
+
+
+ )}
handleRemove(bookmark.room_id, bookmark.event_id)}
+ onClick={() => handleRemove(bookmark)}
aria-label="Remove bookmark"
>
@@ -150,7 +217,7 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
- in {room?.name ?? bookmark.room_id}
+ in {roomName}
{event ? (
@@ -158,17 +225,51 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
const content = event.getContent<{ body?: string }>();
return (
- {content.body ?? 'Unknown content'}
+ {content.body ?? bookmark.body_preview ?? 'Unknown content'}
);
}}
) : (
- Event not in local timeline
+ {bookmark.body_preview ?? 'Message not in local timeline'}
)}
+ {enableBookmarkReminders && reminderOpenId === bookmark.bookmark_id && (
+
+ setReminderInputValue(e.currentTarget.value)}
+ style={{ flex: 1 }}
+ size="300"
+ />
+ handleSaveReminder(bookmark).catch(console.warn)}
+ variant="Primary"
+ radii="400"
+ as="button"
+ >
+ Set
+
+ {getReminderForBookmark(bookmark.bookmark_id) && (
+ handleClearReminder(bookmark.bookmark_id).catch(console.warn)}
+ variant="Critical"
+ radii="400"
+ as="button"
+ >
+ Clear
+
+ )}
+
+ )}
);
})}
@@ -195,7 +296,8 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
?.getEvents()
.find((e) => e.getId() === entry.event_id);
- const senderId = event?.getSender() ?? '';
+ // Fall back to cached account-data fields when event isn't in the local timeline
+ const senderId = event?.getSender() ?? entry.sender ?? '';
const displayName =
(room && senderId ? getMemberDisplayName(room, senderId, nicknames) : undefined) ??
getMxIdLocalPart(senderId) ??
@@ -205,6 +307,8 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
const senderAvatarUrl = senderAvatarMxc
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
+ const displayTs = event?.getTs() ?? entry.event_ts;
+ const roomName = room?.name ?? entry.room_name ?? entry.room_id;
return (
- {event && (
-
- )}
+
- in {room?.name ?? entry.room_id}
+ in {roomName}
{event ? (
@@ -283,14 +385,14 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
const content = event.getContent<{ body?: string }>();
return (
- {content.body ?? 'Unknown content'}
+ {content.body ?? entry.body_preview ?? 'Unknown content'}
);
}}
) : (
- Event not in local timeline
+ {entry.body_preview ?? 'Message not in local timeline'}
)}
diff --git a/src/app/features/bookmarks/bookmarkDomain.ts b/src/app/features/bookmarks/bookmarkDomain.ts
index ab36ba95e..797472420 100644
--- a/src/app/features/bookmarks/bookmarkDomain.ts
+++ b/src/app/features/bookmarks/bookmarkDomain.ts
@@ -36,6 +36,12 @@ export type BookmarkItemContent = {
body_preview?: string;
msgtype?: string;
deleted?: boolean;
+ /**
+ * Sable extension: marks a tombstoned item as permanently dismissed from the
+ * archived list. Matrix account data cannot be deleted from the server, so
+ * this flag is the only way to fully hide an archived bookmark from the UI.
+ */
+ purged?: boolean;
};
/**
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
index 78493d016..27a734e5a 100644
--- a/src/app/features/bookmarks/bookmarkRepository.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -173,7 +173,8 @@ export function listDeletedBookmarks(mx: MatrixClient): BookmarkItemContent[] {
if (seen.has(id)) return;
seen.add(id);
const content = mx.getAccountData(bookmarkItemEventType(id) as any)?.getContent();
- if (isValidBookmarkItem(content) && content.deleted === true) results.push(content);
+ if (isValidBookmarkItem(content) && content.deleted === true && !content.purged)
+ results.push(content);
});
// 2. Orphan tombstones (properly removed from index but item event persists)
@@ -184,12 +185,32 @@ export function listDeletedBookmarks(mx: MatrixClient): BookmarkItemContent[] {
if (seen.has(bookmarkId)) return;
seen.add(bookmarkId);
const content = mx.getAccountData(key as any)?.getContent();
- if (isValidBookmarkItem(content) && content.deleted === true) results.push(content);
+ if (isValidBookmarkItem(content) && content.deleted === true && !content.purged)
+ results.push(content);
});
return results;
}
+/**
+ * Permanently dismiss a tombstoned bookmark from the archived list.
+ *
+ * Matrix account data events cannot be deleted from the server, so this
+ * writes `purged: true` onto the existing tombstoned item event. On the
+ * next page load, `listDeletedBookmarks` will skip items with `purged: true`,
+ * so the bookmark is effectively gone from the UI on all devices.
+ */
+export async function purgeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
+ const evt = mx.getAccountData(bookmarkItemEventType(bookmarkId) as any);
+ const raw = evt?.getContent();
+ if (raw != null) {
+ await mx.setAccountData(
+ bookmarkItemEventType(bookmarkId) as any,
+ { ...(raw as object), deleted: true, purged: true } as any
+ );
+ }
+}
+
/**
* Check whether a specific bookmark ID is in the index.
*
diff --git a/src/app/features/bookmarks/useBookmarks.ts b/src/app/features/bookmarks/useBookmarks.ts
index 7a53dce43..f8b6e055b 100644
--- a/src/app/features/bookmarks/useBookmarks.ts
+++ b/src/app/features/bookmarks/useBookmarks.ts
@@ -1,5 +1,5 @@
import { useAtomValue, useSetAtom } from 'jotai';
-import { useCallback } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from '$hooks/useMatrixClient';
import {
bookmarkDeletedListAtom,
@@ -13,9 +13,14 @@ import {
addBookmark,
listBookmarks,
listDeletedBookmarks,
+ purgeBookmark,
removeBookmark,
isBookmarked,
} from './bookmarkRepository';
+import { clearBookmarkReminder, listReminders, setBookmarkReminder } from './reminderRepository';
+import type { BookmarkReminder } from '$types/matrix/accountData';
+import { useAccountDataCallback } from '$hooks/useAccountDataCallback';
+import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
/** Returns the current ordered bookmark list. */
export function useBookmarkList(): BookmarkItemContent[] {
@@ -109,11 +114,73 @@ export function useBookmarkActions() {
[mx, setList, setDeletedList]
);
+ const purge = useCallback(
+ async (bookmarkId: string) => {
+ // Optimistic update: remove from the archived list immediately
+ setDeletedList((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId));
+ // Write purged:true to account data so the item is hidden on all devices
+ // after the next sync (Matrix account data cannot actually be deleted).
+ await purgeBookmark(mx, bookmarkId);
+ },
+ [mx, setDeletedList]
+ );
+
const checkIsBookmarked = useCallback(
(roomId: string, eventId: string): boolean =>
isBookmarked(mx, computeBookmarkId(roomId, eventId)),
[mx]
);
- return { refresh, add, remove, restore, checkIsBookmarked };
+ return { refresh, add, remove, restore, purge, checkIsBookmarked };
+}
+
+/**
+ * Returns the live list of bookmark reminders, re-read whenever the
+ * `moe.sable.bookmarks.reminders` account data event changes.
+ */
+export function useBookmarkReminders(): BookmarkReminder[] {
+ const mx = useMatrixClient();
+ const [reminders, setReminders] = useState(() => listReminders(mx));
+
+ useAccountDataCallback(
+ mx,
+ useCallback(
+ (mxEvent) => {
+ if (mxEvent.getType() === (AccountDataEvent.SableBookmarksReminders as string)) {
+ setReminders(listReminders(mx));
+ }
+ },
+ [mx]
+ )
+ );
+
+ // Re-read when mx changes (e.g. session switch)
+ useEffect(() => {
+ setReminders(listReminders(mx));
+ }, [mx]);
+
+ return reminders;
+}
+
+/**
+ * Returns callbacks to set and clear a reminder for a specific bookmark.
+ */
+export function useBookmarkReminderActions() {
+ const mx = useMatrixClient();
+
+ const setReminder = useCallback(
+ async (reminder: BookmarkReminder) => {
+ await setBookmarkReminder(mx, reminder);
+ },
+ [mx]
+ );
+
+ const clearReminder = useCallback(
+ async (bookmarkId: string) => {
+ await clearBookmarkReminder(mx, bookmarkId);
+ },
+ [mx]
+ );
+
+ return { setReminder, clearReminder };
}
From 3e55efd3d4c1893feb12c09dc8fd5c89ea128bd2 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 11 May 2026 20:59:33 -0400
Subject: [PATCH 4/9] feat(bookmarks): add bookmark reminders setting and
per-bookmark reminder picker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add enableBookmarkReminders setting (default false, requires
enableMessageBookmarks to be shown in settings)
- Show a 'Bookmark Reminders' sub-toggle in the MSC4438 experimental
settings tile, visible only when bookmarks are enabled
- Gate RemindersFeature on both enableMessageBookmarks and
enableBookmarkReminders so the SW only receives reminder updates when
the feature is active
- New reminderRepository: setBookmarkReminder / clearBookmarkReminder /
listReminders — read/write the moe.sable.bookmarks.reminders account
data event
- New useBookmarkReminders hook: reads reminders from account data and
stays live via useAccountDataCallback
- New useBookmarkReminderActions hook: set and clear reminder callbacks
- Add Bell/BellRing icon button to each active bookmark in BookmarksList
(visible when reminders setting is on); clicking opens an inline
datetime-local picker with Set/Clear actions
---
.../features/bookmarks/reminderRepository.ts | 47 +++++++++++++++++++
.../experimental/MSC4438MessageBookmarks.tsx | 23 +++++++++
src/app/pages/client/ClientNonUIFeatures.tsx | 3 +-
src/app/state/settings.ts | 2 +
4 files changed, 74 insertions(+), 1 deletion(-)
create mode 100644 src/app/features/bookmarks/reminderRepository.ts
diff --git a/src/app/features/bookmarks/reminderRepository.ts b/src/app/features/bookmarks/reminderRepository.ts
new file mode 100644
index 000000000..45261c7d8
--- /dev/null
+++ b/src/app/features/bookmarks/reminderRepository.ts
@@ -0,0 +1,47 @@
+/* oxlint-disable typescript/no-explicit-any -- custom account data event types require `as any` */
+
+import type { MatrixClient } from '$types/matrix-sdk';
+import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
+import type { BookmarkReminder, BookmarksRemindersContent } from '$types/matrix/accountData';
+
+function readReminders(mx: MatrixClient): BookmarkReminder[] {
+ const evt = mx.getAccountData(AccountDataEvent.SableBookmarksReminders as any);
+ const content = evt?.getContent();
+ return content?.reminders ?? [];
+}
+
+async function writeReminders(mx: MatrixClient, reminders: BookmarkReminder[]): Promise {
+ await mx.setAccountData(
+ AccountDataEvent.SableBookmarksReminders as any,
+ {
+ reminders,
+ } as any
+ );
+}
+
+/**
+ * Set (or update) a reminder for a specific bookmark.
+ * If a reminder already exists for `bookmarkId`, it is replaced.
+ */
+export async function setBookmarkReminder(
+ mx: MatrixClient,
+ reminder: BookmarkReminder
+): Promise {
+ const existing = readReminders(mx).filter((r) => r.bookmarkId !== reminder.bookmarkId);
+ await writeReminders(mx, [...existing, reminder]);
+}
+
+/**
+ * Remove the reminder for a specific bookmark, if one exists.
+ */
+export async function clearBookmarkReminder(mx: MatrixClient, bookmarkId: string): Promise {
+ const updated = readReminders(mx).filter((r) => r.bookmarkId !== bookmarkId);
+ await writeReminders(mx, updated);
+}
+
+/**
+ * Read all current reminders from account data (synchronous, from local cache).
+ */
+export function listReminders(mx: MatrixClient): BookmarkReminder[] {
+ return readReminders(mx);
+}
diff --git a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
index 0751a5578..e7e032d39 100644
--- a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
+++ b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
@@ -10,6 +10,10 @@ export function MSC4438MessageBookmarks() {
settingsAtom,
'enableMessageBookmarks'
);
+ const [enableBookmarkReminders, setEnableBookmarkReminders] = useSetting(
+ settingsAtom,
+ 'enableBookmarkReminders'
+ );
return (
@@ -51,6 +55,25 @@ export function MSC4438MessageBookmarks() {
/>
}
/>
+ {enableMessageBookmarks && (
+
+ }
+ />
+ )}
);
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 0628d4b83..aadf40b20 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -875,7 +875,8 @@ function ReminderSync() {
function RemindersFeature() {
const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
- if (!enableMessageBookmarks) return null;
+ const [enableBookmarkReminders] = useSetting(settingsAtom, 'enableBookmarkReminders');
+ if (!enableMessageBookmarks || !enableBookmarkReminders) return null;
return ;
}
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 8c3b025c8..9e48e859d 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -172,6 +172,7 @@ export interface Settings {
// experimental
enableMessageBookmarks: boolean;
+ enableBookmarkReminders: boolean;
// furry stuff
renderAnimals: boolean;
@@ -326,6 +327,7 @@ export const defaultSettings: Settings = {
// experimental
enableMessageBookmarks: false,
+ enableBookmarkReminders: false,
};
function cloneDefaultSettings(): Settings {
From 50f85aa8703f47f07535d65eafc9ef762dd66343 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 12 May 2026 07:35:13 -0400
Subject: [PATCH 5/9] fix(bookmarks): sidebar tab always navigates to bookmarks
page
BookmarksTab was reusing InboxTab's navToActivePath logic, so if the
user had previously visited inbox/notifications, clicking the bookmarks
button would navigate there instead of inbox/bookmarks.
The tab now always navigates directly to getInboxBookmarksPath(),
which also fixes the mobile regression where no navigation occurred
(InboxTab's mobile guard was absent here).
---
src/app/pages/client/sidebar/BookmarksTab.tsx | 10 +---------
1 file changed, 1 insertion(+), 9 deletions(-)
diff --git a/src/app/pages/client/sidebar/BookmarksTab.tsx b/src/app/pages/client/sidebar/BookmarksTab.tsx
index 0b0170b8a..7212998c9 100644
--- a/src/app/pages/client/sidebar/BookmarksTab.tsx
+++ b/src/app/pages/client/sidebar/BookmarksTab.tsx
@@ -1,27 +1,19 @@
import { useNavigate } from 'react-router-dom';
import { Icon, Icons } from 'folds';
-import { useAtomValue } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar';
-import { getInboxBookmarksPath, joinPathComponent } from '$pages/pathUtils';
+import { getInboxBookmarksPath } from '$pages/pathUtils';
import { useInboxBookmarksSelected } from '$hooks/router/useInbox';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
-import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
export function BookmarksTab() {
const navigate = useNavigate();
- const navToActivePath = useAtomValue(useNavToActivePathAtom());
const bookmarksSelected = useInboxBookmarksSelected();
const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
if (!enableMessageBookmarks) return null;
const handleClick = () => {
- const activePath = navToActivePath.get('inbox');
- if (activePath) {
- navigate(joinPathComponent(activePath));
- return;
- }
navigate(getInboxBookmarksPath());
};
From 69234a13e3f9db628bc8e02a95e0ca4c56fb61f8 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 12 May 2026 13:02:35 -0400
Subject: [PATCH 6/9] fix(reminders): clear fired reminders from account data
via SW message
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After checkDueReminders fires a bookmark reminder notification, the SW
persists only the remaining (non-due) reminders to its cache — but
useReminderSync would immediately overwrite that cache by pushing the
full list from Matrix account data (which hadn't been updated).
Fix: the SW now posts a { type: 'remindersFired', bookmarkIds } message
to all open window clients after firing. useReminderSync listens for
this message and calls clearBookmarkReminder for each fired ID, updating
account data so the next syncReminders call pushes the correct subset.
---
src/app/features/bookmarks/useReminderSync.ts | 15 +++++++++++++++
src/sw.ts | 8 ++++++++
2 files changed, 23 insertions(+)
diff --git a/src/app/features/bookmarks/useReminderSync.ts b/src/app/features/bookmarks/useReminderSync.ts
index 1a23a6116..3579d3e0e 100644
--- a/src/app/features/bookmarks/useReminderSync.ts
+++ b/src/app/features/bookmarks/useReminderSync.ts
@@ -4,6 +4,7 @@ import { useAccountDataCallback } from '$hooks/useAccountDataCallback';
import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
import type { BookmarkReminder, BookmarksRemindersContent } from '$types/matrix/accountData';
import type { MatrixEvent } from '$types/matrix-sdk';
+import { clearBookmarkReminder } from './reminderRepository';
function postRemindersToSW(reminders: BookmarkReminder[]): void {
if (!('serviceWorker' in navigator)) return;
@@ -58,6 +59,20 @@ export function useReminderSync(): void {
tryRegisterPeriodicSync().catch(() => undefined);
}, [syncReminders]);
+ // When the SW fires a reminder, it posts a 'remindersFired' message with the
+ // bookmark IDs. We clear them from account data here so that the next syncReminders
+ // call doesn't push them back to the SW cache (which would cause repeated firings).
+ useEffect(() => {
+ if (!('serviceWorker' in navigator)) return undefined;
+ const handler = (event: MessageEvent) => {
+ if (event.data?.type !== 'remindersFired') return;
+ const firedIds: string[] = event.data.bookmarkIds ?? [];
+ firedIds.forEach((id) => clearBookmarkReminder(mx, id).catch(() => undefined));
+ };
+ navigator.serviceWorker.addEventListener('message', handler);
+ return () => navigator.serviceWorker.removeEventListener('message', handler);
+ }, [mx]);
+
// React to account data changes pushed by other devices mid-session.
useAccountDataCallback(
mx,
diff --git a/src/sw.ts b/src/sw.ts
index 1b93e78f1..b9ebc015e 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -169,6 +169,14 @@ async function checkDueReminders(): Promise {
if (due.length > 0) {
await persistReminders(remaining);
+ // Notify open app tabs so they can clear fired reminders from Matrix account data.
+ // Without this, useReminderSync would push all account-data reminders back to the SW
+ // cache on the next sync, causing the same reminders to fire again.
+ const firedIds = due.map((r) => r.bookmarkId);
+ const openClients = await self.clients.matchAll({ type: 'window' });
+ openClients.forEach((client) => {
+ client.postMessage({ type: 'remindersFired', bookmarkIds: firedIds });
+ });
}
}
From 04b846e765be300a2b69883b502b616b88489c5b Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 12 May 2026 13:36:26 -0400
Subject: [PATCH 7/9] fix(bookmarks): open bookmarks panel on reminder
notification tap; minimal purge tombstone
- sw.ts: include isReminder in the notificationClick postMessage so the
running app can handle reminder taps correctly without falling through
to the "no roomId" early return.
- ClientNonUIFeatures.tsx: HandleNotificationClick now opens the
bookmarks panel (bookmarksPanelAtom) when isReminder is true, instead
of doing nothing (previously bailed out at the !roomId guard).
- bookmarkRepository.ts: purgeBookmark now writes the minimal tombstone
{ deleted: true, purged: true } instead of spreading the original
bookmark content. The original room/event metadata no longer lingers
in account data after a permanent archive deletion.
---
src/app/components/AccountDataEditor.tsx | 11 +++++++++-
.../features/bookmarks/bookmarkRepository.ts | 20 +++++++++----------
.../developer-tools/DevelopTools.tsx | 14 +++++++++++++
.../settings/developer-tools/DevelopTools.tsx | 15 ++++++++++++++
src/app/pages/client/ClientNonUIFeatures.tsx | 12 +++++++++--
src/sw.ts | 1 +
6 files changed, 59 insertions(+), 14 deletions(-)
diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx
index 131ae7fab..c0b903cd7 100644
--- a/src/app/components/AccountDataEditor.tsx
+++ b/src/app/components/AccountDataEditor.tsx
@@ -197,8 +197,9 @@ type AccountDataViewProps = {
type: string;
defaultContent: string;
onEdit: () => void;
+ onDelete?: () => void;
};
-function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
+function AccountDataView({ type, defaultContent, onEdit, onDelete }: AccountDataViewProps) {
return (
Edit
+ {onDelete && (
+
+ )}
JSON Content
@@ -246,6 +252,7 @@ export type AccountDataEditorProps = {
type?: string;
content?: object;
submitChange: AccountDataSubmitCallback;
+ onDelete?: () => void;
requestClose: () => void;
};
@@ -253,6 +260,7 @@ export function AccountDataEditor({
type,
content,
submitChange,
+ onDelete,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState({
@@ -315,6 +323,7 @@ export function AccountDataEditor({
type={data.type}
defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
+ onDelete={onDelete}
/>
)}
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
index 27a734e5a..44d2ecb99 100644
--- a/src/app/features/bookmarks/bookmarkRepository.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -196,19 +196,17 @@ export function listDeletedBookmarks(mx: MatrixClient): BookmarkItemContent[] {
* Permanently dismiss a tombstoned bookmark from the archived list.
*
* Matrix account data events cannot be deleted from the server, so this
- * writes `purged: true` onto the existing tombstoned item event. On the
- * next page load, `listDeletedBookmarks` will skip items with `purged: true`,
- * so the bookmark is effectively gone from the UI on all devices.
+ * overwrites the item event with a minimal tombstone. On the next page
+ * load, `listDeletedBookmarks` will skip items with `purged: true`, so
+ * the bookmark is effectively gone from the UI on all devices. The
+ * original bookmark content is discarded so it does not linger in
+ * account data.
*/
export async function purgeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
- const evt = mx.getAccountData(bookmarkItemEventType(bookmarkId) as any);
- const raw = evt?.getContent();
- if (raw != null) {
- await mx.setAccountData(
- bookmarkItemEventType(bookmarkId) as any,
- { ...(raw as object), deleted: true, purged: true } as any
- );
- }
+ await mx.setAccountData(
+ bookmarkItemEventType(bookmarkId) as any,
+ { deleted: true, purged: true } as any
+ );
}
/**
diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
index d35e20da7..589381772 100644
--- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
@@ -150,12 +150,26 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
[mx, room.roomId]
);
+ const deleteRoomAccountData = useCallback(
+ (type: string) => {
+ if (
+ !window.confirm(
+ `Delete room account data '${type}'?\n\nNote: Matrix does not support deleting account data events. This will overwrite the content with an empty object {}. The event type key will remain.`
+ )
+ )
+ return;
+ mx.setRoomAccountData(room.roomId, type, {}).then(() => setAccountDataType(undefined));
+ },
+ [mx, room.roomId]
+ );
+
if (accountDataType !== undefined) {
return (
deleteRoomAccountData(accountDataType) : undefined}
requestClose={handleClose}
/>
);
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index 100119726..ff02a71d0 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -34,6 +34,20 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
[mx]
);
+ const deleteAccountData = useCallback(
+ (type: string) => {
+ if (
+ !window.confirm(
+ `Delete account data '${type}'?\n\nNote: Matrix does not support deleting account data events. This will overwrite the content with an empty object {}. The event type key will remain.`
+ )
+ )
+ return;
+ // as never: developer tools delete arbitrary account data types beyond the typed enum.
+ mx.setAccountData(type as never, {} as never).then(() => setAccountDataType(undefined));
+ },
+ [mx]
+ );
+
if (accountDataType !== undefined) {
return (
deleteAccountData(accountDataType) : undefined}
requestClose={() => setAccountDataType(undefined)}
/>
);
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index aadf40b20..41512f126 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -61,6 +61,7 @@ import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
+import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom';
import { useReminderSync } from '$features/bookmarks/useReminderSync';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -611,6 +612,7 @@ type ClientNonUIFeaturesProps = {
export function HandleNotificationClick() {
const setPending = useSetAtom(pendingNotificationAtom);
const setActiveSessionId = useSetAtom(activeSessionIdAtom);
+ const setBookmarksPanelOpen = useSetAtom(bookmarksPanelAtom);
const navigate = useNavigate();
useEffect(() => {
@@ -620,11 +622,12 @@ export function HandleNotificationClick() {
const { data } = ev;
if (!data || data.type !== 'notificationClick') return;
- const { userId, roomId, eventId, isInvite } = data as {
+ const { userId, roomId, eventId, isInvite, isReminder } = data as {
userId?: string;
roomId?: string;
eventId?: string;
isInvite?: boolean;
+ isReminder?: boolean;
};
if (userId) setActiveSessionId(userId);
@@ -634,13 +637,18 @@ export function HandleNotificationClick() {
return;
}
+ if (isReminder) {
+ setBookmarksPanelOpen(true);
+ return;
+ }
+
if (!roomId) return;
setPending({ roomId, eventId, targetSessionId: userId });
};
navigator.serviceWorker.addEventListener('message', handleMessage);
return () => navigator.serviceWorker.removeEventListener('message', handleMessage);
- }, [setPending, setActiveSessionId, navigate]);
+ }, [setPending, setActiveSessionId, setBookmarksPanelOpen, navigate]);
return null;
}
diff --git a/src/sw.ts b/src/sw.ts
index b9ebc015e..0e29913fb 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -1013,6 +1013,7 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
eventId: pushEventId,
isInvite,
isCall,
+ isReminder,
});
// oxlint-disable-next-line no-await-in-loop
await wc.focus();
From 9e335379ffdea5b6c0d3f5bcc7e6fa55871bf6a4 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 14 May 2026 10:31:14 -0400
Subject: [PATCH 8/9] feat(bookmarks): add reminder support to full-page
bookmarks view
The bell icon + datetime picker were only wired up in the panel/inbox
variant (features/bookmarks/BookmarksList). The full-page view rendered
via the router (pages/client/bookmarks/BookmarksList) had no reminder
UI at all. Add useBookmarkReminders + useBookmarkReminderActions to
BookmarkItemRow so the bell icon and inline picker appear there too
when enableBookmarkReminders is enabled.
---
.../pages/client/bookmarks/BookmarksList.tsx | 85 +++++++++++++++++++
1 file changed, 85 insertions(+)
diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx
index 0a40384f6..34d545234 100644
--- a/src/app/pages/client/bookmarks/BookmarksList.tsx
+++ b/src/app/pages/client/bookmarks/BookmarksList.tsx
@@ -52,6 +52,8 @@ import {
useBookmarkDeletedList,
useBookmarkList,
useBookmarkLoading,
+ useBookmarkReminders,
+ useBookmarkReminderActions,
} from '$features/bookmarks/useBookmarks';
// ---------------------------------------------------------------------------
@@ -121,6 +123,7 @@ type BookmarkItemRowProps = {
onRemove: (item: BookmarkItemContent) => void;
hour24Clock: boolean;
dateFormatString: string;
+ enableReminders: boolean;
};
function BookmarkItemRow({
@@ -130,9 +133,40 @@ function BookmarkItemRow({
onRemove,
hour24Clock,
dateFormatString,
+ enableReminders,
}: BookmarkItemRowProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
+ const reminders = useBookmarkReminders();
+ const { setReminder, clearReminder } = useBookmarkReminderActions();
+ const [reminderPickerOpen, setReminderPickerOpen] = useState(false);
+ const [reminderInputValue, setReminderInputValue] = useState('');
+ const reminder = reminders.find((r) => r.bookmarkId === item.bookmark_id);
+
+ const handleOpenReminderPicker = () => {
+ const defaultValue = reminder ? new Date(reminder.remindAt).toISOString().slice(0, 16) : '';
+ setReminderInputValue(defaultValue);
+ setReminderPickerOpen((prev) => !prev);
+ };
+
+ const handleSaveReminder = async () => {
+ if (!reminderInputValue) return;
+ const remindAt = new Date(reminderInputValue).getTime();
+ if (Number.isNaN(remindAt)) return;
+ await setReminder({
+ bookmarkId: item.bookmark_id,
+ eventId: item.event_id,
+ roomId: item.room_id,
+ remindAt,
+ userId: mx.getUserId() ?? '',
+ });
+ setReminderPickerOpen(false);
+ };
+
+ const handleClearReminder = async () => {
+ await clearReminder(item.bookmark_id);
+ setReminderPickerOpen(false);
+ };
// Try to resolve live room/member data; fall back to stored metadata
const room = mx.getRoom(item.room_id) ?? undefined;
@@ -207,6 +241,18 @@ function BookmarkItemRow({
>
Jump
+ {enableReminders && (
+
+
+
+ )}
)}
+ {enableReminders && reminderPickerOpen && (
+
+ setReminderInputValue(e.currentTarget.value)}
+ style={{ flex: 1 }}
+ size="300"
+ />
+ handleSaveReminder().catch(console.warn)}
+ variant="Primary"
+ radii="400"
+ as="button"
+ >
+ Set
+
+ {reminder && (
+ handleClearReminder().catch(console.warn)}
+ variant="Critical"
+ radii="400"
+ as="button"
+ >
+ Clear
+
+ )}
+
+ )}
);
}
@@ -241,6 +321,7 @@ type BookmarkResultGroupProps = {
onRemove: (item: BookmarkItemContent) => void;
hour24Clock: boolean;
dateFormatString: string;
+ enableReminders: boolean;
};
function BookmarkResultGroup({
@@ -252,6 +333,7 @@ function BookmarkResultGroup({
onRemove,
hour24Clock,
dateFormatString,
+ enableReminders,
}: BookmarkResultGroupProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@@ -293,6 +375,7 @@ function BookmarkResultGroup({
onRemove={onRemove}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
+ enableReminders={enableReminders}
/>
))}
@@ -418,6 +501,7 @@ export function BookmarksList() {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
+ const [enableBookmarkReminders] = useSetting(settingsAtom, 'enableBookmarkReminders');
const bookmarks = useBookmarkList();
const deletedBookmarks = useBookmarkDeletedList();
@@ -573,6 +657,7 @@ export function BookmarksList() {
onRemove={setRemovingItem}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
+ enableReminders={enableBookmarkReminders}
/>
))}
From bca977eb58b5d6de6e45b7157dda53db9e6f9824 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 14 May 2026 10:40:34 -0400
Subject: [PATCH 9/9] chore(bookmarks): remove dead panel/inbox variant
BookmarksPanel and its associated BookmarksList in features/bookmarks
were never wired into the router or any live component. The full-page
bookmarks view at pages/client/bookmarks/BookmarksList is the only
real implementation.
Also fix HandleNotificationClick: bookmarksPanelAtom never existed, so
reminder notification taps were silently broken. Navigate to the inbox
bookmarks route instead.
---
knip.json | 2 +-
src/app/features/bookmarks/BookmarksList.tsx | 406 ------------------
src/app/features/bookmarks/BookmarksPanel.tsx | 48 ---
.../features/bookmarks/bookmarkDomain.test.ts | 2 +-
src/app/hooks/useBookmarks.ts | 197 ---------
src/app/pages/client/ClientNonUIFeatures.tsx | 8 +-
src/app/pages/client/inbox/Bookmarks.tsx | 48 ---
src/app/pages/client/inbox/index.ts | 1 -
8 files changed, 5 insertions(+), 707 deletions(-)
delete mode 100644 src/app/features/bookmarks/BookmarksList.tsx
delete mode 100644 src/app/features/bookmarks/BookmarksPanel.tsx
delete mode 100644 src/app/hooks/useBookmarks.ts
delete mode 100644 src/app/pages/client/inbox/Bookmarks.tsx
diff --git a/knip.json b/knip.json
index a2c24c8cb..6cc8c8581 100644
--- a/knip.json
+++ b/knip.json
@@ -1,7 +1,7 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/sw.ts", "scripts/normalize-imports.js"],
- "ignore": ["oxlint.config.ts", "oxfmt.config.ts", "src/app/features/bookmarks/BookmarksPanel.tsx"],
+ "ignore": ["oxlint.config.ts", "oxfmt.config.ts"],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
diff --git a/src/app/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx
deleted file mode 100644
index 533cdbff7..000000000
--- a/src/app/features/bookmarks/BookmarksList.tsx
+++ /dev/null
@@ -1,406 +0,0 @@
-import { Avatar, Box, Chip, Icon, IconButton, Icons, Input, Line, Text, config } from 'folds';
-import { useAtomValue } from 'jotai';
-import { useCallback, useState } from 'react';
-import {
- useBookmarkList,
- useBookmarkDeletedList,
- useBookmarkActions,
- useBookmarkReminders,
- useBookmarkReminderActions,
-} from '$features/bookmarks/useBookmarks';
-import { useMatrixClient } from '$hooks/useMatrixClient';
-import { useRoomNavigate } from '$hooks/useRoomNavigate';
-import { useGetRoom, useAllJoinedRoomsSet } from '$hooks/useGetRoom';
-import { getMemberAvatarMxc, getMemberDisplayName } from '$utils/room';
-import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
-import { UserAvatar } from '$components/user-avatar';
-import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
-import { SequenceCard } from '$components/sequence-card';
-import { AvatarBase, ModernLayout, Time, Username, UsernameBold } from '$components/message';
-import { ContainerColor } from '$styles/ContainerColor.css';
-import { EncryptedContent } from '$features/room/message';
-import { nicknamesAtom } from '$state/nicknames';
-import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
-import type { BookmarkItemContent } from '$features/bookmarks/bookmarkDomain';
-
-type BookmarksListProps = {
- onNavigate?: () => void;
-};
-
-export function BookmarksList({ onNavigate }: BookmarksListProps) {
- const mx = useMatrixClient();
- const bookmarks = useBookmarkList();
- const archived = useBookmarkDeletedList();
- const { remove, restore, purge } = useBookmarkActions();
- const reminders = useBookmarkReminders();
- const { setReminder, clearReminder } = useBookmarkReminderActions();
- const [enableBookmarkReminders] = useSetting(settingsAtom, 'enableBookmarkReminders');
- // Track which bookmark has the reminder picker open, and the current input value
- const [reminderOpenId, setReminderOpenId] = useState(null);
- const [reminderInputValue, setReminderInputValue] = useState('');
-
- const getReminderForBookmark = useCallback(
- (bookmarkId: string) => reminders.find((r) => r.bookmarkId === bookmarkId),
- [reminders]
- );
-
- const handleOpenReminderPicker = (bookmark: BookmarkItemContent) => {
- const existing = getReminderForBookmark(bookmark.bookmark_id);
- const defaultValue = existing ? new Date(existing.remindAt).toISOString().slice(0, 16) : '';
- setReminderInputValue(defaultValue);
- setReminderOpenId((prev) => (prev === bookmark.bookmark_id ? null : bookmark.bookmark_id));
- };
-
- const handleSaveReminder = async (bookmark: BookmarkItemContent) => {
- if (!reminderInputValue) return;
- const remindAt = new Date(reminderInputValue).getTime();
- if (Number.isNaN(remindAt)) return;
- await setReminder({
- bookmarkId: bookmark.bookmark_id,
- eventId: bookmark.event_id,
- roomId: bookmark.room_id,
- remindAt,
- userId: mx.getUserId() ?? '',
- });
- setReminderOpenId(null);
- };
-
- const handleClearReminder = async (bookmarkId: string) => {
- await clearReminder(bookmarkId);
- setReminderOpenId(null);
- };
- const { navigateRoom } = useRoomNavigate();
- const useAuthentication = useMediaAuthentication();
- const allRoomsSet = useAllJoinedRoomsSet();
- const getRoom = useGetRoom(allRoomsSet);
- const nicknames = useAtomValue(nicknamesAtom);
- const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
- const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
-
- const handleOpen = (roomId: string, eventId: string) => {
- navigateRoom(roomId, eventId);
- onNavigate?.();
- };
-
- const handleRemove = (bookmark: BookmarkItemContent) => {
- remove(bookmark.bookmark_id).catch(console.warn);
- };
-
- const handleRestore = (entry: BookmarkItemContent) => {
- restore(entry).catch(console.warn);
- };
-
- const handlePermanentDelete = (entry: BookmarkItemContent) => {
- purge(entry.bookmark_id).catch(console.warn);
- };
-
- if (bookmarks.length === 0 && archived.length === 0) {
- return (
-
- No Bookmarks
- Bookmark messages from the message menu to save them here.
-
- );
- }
-
- return (
-
- {bookmarks.map((bookmark) => {
- const room = getRoom(bookmark.room_id);
- const event = room
- ?.getTimelineForEvent(bookmark.event_id)
- ?.getEvents()
- .find((e) => e.getId() === bookmark.event_id);
-
- // Fall back to cached account-data fields when event isn't in the local timeline
- const senderId = event?.getSender() ?? bookmark.sender ?? '';
- const displayName =
- (room && senderId ? getMemberDisplayName(room, senderId, nicknames) : undefined) ??
- getMxIdLocalPart(senderId) ??
- senderId;
- const senderAvatarMxc = room && senderId ? getMemberAvatarMxc(room, senderId) : undefined;
- const senderAvatarUrl = senderAvatarMxc
- ? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
- : undefined;
- const displayTs = event?.getTs() ?? bookmark.event_ts;
- const roomName = room?.name ?? bookmark.room_name ?? bookmark.room_id;
-
- return (
-
-
-
- }
- />
-
-
- }
- >
-
-
-
-
-
- {displayName || 'Unknown'}
-
-
-
-
-
-
- handleOpen(bookmark.room_id, bookmark.event_id)}
- variant="Secondary"
- radii="400"
- >
- Open
-
- {enableBookmarkReminders && (
- handleOpenReminderPicker(bookmark)}
- aria-label={
- getReminderForBookmark(bookmark.bookmark_id)
- ? 'Edit reminder'
- : 'Set reminder'
- }
- title={
- getReminderForBookmark(bookmark.bookmark_id)
- ? 'Edit reminder'
- : 'Set reminder'
- }
- >
-
-
- )}
- handleRemove(bookmark)}
- aria-label="Remove bookmark"
- >
-
-
-
-
-
- in {roomName}
-
- {event ? (
-
- {() => {
- const content = event.getContent<{ body?: string }>();
- return (
-
- {content.body ?? bookmark.body_preview ?? 'Unknown content'}
-
- );
- }}
-
- ) : (
-
- {bookmark.body_preview ?? 'Message not in local timeline'}
-
- )}
-
- {enableBookmarkReminders && reminderOpenId === bookmark.bookmark_id && (
-
- setReminderInputValue(e.currentTarget.value)}
- style={{ flex: 1 }}
- size="300"
- />
- handleSaveReminder(bookmark).catch(console.warn)}
- variant="Primary"
- radii="400"
- as="button"
- >
- Set
-
- {getReminderForBookmark(bookmark.bookmark_id) && (
- handleClearReminder(bookmark.bookmark_id).catch(console.warn)}
- variant="Critical"
- radii="400"
- as="button"
- >
- Clear
-
- )}
-
- )}
-
- );
- })}
- {archived.length > 0 && (
- <>
-
-
-
-
-
- Archived
-
-
-
-
- {archived.map((entry) => {
- const room = getRoom(entry.room_id);
- const event = room
- ?.getTimelineForEvent(entry.event_id)
- ?.getEvents()
- .find((e) => e.getId() === entry.event_id);
-
- // Fall back to cached account-data fields when event isn't in the local timeline
- const senderId = event?.getSender() ?? entry.sender ?? '';
- const displayName =
- (room && senderId ? getMemberDisplayName(room, senderId, nicknames) : undefined) ??
- getMxIdLocalPart(senderId) ??
- senderId;
- const senderAvatarMxc =
- room && senderId ? getMemberAvatarMxc(room, senderId) : undefined;
- const senderAvatarUrl = senderAvatarMxc
- ? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
- : undefined;
- const displayTs = event?.getTs() ?? entry.event_ts;
- const roomName = room?.name ?? entry.room_name ?? entry.room_id;
-
- return (
-
-
-
- }
- />
-
-
- }
- >
-
-
-
-
-
- {displayName || 'Unknown'}
-
-
-
-
-
-
- handleOpen(entry.room_id, entry.event_id)}
- variant="Secondary"
- radii="400"
- >
- Open
-
- handleRestore(entry)}
- aria-label="Restore bookmark"
- title="Restore"
- >
-
-
- handlePermanentDelete(entry)}
- aria-label="Permanently delete bookmark"
- title="Delete permanently"
- >
-
-
-
-
-
- in {roomName}
-
- {event ? (
-
- {() => {
- const content = event.getContent<{ body?: string }>();
- return (
-
- {content.body ?? entry.body_preview ?? 'Unknown content'}
-
- );
- }}
-
- ) : (
-
- {entry.body_preview ?? 'Message not in local timeline'}
-
- )}
-
-
- );
- })}
- >
- )}
-
- );
-}
diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx
deleted file mode 100644
index b38ba0bc8..000000000
--- a/src/app/features/bookmarks/BookmarksPanel.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Box, color, Dialog, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
-import { BookmarksList } from './BookmarksList';
-
-export { BookmarksList } from './BookmarksList';
-
-type BookmarksPanelProps = {
- requestClose: () => void;
-};
-
-export function BookmarksPanel({ requestClose }: BookmarksPanelProps) {
- return (
-
- );
-}
diff --git a/src/app/features/bookmarks/bookmarkDomain.test.ts b/src/app/features/bookmarks/bookmarkDomain.test.ts
index 2f70879da..ff9fb9353 100644
--- a/src/app/features/bookmarks/bookmarkDomain.test.ts
+++ b/src/app/features/bookmarks/bookmarkDomain.test.ts
@@ -4,7 +4,7 @@
*/
import { describe, it, expect } from 'vitest';
import type { MatrixEvent, Room } from '$types/matrix-sdk';
-import { AccountDataEvent } from '$types/matrix/accountData';
+import { CustomAccountDataEvent as AccountDataEvent } from '$types/matrix/accountData';
import {
bookmarkItemEventType,
buildMatrixURI,
diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts
deleted file mode 100644
index cc1f59bdf..000000000
--- a/src/app/hooks/useBookmarks.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-/* eslint-disable typescript/no-explicit-any -- MatrixClient.setAccountData only accepts
- SDK-known event types (keyof AccountDataEvents); custom MSC4438 account data event types
- require `as any` to bypass the constraint without a full SDK fork. */
-import { useCallback, useEffect, useState } from 'react';
-import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk';
-import { ClientEvent } from '$types/matrix-sdk';
-import { useMatrixClient } from '$hooks/useMatrixClient';
-import { CustomAccountDataEvent } from '$types/matrix/accountData';
-
-export type BookmarkEntry = {
- event_id: string;
- room_id: string;
- /** MSC4438 bookmark key suffix, e.g. "bmk_a1b2c3d4" */
- id: string;
-};
-
-// ---------------------------------------------------------------------------
-// MSC4438 helpers
-// ---------------------------------------------------------------------------
-
-const BOOKMARK_PREFIX = CustomAccountDataEvent.BookmarkItemPrefix; // 'org.matrix.msc4438.bookmark.'
-const INDEX_KEY = CustomAccountDataEvent.BookmarksIndex; // 'org.matrix.msc4438.bookmarks.index'
-
-function generateBookmarkId(): string {
- // 8 random hex chars, prefixed with "bmk_"
- const bytes = new Uint8Array(4);
- crypto.getRandomValues(bytes);
- return `bmk_${Array.from(bytes)
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('')}`;
-}
-
-function getIndexIds(mx: MatrixClient): string[] {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
- const ev = mx.getAccountData(INDEX_KEY as any);
- if (!ev) return [];
- const content = ev.getContent<{ bookmark_ids?: string[] }>();
- return Array.isArray(content.bookmark_ids) ? content.bookmark_ids : [];
-}
-
-export function readBookmarks(mx: MatrixClient): BookmarkEntry[] {
- const ids = getIndexIds(mx);
- const entries: BookmarkEntry[] = [];
- for (const id of ids) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
- const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}` as any);
- if (!ev) continue;
- const c = ev.getContent<{ room_id?: string; event_id?: string; deleted?: boolean }>();
- if (!c.deleted && c.room_id && c.event_id) {
- entries.push({ id, room_id: c.room_id, event_id: c.event_id });
- }
- }
- return entries;
-}
-
-export function readArchivedBookmarks(mx: MatrixClient): BookmarkEntry[] {
- const ids = getIndexIds(mx);
- const entries: BookmarkEntry[] = [];
- for (const id of ids) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
- const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}` as any);
- if (!ev) continue;
- const c = ev.getContent<{ room_id?: string; event_id?: string; deleted?: boolean }>();
- if (c.deleted && c.room_id && c.event_id) {
- entries.push({ id, room_id: c.room_id, event_id: c.event_id });
- }
- }
- return entries;
-}
-
-// ---------------------------------------------------------------------------
-// Hook
-// ---------------------------------------------------------------------------
-
-export function useBookmarks(): BookmarkEntry[] {
- const mx = useMatrixClient();
- const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx));
-
- const refresh = useCallback(() => setBookmarks(readBookmarks(mx)), [mx]);
-
- useEffect(() => {
- refresh();
- const handler = (event: MatrixEvent) => {
- const type = event.getType();
- if (type === INDEX_KEY || type.startsWith(BOOKMARK_PREFIX)) {
- refresh();
- }
- };
- mx.on(ClientEvent.AccountData, handler);
- return () => {
- mx.off(ClientEvent.AccountData, handler);
- };
- }, [mx, refresh]);
-
- return bookmarks;
-}
-
-export function useArchivedBookmarks(): BookmarkEntry[] {
- const mx = useMatrixClient();
- const [archived, setArchived] = useState(() => readArchivedBookmarks(mx));
-
- const refresh = useCallback(() => setArchived(readArchivedBookmarks(mx)), [mx]);
-
- useEffect(() => {
- refresh();
- const handler = (event: MatrixEvent) => {
- const type = event.getType();
- if (type === INDEX_KEY || type.startsWith(BOOKMARK_PREFIX)) {
- refresh();
- }
- };
- mx.on(ClientEvent.AccountData, handler);
- return () => {
- mx.off(ClientEvent.AccountData, handler);
- };
- }, [mx, refresh]);
-
- return archived;
-}
-
-// ---------------------------------------------------------------------------
-// Utilities
-// ---------------------------------------------------------------------------
-
-export function isBookmarked(bookmarks: BookmarkEntry[], eventId: string): boolean {
- return bookmarks.some((b) => b.event_id === eventId);
-}
-
-export async function toggleBookmark(
- mx: MatrixClient,
- roomId: string,
- eventId: string,
- currentBookmarks: BookmarkEntry[]
-): Promise {
- const existing = currentBookmarks.find((b) => b.event_id === eventId);
- if (existing) {
- // Archive: keep the id in the index so the archive section can find it,
- // mark as deleted but retain room_id + event_id so readArchivedBookmarks
- // can reconstruct the entry.
- await mx.setAccountData(
- `${BOOKMARK_PREFIX}${existing.id}` as any,
- {
- deleted: true,
- bookmark_id: existing.id,
- room_id: existing.room_id,
- event_id: existing.event_id,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any
- );
- } else {
- // Add: write individual event, then update index
- const id = generateBookmarkId();
- await mx.setAccountData(
- `${BOOKMARK_PREFIX}${id}` as any,
- {
- room_id: roomId,
- event_id: eventId,
- bookmark_id: id,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any
- );
- const newIds = [...currentBookmarks.map((b) => b.id), id];
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
- await (mx.setAccountData as any)(INDEX_KEY, { bookmark_ids: newIds });
- }
-}
-
-/** Restore an archived bookmark back to the active list. */
-export async function restoreBookmark(mx: MatrixClient, entry: BookmarkEntry): Promise {
- await mx.setAccountData(
- `${BOOKMARK_PREFIX}${entry.id}` as any,
- {
- room_id: entry.room_id,
- event_id: entry.event_id,
- bookmark_id: entry.id,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any
- );
-}
-
-/**
- * Permanently remove a bookmark: strip it from the index and clear its
- * account data entry so it no longer consumes account data space.
- */
-export async function permanentlyDeleteBookmark(
- mx: MatrixClient,
- entry: BookmarkEntry,
- allIds: string[]
-): Promise {
- const newIds = allIds.filter((id) => id !== entry.id);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- custom MSC event type not in SDK AccountDataEvents
- await (mx.setAccountData as any)(INDEX_KEY, { bookmark_ids: newIds });
- // Clear the individual event data — write a minimal tombstone so syncing
- // clients discard the entry rather than seeing a stale object.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await (mx.setAccountData as any)(`${BOOKMARK_PREFIX}${entry.id}`, { deleted: true });
-}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 41512f126..f3b51cac5 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -61,9 +61,8 @@ import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
-import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom';
import { useReminderSync } from '$features/bookmarks/useReminderSync';
-import { getInboxInvitesPath } from '../pathUtils';
+import { getInboxBookmarksPath, getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
const pushRelayLog = createDebugLogger('push-relay');
@@ -612,7 +611,6 @@ type ClientNonUIFeaturesProps = {
export function HandleNotificationClick() {
const setPending = useSetAtom(pendingNotificationAtom);
const setActiveSessionId = useSetAtom(activeSessionIdAtom);
- const setBookmarksPanelOpen = useSetAtom(bookmarksPanelAtom);
const navigate = useNavigate();
useEffect(() => {
@@ -638,7 +636,7 @@ export function HandleNotificationClick() {
}
if (isReminder) {
- setBookmarksPanelOpen(true);
+ navigate(getInboxBookmarksPath());
return;
}
@@ -648,7 +646,7 @@ export function HandleNotificationClick() {
navigator.serviceWorker.addEventListener('message', handleMessage);
return () => navigator.serviceWorker.removeEventListener('message', handleMessage);
- }, [setPending, setActiveSessionId, setBookmarksPanelOpen, navigate]);
+ }, [setPending, setActiveSessionId, navigate]);
return null;
}
diff --git a/src/app/pages/client/inbox/Bookmarks.tsx b/src/app/pages/client/inbox/Bookmarks.tsx
deleted file mode 100644
index c243eeadd..000000000
--- a/src/app/pages/client/inbox/Bookmarks.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { useRef } from 'react';
-import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
-import { Page, PageContent, PageContentCenter, PageHeader } from '$components/page';
-import { BookmarksList } from '$features/bookmarks/BookmarksList';
-import { BackRouteHandler } from '$components/BackRouteHandler';
-import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
-
-export function Bookmarks() {
- const scrollRef = useRef(null);
- const screenSize = useScreenSizeContext();
-
- return (
-
-
-
-
- {screenSize === ScreenSize.Mobile && (
-
- {(onBack) => (
-
-
-
- )}
-
- )}
-
-
- {screenSize !== ScreenSize.Mobile && }
-
- Bookmarks
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts
index dc02ccee6..c8036b471 100644
--- a/src/app/pages/client/inbox/index.ts
+++ b/src/app/pages/client/inbox/index.ts
@@ -1,4 +1,3 @@
export * from './Inbox';
export * from './Notifications';
export * from './Invites';
-export * from './Bookmarks';