diff --git a/docs/apps.md b/docs/apps.md
index 94f117a..f17a0fb 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
}
```
-## The eight apps
+## The nine apps
| App | `id` | Params | Notes |
|---|---|---|---|
@@ -98,10 +98,19 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Note | `notes` | `id?`, `relays?` | One note and its replies, or a blank local draft when `id` is absent. **Not** a singleton |
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown`, NIP-84 highlights |
| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles |
+| Web Bookmarks | `web-bookmarks` | — | NIP-B0 kind 39701 — one addressable event per saved URL |
| Relays | `relays` | — | Connection state, subscription count, measured latency |
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
| About | `about` | — | What this is, the app list, the shortcuts |
+### Web bookmarks are one event per URL, not a list
+
+Unlike a NIP-51 list, each NIP-B0 web bookmark (kind 39701) is its own addressable event —
+the `d` tag is the URL itself (scheme stripped for `https`, see `bookmarkDTag` in
+`src/hooks/useWebBookmarks.ts`). Removing one publishes a NIP-09 kind 5 deletion request,
+which relays are free to ignore, so the client also drops it from its own query cache
+rather than trusting a refetch to reflect it.
+
### Highlighting selects against the DOM, not the markdown source
`HighlightLayer` (`src/apps/articles/HighlightLayer.tsx`) tracks `window.getSelection()`
diff --git a/src/apps/web-bookmarks/index.tsx b/src/apps/web-bookmarks/index.tsx
new file mode 100644
index 0000000..96239ca
--- /dev/null
+++ b/src/apps/web-bookmarks/index.tsx
@@ -0,0 +1,230 @@
+import { useEffect, useState } from 'react';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { ExternalLink, Loader2, Plus, X } from 'lucide-react';
+import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome';
+import { LoginRequired } from '@/components/nostr/LoginRequired';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Textarea } from '@/components/ui/textarea';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useToast } from '@/hooks/useToast';
+import {
+ bookmarkUrl,
+ useCreateWebBookmark,
+ useDeleteWebBookmark,
+ useMyWebBookmarks,
+ webBookmarkTopics,
+} from '@/hooks/useWebBookmarks';
+import { relativeTime, sanitizeUrl, tagValue } from '@/lib/nostrUtils';
+import type { AppProps } from '@/os/types';
+
+/**
+ * Same protocol allowlist as sanitizeUrl(), but for an absolute bookmark URL
+ * rather than an href/src that may legitimately be relative to this app's
+ * own origin — sanitizeUrl() would resolve a bare "example.com" against
+ * `window.location.origin` and "validate" it as a link back into this app.
+ */
+const BOOKMARKABLE_SCHEMES = new Set(['https:', 'http:', 'mailto:', 'nostr:']);
+function isBookmarkableUrl(value: string): boolean {
+ try {
+ return BOOKMARKABLE_SCHEMES.has(new URL(value).protocol);
+ } catch {
+ return false;
+ }
+}
+
+export default function WebBookmarksApp({ setTitle }: AppProps) {
+ const { user } = useCurrentUser();
+ const [formOpen, setFormOpen] = useState(false);
+
+ useEffect(() => setTitle('Web Bookmarks'), [setTitle]);
+
+ const bookmarks = useMyWebBookmarks();
+
+ if (!user) {
+ return ;
+ }
+
+ return (
+
+
+ Web Bookmarks
+ setFormOpen((open) => !open)}
+ >
+ {formOpen ? : }
+ {formOpen ? 'Cancel' : 'Add bookmark'}
+
+
+
+
+ {formOpen && setFormOpen(false)} />}
+
+ {bookmarks.isLoading ? (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ ) : bookmarks.data && bookmarks.data.length > 0 ? (
+ bookmarks.data.map((event) => )
+ ) : (
+ setFormOpen(true)}>
+ Add a bookmark
+
+ )
+ }
+ />
+ )}
+
+
+ );
+}
+
+function NewBookmarkForm({ onDone }: { onDone: () => void }) {
+ const [url, setUrl] = useState('');
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [tags, setTags] = useState('');
+ const create = useCreateWebBookmark();
+ const { toast } = useToast();
+
+ const trimmedUrl = url.trim();
+ const isValid = isBookmarkableUrl(trimmedUrl);
+
+ const submit = async () => {
+ if (!isValid) return;
+ try {
+ await create.mutateAsync({
+ url: trimmedUrl,
+ title: title.trim() || undefined,
+ description: description.trim() || undefined,
+ tags: tags
+ .split(',')
+ .map((tag) => tag.trim())
+ .filter(Boolean),
+ });
+ toast({ title: 'Bookmark saved' });
+ onDone();
+ } catch (error) {
+ toast({
+ title: 'Could not save bookmark',
+ description: error instanceof Error ? error.message : 'No relay accepted the update.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ );
+}
+
+function WebBookmarkRow({ event }: { event: NostrEvent }) {
+ const del = useDeleteWebBookmark();
+ const { toast } = useToast();
+
+ const dTag = tagValue(event, 'd') ?? '';
+ // sanitizeUrl() returning undefined means the reconstructed URL uses a
+ // protocol that could execute script (e.g. a malicious "d" tag) — in that
+ // case there is no safe href to link out to, full stop, not a fallback to
+ // the very value that just failed sanitization.
+ const url = sanitizeUrl(bookmarkUrl(dTag));
+ const title = tagValue(event, 'title') ?? dTag;
+ const topics = webBookmarkTopics(event);
+
+ const handleDelete = async () => {
+ try {
+ await del.mutateAsync(event);
+ toast({ title: 'Bookmark removed' });
+ } catch (error) {
+ toast({
+ title: 'Could not remove bookmark',
+ description: error instanceof Error ? error.message : 'No relay accepted the update.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+
+ {url ? (
+
+ {title}
+
+
+ ) : (
+
+ {title}
+
+ )}
+
{relativeTime(event.created_at)}
+
+
+ {dTag}
+
+ {event.content && {event.content}
}
+
+
+ {topics.map((topic) => (
+
+ {topic}
+
+ ))}
+
+ {del.isPending ? : }
+ Remove
+
+
+
+ );
+}
diff --git a/src/hooks/useWebBookmarks.test.ts b/src/hooks/useWebBookmarks.test.ts
new file mode 100644
index 0000000..dda9878
--- /dev/null
+++ b/src/hooks/useWebBookmarks.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { bookmarkDTag, bookmarkUrl, dedupeLatestByDTag } from './useWebBookmarks';
+
+function bookmarkEvent(dTag: string, createdAt: number, id = `${dTag}-${createdAt}`): NostrEvent {
+ return { id, pubkey: 'author', created_at: createdAt, kind: 39701, tags: [['d', dTag]], content: '', sig: '' };
+}
+
+describe('bookmarkDTag / bookmarkUrl', () => {
+ it('strips the https scheme per NIP-B0', () => {
+ expect(bookmarkDTag('https://alice.blog/post')).toBe('alice.blog/post');
+ });
+
+ it('keeps non-https schemes in full', () => {
+ expect(bookmarkDTag('http://alice.blog/post')).toBe('http://alice.blog/post');
+ expect(bookmarkDTag('gemini://example.com/')).toBe('gemini://example.com/');
+ });
+
+ it('round-trips an https URL back through bookmarkUrl', () => {
+ const url = 'https://alice.blog/post';
+ expect(bookmarkUrl(bookmarkDTag(url))).toBe(url);
+ });
+
+ it('round-trips a non-https URL back through bookmarkUrl', () => {
+ const url = 'http://alice.blog/post';
+ expect(bookmarkUrl(bookmarkDTag(url))).toBe(url);
+ });
+
+ it('strips the https scheme case-insensitively, so casing does not create duplicate bookmarks', () => {
+ expect(bookmarkDTag('HTTPS://alice.blog/post')).toBe('alice.blog/post');
+ expect(bookmarkDTag('HtTpS://alice.blog/post')).toBe('alice.blog/post');
+ expect(bookmarkDTag('HTTPS://alice.blog/post')).toBe(bookmarkDTag('https://alice.blog/post'));
+ });
+
+ it('round-trips schemes with no "//", like mailto: and nostr:, instead of prefixing them with https://', () => {
+ expect(bookmarkUrl(bookmarkDTag('mailto:hello@example.com'))).toBe('mailto:hello@example.com');
+ expect(bookmarkUrl(bookmarkDTag('nostr:npub1abc'))).toBe('nostr:npub1abc');
+ });
+
+ it('round-trips a hierarchical non-https scheme like gemini://', () => {
+ const url = 'gemini://example.com/';
+ expect(bookmarkUrl(bookmarkDTag(url))).toBe(url);
+ });
+
+ it('does not mistake a port number in a stripped https URL for a scheme', () => {
+ expect(bookmarkUrl('alice.blog:8080/post')).toBe('https://alice.blog:8080/post');
+ });
+});
+
+describe('dedupeLatestByDTag', () => {
+ it('keeps only the newest event for each d tag', () => {
+ const older = bookmarkEvent('alice.blog/post', 100);
+ const newer = bookmarkEvent('alice.blog/post', 200);
+ const result = dedupeLatestByDTag([older, newer]);
+ expect(result).toEqual([newer]);
+ });
+
+ it('is order-independent', () => {
+ const older = bookmarkEvent('alice.blog/post', 100);
+ const newer = bookmarkEvent('alice.blog/post', 200);
+ expect(dedupeLatestByDTag([newer, older])).toEqual([newer]);
+ });
+
+ it('keeps distinct d tags separately, sorted newest first', () => {
+ const a = bookmarkEvent('a.com', 100);
+ const b = bookmarkEvent('b.com', 300);
+ const c = bookmarkEvent('c.com', 200);
+ expect(dedupeLatestByDTag([a, b, c])).toEqual([b, c, a]);
+ });
+
+ it('drops events with no d tag', () => {
+ const noD: NostrEvent = { id: 'x', pubkey: 'author', created_at: 0, kind: 39701, tags: [], content: '', sig: '' };
+ expect(dedupeLatestByDTag([noD])).toEqual([]);
+ });
+});
diff --git a/src/hooks/useWebBookmarks.ts b/src/hooks/useWebBookmarks.ts
new file mode 100644
index 0000000..31bd9f0
--- /dev/null
+++ b/src/hooks/useWebBookmarks.ts
@@ -0,0 +1,158 @@
+import { useNostr } from '@nostrify/react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { useCurrentUser } from './useCurrentUser';
+import { useNostrPublish } from './useNostrPublish';
+import { tagValue, tagValues } from '@/lib/nostrUtils';
+
+/** NIP-B0 "Web Bookmarking": one addressable event per bookmarked URL. */
+export const WEB_BOOKMARK_KIND = 39701;
+
+function queryKey(pubkey: string | undefined) {
+ return ['nostr', 'web-bookmarks', pubkey ?? ''] as const;
+}
+
+const HTTPS_SCHEME_RE = /^https:\/\//i;
+
+/**
+ * The `d` tag per NIP-B0: the URI with the `https://` scheme stripped (every
+ * other scheme keeps its full form, so it round-trips through `bookmarkUrl`).
+ * The scheme match is case-insensitive so "HTTPS://" and "https://" collapse
+ * to the same `d` tag instead of creating duplicate bookmarks.
+ */
+export function bookmarkDTag(url: string): string {
+ return HTTPS_SCHEME_RE.test(url) ? url.slice('https://'.length) : url;
+}
+
+/** Hierarchical URIs, e.g. `http://…` or `gemini://…` — the `//` is what rules out a false match on a stripped https URL that happens to contain a port, e.g. `alice.blog:8080/post`. */
+const HIERARCHICAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
+/** Non-hierarchical URIs with no `//`, e.g. `mailto:` and `nostr:` — not matched by the pattern above. */
+const OPAQUE_SCHEMES = ['mailto:', 'nostr:'];
+
+/** Reconstructs a clickable URL from a `d` tag written by `bookmarkDTag`. */
+export function bookmarkUrl(dTag: string): string {
+ const lower = dTag.toLowerCase();
+ const alreadyHasScheme =
+ HIERARCHICAL_SCHEME_RE.test(dTag) || OPAQUE_SCHEMES.some((scheme) => lower.startsWith(scheme));
+ return alreadyHasScheme ? dTag : `https://${dTag}`;
+}
+
+export interface WebBookmarkInput {
+ url: string;
+ title?: string;
+ description?: string;
+ tags?: string[];
+}
+
+/**
+ * Addressable events: relays across the pool can hand back more than one
+ * revision of the same `d` tag (an edit history, or just multiple relays
+ * disagreeing on what's current). Keeps only the newest per `d`, newest first.
+ */
+export function dedupeLatestByDTag(events: NostrEvent[]): NostrEvent[] {
+ const latest = new Map();
+ for (const event of events) {
+ const dTag = tagValue(event, 'd');
+ if (!dTag) continue;
+ const current = latest.get(dTag);
+ if (!current || event.created_at > current.created_at) latest.set(dTag, event);
+ }
+ return [...latest.values()].sort((a, b) => b.created_at - a.created_at);
+}
+
+export function useMyWebBookmarks() {
+ const { nostr } = useNostr();
+ const { user } = useCurrentUser();
+
+ return useQuery({
+ queryKey: queryKey(user?.pubkey),
+ enabled: Boolean(user),
+ queryFn: async ({ signal }) => {
+ const events = await nostr.query(
+ [{ kinds: [WEB_BOOKMARK_KIND], authors: [user!.pubkey], limit: 200 }],
+ { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
+ );
+ return dedupeLatestByDTag(events);
+ },
+ staleTime: 60_000,
+ });
+}
+
+export function useCreateWebBookmark() {
+ const { nostr } = useNostr();
+ const { user } = useCurrentUser();
+ const publish = useNostrPublish();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ url, title, description, tags }: WebBookmarkInput) => {
+ if (!user) throw new Error('Sign in to bookmark a page');
+
+ const dTag = bookmarkDTag(url);
+ // Re-bookmarking an already-saved URL is an edit of the same
+ // addressable event, not a new bookmark — published_at per NIP-B0 is
+ // "the first time the bookmark was published", so it must carry over
+ // rather than being reset to now on every edit.
+ const [existing] = await nostr.query(
+ [{ kinds: [WEB_BOOKMARK_KIND], authors: [user.pubkey], '#d': [dTag], limit: 1 }],
+ { signal: AbortSignal.timeout(6000) },
+ );
+ const publishedAt = existing
+ ? (tagValue(existing, 'published_at') ?? String(existing.created_at))
+ : String(Math.floor(Date.now() / 1000));
+
+ const eventTags: string[][] = [['d', dTag]];
+ if (title?.trim()) eventTags.push(['title', title.trim()]);
+ for (const tag of tags ?? []) {
+ if (tag.trim()) eventTags.push(['t', tag.trim().toLowerCase()]);
+ }
+ eventTags.push(['published_at', publishedAt]);
+
+ return publish.mutateAsync({
+ kind: WEB_BOOKMARK_KIND,
+ content: description?.trim() ?? '',
+ tags: eventTags,
+ });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKey(user?.pubkey) });
+ },
+ });
+}
+
+/**
+ * A NIP-09 deletion request. Relays are free to ignore it — deletion is a
+ * request, not a guarantee — so the removed bookmark is also dropped from
+ * the local cache directly rather than waiting on a relay refetch that may
+ * still hand it back.
+ */
+export function useDeleteWebBookmark() {
+ const { user } = useCurrentUser();
+ const publish = useNostrPublish();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (event: NostrEvent) => {
+ if (!user) throw new Error('Sign in to remove a bookmark');
+ const dTag = tagValue(event, 'd') ?? '';
+ return publish.mutateAsync({
+ kind: 5,
+ content: '',
+ tags: [
+ ['a', `${WEB_BOOKMARK_KIND}:${user.pubkey}:${dTag}`],
+ ['e', event.id],
+ ['k', String(WEB_BOOKMARK_KIND)],
+ ],
+ });
+ },
+ onSuccess: (_data, removed) => {
+ queryClient.setQueryData(queryKey(user?.pubkey), (current) =>
+ current?.filter((event) => event.id !== removed.id),
+ );
+ },
+ });
+}
+
+export function webBookmarkTopics(event: NostrEvent): string[] {
+ return tagValues(event, 't');
+}
diff --git a/src/os/registry.ts b/src/os/registry.ts
index 4766b6b..d57c458 100644
--- a/src/os/registry.ts
+++ b/src/os/registry.ts
@@ -1,5 +1,5 @@
import { lazy } from 'react';
-import { Activity, Bookmark, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react';
+import { Activity, Bookmark, BookOpen, FileText, Info, Link2, Rss, Settings, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -59,6 +59,16 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 600, height: 660 },
minSize: { width: 340, height: 300 },
},
+ {
+ id: 'web-bookmarks',
+ title: 'Web Bookmarks',
+ description: 'Links you have saved from around the web',
+ icon: Link2,
+ category: 'social',
+ component: lazy(() => import('@/apps/web-bookmarks')),
+ defaultSize: { width: 600, height: 660 },
+ minSize: { width: 340, height: 300 },
+ },
{
id: 'relays',
title: 'Relays',