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();