Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/app/components/AccountDataEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box
direction="Column"
Expand All @@ -222,6 +223,11 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
<Text size="B400">Edit</Text>
</Button>
{onDelete && (
<Button variant="Critical" fill="Soft" size="400" radii="300" onClick={onDelete}>
<Text size="B400">Delete</Text>
</Button>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">JSON Content</Text>
Expand All @@ -246,13 +252,15 @@ export type AccountDataEditorProps = {
type?: string;
content?: object;
submitChange: AccountDataSubmitCallback;
onDelete?: () => void;
requestClose: () => void;
};

export function AccountDataEditor({
type,
content,
submitChange,
onDelete,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataInfo>({
Expand Down Expand Up @@ -315,6 +323,7 @@ export function AccountDataEditor({
type={data.type}
defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
onDelete={onDelete}
/>
)}
</Box>
Expand Down
223 changes: 223 additions & 0 deletions src/app/features/bookmarks/bookmarkRepository.ts
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 15 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find module './bookmarkDomain' or its corresponding type declarations.
import {
bookmarkItemEventType,
emptyIndex,
isValidBookmarkItem,
isValidIndexContent,
} from './bookmarkDomain';

Check failure on line 21 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find module './bookmarkDomain' or its corresponding type declarations.

// Internal helpers
function readIndex(mx: MatrixClient): BookmarkIndexContent {
const evt = mx.getAccountData(AccountDataEvent.BookmarksIndex as any);

Check failure on line 25 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Property 'BookmarksIndex' does not exist on type '{ readonly CinnySpaces: "in.cinny.spaces"; readonly ElementRecentEmoji: "io.element.recent_emoji"; readonly PoniesUserEmotes: "im.ponies.user_emotes"; readonly PoniesEmoteRooms: "im.ponies.emote_rooms"; readonly SableNicknames: "moe.sable.app.nicknames"; readonly SablePinStatus: "moe.sable.app.pins_read_marker"; rea...'.
const content = evt?.getContent();
if (isValidIndexContent(content)) return content;
return emptyIndex();
}

function readItem(mx: MatrixClient, bookmarkId: string): BookmarkItemContent | undefined {

Check failure on line 31 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(no-redundant-type-constituents)

'BookmarkItemContent' is an 'error' type that acts as 'any' and overrides all other types in this union type.
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;

Check failure on line 35 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

'content' is possibly 'undefined'.
return undefined;
}

async function writeIndex(mx: MatrixClient, index: BookmarkIndexContent): Promise<void> {
await mx.setAccountData(AccountDataEvent.BookmarksIndex as any, index as any);

Check failure on line 40 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Property 'BookmarksIndex' does not exist on type '{ readonly CinnySpaces: "in.cinny.spaces"; readonly ElementRecentEmoji: "io.element.recent_emoji"; readonly PoniesUserEmotes: "im.ponies.user_emotes"; readonly PoniesEmoteRooms: "im.ponies.emote_rooms"; readonly SableNicknames: "moe.sable.app.nicknames"; readonly SablePinStatus: "moe.sable.app.pins_read_marker"; rea...'.
}

async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise<void> {
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<void> {
// 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);

Check warning on line 64 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(no-unnecessary-type-assertion)

This assertion is unnecessary since it does not change the type of the expression.

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<void> {
// 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);

Check failure on line 105 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Parameter 'id' implicitly has an 'any' type.
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<string>();

const items = index.bookmark_ids
.filter((id) => {

Check failure on line 130 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Parameter 'id' implicitly has an 'any' type.
if (seen.has(id)) return false;
seen.add(id);
return true;
})
.map((id) => readItem(mx, id))

Check failure on line 135 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Parameter 'id' implicitly has an 'any' type.
.filter((item): item is BookmarkItemContent => item != null);

Check failure on line 136 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Parameter 'item' implicitly has an 'any' type.

// Walk the in-memory account data store for orphaned item events.
const prefix = AccountDataEvent.BookmarkItemPrefix as string;

Check failure on line 139 in src/app/features/bookmarks/bookmarkRepository.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Property 'BookmarkItemPrefix' does not exist on type '{ readonly CinnySpaces: "in.cinny.spaces"; readonly ElementRecentEmoji: "io.element.recent_emoji"; readonly PoniesUserEmotes: "im.ponies.user_emotes"; readonly PoniesEmoteRooms: "im.ponies.emote_rooms"; readonly SableNicknames: "moe.sable.app.nicknames"; readonly SablePinStatus: "moe.sable.app.pins_read_marker"; rea...'.
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<string>();

// 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<void> {
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);
}
88 changes: 88 additions & 0 deletions src/app/features/bookmarks/useReminderSync.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> };
}
).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<BookmarksRemindersContent>();
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]
)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AccountDataEditor
type={accountDataType ?? undefined}
content={accountDataType ? accountData.get(accountDataType) : undefined}
submitChange={submitAccountData}
onDelete={accountDataType ? () => deleteRoomAccountData(accountDataType) : undefined}
requestClose={handleClose}
/>
);
Expand Down
Loading
Loading