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

* 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

* fix: address review feedback on web bookmarks

Per review:
- bookmarkDTag() now matches the https scheme case-insensitively, so
  "HTTPS://…" and "https://…" collapse to the same d tag instead of
  creating duplicate bookmarks.
- The form now accepts every scheme sanitizeUrl() allows (https,
  http, mailto, nostr) via a dedicated isBookmarkableUrl() check —
  not sanitizeUrl() itself, which resolves relative URLs against this
  app's own origin and would have "validated" a bare hostname like
  "example.com" as a link back into the app.
- WebBookmarkRow no longer falls back to the raw unsanitized URL when
  sanitizeUrl() rejects it (e.g. a malicious "d" tag) — it renders
  plain text with no link instead of defeating the sanitization.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: round-trip mailto:/nostr: bookmarks and preserve published_at

Per review:
- bookmarkUrl() required "scheme://" to recognize an already-schemed
  d tag, so opaque URIs with no "//" — mailto: and nostr: — were
  incorrectly prefixed with "https://". Fixed by also checking a
  closed list of the opaque schemes this app supports, alongside the
  existing "://" check (kept as-is so a hierarchical scheme like
  gemini:// still round-trips, and so a stripped https URL containing
  a port, e.g. alice.blog:8080/post, still isn't misread as scheme
  "alice.blog"). Added regression tests for all three cases.
- useCreateWebBookmark now looks up the existing bookmark for the
  same d tag before publishing and carries its published_at forward,
  instead of resetting it to now on every edit — per NIP-B0,
  published_at is "the first time the bookmark was published."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: dedupe multiple revisions of the same web bookmark

Per an earlier "previously missed" finding: useMyWebBookmarks()
returned every kind-39701 event a relay handed back, but for an
addressable event the pool can return more than one revision of the
same d tag (an edit history, or relays disagreeing on what's
current), which showed up as duplicate rows for the same URL.
Extracted dedupeLatestByDTag() (keeps the newest per d, newest-first)
and covered it with regression tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mroxso
2026-09-06 18:50:15 +02:00
committed by GitHub
parent 50795167f5
commit d4a83f22b5
5 changed files with 484 additions and 2 deletions

View File

@@ -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()`

View File

@@ -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 <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 = 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 (
<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') ?? '';
// 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 (
<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">
{url ? (
<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="truncate text-[14px] font-medium text-muted-foreground" title="Not a safe URL to open">
{title}
</span>
)}
<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,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([]);
});
});

View File

@@ -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<string, NostrEvent>();
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<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 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<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, 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',