Files
website/src/hooks/useWebBookmarks.ts
mroxso d4a83f22b5 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>
2026-09-06 18:50:15 +02:00

159 lines
5.7 KiB
TypeScript

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