Files
sovereign_browser/plans/bookmarks.md

12 KiB

Plan: Nostr-Encrypted Bookmarks with Directories

Overview

Store browser bookmarks as NIP-44 encrypted Nostr events so they sync across all devices the user logs in on. Bookmarks are organized into directories (folders). A bookmark button in the toolbar (right of the URL bar) lets the user quickly bookmark the current page and choose/create a directory. Bookmarks also appear in the hamburger dropdown menu and are managed via a sovereign://bookmarks page.

Event Design

Kind 30003 (NIP-51 Bookmark Sets) — one event per directory

Per NIP-51, kind 30003 is for "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups." Each directory is a separate parameterized replaceable event with a d tag = directory name.

NIP-51 encryption pattern (line 11):

Private items are specified in a JSON array that mimics the structure of the event tags array, but stringified and encrypted using NIP-44 (the shared key is computed using the author's public and private key) and stored in the .content.

Event structure (one per directory)

{
  "kind": 30003,
  "pubkey": "<user pubkey hex>",
  "tags": [["d", "<directory name>"]],
  "content": "<NIP-44 encrypted JSON array of bookmarks>",
  "created_at": <unix timestamp>,
  ...
}

Plaintext format (encrypted in content)

[
  ["bookmark", "https://example.com", "Example", 1690000000],
  ["bookmark", "https://nostr.com", "Nostr", 1690000001]
]

Directory model

  • Each directory = one kind 30003 event with d = directory name.
  • A default directory "General" holds bookmarks that aren't assigned to a specific folder.
  • The user can create/rename/delete directories. Deleting a directory moves its bookmarks to "General" (or deletes them, with confirmation).
  • The list of directory names is derived from the d tags of all the user's kind 30003 events — no separate "directory list" event needed.

Why kind 30003 (sets) instead of kind 10003 (flat list)?

Feature Kind 10003 Kind 30003
Directories/folders Not supported (flat list) Supported — each d tag is a directory
Replaceable Normal replaceable (one event) Parameterized replaceable (one event per d tag)
NIP-51 standard Yes — "Bookmarks" Yes — "Bookmark sets"
NIP-44 encryption Documented Documented (same pattern)

Since you want directories, kind 30003 is the correct NIP-51 kind. It was designed exactly for this: "bookmarks must be in labeled separate groups."

flowchart TD
    A["User clicks bookmark button"] --> B["Show directory picker dialog"]
    B --> C{"Select existing or create new?"}
    C -->|Existing| D["Add bookmark to that directory"]
    C -->|Create new| E["Enter new directory name"]
    E --> D
    D --> F["Update in-memory list for that directory"]
    F --> G["Serialize directory bookmarks to JSON"]
    G --> H["NIP-44 encrypt to self"]
    H --> I["Build kind 30003 event with d=directory"]
    I --> J["Sign with signer"]
    J --> K["Publish to bootstrap relays"]
    J --> L["Store in SQLite db_store_event"]

    M["Browser startup / login"] --> N["Fetch all kind 30003 events from relays"]
    N --> O["Store in SQLite"]
    O --> P["For each event: decrypt content with NIP-44"]
    P --> Q["Parse JSON to in-memory bookmarks grouped by d-tag directory"]
    Q --> R["Populate dropdown menu + bookmarks page"]

    S["sovereign://bookmarks page"] --> T["Show directories as folders + bookmarks"]
    T --> U["Add/edit/delete/move bookmarks between directories"]
    U --> V["Save re-encrypt publish affected directory event"]

Implementation

New files: src/bookmarks.h / src/bookmarks.c

/* bookmarks.h */
#pragma once
#include <glib.h>
#include "../nostr_core_lib/cjson/cJSON.h"

#define BOOKMARKS_MAX_PER_DIR 500
#define BOOKMARKS_DIR_NAME_MAX 128

typedef struct {
    char *url;
    char *title;
    long added;  /* unix timestamp */
} bookmark_t;

typedef struct {
    char name[BOOKMARKS_DIR_NAME_MAX];
    bookmark_t *bookmarks;
    int count;
} bookmark_dir_t;

/* Initialize the bookmarks module. Loads cached bookmarks from SQLite
 * and decrypts them if a signer is available. Call after login. */
int  bookmarks_init(void);

/* Free the in-memory bookmark list. */
void bookmarks_cleanup(void);

/* Get the list of directories (read-only). */
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);

/* Get a specific directory by name (read-only). Returns NULL if not found. */
const bookmark_dir_t *bookmarks_get_dir(const char *name);

/* Add a bookmark to a directory. If the directory doesn't exist, it's
 * created. Updates the in-memory list, re-encrypts, publishes a new
 * kind 30003 event for that directory, and stores in SQLite.
 * Returns 0 on success, -1 on error. */
int  bookmarks_add(const char *dir, const char *url, const char *title);

/* Remove a bookmark by URL from a directory.
 * Returns 0 on success, -1 on not found / error. */
int  bookmarks_remove(const char *dir, const char *url);

/* Move a bookmark from one directory to another.
 * Returns 0 on success, -1 on error. */
int  bookmarks_move(const char *from_dir, const char *url,
                    const char *to_dir);

/* Create a new empty directory. Publishes an empty kind 30003 event.
 * Returns 0 on success, -1 if directory already exists / error. */
int  bookmarks_create_dir(const char *name);

/* Rename a directory. Publishes a new event with the new d-tag and
 * deletes the old one (kind 5 deletion event).
 * Returns 0 on success, -1 on error. */
int  bookmarks_rename_dir(const char *old_name, const char *new_name);

/* Delete a directory. Optionally move bookmarks to "General" first.
 * Publishes a kind 5 deletion event for the old directory event.
 * Returns 0 on success, -1 on error. */
int  bookmarks_delete_dir(const char *name, int move_to_general);

/* Load bookmarks for a directory from a decrypted kind 30003 event's
 * content JSON. Called internally by bookmarks_init. */
int  bookmarks_load_dir_from_json(const char *dir_name, const char *json);

/* Serialize a directory's bookmarks to NIP-51 tag-array JSON. */
char *bookmarks_dir_to_json(const char *dir_name);

/* Build and publish the kind 30003 event for a directory.
 * Encrypts the directory's bookmarks with NIP-44 (self-to-self),
 * signs, publishes to bootstrap relays, and stores in SQLite.
 * Returns 0 on success, -1 on error. */
int  bookmarks_publish_dir(const char *dir_name);

Bookmark button in the toolbar

In tab_manager.c's tab_create(), add a bookmark button to the toolbar (right of the URL entry):

/* Bookmark button — right of the URL entry. */
GtkWidget *bookmark_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(bookmark_btn),
    gtk_image_new_from_icon_name("user-bookmarks-symbolic",
                                  GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page");
g_signal_connect(bookmark_btn, "clicked",
                 G_CALLBACK(on_bookmark_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0);

The on_bookmark_clicked handler:

  1. Gets the active tab's current URL and title.
  2. Shows a directory picker dialog — a small GTK dialog with:
    • A combo box (dropdown) of existing directories + "New Directory…" option
    • If "New Directory…" is selected, show a text entry for the new name
    • An "Add" button and a "Cancel" button
  3. On "Add", calls bookmarks_add(dir, url, title).
  4. Shows a brief confirmation (e.g. "Bookmarked to ").

sovereign://bookmarks page

Add a handle_bookmarks_page function in nostr_bridge.c:

  • Shows directories as a folder list on the left (or as section headings)
  • Shows bookmarks in the selected directory with URL, title, "Open" / "Delete" / "Move" buttons
  • "Add Bookmark" form (URL + title + directory dropdown)
  • "Create Directory" button
  • "Rename Directory" / "Delete Directory" buttons per directory
  • Uses sovereign://bookmarks/add?dir=...&url=...&title=..., sovereign://bookmarks/delete?dir=...&url=..., sovereign://bookmarks/move?from=...&to=...&url=..., sovereign://bookmarks/createdir?name=..., sovereign://bookmarks/renamedir?old=...&new=..., sovereign://bookmarks/deletedir?name=... routes
  • Styled consistently with settings/profile pages (dark theme, monospace)
  • In read-only / no-login mode, shows cached bookmarks but disables add/delete with "Sign in to manage bookmarks"

Dropdown menu integration

In tab_manager.c's build_hamburger_menu(), add a "Bookmarks" submenu (like "Recents") that:

  • Shows directories as sub-submenus (nested GTK menus)
  • Each directory submenu lists its bookmarks as clickable items
  • Clicking a bookmark navigates the active tab to that URL
  • "Manage Bookmarks…" item at the bottom opens sovereign://bookmarks
  • Rebuilds on each show (like on_history_menu_show)

Relay fetch integration

Add kind 30003 to the relay fetch filter in relay_fetch.c:

cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003));  /* NEW: bookmark sets */

SQLite

The existing events + event_tags tables store kind 30003 events. The event_tags table allows querying by d tag to find all directory events for a pubkey. No schema changes needed.

Makefile

Add src/bookmarks.c to SRC.


File change summary

File Change
src/bookmarks.h NEW — bookmark/directory data structures + API
src/bookmarks.c NEW — in-memory list, NIP-44 encrypt/decrypt, publish/load, directory management
src/nostr_bridge.c Add sovereign://bookmarks page + action routes (add/delete/move/createdir/renamedir/deletedir)
src/tab_manager.c Add bookmark button in toolbar + "Bookmarks" submenu in hamburger menu
src/main.c Call bookmarks_init() after login, bookmarks_cleanup() on shutdown
src/relay_fetch.c Add kind 30003 to the bootstrap fetch filter
Makefile Add src/bookmarks.c to SRC

Threading and read-only mode

  • Signing required: publishing bookmark events requires a signer. In read-only / no-login mode, bookmarks can be read from SQLite but not modified. The bookmark button and add/delete/move UI are disabled.
  • Background publish: bookmarks_publish_dir() calls synchronous_publish_event_with_progress() which blocks. Run in a g_thread_new() background thread so the UI doesn't freeze. The in-memory list is updated immediately; the publish happens async.
  • SQLite thread safety: SQLITE_OPEN_FULLMUTEX already set.

Edge cases

  1. No signer (read-only / no-login): bookmark button is disabled (greyed out). Bookmarks page shows cached bookmarks read-only with "Sign in to manage bookmarks."
  2. No cached bookmarks: empty state — "No bookmarks yet. Click the bookmark button to save a page."
  3. Decryption failure: show error, treat that directory as empty.
  4. Directory deletion: confirm dialog. Option to move bookmarks to "General" or delete them. Publishes a kind 5 deletion event for the old directory's kind 30003 event.
  5. Directory rename: publish new kind 30003 with new d tag + kind 5 deletion for old d tag.
  6. Large directories: 500 bookmarks per directory limit. NIP-44 supports up to 1MB plaintext.
  7. Conflict resolution: kind 30003 is parameterized replaceable — latest created_at per d tag wins. Next relay fetch reconciles.
  8. Default directory: "General" is always present (created on first bookmark if no directories exist).