diff --git a/docs/apps.md b/docs/apps.md
index 6cc09bf..e98da63 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
}
```
-## The seven apps
+## The eight apps
| App | `id` | Params | Notes |
|---|---|---|---|
@@ -97,10 +97,19 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow |
| Note | `notes` | `id`, `relays?` | One note and its replies. **Not** a singleton |
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` |
+| 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.
+
### Follow lists are a whole-list replacement
kind 3 replaces the entire contact list. The follow button therefore reads the current
diff --git a/src/apps/web-bookmarks/index.tsx b/src/apps/web-bookmarks/index.tsx
new file mode 100644
index 0000000..6e9fcd2
--- /dev/null
+++ b/src/apps/web-bookmarks/index.tsx
@@ -0,0 +1,205 @@
+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';
+
+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
+
+
+
+
+ {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 = /^https?:\/\/.+/i.test(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') ?? '';
+ const url = sanitizeUrl(bookmarkUrl(dTag)) ?? 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 (
+
+
+
+ {dTag}
+
+ {event.content && {event.content}
}
+
+
+ {topics.map((topic) => (
+
+ {topic}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/hooks/useWebBookmarks.test.ts b/src/hooks/useWebBookmarks.test.ts
new file mode 100644
index 0000000..cb08ace
--- /dev/null
+++ b/src/hooks/useWebBookmarks.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { bookmarkDTag, bookmarkUrl } from './useWebBookmarks';
+
+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);
+ });
+});
diff --git a/src/hooks/useWebBookmarks.ts b/src/hooks/useWebBookmarks.ts
new file mode 100644
index 0000000..9c0b4c4
--- /dev/null
+++ b/src/hooks/useWebBookmarks.ts
@@ -0,0 +1,118 @@
+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;
+}
+
+/**
+ * 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`).
+ */
+export function bookmarkDTag(url: string): string {
+ return url.startsWith('https://') ? url.slice('https://'.length) : url;
+}
+
+/** Reconstructs a clickable URL from a `d` tag written by `bookmarkDTag`. */
+export function bookmarkUrl(dTag: string): string {
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(dTag) ? dTag : `https://${dTag}`;
+}
+
+export interface WebBookmarkInput {
+ url: string;
+ title?: string;
+ description?: string;
+ tags?: string[];
+}
+
+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 events
+ .filter((event) => Boolean(tagValue(event, 'd')))
+ .sort((a, b) => b.created_at - a.created_at);
+ },
+ staleTime: 60_000,
+ });
+}
+
+export function useCreateWebBookmark() {
+ 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 eventTags: string[][] = [['d', bookmarkDTag(url)]];
+ 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', String(Math.floor(Date.now() / 1000))]);
+
+ 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 596dd3f..7c1033d 100644
--- a/src/os/registry.ts
+++ b/src/os/registry.ts
@@ -1,5 +1,5 @@
import { lazy } from 'react';
-import { Activity, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react';
+import { Activity, BookOpen, FileText, Info, Link2, Rss, Settings, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -49,6 +49,16 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 780, height: 760 },
minSize: { width: 360, height: 320 },
},
+ {
+ 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',