feat: web bookmarks for arbitrary URLs (NIP-B0)

Adds a Web Bookmarks app backed by NIP-B0 (kind 39701): one
addressable event per saved URL, distinct from the NIP-51 bookmark
list (#21) since it carries its own title/description/tags per page
rather than being an entry in a list.

- src/hooks/useWebBookmarks.ts: create/list/delete, plus
  bookmarkDTag/bookmarkUrl implementing the spec's "strip https://"
  d-tag rule (round-tripped by a unit test).
- Delete publishes a NIP-09 kind 5 request and also drops the item
  from the local query cache directly, since relays aren't obligated
  to honor the deletion.
- New src/apps/web-bookmarks/index.tsx: inline add form, list with
  title/description/tags, opens the saved URL in a new tab.

Closes #25

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-06 13:48:00 +02:00
parent 809077d054
commit 14d4588a18
5 changed files with 367 additions and 2 deletions

View File

@@ -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

View File

@@ -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 <LoginRequired action="save web bookmarks" />;
}
return (
<AppLayout>
<AppToolbar>
<span className="text-[13px] font-medium">Web Bookmarks</span>
<Button
variant="ghost"
size="sm"
className="ml-auto h-7 gap-1.5 px-2 text-xs"
onClick={() => setFormOpen((open) => !open)}
>
{formOpen ? <X className="size-3.5" aria-hidden /> : <Plus className="size-3.5" aria-hidden />}
{formOpen ? 'Cancel' : 'Add bookmark'}
</Button>
</AppToolbar>
<AppBody>
{formOpen && <NewBookmarkForm onDone={() => setFormOpen(false)} />}
{bookmarks.isLoading ? (
<div className="space-y-3 p-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-16 w-full" />
))}
</div>
) : bookmarks.data && bookmarks.data.length > 0 ? (
bookmarks.data.map((event) => <WebBookmarkRow key={event.id} event={event} />)
) : (
<EmptyState
title="No web bookmarks yet"
hint="Save a link and it will show up here."
action={
!formOpen && (
<Button size="sm" onClick={() => setFormOpen(true)}>
Add a bookmark
</Button>
)
}
/>
)}
</AppBody>
</AppLayout>
);
}
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 (
<div className="space-y-2.5 border-b border-border px-4 py-3">
<Input
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://…"
autoFocus
aria-invalid={url.length > 0 && !isValid}
/>
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Title (optional)" />
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Description (optional)"
rows={2}
className="min-h-16 resize-none text-[14px]"
/>
<Input
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="Tags, comma separated (optional)"
/>
<div className="flex justify-end">
<Button size="sm" onClick={submit} disabled={!isValid || create.isPending} className="gap-1.5">
{create.isPending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
Save bookmark
</Button>
</div>
</div>
);
}
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 (
<article className="group border-b border-border px-4 py-3 transition-colors last:border-b-0 hover:bg-muted/40">
<div className="flex items-start justify-between gap-3">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-0 items-center gap-1.5 text-[14px] font-medium hover:underline"
>
<span className="truncate">{title}</span>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
</a>
<span className="shrink-0 text-xs text-muted-foreground">{relativeTime(event.created_at)}</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{dTag}</p>
{event.content && <p className="mt-1.5 text-[13px] leading-relaxed">{event.content}</p>}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{topics.map((topic) => (
<Badge key={topic} variant="secondary" className="text-[11px]">
{topic}
</Badge>
))}
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 gap-1 px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
onClick={handleDelete}
disabled={del.isPending}
>
{del.isPending ? <Loader2 className="size-3 animate-spin" aria-hidden /> : <X className="size-3" aria-hidden />}
Remove
</Button>
</div>
</article>
);
}

View File

@@ -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);
});
});

View File

@@ -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<NostrEvent[]>({
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<NostrEvent[]>(queryKey(user?.pubkey), (current) =>
current?.filter((event) => event.id !== removed.id),
);
},
});
}
export function webBookmarkTopics(event: NostrEvent): string[] {
return tagValues(event, 't');
}

View File

@@ -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',