From d4a83f22b5982bf9c81630590a8ba2967713b7f5 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:50:15 +0200 Subject: [PATCH] feat: web bookmarks for arbitrary URLs (NIP-B0) (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 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 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --------- Co-authored-by: highperfocused Co-authored-by: Claude Sonnet 5 --- docs/apps.md | 11 +- src/apps/web-bookmarks/index.tsx | 230 ++++++++++++++++++++++++++++++ src/hooks/useWebBookmarks.test.ts | 75 ++++++++++ src/hooks/useWebBookmarks.ts | 158 ++++++++++++++++++++ src/os/registry.ts | 12 +- 5 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 src/apps/web-bookmarks/index.tsx create mode 100644 src/hooks/useWebBookmarks.test.ts create mode 100644 src/hooks/useWebBookmarks.ts 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 + + + + + {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 ( +
+ setUrl(event.target.value)} + placeholder="https://…" + autoFocus + aria-invalid={url.length > 0 && !isValid} + /> + setTitle(event.target.value)} placeholder="Title (optional)" /> +