Files
sovereign_browser/plans/bookmarks-tree-view.md

15 KiB

Plan: Nested Bookmark Tree View + Bookmarks Toolbar

Goal

  1. Support nested folders for bookmarks (e.g. Work/Projects/Secret) while staying fully NIP-51 kind 30003 compliant.
  2. Render the sovereign://bookmarks page as an expandable/collapsible tree view.
  3. Add a bookmarks toolbar (a horizontal bar of folder/bookmark buttons) below the URL bar in tab_manager.c.

NIP-51 Compliance + Privacy — How Nesting Works

NIP-51 kind 30003 is parameterized replaceable: each event has a d tag that is an opaque identifier string used by relays to decide which event replaces which. We need the d tag to be:

  1. Opaque — relays must not learn the folder path (e.g. Work/Projects/Secret), because that leaks the user's organizational structure.
  2. Deterministic — the same path must always produce the same d so re-publishing replaces the prior event instead of accumulating duplicates.

Encryption (NIP-4 or NIP-44) cannot satisfy (2): both use a random IV/nonce per call, so encrypting the same plaintext twice yields different ciphertexts. That would break NIP-33 replaceability.

The right tool is a MAC: d = HMAC-SHA256(key, path) is deterministic and one-way. The HMAC key is derived from the user's Nostr privkey so it is per-user, never published, and automatically available on every device the user logs in on:

hmac_key = HMAC-SHA256(privkey_hex, "sovereign-browser/bookmarks-folder-id-v1")
d        = HMAC-SHA256(hmac_key, path)   → 64 hex chars

Event layout (one per folder)

{
  "kind": 30003,
  "pubkey": "<user pubkey hex>",
  "tags":  [["d", "<HMAC-SHA256(hmac_key, path) hex>"]],
  "content": "<NIP-44 encrypted JSON>",
  ...
}

The NIP-44 encrypted content plaintext is:

{"path": "Work/Projects/Secret", "bookmarks": [
  ["bookmark", "https://example.com", "Example", 1690000000]
]}

The real path lives inside the encrypted content — relays see only the HMAC hash and opaque ciphertext. The d tag is used by relays purely for replaceability; the client never interprets it.

Tree reconstruction (client-side)

  1. Fetch all kind=30003 events for the user's pubkey.
  2. For each event: NIP-44 decrypt the content, read path from the plaintext JSON.
  3. Split path on / and walk/create the trie (node_ensure_path).
  4. Attach the decrypted bookmarks array to that leaf node.

Intermediate (empty) folders are represented by empty-bookmarks events for that path, so folder state — including empty folders — is preserved across devices.

Why this is safe and nostr-ish

  • The d tag is defined by NIP-33 as an arbitrary string identifier; a 64-char hex HMAC fits cleanly.
  • Content encryption (NIP-44 self-to-self) is unchanged from the existing implementation in src/bookmarks.c.
  • HMAC-SHA256 is a one-line standard primitive; the key is derived from the user's Nostr privkey, so no extra credentials are needed.
  • Relays learn nothing about folder names, structure, or even folder count (they see only opaque hashes).
  • Existing flat directories (no slashes) load as root-level folders — no migration needed for the in-memory model. Legacy events with plaintext d = "General" will be detected on load (HMAC hex strings are 64 chars of [0-9a-f]; legacy names are not), decrypted, and re-published in the new HMAC-d format; the old events get a kind 5 deletion.
flowchart TD
    A["kind 30003 events in SQLite/relays"] --> B["NIP-44 decrypt each content"]
    B --> C["Read path from plaintext JSON"]
    C --> D["Split path on / to get segments"]
    D --> E["Build trie of folders via node_ensure_path"]
    E --> F["Attach decrypted bookmarks to leaf node"]
    F --> G["Render tree on sovereign://bookmarks page"]
    G --> H["User adds/moves/renames/deletes"]
    H --> I["Compute HMAC d tag for affected path(s)"]
    I --> J["NIP-44 encrypt new content and publish kind 30003"]
    I --> K["Emit kind 5 deletion for old d tags if renamed/moved/deleted"]

Implementation

1. Data model — src/bookmarks.h / src/bookmarks.c

The in-memory model changes from a flat bookmark_dir_t[] to a tree.

New struct

typedef struct bookmark_node {
    char                *name;     /* last segment of path, e.g. "Secret" */
    char                *path;     /* full path, e.g. "Work/Projects/Secret" */
    bookmark_t          *bookmarks;
    int                  bookmark_count;
    struct bookmark_node *children; /* array of child nodes */
    int                  child_count;
    int                  child_cap;
} bookmark_node_t;

The root is a single bookmark_node_t with name = "", path = "", no bookmarks, and children = top-level folders.

New / changed API

Keep the existing public functions working (they now operate on paths, not flat names):

  • bookmarks_add(const char *path, const char *url, const char *title)path may contain slashes; intermediate nodes are created as empty events if they don't exist.
  • bookmarks_remove(const char *path, const char *url)
  • bookmarks_move(const char *from_path, const char *url, const char *to_path)
  • bookmarks_create_dir(const char *path) — publishes an empty kind 30003 event with d = HMAC-SHA256(hmac_key, path).
  • bookmarks_rename_dir(const char *old_path, const char *new_path) — re-publishes the moved subtree: for the renamed node and every descendant, publish a new event with the new HMAC d tag and a kind 5 deletion for the old HMAC d tag.
  • bookmarks_delete_dir(const char *path, int move_to_general) — recursively delete the subtree (kind 5 deletion for each HMAC d tag in the subtree); optionally move bookmarks to General.
  • const bookmark_node_t *bookmarks_get_root(void) — returns the root node for traversal (replaces bookmarks_get_dirs).
  • const bookmark_node_t *bookmarks_find(const char *path) — lookup by path.

Internal helpers

  • static bookmark_node_t *node_find_child(bookmark_node_t *parent, const char *name)
  • static bookmark_node_t *node_ensure_path(bookmark_node_t *root, const char *path) — walks/creates the trie.
  • static void node_free(bookmark_node_t *node) — recursive free.
  • static void compute_hmac_key(unsigned char out[32])HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1"). The privkey is obtained from the signer (add a nostr_signer_get_privkey_hex accessor if not already present, or compute the HMAC inside the signer to avoid exposing the raw privkey).
  • static char *path_to_d_tag(const char *path)HMAC-SHA256(hmac_key, path) → 64-char hex string. Caller frees.
  • static int publish_path(const char *path) — replaces publish_directory; builds d = path_to_d_tag(path), encrypts {"path": path, "bookmarks": [...]} with NIP-44, signs, publishes, stores in SQLite.
  • static int delete_event_for_path(const char *path) — publishes a kind 5 deletion event referencing the kind 30003 event for d = path_to_d_tag(path).

Loading

bookmarks_init already iterates all kind 30003 events for the pubkey. Change the loop to:

  1. Read the d tag.
  2. If the d tag is not 64 hex chars (i.e. a legacy plaintext directory name), treat it as a legacy event: decrypt content as the old flat [[bookmark,...]] array, use the d tag string as the path, then re-publish in the new HMAC-d format and emit a kind 5 deletion for the old event id.
  3. If the d tag is 64 hex chars, decrypt the content and read path from the plaintext JSON {"path": ..., "bookmarks": [...]}.
  4. node_ensure_path(root, path).
  5. Load bookmarks into that leaf.

2. JSON endpoint — src/nostr_bridge.c handle_bookmarks_list_json

Change the JSON shape from a flat dirs[] array to a nested tree object:

{
  "have_signer": true,
  "tree": {
    "name": "",
    "path": "",
    "bookmarks": [],
    "children": [
      { "name": "General", "path": "General", "bookmarks": [...], "children": [] },
      { "name": "Work",    "path": "Work",    "bookmarks": [], "children": [
        { "name": "Projects", "path": "Work/Projects", "bookmarks": [...], "children": [] }
      ]}
    ]
  }
}

Add a small recursive helper node_to_json(const bookmark_node_t *node, cJSON *parent).

3. Web UI — www/bookmarks.html, www/bookmarks.js, www/bookmarks.css

HTML

  • Replace the flat #bm-list div with a <div id="bm-tree"> container.
  • Add an "Add Folder" button next to "Add Bookmark" that prompts for a path (with a parent-picker).
  • Add a path breadcrumb / parent selector in the add form.

JS

  • renderTree(data.tree) — recursively render nested <ul class="tree-node"> with:
    • A folder row: expand/collapse caret, folder icon, name, count, "Add Bookmark Here", "New Subfolder", "Rename", "Delete" buttons.
    • A bookmark list under the folder (collapsible).
  • State: keep a Set of expanded paths in localStorage so expand state persists.
  • Clicking a folder caret toggles expanded class on the child <ul>.
  • "New Subfolder" prompts for a name and navigates to sovereign://bookmarks/createdir?name=<parent_path>/<new_name>.
  • "Move" action on each bookmark: prompt for destination path, navigate to sovereign://bookmarks/move?from=<dir>&to=<new_dir>&url=<url>.
  • "Add Bookmark Here" pre-fills the add form's directory field with the folder's path.

CSS

  • Add .tree-node, .tree-children, .caret, .caret.expanded, .folder-row, .folder-icon rules to www/bookmarks.css.
  • Caret uses a CSS triangle that rotates 90° when expanded.
  • Indent .tree-children by 20px per level.
  • Reuse existing .bm, .btn, .btn-del classes for bookmark rows and buttons.

4. Bookmarks toolbar — src/tab_manager.c

Add a bookmarks toolbar below the URL toolbar. This is a horizontal GtkBox that shows buttons for the bookmarks in a configurable "Bookmarks Bar" folder (default path: Bookmarks Bar), plus a dropdown for any folders.

Layout

[ hamburger ][back][fwd][refresh][ === URL entry === ][bookmark btn]
[ 📁 Bookmarks Bar: ][example.com][nostr.com][▾ Folders ▾]
[ ============ WebKitWebView ============ ]

Implementation

  1. In tab_create(), after packing toolbar into tab->page, create:
    tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
    gtk_widget_set_margin_top(tab->bookmark_bar, 2);
    gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
    gtk_widget_set_margin_start(tab->bookmark_bar, 4);
    gtk_widget_set_margin_end(tab->bookmark_bar, 4);
    gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
    
  2. Add a GtkToggleButton "Bookmarks Bar" toggle (icon user-bookmarks-symbolic) on the left of the URL toolbar that shows/hides tab->bookmark_bar (persist visibility in settings).
  3. bookmark_bar_refresh(tab) — clears and repopulates tab->bookmark_bar:
    • Looks up the "Bookmarks Bar" node via bookmarks_find("Bookmarks Bar").
    • For each bookmark in that node, adds a GtkButton labeled with the bookmark title (or URL). Clicking loads the URL in tab->webview.
    • For each child folder, adds a GtkMenuButton with a popover listing that folder's bookmarks (and subfolders, one level deep).
  4. Call bookmark_bar_refresh(tab) after: tab creation, bookmarks_add/remove/move/delete_dir (via a global "bookmarks changed" notification — see below).
  5. Add a "Bookmark this page to Bookmarks Bar" quick action: right-click on the bookmark button → menu item "Add to Bookmarks Bar" that calls bookmarks_add("Bookmarks Bar", url, title) directly without the directory picker dialog.

Cross-tab refresh

Bookmarks can change from any tab (or from relay fetch). Add a lightweight notification:

  • bookmarks_subscribe_changed(void (*cb)(void *user_data), void *user_data) in src/bookmarks.h.
  • tab_manager.c registers a callback on startup that iterates all open tabs and calls bookmark_bar_refresh(tab) for each.
  • bookmarks_add/remove/move/create_dir/delete_dir/rename_dir and the relay-fetch load path invoke the registered callbacks after mutating state.

5. Routes — src/nostr_bridge.c

Existing routes already accept dir= query params. They continue to work — dir is now interpreted as a path (URL-encoded, may contain %2F). Add one new route:

  • sovereign://bookmarks/move?from=<path>&to=<path>&url=<url> — calls bookmarks_move.

Update handle_bookmarks_add/delete/createdir/deletedir to pass the path straight through to the renamed API functions (no behavioral change beyond accepting slashes).

6. History filter — src/history.c

Already excludes sovereign://bookmarks/{add,delete,createdir,deletedir}. Add sovereign://bookmarks/move to the skip list.

7. Tests

  • Add tests/test_bookmarks_tree.c (or extend existing tests) covering:
    • node_ensure_path creates intermediate nodes.
    • path_to_d_tag is deterministic (same path + key → same hex) and 64 hex chars.
    • path_to_d_tag is opaque (different paths → uncorrelated hashes; no prefix leakage).
    • bookmarks_add("Work/Projects/Secret", ...) publishes an event whose d tag is HMAC-SHA256(hmac_key, "Work/Projects/Secret") (not the plaintext path) and whose decrypted content contains "path": "Work/Projects/Secret".
    • bookmarks_rename_dir("Work", "Personal") re-publishes Personal, Personal/Projects, Personal/Projects/Secret (new HMAC d tags, new encrypted content) and emits kind 5 deletions for the old HMAC d tags.
    • bookmarks_delete_dir("Work", 0) emits kind 5 deletions for the whole subtree's HMAC d tags.
    • Loading a mix of events with HMAC d tags reconstructs the tree from the decrypted path field, not from the d tag.
    • Legacy event with plaintext d = "General" is detected, loaded, re-published with an HMAC d tag, and the old event is marked for kind 5 deletion.
  • Manual test: ./browser.sh restart --login-method generate --url sovereign://bookmarks and exercise the tree UI.

Migration

Existing flat directories (no slashes) load as root-level folders — no migration needed. The "General" default folder continues to work as a root node.

Files touched

File Change
src/bookmarks.h Replace bookmark_dir_t with bookmark_node_t; tree API; change callback.
src/bookmarks.c Rewrite in-memory model as a trie; path-based publish/delete; rename/delete recursion.
src/nostr_bridge.c handle_bookmarks_list_json emits nested tree; add move route.
www/bookmarks.html Tree container, folder add form.
www/bookmarks.js Recursive renderTree, expand/collapse, move dialog.
www/bookmarks.css Tree node / caret / indentation styles.
src/tab_manager.c Bookmarks toolbar widget, refresh on change.
src/history.c Skip sovereign://bookmarks/move.
tests/test_bookmarks_tree.c New test for tree operations.
plans/bookmarks-tree-view.md This plan.