From 6e70b23398ad0091a73d1608ffe2c066bea67694 Mon Sep 17 00:00:00 2001
From: "sable-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 11 May 2026 00:43:34 +0000
Subject: [PATCH 1/6] chore(nix): auto-fix nix hashes
---
flake.nix | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flake.nix b/flake.nix
index 822e02de3..c19856c22 100644
--- a/flake.nix
+++ b/flake.nix
@@ -61,7 +61,7 @@
;
pname = "sable";
fetcherVersion = 3;
- hash = "sha256-9QIBOF1d7Z086IsOAHpOayKA3uNY0e5imYQixHKFXxw=";
+ hash = "sha256-IJrBo2/PsHiMBbN7eUu46U6V8flL9KYFDphz5cirfrU=";
};
mkPnpmCheck =
From ac50f87358e7afbe2c9c04d571f7ed9815a30fc5 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 7 May 2026 21:19:52 -0400
Subject: [PATCH 2/6] 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 | 39 +-
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, 3540 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 3224480c6..529e07f86 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;
@@ -1107,6 +1152,7 @@ function MessageInternal(
)}
+
{canPinEvent && (
)}
@@ -1445,6 +1491,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 b089cf8f8..0875cf4d2 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 661435513..b712e31ea 100644
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ b/src/app/pages/client/inbox/Inbox.tsx
@@ -1,12 +1,22 @@
import { Avatar, Box, Icon, Icons, Text } 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';
import { PageNav, PageNavContent, PageNavHeader } from '$components/page';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
function InvitesNavItem() {
const invitesSelected = useInboxInvitesSelected();
@@ -39,9 +49,33 @@ function InvitesNavItem() {
);
}
+function BookmarksNavItem() {
+ const bookmarksSelected = useInboxBookmarksSelected();
+
+ return (
+
+
+
+
+
+
+
+
+
+ Bookmarks
+
+
+
+
+
+
+ );
+}
+
export function Inbox() {
useNavToActivePathMapper('inbox');
const notificationsSelected = useInboxNotificationsSelected();
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
return (
@@ -75,6 +109,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 401963f49..a0a1b790c 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -154,6 +154,9 @@ export interface Settings {
showPersonaSetting: boolean;
closeFoldersByDefault: boolean;
+ // experimental
+ enableMessageBookmarks: boolean;
+
// furry stuff
renderAnimals: boolean;
@@ -295,6 +298,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 222156e72..dd3b38993 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 833ae7b646d70c0ff768e3797f44160ec4e5a24b Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 7 May 2026 22:20:35 -0400
Subject: [PATCH 3/6] 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 611f194f6d12966543512c4824cea4a6a706af9b Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 11 May 2026 09:05:42 -0400
Subject: [PATCH 4/6] fix(bookmarks): use cached account-data fields as
fallback when event not in local timeline
---
src/app/features/bookmarks/BookmarksList.tsx | 77 ++++++++++----------
1 file changed, 39 insertions(+), 38 deletions(-)
diff --git a/src/app/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx
index e476fea93..54b52cfa4 100644
--- a/src/app/features/bookmarks/BookmarksList.tsx
+++ b/src/app/features/bookmarks/BookmarksList.tsx
@@ -1,12 +1,10 @@
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';
+ useBookmarkList,
+ useBookmarkDeletedList,
+ useBookmarkActions,
+} from '$features/bookmarks/useBookmarks';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
import { useGetRoom, useAllJoinedRoomsSet } from '$hooks/useGetRoom';
@@ -21,6 +19,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 +27,9 @@ 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 } = useBookmarkActions();
const { navigateRoom } = useRoomNavigate();
const useAuthentication = useMediaAuthentication();
const allRoomsSet = useAllJoinedRoomsSet();
@@ -43,17 +43,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) => {
+ remove(entry.bookmark_id).catch(console.warn);
};
if (bookmarks.length === 0 && archived.length === 0) {
@@ -82,7 +81,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 +91,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 && (
-
- )}
+
handleRemove(bookmark.room_id, bookmark.event_id)}
+ onClick={() => handleRemove(bookmark)}
aria-label="Remove bookmark"
>
@@ -150,7 +150,7 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
- in {room?.name ?? bookmark.room_id}
+ in {roomName}
{event ? (
@@ -158,14 +158,14 @@ 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'}
)}
@@ -195,7 +195,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 +206,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 +284,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'}
)}
From 5f8203ece26ebe2ab9cf8f73c31408df7cc5f324 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 11 May 2026 20:59:21 -0400
Subject: [PATCH 5/6] fix(bookmarks): permanently delete archived bookmark
removes it from UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The delete icon on archived bookmarks was calling remove() which only
moves active → deleted and cannot find entries already in the deleted
list, so it was a no-op locally.
Fix: add a purge() action that removes the entry from bookmarkDeletedListAtom
immediately (optimistic update) and writes purged:true to the server-side
account data item event. Since Matrix account data cannot be deleted, the
purged flag is used by listDeletedBookmarks() to skip the item on next
load, so it stays gone across all devices after the next sync.
---
src/app/features/bookmarks/BookmarksList.tsx | 107 +++++++++++++++++-
src/app/features/bookmarks/bookmarkDomain.ts | 6 +
.../features/bookmarks/bookmarkRepository.ts | 25 +++-
src/app/features/bookmarks/useBookmarks.ts | 71 +++++++++++-
4 files changed, 202 insertions(+), 7 deletions(-)
diff --git a/src/app/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx
index 54b52cfa4..533cdbff7 100644
--- a/src/app/features/bookmarks/BookmarksList.tsx
+++ b/src/app/features/bookmarks/BookmarksList.tsx
@@ -1,9 +1,12 @@
-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 {
useBookmarkList,
useBookmarkDeletedList,
useBookmarkActions,
+ useBookmarkReminders,
+ useBookmarkReminderActions,
} from '$features/bookmarks/useBookmarks';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useRoomNavigate } from '$hooks/useRoomNavigate';
@@ -29,7 +32,44 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
const mx = useMatrixClient();
const bookmarks = useBookmarkList();
const archived = useBookmarkDeletedList();
- const { remove, restore } = useBookmarkActions();
+ 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();
@@ -52,7 +92,7 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
};
const handlePermanentDelete = (entry: BookmarkItemContent) => {
- remove(entry.bookmark_id).catch(console.warn);
+ purge(entry.bookmark_id).catch(console.warn);
};
if (bookmarks.length === 0 && archived.length === 0) {
@@ -138,6 +178,33 @@ export function BookmarksList({ onNavigate }: BookmarksListProps) {
>
Open
+ {enableBookmarkReminders && (
+ handleOpenReminderPicker(bookmark)}
+ aria-label={
+ getReminderForBookmark(bookmark.bookmark_id)
+ ? 'Edit reminder'
+ : 'Set reminder'
+ }
+ title={
+ getReminderForBookmark(bookmark.bookmark_id)
+ ? 'Edit reminder'
+ : 'Set reminder'
+ }
+ >
+
+
+ )}
)}
+ {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
+
+ )}
+
+ )}
);
})}
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 7464afe1aaec80d189a3154d9ea81d30ddb43189 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 11 May 2026 20:59:33 -0400
Subject: [PATCH 6/6] 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 0875cf4d2..ebdb989b0 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 a0a1b790c..895e762ca 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -156,6 +156,7 @@ export interface Settings {
// experimental
enableMessageBookmarks: boolean;
+ enableBookmarkReminders: boolean;
// furry stuff
renderAnimals: boolean;
@@ -301,6 +302,7 @@ export const defaultSettings: Settings = {
// experimental
enableMessageBookmarks: false,
+ enableBookmarkReminders: false,
};
function cloneDefaultSettings(): Settings {