diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index b0ce894931..fef1a7ee43 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -9,7 +9,8 @@ export type AppView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "saved"; const WINDOW_DRAG_HANDLE_HEIGHT = 44; const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region"; @@ -153,6 +154,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/saved") { + return { + selectedChannelId: null, + selectedView: "saved", + }; + } + return { selectedChannelId: null, selectedView: "home", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 75f57257cc..78aac9483e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -72,6 +72,8 @@ import { requestFocusedThreadClose } from "@/features/channels/focusedThreadClos import { CommunityRail } from "@/features/sidebar/ui/CommunityRail"; import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; +import { BookmarksProvider } from "@/features/bookmarks/lib/BookmarksContext"; +import { useBookmarks } from "@/features/bookmarks/lib/useBookmarks"; import { useCommunities } from "@/features/communities/useCommunities"; import { consumePendingCommunityRestore, @@ -129,6 +131,7 @@ export function AppShell() { goNewMessage, goProjects, goPulse, + goSaved, goSettings, goWorkflows, closeSettings, @@ -167,6 +170,7 @@ export function AppShell() { const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, ); + const bookmarks = useBookmarks(identityQuery.data?.pubkey); usePersonaSync( identityQuery.data?.pubkey, communitiesHook.activeCommunity?.relayUrl, @@ -884,6 +888,7 @@ export function AppShell() { onSelectHome={() => void goHome()} onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} + onSelectSaved={() => void goSaved()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} onSetPresenceStatus={(status) => @@ -926,7 +931,9 @@ export function AppShell() { style={chromeCssVarDefaults as React.CSSProperties} > - + + + diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f928970610..19e8c98da8 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -78,6 +78,16 @@ export function useAppNavigation() { ), [commitNavigation], ); + const goSaved = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/saved", + }, + behavior, + ), + [commitNavigation], + ); const goProjects = React.useCallback( (behavior?: NavigationBehavior) => @@ -303,6 +313,7 @@ export function useAppNavigation() { goProject, goProjects, goPulse, + goSaved, goSettings, goWorkflow, goWorkflows, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6..223496b6ae 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -7,6 +7,7 @@ import { Route as rootRouteImport } from "./routes/root"; import { Route as workflowsRouteImport } from "./routes/workflows"; import { Route as settingsRouteImport } from "./routes/settings"; +import { Route as savedRouteImport } from "./routes/saved"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; @@ -28,6 +29,11 @@ const settingsRoute = settingsRouteImport.update({ path: "/settings", getParentRoute: () => rootRouteImport, } as any); +const savedRoute = savedRouteImport.update({ + id: "/saved", + path: "/saved", + getParentRoute: () => rootRouteImport, +} as any); const remindersRoute = remindersRouteImport.update({ id: "/reminders", path: "/reminders", @@ -86,6 +92,7 @@ export interface FileRoutesByFullPath { "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; + "/saved": typeof savedRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; @@ -100,6 +107,7 @@ export interface FileRoutesByTo { "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; + "/saved": typeof savedRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; @@ -115,6 +123,7 @@ export interface FileRoutesById { "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; + "/saved": typeof savedRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; @@ -131,6 +140,7 @@ export interface FileRouteTypes { | "/projects" | "/pulse" | "/reminders" + | "/saved" | "/settings" | "/workflows" | "/channels/$channelId" @@ -145,6 +155,7 @@ export interface FileRouteTypes { | "/projects" | "/pulse" | "/reminders" + | "/saved" | "/settings" | "/workflows" | "/channels/$channelId" @@ -159,6 +170,7 @@ export interface FileRouteTypes { | "/projects" | "/pulse" | "/reminders" + | "/saved" | "/settings" | "/workflows" | "/channels/$channelId" @@ -174,6 +186,7 @@ export interface RootRouteChildren { projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; + savedRoute: typeof savedRoute; settingsRoute: typeof settingsRoute; workflowsRoute: typeof workflowsRoute; channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute; @@ -199,6 +212,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof settingsRouteImport; parentRoute: typeof rootRouteImport; }; + "/saved": { + id: "/saved"; + path: "/saved"; + fullPath: "/saved"; + preLoaderRoute: typeof savedRouteImport; + parentRoute: typeof rootRouteImport; + }; "/reminders": { id: "/reminders"; path: "/reminders"; @@ -278,6 +298,7 @@ const rootRouteChildren: RootRouteChildren = { projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, + savedRoute: savedRoute, settingsRoute: settingsRoute, workflowsRoute: workflowsRoute, channelsDotchannelIdRoute: channelsDotchannelIdRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11..14813879ba 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -4,6 +4,7 @@ export const routes = rootRoute("root.tsx", [ index("index.tsx"), route("/agents", "agents.tsx"), route("/pulse", "pulse.tsx"), + route("/saved", "saved.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), route("/workflows", "workflows.tsx"), diff --git a/desktop/src/app/routes/saved.tsx b/desktop/src/app/routes/saved.tsx new file mode 100644 index 0000000000..fa5c30f330 --- /dev/null +++ b/desktop/src/app/routes/saved.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const SavedScreen = React.lazy(async () => { + const module = await import("@/features/bookmarks/ui/SavedScreen"); + return { default: module.SavedScreen }; +}); + +export const Route = createFileRoute("/saved")({ + component: SavedRouteComponent, +}); + +function SavedRouteComponent() { + return ( + }> + + + ); +} diff --git a/desktop/src/features/bookmarks/lib/BookmarksContext.tsx b/desktop/src/features/bookmarks/lib/BookmarksContext.tsx new file mode 100644 index 0000000000..72bbd31001 --- /dev/null +++ b/desktop/src/features/bookmarks/lib/BookmarksContext.tsx @@ -0,0 +1,80 @@ +import * as React from "react"; + +import type { TimelineMessage } from "@/features/messages/types"; +import { PREVIEW_MAX_LENGTH } from "./bookmarksStorage"; +import type { BookmarkTarget, UseBookmarks } from "./useBookmarks"; + +/** + * The per-message toggle surface consumed by every message row. Split out from + * the saved-list channel so that a no-op remote sync (which rebuilds the saved + * array) never re-renders the timeline — only a genuine membership change to + * `isBookmarked` does. `enabled` is false when no provider is mounted so + * consumers can render standalone (tests, pre-auth). + */ +export type BookmarkActions = { + enabled: boolean; + isBookmarked: (eventId: string) => boolean; + toggleBookmark: (target: BookmarkTarget) => void; +}; + +type SavedEntries = UseBookmarks["savedEntries"]; + +const DISABLED_ACTIONS: BookmarkActions = { + enabled: false, + isBookmarked: () => false, + toggleBookmark: () => {}, +}; + +const BookmarkActionsContext = + React.createContext(DISABLED_ACTIONS); +const SavedEntriesContext = React.createContext([]); + +export function BookmarksProvider({ + value, + children, +}: { + value: UseBookmarks; + children: React.ReactNode; +}) { + const actions = React.useMemo( + () => ({ + enabled: true, + isBookmarked: value.isBookmarked, + toggleBookmark: value.toggleBookmark, + }), + [value.isBookmarked, value.toggleBookmark], + ); + return ( + + + {children} + + + ); +} + +/** Timeline rows: the stable toggle + membership check. */ +export function useBookmarkActions(): BookmarkActions { + return React.useContext(BookmarkActionsContext); +} + +/** The Saved view: the current user's bookmarked messages, newest-first. */ +export function useSavedBookmarks(): SavedEntries { + return React.useContext(SavedEntriesContext); +} + +/** Build the persisted snapshot for a timeline message in a given channel. */ +export function messageBookmarkTarget( + message: TimelineMessage, + channelId: string, +): BookmarkTarget { + return { + eventId: message.id, + channelId, + authorPubkey: message.pubkey, + authorName: message.author, + preview: message.body?.slice(0, PREVIEW_MAX_LENGTH), + createdAt: message.createdAt, + threadRootId: message.rootId ?? undefined, + }; +} diff --git a/desktop/src/features/bookmarks/lib/bookmarksStorage.test.mjs b/desktop/src/features/bookmarks/lib/bookmarksStorage.test.mjs new file mode 100644 index 0000000000..ab1be30000 --- /dev/null +++ b/desktop/src/features/bookmarks/lib/bookmarksStorage.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bookmarkedIdsFromStore, + mergeStores, + parseBookmarkPayload, + PREVIEW_MAX_LENGTH, + savedEntriesFromStore, +} from "./bookmarksStorage.ts"; + +function entry(overrides = {}) { + return { + bookmarked: true, + updatedAt: 100, + channelId: "chan-1", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// parseBookmarkPayload — defensive parsing of untrusted (decrypted) JSON +// --------------------------------------------------------------------------- + +test("parseBookmarkPayload: rejects non-objects and wrong version", () => { + assert.equal(parseBookmarkPayload(null), null); + assert.equal(parseBookmarkPayload(42), null); + assert.equal(parseBookmarkPayload({ version: 2, bookmarks: {} }), null); +}); + +test("parseBookmarkPayload: keeps valid entries, drops malformed ones", () => { + const store = parseBookmarkPayload({ + version: 1, + bookmarks: { + good: entry(), + tombstone: entry({ bookmarked: false, updatedAt: 200 }), + missingChannel: { bookmarked: true, updatedAt: 5 }, + badUpdatedAt: { bookmarked: true, updatedAt: -1, channelId: "c" }, + notBool: { bookmarked: "yes", updatedAt: 5, channelId: "c" }, + }, + }); + assert.deepEqual(Object.keys(store.bookmarks).sort(), ["good", "tombstone"]); +}); + +test("parseBookmarkPayload: missing/invalid bookmarks map yields empty store", () => { + assert.deepEqual(parseBookmarkPayload({ version: 1 }), { + version: 1, + bookmarks: {}, + }); + assert.deepEqual(parseBookmarkPayload({ version: 1, bookmarks: [] }), { + version: 1, + bookmarks: {}, + }); +}); + +test("parseBookmarkPayload: keeps threadRootId, drops empty string", () => { + const store = parseBookmarkPayload({ + version: 1, + bookmarks: { + reply: entry({ threadRootId: "root-abc" }), + empty: entry({ threadRootId: "" }), + }, + }); + assert.equal(store.bookmarks.reply.threadRootId, "root-abc"); + assert.equal(store.bookmarks.empty.threadRootId, undefined); +}); + +test("parseBookmarkPayload: truncates oversized previews", () => { + const long = "x".repeat(PREVIEW_MAX_LENGTH + 50); + const store = parseBookmarkPayload({ + version: 1, + bookmarks: { a: entry({ preview: long }) }, + }); + assert.equal(store.bookmarks.a.preview.length, PREVIEW_MAX_LENGTH); +}); + +// --------------------------------------------------------------------------- +// mergeStores — per-key last-write-wins by updatedAt +// --------------------------------------------------------------------------- + +test("mergeStores: newer updatedAt wins per key; keys union", () => { + const local = { + version: 1, + bookmarks: { + shared: entry({ bookmarked: true, updatedAt: 100 }), + localOnly: entry({ updatedAt: 10 }), + }, + }; + const remote = { + version: 1, + bookmarks: { + shared: entry({ bookmarked: false, updatedAt: 200 }), + remoteOnly: entry({ updatedAt: 20 }), + }, + }; + const merged = mergeStores(local, remote); + // Remote's newer tombstone wins for the shared key. + assert.equal(merged.bookmarks.shared.bookmarked, false); + assert.equal(merged.bookmarks.shared.updatedAt, 200); + assert.ok(merged.bookmarks.localOnly); + assert.ok(merged.bookmarks.remoteOnly); +}); + +test("mergeStores: equal updatedAt keeps local (>=)", () => { + const local = { version: 1, bookmarks: { a: entry({ channelId: "L" }) } }; + const remote = { version: 1, bookmarks: { a: entry({ channelId: "R" }) } }; + assert.equal(mergeStores(local, remote).bookmarks.a.channelId, "L"); +}); + +// --------------------------------------------------------------------------- +// selectors — tombstones excluded, savedEntries newest-first +// --------------------------------------------------------------------------- + +test("bookmarkedIdsFromStore: excludes tombstones", () => { + const store = { + version: 1, + bookmarks: { + on: entry({ bookmarked: true }), + off: entry({ bookmarked: false }), + }, + }; + const ids = bookmarkedIdsFromStore(store); + assert.ok(ids.has("on")); + assert.ok(!ids.has("off")); +}); + +test("savedEntriesFromStore: newest-first, tombstones excluded", () => { + const store = { + version: 1, + bookmarks: { + older: entry({ updatedAt: 100 }), + newer: entry({ updatedAt: 300 }), + middle: entry({ updatedAt: 200 }), + removed: entry({ bookmarked: false, updatedAt: 999 }), + }, + }; + const order = savedEntriesFromStore(store).map((e) => e.eventId); + assert.deepEqual(order, ["newer", "middle", "older"]); +}); diff --git a/desktop/src/features/bookmarks/lib/bookmarksStorage.ts b/desktop/src/features/bookmarks/lib/bookmarksStorage.ts new file mode 100644 index 0000000000..1992a04ebe --- /dev/null +++ b/desktop/src/features/bookmarks/lib/bookmarksStorage.ts @@ -0,0 +1,149 @@ +const STORAGE_KEY_PREFIX = "buzz-bookmarks.v1"; + +/** + * A single saved message. `bookmarked: false` is a tombstone kept in the store + * so that un-bookmarking merges last-write-wins across devices (identical to the + * channel-stars `starred` flag). The snapshot fields are denormalized so the + * Saved view can render an entry without refetching the original message (and + * survive the message later becoming inaccessible). + */ +export type BookmarkEntry = { + bookmarked: boolean; + updatedAt: number; + /** Channel UUID the message lives in — for jump-to-context + refetch. */ + channelId: string; + /** Denormalized snapshot captured at save time. */ + authorPubkey?: string; + authorName?: string; + preview?: string; + createdAt?: number; + /** Thread root id when the saved message is a reply — for jump-to-context. */ + threadRootId?: string; +}; + +export type BookmarkStore = { + version: 1; + bookmarks: Record; +}; + +export const DEFAULT_STORE: BookmarkStore = Object.freeze({ + version: 1, + bookmarks: {}, +}); + +/** Longest preview we persist; keeps the encrypted blob small. */ +export const PREVIEW_MAX_LENGTH = 280; + +export function storageKey(pubkey: string): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}`; +} + +function isNonNegativeFinite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function parseEntry(value: unknown): BookmarkEntry | null { + if (typeof value !== "object" || value === null) return null; + const v = value as Record; + if (typeof v.bookmarked !== "boolean") return null; + if (!isNonNegativeFinite(v.updatedAt)) return null; + if (typeof v.channelId !== "string" || v.channelId.length === 0) return null; + const entry: BookmarkEntry = { + bookmarked: v.bookmarked, + updatedAt: v.updatedAt, + channelId: v.channelId, + }; + if (typeof v.authorPubkey === "string") entry.authorPubkey = v.authorPubkey; + if (typeof v.authorName === "string") entry.authorName = v.authorName; + if (typeof v.preview === "string") { + entry.preview = v.preview.slice(0, PREVIEW_MAX_LENGTH); + } + if (isNonNegativeFinite(v.createdAt)) entry.createdAt = v.createdAt; + if (typeof v.threadRootId === "string" && v.threadRootId.length > 0) { + entry.threadRootId = v.threadRootId; + } + return entry; +} + +export function parseBookmarkPayload(json: unknown): BookmarkStore | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + if (obj.version !== 1) return null; + const bookmarks: Record = + typeof obj.bookmarks === "object" && + obj.bookmarks !== null && + !Array.isArray(obj.bookmarks) + ? Object.fromEntries( + Object.entries(obj.bookmarks as Record) + .map(([id, value]) => [id, parseEntry(value)] as const) + .filter( + (entry): entry is [string, BookmarkEntry] => entry[1] !== null, + ), + ) + : {}; + return { version: 1, bookmarks }; +} + +export function readBookmarkStore(pubkey: string): BookmarkStore { + try { + const raw = window.localStorage.getItem(storageKey(pubkey)); + if (!raw) return DEFAULT_STORE; + const parsed = JSON.parse(raw); + return parseBookmarkPayload(parsed) ?? DEFAULT_STORE; + } catch { + return DEFAULT_STORE; + } +} + +export function writeBookmarkStore( + pubkey: string, + store: BookmarkStore, +): boolean { + try { + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store)); + return true; + } catch { + return false; + } +} + +/** Per-key last-write-wins by `updatedAt` (mirror of channel-stars merge). */ +export function mergeStores( + local: BookmarkStore, + remote: BookmarkStore, +): BookmarkStore { + const allIds = new Set([ + ...Object.keys(local.bookmarks), + ...Object.keys(remote.bookmarks), + ]); + const merged: Record = {}; + for (const id of allIds) { + const l = local.bookmarks[id]; + const r = remote.bookmarks[id]; + if (l && r) { + merged[id] = l.updatedAt >= r.updatedAt ? l : r; + } else { + merged[id] = (l ?? r) as BookmarkEntry; + } + } + return { version: 1, bookmarks: merged }; +} + +/** Event ids of currently-saved messages (tombstones excluded). */ +export function bookmarkedIdsFromStore(store: BookmarkStore): Set { + return new Set( + Object.entries(store.bookmarks) + .filter(([, entry]) => entry.bookmarked) + .map(([id]) => id), + ); +} + +/** Saved entries, newest-first, tombstones excluded — for the Saved view. */ +export function savedEntriesFromStore( + store: BookmarkStore, +): Array<{ eventId: string; entry: BookmarkEntry }> { + return Object.entries(store.bookmarks) + .filter(([, entry]) => entry.bookmarked) + .map(([eventId, entry]) => ({ eventId, entry })) + .sort((a, b) => b.entry.updatedAt - a.entry.updatedAt); +} diff --git a/desktop/src/features/bookmarks/lib/bookmarksSync.ts b/desktop/src/features/bookmarks/lib/bookmarksSync.ts new file mode 100644 index 0000000000..003789ce6a --- /dev/null +++ b/desktop/src/features/bookmarks/lib/bookmarksSync.ts @@ -0,0 +1,217 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_BOOKMARKS } from "@/shared/constants/kinds"; +import { + mergeStores, + parseBookmarkPayload, + type BookmarkStore, +} from "./bookmarksStorage"; + +const D_TAG = "bookmarks"; +const DEBOUNCE_MS = 2_000; + +export type RemoteBookmarks = { + store: BookmarkStore; + createdAt: number; + eventId: string; +}; + +async function decryptAndParse( + event: RelayEvent, +): Promise { + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const store = parseBookmarkPayload(JSON.parse(plaintext)); + if (!store) return null; + return { store, createdAt: event.created_at, eventId: event.id }; + } catch { + return null; + } +} + +/** + * Per-user private bookmark list synced as an encrypted NIP-78 (kind 30078) + * replaceable event, d-tag "bookmarks". Structural clone of + * `ChannelStarSyncManager` — debounced encrypted publish with a + * fetch-merge-before-publish guard so concurrent devices don't clobber, plus a + * live subscription for cross-device updates. + */ +export class BookmarkSyncManager { + private pubkey: string; + private debounceTimer: number | null = null; + private lastRemoteCreatedAt = 0; + private pendingStore: BookmarkStore | null = null; + private lastPublishedStore: BookmarkStore | null = null; + + constructor(pubkey: string) { + this.pubkey = pubkey; + } + + async fetchRemoteBookmarks(): Promise { + try { + const events = await relayClient.fetchEvents({ + kinds: [KIND_BOOKMARKS], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0) return null; + if (events[0].pubkey !== this.pubkey) return null; + const result = await decryptAndParse(events[0]); + if (result) { + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + result.createdAt, + ); + } + return result; + } catch { + return null; + } + } + + cancelPendingBookmarkPublish(): void { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + } + + getPendingBookmarkStore(): BookmarkStore | null { + return this.pendingStore; + } + + publishBookmarks(store: BookmarkStore): void { + this.pendingStore = store; + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + void this.doPublish(store); + }, DEBOUNCE_MS); + } + + private async fetchOwnBlobBeforePublish( + store: BookmarkStore, + ): Promise { + try { + const events = await relayClient.fetchEvents({ + kinds: [KIND_BOOKMARKS], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; + const remote = await decryptAndParse(events[0]); + if (!remote) return store; + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + remote.createdAt, + ); + return mergeStores(store, remote.store); + } catch { + return store; + } + } + + private isIdenticalToLastPublished(store: BookmarkStore): boolean { + if (!this.lastPublishedStore) return false; + const lastKeys = Object.keys(this.lastPublishedStore.bookmarks); + const currentKeys = Object.keys(store.bookmarks); + if (lastKeys.length !== currentKeys.length) return false; + for (const key of currentKeys) { + const last = this.lastPublishedStore.bookmarks[key]; + const current = store.bookmarks[key]; + if ( + !last || + last.bookmarked !== current.bookmarked || + last.updatedAt !== current.updatedAt + ) + return false; + } + return true; + } + + private async doPublish(store: BookmarkStore): Promise { + try { + const merged = await this.fetchOwnBlobBeforePublish(store); + if (this.isIdenticalToLastPublished(merged)) { + this.pendingStore = null; + return; + } + const payload = { + version: 1, + bookmarks: merged.bookmarks, + }; + const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); + const createdAt = Math.max( + Math.floor(Date.now() / 1_000), + this.lastRemoteCreatedAt + 1, + ); + const event = await signRelayEvent({ + kind: KIND_BOOKMARKS, + content: ciphertext, + createdAt, + tags: [ + ["d", D_TAG], + ["t", D_TAG], // relay discoverability; not used in our filters + ], + }); + await relayClient.publishEvent( + event, + "Timed out publishing bookmarks.", + "Failed to publish bookmarks.", + ); + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + event.created_at, + ); + this.lastPublishedStore = merged; + this.pendingStore = null; + } catch (error) { + console.warn("[bookmarksSync] publish failed:", error); + } + } + + async subscribeToBookmarks( + onUpdate: (remote: RemoteBookmarks) => void, + ): Promise<() => Promise> { + return relayClient.subscribeLive( + { + kinds: [KIND_BOOKMARKS], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 0, + }, + (event: RelayEvent) => { + if (event.pubkey !== this.pubkey) return; + void decryptAndParse(event).then((result) => { + if (result) { + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + result.createdAt, + ); + onUpdate(result); + } + }); + }, + ); + } + + destroy(): void { + if (this.debounceTimer !== null && this.pendingStore !== null) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = null; + void this.doPublish(this.pendingStore); + } else if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + } +} diff --git a/desktop/src/features/bookmarks/lib/useBookmarks.ts b/desktop/src/features/bookmarks/lib/useBookmarks.ts new file mode 100644 index 0000000000..6d3c69121b --- /dev/null +++ b/desktop/src/features/bookmarks/lib/useBookmarks.ts @@ -0,0 +1,219 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import { useStableSet } from "@/shared/hooks/useStableReference"; +import { + bookmarkedIdsFromStore, + DEFAULT_STORE, + mergeStores, + PREVIEW_MAX_LENGTH, + readBookmarkStore, + savedEntriesFromStore, + storageKey, + writeBookmarkStore, + type BookmarkEntry, + type BookmarkStore, +} from "./bookmarksStorage"; +import { BookmarkSyncManager } from "./bookmarksSync"; +import type { RemoteBookmarks } from "./bookmarksSync"; + +/** Snapshot a caller supplies when saving a message. */ +export type BookmarkTarget = { + eventId: string; + channelId: string; + authorPubkey?: string; + authorName?: string; + preview?: string; + createdAt?: number; + /** Thread root id when the message is a reply — enables jump-to-context to + * open the thread it lives in, not just the channel. */ + threadRootId?: string; +}; + +export type UseBookmarks = { + bookmarkedIds: ReadonlySet; + savedEntries: Array<{ eventId: string; entry: BookmarkEntry }>; + isBookmarked: (eventId: string) => boolean; + toggleBookmark: (target: BookmarkTarget) => void; +}; + +export function useBookmarks(pubkey: string | undefined): UseBookmarks { + const [store, setStore] = React.useState(() => { + if (!pubkey) return DEFAULT_STORE; + return readBookmarkStore(pubkey); + }); + + const managerRef = React.useRef(null); + const lastAppliedRemoteTs = React.useRef(0); + const lastAppliedEventId = React.useRef(""); + + React.useEffect(() => { + if (!pubkey) { + setStore(DEFAULT_STORE); + lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; + return; + } + setStore(readBookmarkStore(pubkey)); + lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; + managerRef.current = new BookmarkSyncManager(pubkey); + return () => { + managerRef.current?.destroy(); + managerRef.current = null; + }; + }, [pubkey]); + + React.useEffect(() => { + if (!pubkey) return; + const key = storageKey(pubkey); + const handler = (e: StorageEvent) => { + if (e.key !== key) return; + setStore(readBookmarkStore(pubkey)); + }; + window.addEventListener("storage", handler); + return () => { + window.removeEventListener("storage", handler); + }; + }, [pubkey]); + + const applyRemote = React.useCallback( + (remote: RemoteBookmarks): ((prev: BookmarkStore) => BookmarkStore) => { + return (prev) => { + if (!pubkey) return prev; + if (remote.createdAt < lastAppliedRemoteTs.current) return prev; + if ( + remote.createdAt === lastAppliedRemoteTs.current && + remote.eventId <= lastAppliedEventId.current + ) + return prev; + lastAppliedRemoteTs.current = remote.createdAt; + lastAppliedEventId.current = remote.eventId; + managerRef.current?.cancelPendingBookmarkPublish(); + const merged = mergeStores(prev, remote.store); + if (!writeBookmarkStore(pubkey, merged)) return prev; + return merged; + }; + }, + [pubkey], + ); + + React.useEffect(() => { + if (!pubkey) return; + let cancelled = false; + void managerRef.current?.fetchRemoteBookmarks().then((remote) => { + if (cancelled) return; + if (remote) { + setStore(applyRemote(remote)); + } else { + const local = readBookmarkStore(pubkey); + if (Object.keys(local.bookmarks).length > 0) { + managerRef.current?.publishBookmarks(local); + } + } + }); + return () => { + cancelled = true; + }; + }, [pubkey, applyRemote]); + + React.useEffect(() => { + if (!pubkey) return; + let unsub: (() => Promise) | null = null; + let cancelled = false; + void managerRef.current + ?.subscribeToBookmarks((remote) => { + if (cancelled) return; + setStore(applyRemote(remote)); + }) + .then((dispose) => { + if (cancelled) { + void dispose(); + } else { + unsub = dispose; + } + }); + return () => { + cancelled = true; + if (unsub) void unsub(); + }; + }, [pubkey, applyRemote]); + + React.useEffect(() => { + if (!pubkey) return; + let cancelled = false; + const unsub = relayClient.subscribeToReconnects(() => { + void managerRef.current?.fetchRemoteBookmarks().then((remote) => { + if (cancelled) return; + if (remote) { + setStore(applyRemote(remote)); + } + const pending = managerRef.current?.getPendingBookmarkStore(); + if (pending) { + managerRef.current?.publishBookmarks(pending); + } + }); + }); + return () => { + cancelled = true; + unsub(); + }; + }, [pubkey, applyRemote]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: store.bookmarks is the relevant dep — the outer store identity can change without bookmarks changing (e.g., on reconnect writes) + const bookmarkedIdsRaw = React.useMemo( + () => bookmarkedIdsFromStore(store), + [store.bookmarks], + ); + // Content-stable: a no-op remote sync/reconnect rebuilds `store.bookmarks` + // with a fresh identity but identical membership — `useStableSet` preserves + // the previous Set so `isBookmarked` (and the actions context that every + // message row consumes) does not churn the timeline. Genuine toggles change + // membership and correctly produce a new identity. + const bookmarkedIds = useStableSet(bookmarkedIdsRaw); + + // biome-ignore lint/correctness/useExhaustiveDependencies: see above + const savedEntries = React.useMemo( + () => savedEntriesFromStore(store), + [store.bookmarks], + ); + + // Stable across toggles (dep is only `pubkey`); the current state is read + // inside the functional updater so rapid double-clicks batch correctly. + const toggleBookmark = React.useCallback( + (target: BookmarkTarget) => { + if (!pubkey) return; + setStore((prev) => { + const currently = prev.bookmarks[target.eventId]?.bookmarked === true; + const entry: BookmarkEntry = { + bookmarked: !currently, + updatedAt: Math.floor(Date.now() / 1000), + channelId: target.channelId, + authorPubkey: target.authorPubkey, + authorName: target.authorName, + preview: target.preview?.slice(0, PREVIEW_MAX_LENGTH), + createdAt: target.createdAt, + threadRootId: target.threadRootId, + }; + const next: BookmarkStore = { + version: 1, + bookmarks: { ...prev.bookmarks, [target.eventId]: entry }, + }; + if (!writeBookmarkStore(pubkey, next)) return prev; + managerRef.current?.publishBookmarks(next); + return next; + }); + }, + [pubkey], + ); + + const isBookmarked = React.useCallback( + (eventId: string) => bookmarkedIds.has(eventId), + [bookmarkedIds], + ); + + return React.useMemo( + () => ({ bookmarkedIds, savedEntries, isBookmarked, toggleBookmark }), + [bookmarkedIds, savedEntries, isBookmarked, toggleBookmark], + ); +} diff --git a/desktop/src/features/bookmarks/ui/SavedScreen.tsx b/desktop/src/features/bookmarks/ui/SavedScreen.tsx new file mode 100644 index 0000000000..9214376993 --- /dev/null +++ b/desktop/src/features/bookmarks/ui/SavedScreen.tsx @@ -0,0 +1,82 @@ +import { Bookmark } from "lucide-react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useSavedBookmarks } from "@/features/bookmarks/lib/BookmarksContext"; +import { formatFullDateTime } from "@/features/messages/lib/dateFormatters"; +import { cn } from "@/shared/lib/cn"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +/** + * "Saved" view — lists the current user's bookmarked messages (newest first) + * with jump-to-context. Reads the private, encrypted bookmark list surfaced by + * the app-level `BookmarksProvider`. + */ +export function SavedScreen() { + const savedEntries = useSavedBookmarks(); + const { goChannel } = useAppNavigation(); + + return ( +
+
+ +

Saved

+ {savedEntries.length > 0 ? ( + + {savedEntries.length} + + ) : null} +
+ + {savedEntries.length === 0 ? ( +
+ +

Nothing saved yet

+

+ Hover any message and tap the bookmark icon to save it for later. + Your saved messages are private to you. +

+
+ ) : ( +
+ {savedEntries.map(({ eventId, entry }) => ( + + ))} +
+ )} +
+ ); +} diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 04deafb3ff..468007e971 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; +import { + messageBookmarkTarget, + useBookmarkActions, +} from "@/features/bookmarks/lib/BookmarksContext"; import type { InboxContextMessage } from "@/features/home/lib/inbox"; import { toTimelineMessage } from "@/features/home/lib/inboxViewHelpers"; import { formatTimeWithoutDayPeriod } from "@/features/messages/lib/dateFormatters"; @@ -70,6 +74,16 @@ export function InboxMessageRow({ errorMessage: reactionErrorMessage, select: handleReactionSelect, } = useReactionHandler(timelineMessage, onToggleReaction); + const bookmarks = useBookmarkActions(); + const canBookmark = + bookmarks.enabled && Boolean(channelId) && !timelineMessage.pending; + const handleBookmark = React.useCallback( + (msg: TimelineMessage) => { + if (!channelId) return; + bookmarks.toggleBookmark(messageBookmarkTarget(msg, channelId)); + }, + [bookmarks, channelId], + ); // "Is this pubkey an agent" = the community-scoped baseline every surface // shares plus this surface's extras passed via `agentPubkeys` (HomeView // folds feed-profile `isAgent` flags in). Mirrors MessageRow's predicate. @@ -116,7 +130,7 @@ export function InboxMessageRow({ : "home-inbox-context-message" } > - {canReply || canToggleReactions ? ( + {canReply || canToggleReactions || canBookmark ? (
Promise; onRemindLater?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + /** Toggle the message's saved/bookmarked state. Hidden when omitted. */ + onBookmark?: (message: TimelineMessage) => void; + /** Whether the message is currently saved — drives the filled icon + label. */ + isBookmarked?: boolean; onUnfollowThread?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; reactions: TimelineReaction[]; @@ -422,6 +429,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ ); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); + const hasBookmarkAction = Boolean(onBookmark); const hasMoreMenuActions = Boolean(onEdit) || @@ -546,6 +554,37 @@ export const MessageActionBar = React.memo(function MessageActionBar({ ) : null} + {hasBookmarkAction ? ( + + + + + + {isBookmarked ? "Remove bookmark" : "Save for later"} + + + ) : null} + {hasReplyAction ? ( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index afcb3e863c..fcb7983bdc 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -39,6 +39,10 @@ import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedB import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; +import { + messageBookmarkTarget, + useBookmarkActions, +} from "@/features/bookmarks/lib/BookmarksContext"; import { MessageActionBar } from "./MessageActionBar"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; @@ -179,6 +183,14 @@ export const MessageRow = React.memo( }, [channelId, openReminder], ); + const bookmarks = useBookmarkActions(); + const handleBookmark = React.useCallback( + (msg: TimelineMessage) => { + if (!channelId) return; + bookmarks.toggleBookmark(messageBookmarkTarget(msg, channelId)); + }, + [bookmarks, channelId], + ); const { mentionNames, mentionPubkeysByName } = React.useMemo( () => resolveMentionProps(message.tags, profiles), [profiles, message.tags], @@ -467,6 +479,9 @@ export const MessageRow = React.memo( /> ) : null; + const canBookmark = + bookmarks.enabled && Boolean(channelId) && !message.pending; + const actionBarNode = (
; unreadChannelIds: ReadonlySet; communities: Community[]; @@ -145,6 +146,7 @@ type AppSidebarProps = { onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; + onSelectSaved: () => void; onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; @@ -214,6 +216,7 @@ export function AppSidebar({ onSelectAgents, onSelectProjects, onSelectPulse, + onSelectSaved, onSelectWorkflows, onSelectHome, onSelectChannel, @@ -612,6 +615,7 @@ export function AppSidebar({ onSelectHome={onSelectHome} onSelectProjects={onSelectProjects} onSelectPulse={onSelectPulse} + onSelectSaved={onSelectSaved} onSelectWorkflows={onSelectWorkflows} selectedView={selectedView} /> diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index a673492ef1..135b887dee 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,4 +1,4 @@ -import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; +import { Activity, Bookmark, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { FeatureGate } from "@/shared/features"; @@ -19,7 +19,8 @@ type SidebarSelectedView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "saved"; type AppSidebarPinnedHeaderProps = { channelLabels: Record; @@ -41,6 +42,7 @@ type AppSidebarPrimaryMenuProps = { onSelectHome: () => void; onSelectProjects: () => void; onSelectPulse: () => void; + onSelectSaved: () => void; onSelectWorkflows: () => void; selectedView: SidebarSelectedView; }; @@ -86,6 +88,7 @@ export function AppSidebarPrimaryMenu({ onSelectHome, onSelectProjects, onSelectPulse, + onSelectSaved, onSelectWorkflows, selectedView, }: AppSidebarPrimaryMenuProps) { @@ -115,6 +118,18 @@ export function AppSidebarPrimaryMenu({ ) : null} + + + + Saved + + ", "channel-sections", "channel-mutes", "channel-stars", "channel-sort"). +// differentiates them by d-tag ("read-state:", "channel-sections", "channel-mutes", "channel-stars", "channel-sort", "bookmarks"). export const KIND_READ_STATE = 30078; export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_CHANNEL_MUTES = 30078; export const KIND_CHANNEL_STARS = 30078; export const KIND_CHANNEL_SORT = 30078; +// Private, per-user "save for later" list (d-tag "bookmarks"), encrypted to self. +export const KIND_BOOKMARKS = 30078; // NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published // backend-side as secrets-stripped snapshots; the inbound sync hook subscribes // to all three to patch local records. Mirror of buzz-core's KIND_PERSONA etc.