From 2e8aa55f066950d4361368c8870ec4d9a4fe6e39 Mon Sep 17 00:00:00 2001
From: Ognjen Divljak <ogilud@gmail.com>
Date: Thu, 30 Jul 2026 10:39:07 +0200
Subject: [PATCH] feat(desktop): add message bookmarks + Saved view
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Adds a "Save for later" bookmark toggle to the message hover toolbar
(between react and reply) and a top-level "Saved" left-nav view listing
bookmarked messages with jump-to-context.

Bookmarks are stored as a private, per-user NIP-78 (kind 30078) app-data
list with d-tag "bookmarks", encrypted to self via nip44 — mirroring the
existing channel-stars sync engine. No relay/backend change required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Ognjen Divljak <ogilud@gmail.com>
---
 desktop/src/app/AppShell.helpers.ts           |  10 +-
 desktop/src/app/AppShell.tsx                  |   9 +-
 .../src/app/navigation/useAppNavigation.ts    |  11 +
 desktop/src/app/routeTree.gen.ts              |  21 ++
 desktop/src/app/routes.ts                     |   1 +
 desktop/src/app/routes/saved.tsx              |  21 ++
 .../bookmarks/lib/BookmarksContext.tsx        |  80 +++++++
 .../bookmarks/lib/bookmarksStorage.test.mjs   | 139 +++++++++++
 .../bookmarks/lib/bookmarksStorage.ts         | 149 ++++++++++++
 .../features/bookmarks/lib/bookmarksSync.ts   | 217 +++++++++++++++++
 .../features/bookmarks/lib/useBookmarks.ts    | 219 ++++++++++++++++++
 .../src/features/bookmarks/ui/SavedScreen.tsx |  82 +++++++
 .../src/features/home/ui/InboxMessageRow.tsx  |  20 +-
 .../features/messages/ui/MessageActionBar.tsx |  39 ++++
 .../src/features/messages/ui/MessageRow.tsx   |  17 ++
 .../src/features/sidebar/ui/AppSidebar.tsx    |   6 +-
 .../sidebar/ui/AppSidebarPinnedHeader.tsx     |  19 +-
 desktop/src/shared/constants/kinds.ts         |   4 +-
 18 files changed, 1057 insertions(+), 7 deletions(-)
 create mode 100644 desktop/src/app/routes/saved.tsx
 create mode 100644 desktop/src/features/bookmarks/lib/BookmarksContext.tsx
 create mode 100644 desktop/src/features/bookmarks/lib/bookmarksStorage.test.mjs
 create mode 100644 desktop/src/features/bookmarks/lib/bookmarksStorage.ts
 create mode 100644 desktop/src/features/bookmarks/lib/bookmarksSync.ts
 create mode 100644 desktop/src/features/bookmarks/lib/useBookmarks.ts
 create mode 100644 desktop/src/features/bookmarks/ui/SavedScreen.tsx

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}
                           >
                             <BuzzTheme.ContentSurface>
-                              <Outlet />
+                              <BookmarksProvider value={bookmarks}>
+                                <Outlet />
+                              </BookmarksProvider>
                             </BuzzTheme.ContentSurface>
                           </SidebarInset>
                         </MainInsetProvider>
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 (
+    <React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
+      <SavedScreen />
+    </React.Suspense>
+  );
+}
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<BookmarkActions>(DISABLED_ACTIONS);
+const SavedEntriesContext = React.createContext<SavedEntries>([]);
+
+export function BookmarksProvider({
+  value,
+  children,
+}: {
+  value: UseBookmarks;
+  children: React.ReactNode;
+}) {
+  const actions = React.useMemo<BookmarkActions>(
+    () => ({
+      enabled: true,
+      isBookmarked: value.isBookmarked,
+      toggleBookmark: value.toggleBookmark,
+    }),
+    [value.isBookmarked, value.toggleBookmark],
+  );
+  return (
+    <BookmarkActionsContext.Provider value={actions}>
+      <SavedEntriesContext.Provider value={value.savedEntries}>
+        {children}
+      </SavedEntriesContext.Provider>
+    </BookmarkActionsContext.Provider>
+  );
+}
+
+/** 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<string, BookmarkEntry>;
+};
+
+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<string, unknown>;
+  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<string, unknown>;
+  if (obj.version !== 1) return null;
+  const bookmarks: Record<string, BookmarkEntry> =
+    typeof obj.bookmarks === "object" &&
+    obj.bookmarks !== null &&
+    !Array.isArray(obj.bookmarks)
+      ? Object.fromEntries(
+          Object.entries(obj.bookmarks as Record<string, unknown>)
+            .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<string, BookmarkEntry> = {};
+  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<string> {
+  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<RemoteBookmarks | null> {
+  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<RemoteBookmarks | null> {
+    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<BookmarkStore> {
+    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<void> {
+    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<void>> {
+    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<string>;
+  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<BookmarkStore>(() => {
+    if (!pubkey) return DEFAULT_STORE;
+    return readBookmarkStore(pubkey);
+  });
+
+  const managerRef = React.useRef<BookmarkSyncManager | null>(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<void>) | 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 (
+    <div className="flex h-full min-h-0 flex-col">
+      <header className="flex shrink-0 items-center gap-2 border-b border-border/60 px-4 py-3">
+        <Bookmark className="h-4 w-4 text-muted-foreground" />
+        <h1 className="text-base font-semibold">Saved</h1>
+        {savedEntries.length > 0 ? (
+          <span className="text-2xs tabular-nums text-muted-foreground">
+            {savedEntries.length}
+          </span>
+        ) : null}
+      </header>
+
+      {savedEntries.length === 0 ? (
+        <div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
+          <Bookmark className="h-8 w-8 text-muted-foreground/50" />
+          <p className="text-base font-medium">Nothing saved yet</p>
+          <p className="max-w-xs text-sm text-muted-foreground">
+            Hover any message and tap the bookmark icon to save it for later.
+            Your saved messages are private to you.
+          </p>
+        </div>
+      ) : (
+        <div className="min-h-0 flex-1 overflow-y-auto py-2">
+          {savedEntries.map(({ eventId, entry }) => (
+            <button
+              className={cn(
+                "flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors",
+                "hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none",
+              )}
+              key={eventId}
+              onClick={() => {
+                void goChannel(entry.channelId, {
+                  messageId: eventId,
+                  threadRootId: entry.threadRootId ?? null,
+                });
+              }}
+              type="button"
+            >
+              <UserAvatar
+                avatarUrl={null}
+                displayName={entry.authorName ?? "Unknown"}
+                size="sm"
+              />
+              <div className="min-w-0 flex-1">
+                <div className="flex items-baseline gap-2">
+                  <span className="truncate text-base font-medium">
+                    {entry.authorName ?? "Unknown"}
+                  </span>
+                  {entry.createdAt ? (
+                    <span className="shrink-0 text-2xs tabular-nums text-muted-foreground">
+                      {formatFullDateTime(entry.createdAt)}
+                    </span>
+                  ) : null}
+                </div>
+                <p className="line-clamp-2 text-sm text-muted-foreground">
+                  {entry.preview?.trim() || "(no preview)"}
+                </p>
+              </div>
+            </button>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
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 ? (
           <div
             className={cn(
               "absolute right-2 top-1 z-10",
@@ -126,6 +140,10 @@ export function InboxMessageRow({
             <MessageActionBar
               channelId={channelId}
               message={timelineMessage}
+              isBookmarked={
+                canBookmark && bookmarks.isBookmarked(timelineMessage.id)
+              }
+              onBookmark={canBookmark ? handleBookmark : undefined}
               onReactionSelect={
                 canToggleReactions ? handleReactionSelect : undefined
               }
diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx
index ba45bc62fb..caf680b208 100644
--- a/desktop/src/features/messages/ui/MessageActionBar.tsx
+++ b/desktop/src/features/messages/ui/MessageActionBar.tsx
@@ -1,6 +1,7 @@
 import {
   BellOff,
   BellRing,
+  Bookmark,
   Clock,
   Copy,
   CornerUpLeft,
@@ -377,6 +378,8 @@ export const MessageActionBar = React.memo(function MessageActionBar({
   onReactionSelect,
   onRemindLater,
   onReply,
+  onBookmark,
+  isBookmarked = false,
   onUnfollowThread,
   reactionErrorMessage = null,
   reactions,
@@ -396,6 +399,10 @@ export const MessageActionBar = React.memo(function MessageActionBar({
   onReactionSelect?: (emoji: string) => Promise<void>;
   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({
             </Popover>
           ) : null}
 
+          {hasBookmarkAction ? (
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <Button
+                  aria-label={
+                    isBookmarked ? "Remove bookmark" : "Save for later"
+                  }
+                  aria-pressed={isBookmarked}
+                  className={ACTION_BUTTON_CLASS}
+                  data-testid={`bookmark-message-${message.id}`}
+                  onClick={() => {
+                    onBookmark?.(message);
+                  }}
+                  size="sm"
+                  type="button"
+                  variant={isBookmarked ? "secondary" : "ghost"}
+                >
+                  <Bookmark
+                    className={cn(
+                      ACTION_ICON_CLASS,
+                      isBookmarked && "fill-current",
+                    )}
+                  />
+                </Button>
+              </TooltipTrigger>
+              <TooltipContent>
+                {isBookmarked ? "Remove bookmark" : "Save for later"}
+              </TooltipContent>
+            </Tooltip>
+          ) : null}
+
           {hasReplyAction ? (
             <Tooltip>
               <TooltipTrigger asChild>
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 = (
       <div
         className={cn(
@@ -478,6 +493,8 @@ export const MessageRow = React.memo(
       >
         <MessageActionBar
           channelId={channelId}
+          isBookmarked={canBookmark && bookmarks.isBookmarked(message.id)}
+          onBookmark={canBookmark ? handleBookmark : undefined}
           isFollowingThread={isFollowingThread}
           isUnread={isUnread}
           message={message}
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 55f467f215..bc6bd52212 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -105,7 +105,8 @@ type AppSidebarProps = {
     | "agents"
     | "workflows"
     | "pulse"
-    | "projects";
+    | "projects"
+    | "saved";
   unreadChannelCounts: ReadonlyMap<string, number>;
   unreadChannelIds: ReadonlySet<string>;
   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<string, string>;
@@ -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({
             </SidebarMenuBadge>
           ) : null}
         </SidebarMenuItem>
+        <SidebarMenuItem>
+          <SidebarMenuButton
+            data-testid="open-saved-view"
+            isActive={selectedView === "saved"}
+            onClick={onSelectSaved}
+            tooltip="Saved"
+            type="button"
+          >
+            <Bookmark className="h-4 w-4" />
+            <SidebarMenuLabel>Saved</SidebarMenuLabel>
+          </SidebarMenuButton>
+        </SidebarMenuItem>
         <FeatureGate feature="pulse">
           <SidebarMenuItem>
             <SidebarMenuButton
diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts
index ef3234f4c5..a366943a63 100644
--- a/desktop/src/shared/constants/kinds.ts
+++ b/desktop/src/shared/constants/kinds.ts
@@ -40,12 +40,14 @@ export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101;
 export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102;
 export const KIND_HUDDLE_ENDED = 48103;
 // NIP-78 application-specific data. All use kind 30078; the relay
-// differentiates them by d-tag ("read-state:<slotId>", "channel-sections", "channel-mutes", "channel-stars", "channel-sort").
+// differentiates them by d-tag ("read-state:<slotId>", "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.
