From 88164973ac1171314ad7a68825848ffb8ffb10b9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:02:35 -0400 Subject: [PATCH 1/2] fix(reminders): clear fired reminders from account data via SW message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After checkDueReminders fires a bookmark reminder notification, the SW persists only the remaining (non-due) reminders to its cache — but useReminderSync would immediately overwrite that cache by pushing the full list from Matrix account data (which hadn't been updated). Fix: the SW now posts a { type: 'remindersFired', bookmarkIds } message to all open window clients after firing. useReminderSync listens for this message and calls clearBookmarkReminder for each fired ID, updating account data so the next syncReminders call pushes the correct subset. --- src/app/features/bookmarks/useReminderSync.ts | 88 +++++++++++++++++++ src/sw.ts | 60 +++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/app/features/bookmarks/useReminderSync.ts 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/sw.ts b/src/sw.ts index 222156e72..53f51c8b7 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; From 7bd1f818c9f7ae3735fcf25d0ec57ab9fcbe6f3e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:36:26 -0400 Subject: [PATCH 2/2] fix(bookmarks): open bookmarks panel on reminder notification tap; minimal purge tombstone - sw.ts: include isReminder in the notificationClick postMessage so the running app can handle reminder taps correctly without falling through to the "no roomId" early return. - ClientNonUIFeatures.tsx: HandleNotificationClick now opens the bookmarks panel (bookmarksPanelAtom) when isReminder is true, instead of doing nothing (previously bailed out at the !roomId guard). - bookmarkRepository.ts: purgeBookmark now writes the minimal tombstone { deleted: true, purged: true } instead of spreading the original bookmark content. The original room/event metadata no longer lingers in account data after a permanent archive deletion. --- src/app/components/AccountDataEditor.tsx | 11 +- .../features/bookmarks/bookmarkRepository.ts | 223 ++++++++++++++++++ .../developer-tools/DevelopTools.tsx | 14 ++ .../settings/developer-tools/DevelopTools.tsx | 15 ++ src/app/pages/client/ClientNonUIFeatures.tsx | 14 +- src/sw.ts | 1 + 6 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 src/app/features/bookmarks/bookmarkRepository.ts 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/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 53f51c8b7..a1dbe9ad6 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -966,6 +966,7 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { eventId: pushEventId, isInvite, isCall, + isReminder, }); // oxlint-disable-next-line no-await-in-loop await wc.focus();