15 KiB
Plan: Nested Bookmark Tree View + Bookmarks Toolbar
Goal
- Support nested folders for bookmarks (e.g.
Work/Projects/Secret) while staying fully NIP-51 kind 30003 compliant. - Render the
sovereign://bookmarkspage as an expandable/collapsible tree view. - 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:
- Opaque — relays must not learn the folder path (e.g.
Work/Projects/Secret), because that leaks the user's organizational structure. - Deterministic — the same path must always produce the same
dso 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)
- Fetch all
kind=30003events for the user's pubkey. - For each event: NIP-44 decrypt the content, read
pathfrom the plaintext JSON. - Split
pathon/and walk/create the trie (node_ensure_path). - Attach the decrypted
bookmarksarray 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
dtag 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-dformat; 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)—pathmay 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 withd = 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 HMACdtag and a kind 5 deletion for the old HMACdtag.bookmarks_delete_dir(const char *path, int move_to_general)— recursively delete the subtree (kind 5 deletion for each HMACdtag in the subtree); optionally move bookmarks toGeneral.const bookmark_node_t *bookmarks_get_root(void)— returns the root node for traversal (replacesbookmarks_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 anostr_signer_get_privkey_hexaccessor 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)— replacespublish_directory; buildsd = 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 ford = path_to_d_tag(path).
Loading
bookmarks_init already iterates all kind 30003 events for the pubkey. Change the loop to:
- Read the
dtag. - If the
dtag 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 thedtag string as the path, then re-publish in the new HMAC-dformat and emit a kind 5 deletion for the old event id. - If the
dtag is 64 hex chars, decrypt the content and readpathfrom the plaintext JSON{"path": ..., "bookmarks": [...]}. node_ensure_path(root, path).- 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-listdiv 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
Setof expanded paths inlocalStorageso expand state persists. - Clicking a folder caret toggles
expandedclass 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-iconrules towww/bookmarks.css. - Caret uses a CSS triangle that rotates 90° when expanded.
- Indent
.tree-childrenby20pxper level. - Reuse existing
.bm,.btn,.btn-delclasses 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
- In
tab_create(), after packingtoolbarintotab->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); - Add a
GtkToggleButton"Bookmarks Bar" toggle (iconuser-bookmarks-symbolic) on the left of the URL toolbar that shows/hidestab->bookmark_bar(persist visibility in settings). bookmark_bar_refresh(tab)— clears and repopulatestab->bookmark_bar:- Looks up the "Bookmarks Bar" node via
bookmarks_find("Bookmarks Bar"). - For each bookmark in that node, adds a
GtkButtonlabeled with the bookmark title (or URL). Clicking loads the URL intab->webview. - For each child folder, adds a
GtkMenuButtonwith a popover listing that folder's bookmarks (and subfolders, one level deep).
- Looks up the "Bookmarks Bar" node via
- Call
bookmark_bar_refresh(tab)after: tab creation,bookmarks_add/remove/move/delete_dir(via a global "bookmarks changed" notification — see below). - 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)insrc/bookmarks.h.tab_manager.cregisters a callback on startup that iterates all open tabs and callsbookmark_bar_refresh(tab)for each.bookmarks_add/remove/move/create_dir/delete_dir/rename_dirand 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>— callsbookmarks_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_pathcreates intermediate nodes.path_to_d_tagis deterministic (same path + key → same hex) and 64 hex chars.path_to_d_tagis opaque (different paths → uncorrelated hashes; no prefix leakage).bookmarks_add("Work/Projects/Secret", ...)publishes an event whosedtag isHMAC-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-publishesPersonal,Personal/Projects,Personal/Projects/Secret(new HMACdtags, new encrypted content) and emits kind 5 deletions for the old HMACdtags.bookmarks_delete_dir("Work", 0)emits kind 5 deletions for the whole subtree's HMACdtags.- Loading a mix of events with HMAC
dtags reconstructs the tree from the decryptedpathfield, not from thedtag. - Legacy event with plaintext
d = "General"is detected, loaded, re-published with an HMACdtag, and the old event is marked for kind 5 deletion.
- Manual test:
./browser.sh restart --login-method generate --url sovereign://bookmarksand 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. |