diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx
index 131ae7fab..c0b903cd7 100644
--- a/src/app/components/AccountDataEditor.tsx
+++ b/src/app/components/AccountDataEditor.tsx
@@ -197,8 +197,9 @@ type AccountDataViewProps = {
type: string;
defaultContent: string;
onEdit: () => void;
+ onDelete?: () => void;
};
-function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
+function AccountDataView({ type, defaultContent, onEdit, onDelete }: AccountDataViewProps) {
return (
Edit
+ {onDelete && (
+
+ )}
JSON Content
@@ -246,6 +252,7 @@ export type AccountDataEditorProps = {
type?: string;
content?: object;
submitChange: AccountDataSubmitCallback;
+ onDelete?: () => void;
requestClose: () => void;
};
@@ -253,6 +260,7 @@ export function AccountDataEditor({
type,
content,
submitChange,
+ onDelete,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState({
@@ -315,6 +323,7 @@ export function AccountDataEditor({
type={data.type}
defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
+ onDelete={onDelete}
/>
)}
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
new file mode 100644
index 000000000..44d2ecb99
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -0,0 +1,223 @@
+/**
+ * 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 && !content.purged)
+ 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 && !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
+ * overwrites the item event with a minimal tombstone. On the next page
+ * load, `listDeletedBookmarks` will skip items with `purged: true`, so
+ * the bookmark is effectively gone from the UI on all devices. The
+ * original bookmark content is discarded so it does not linger in
+ * account data.
+ */
+export async function purgeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
+ await mx.setAccountData(
+ bookmarkItemEventType(bookmarkId) as any,
+ { deleted: true, purged: true } as any
+ );
+}
+
+/**
+ * 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/useReminderSync.ts b/src/app/features/bookmarks/useReminderSync.ts
new file mode 100644
index 000000000..3579d3e0e
--- /dev/null
+++ b/src/app/features/bookmarks/useReminderSync.ts
@@ -0,0 +1,88 @@
+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';
+import { clearBookmarkReminder } from './reminderRepository';
+
+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]);
+
+ // When the SW fires a reminder, it posts a 'remindersFired' message with the
+ // bookmark IDs. We clear them from account data here so that the next syncReminders
+ // call doesn't push them back to the SW cache (which would cause repeated firings).
+ useEffect(() => {
+ if (!('serviceWorker' in navigator)) return undefined;
+ const handler = (event: MessageEvent) => {
+ if (event.data?.type !== 'remindersFired') return;
+ const firedIds: string[] = event.data.bookmarkIds ?? [];
+ firedIds.forEach((id) => clearBookmarkReminder(mx, id).catch(() => undefined));
+ };
+ navigator.serviceWorker.addEventListener('message', handler);
+ return () => navigator.serviceWorker.removeEventListener('message', handler);
+ }, [mx]);
+
+ // React to account data changes pushed by other devices mid-session.
+ useAccountDataCallback(
+ mx,
+ useCallback(
+ (mxEvent: MatrixEvent) => {
+ if (mxEvent.getType() === (AccountDataEvent.SableBookmarksReminders as string)) {
+ syncReminders();
+ }
+ },
+ [syncReminders]
+ )
+ );
+}
diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
index d35e20da7..589381772 100644
--- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
@@ -150,12 +150,26 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
[mx, room.roomId]
);
+ const deleteRoomAccountData = useCallback(
+ (type: string) => {
+ if (
+ !window.confirm(
+ `Delete room account data '${type}'?\n\nNote: Matrix does not support deleting account data events. This will overwrite the content with an empty object {}. The event type key will remain.`
+ )
+ )
+ return;
+ mx.setRoomAccountData(room.roomId, type, {}).then(() => setAccountDataType(undefined));
+ },
+ [mx, room.roomId]
+ );
+
if (accountDataType !== undefined) {
return (
deleteRoomAccountData(accountDataType) : undefined}
requestClose={handleClose}
/>
);
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index 100119726..ff02a71d0 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -34,6 +34,20 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
[mx]
);
+ const deleteAccountData = useCallback(
+ (type: string) => {
+ if (
+ !window.confirm(
+ `Delete account data '${type}'?\n\nNote: Matrix does not support deleting account data events. This will overwrite the content with an empty object {}. The event type key will remain.`
+ )
+ )
+ return;
+ // as never: developer tools delete arbitrary account data types beyond the typed enum.
+ mx.setAccountData(type as never, {} as never).then(() => setAccountDataType(undefined));
+ },
+ [mx]
+ );
+
if (accountDataType !== undefined) {
return (
deleteAccountData(accountDataType) : undefined}
requestClose={() => setAccountDataType(undefined)}
/>
);
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index b089cf8f8..102b6f00b 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -60,6 +60,9 @@ 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 { bookmarksPanelAtom } from '$state/bookmarksPanelAtom';
+import { useReminderSync } from '$features/bookmarks/useReminderSync';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -609,6 +612,7 @@ type ClientNonUIFeaturesProps = {
export function HandleNotificationClick() {
const setPending = useSetAtom(pendingNotificationAtom);
const setActiveSessionId = useSetAtom(activeSessionIdAtom);
+ const setBookmarksPanelOpen = useSetAtom(bookmarksPanelAtom);
const navigate = useNavigate();
useEffect(() => {
@@ -618,11 +622,12 @@ export function HandleNotificationClick() {
const { data } = ev;
if (!data || data.type !== 'notificationClick') return;
- const { userId, roomId, eventId, isInvite } = data as {
+ const { userId, roomId, eventId, isInvite, isReminder } = data as {
userId?: string;
roomId?: string;
eventId?: string;
isInvite?: boolean;
+ isReminder?: boolean;
};
if (userId) setActiveSessionId(userId);
@@ -632,13 +637,18 @@ export function HandleNotificationClick() {
return;
}
+ if (isReminder) {
+ setBookmarksPanelOpen(true);
+ return;
+ }
+
if (!roomId) return;
setPending({ roomId, eventId, targetSessionId: userId });
};
navigator.serviceWorker.addEventListener('message', handleMessage);
return () => navigator.serviceWorker.removeEventListener('message', handleMessage);
- }, [setPending, setActiveSessionId, navigate]);
+ }, [setPending, setActiveSessionId, setBookmarksPanelOpen, navigate]);
return null;
}
diff --git a/src/sw.ts b/src/sw.ts
index 222156e72..a1dbe9ad6 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -108,6 +108,66 @@ 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);
+ // Notify open app tabs so they can clear fired reminders from Matrix account data.
+ // Without this, useReminderSync would push all account-data reminders back to the SW
+ // cache on the next sync, causing the same reminders to fire again.
+ const firedIds = due.map((r) => r.bookmarkId);
+ const openClients = await self.clients.matchAll({ type: 'window' });
+ openClients.forEach((client) => {
+ client.postMessage({ type: 'remindersFired', bookmarkIds: firedIds });
+ });
+ }
+}
+
+// Check for due reminders every minute.
+setInterval(() => checkDueReminders().catch(() => undefined), 60_000);
+
type SessionInfo = {
accessToken: string;
baseUrl: string;
@@ -906,6 +966,7 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
eventId: pushEventId,
isInvite,
isCall,
+ isReminder,
});
// oxlint-disable-next-line no-await-in-loop
await wc.focus();