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
tagsarray, 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
dtags 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:
- Gets the active tab's current URL and title.
- 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
- On "Add", calls
bookmarks_add(dir, url, title). - 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()callssynchronous_publish_event_with_progress()which blocks. Run in ag_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_FULLMUTEXalready set.
Edge cases
- 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."
- No cached bookmarks: empty state — "No bookmarks yet. Click the bookmark button to save a page."
- Decryption failure: show error, treat that directory as empty.
- 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.
- Directory rename: publish new kind 30003 with new
dtag + kind 5 deletion for olddtag. - Large directories: 500 bookmarks per directory limit. NIP-44 supports up to 1MB plaintext.
- Conflict resolution: kind 30003 is parameterized replaceable — latest
created_atperdtag wins. Next relay fetch reconciles. - Default directory: "General" is always present (created on first bookmark if no directories exist).