From 941cfab5d0801c12795885206b08b99ae8a32ef3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 8 Jun 2026 10:27:14 +0200 Subject: [PATCH] support non-standard web bookmarks --- .../RssArticleWebBookmarks/index.tsx | 9 +- src/lib/draft-event.ts | 16 ++-- src/lib/rss-article.ts | 7 +- src/lib/web-bookmark-nip.test.ts | 85 +++++++++++++++++++ src/lib/web-bookmark-nip.ts | 23 ++++- src/services/note-stats.service.ts | 5 ++ 6 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 src/lib/web-bookmark-nip.test.ts diff --git a/src/components/RssArticleWebBookmarks/index.tsx b/src/components/RssArticleWebBookmarks/index.tsx index 43d3d604..1ded8292 100644 --- a/src/components/RssArticleWebBookmarks/index.tsx +++ b/src/components/RssArticleWebBookmarks/index.tsx @@ -14,6 +14,7 @@ import { expandArticleUrlThreadQueryValues, getWebBookmarkArticleUrl } from '@/lib/rss-article' +import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' @@ -38,6 +39,7 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str const v = expandArticleUrlThreadQueryValues(canonical) return v.length > 0 ? v : [canonical] }, [canonical]) + const dVals = useMemo(() => expandWebBookmarkDTagQueryValues(canonical), [canonical]) const relayUrls = useMemo(() => { const read = userReadInboxUrls(relayList, cacheRelayListEvent) @@ -61,7 +63,10 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str try { const filters = [ { authors: [pubkey], kinds: [ExtendedKind.WEB_BOOKMARK], '#i': iVals, limit: 40 }, - { authors: [pubkey], kinds: [ExtendedKind.WEB_BOOKMARK], '#I': iVals, limit: 40 } + { authors: [pubkey], kinds: [ExtendedKind.WEB_BOOKMARK], '#I': iVals, limit: 40 }, + ...(dVals.length + ? [{ authors: [pubkey], kinds: [ExtendedKind.WEB_BOOKMARK], '#d': dVals, limit: 40 }] + : []) ] const batches = await Promise.all( filters.map((f) => client.fetchEvents(relayUrls, f, { cache: false }).catch(() => [] as Event[])) @@ -83,7 +88,7 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str } finally { setLoading(false) } - }, [pubkey, relayUrls, iVals, canonical]) + }, [pubkey, relayUrls, iVals, dVals, canonical]) useEffect(() => { void reload() diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index ee268a74..54fcfafd 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -1048,10 +1048,11 @@ export function createReplaceablePersonalListDraftEvent( } } -/** NIP-B0 (kind 39701): parameterized web bookmark; `d` = URL without scheme, `i`/`I` = canonical http(s) URL. */ +/** NIP-B0 (kind 39701): parameterized web bookmark; required `d` = URL without scheme. */ export function createWebBookmarkDraftEvent(options: { url: string title?: string + /** NIP-B0 `.content` — detailed description (optional). */ note?: string /** Preserve first publication time when editing (unix seconds string). */ publishedAtUnix?: string @@ -1060,26 +1061,23 @@ export function createWebBookmarkDraftEvent(options: { const raw = options.url.trim() if (!raw) throw new Error('Web bookmark URL is required') const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}` - const canonical = canonicalizeHttpUrlForITags(canonicalizeRssArticleUrl(href)) + const canonical = canonicalizeRssArticleUrl(href) const d = urlToWebBookmarkDTag(canonical) if (!d) throw new Error('Invalid web bookmark URL') - const tags: string[][] = [ - ['d', d], - ['I', canonical], - ['i', canonical] - ] + const tags: string[][] = [['d', d]] const title = options.title?.trim() if (title) tags.push(['title', title]) - const now = dayjs().unix() - tags.push(['published_at', options.publishedAtUnix ?? String(now)]) + const publishedAt = options.publishedAtUnix?.trim() + if (publishedAt) tags.push(['published_at', publishedAt]) for (const topic of options.topicTags ?? []) { const n = normalizeTopic(topic) if (n) tags.push(['t', n]) } + const now = dayjs().unix() return { kind: ExtendedKind.WEB_BOOKMARK, content: options.note?.trim() ?? '', diff --git a/src/lib/rss-article.ts b/src/lib/rss-article.ts index 57cee67d..80f27bda 100644 --- a/src/lib/rss-article.ts +++ b/src/lib/rss-article.ts @@ -81,7 +81,7 @@ export function getArticleUrlFromCommentITags(event: Event): string | undefined return event.tags.find((t) => t[0] === 'i')?.[1] } -/** HTTP(S) URL from kind 39701 web bookmarks (`i`/`I`/`r` tags). */ +/** HTTP(S) URL from kind 39701 web bookmarks (`d` tag per NIP-B0; legacy `i`/`I`/`r` supported). */ export function getWebBookmarkArticleUrl(event: Pick): string | undefined { if (event.kind !== ExtendedKind.WEB_BOOKMARK) return undefined const fromII = getArticleUrlFromCommentITags(event as Event) @@ -96,6 +96,11 @@ export function getWebBookmarkArticleUrl(event: Pick): s if (u.startsWith('http://') || u.startsWith('https://')) return canonicalizeRssArticleUrl(u) } } + const dTag = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() + if (dTag) { + const fromD = normalizeHttpArticleUrl(dTag) + if (fromD) return fromD + } return undefined } diff --git a/src/lib/web-bookmark-nip.test.ts b/src/lib/web-bookmark-nip.test.ts new file mode 100644 index 00000000..15efcff2 --- /dev/null +++ b/src/lib/web-bookmark-nip.test.ts @@ -0,0 +1,85 @@ +import { ExtendedKind } from '@/constants' +import { createWebBookmarkDraftEvent } from '@/lib/draft-event' +import { getWebBookmarkArticleUrl } from '@/lib/rss-article' +import { + expandWebBookmarkDTagQueryValues, + urlToWebBookmarkDTag, + webBookmarkDTagToUrl +} from '@/lib/web-bookmark-nip' +import { describe, expect, it } from 'vitest' +import type { Event } from 'nostr-tools' + +describe('web bookmark NIP-B0 d-tag', () => { + it('round-trips URL through d-tag helpers', () => { + const url = 'https://blog.elenarossini.com/the-untold-story/' + const d = urlToWebBookmarkDTag(url) + expect(d).toBe('blog.elenarossini.com/the-untold-story/') + expect(webBookmarkDTagToUrl(d)).toBe('https://blog.elenarossini.com/the-untold-story/') + }) + + it('parses URL from d-tag-only kind 39701 events', () => { + const event: Pick = { + kind: ExtendedKind.WEB_BOOKMARK, + tags: [ + [ + 'd', + 'blog.elenarossini.com/the-untold-story-about-w-social-unconventional-beginnings-strategic-pitches-conflicting-signals/' + ] + ] + } + expect(getWebBookmarkArticleUrl(event)).toBe( + 'https://blog.elenarossini.com/the-untold-story-about-w-social-unconventional-beginnings-strategic-pitches-conflicting-signals/' + ) + }) + + it('prefers i/I tags over d when both are present', () => { + const event: Pick = { + kind: ExtendedKind.WEB_BOOKMARK, + tags: [ + ['d', 'example.com/other'], + ['I', 'https://example.com/preferred'] + ] + } + expect(getWebBookmarkArticleUrl(event)).toBe('https://example.com/preferred') + }) + + it('expands d-tag query values from a canonical article URL', () => { + const vals = expandWebBookmarkDTagQueryValues('https://example.com/path/') + expect(vals).toContain('example.com/path/') + expect(vals).toContain('example.com/path') + }) + + it('creates NIP-B0 drafts with only d plus optional metadata', () => { + const url = + 'https://www.br.de/radio/bayern2/sendungen/radioreisen/nordspanien-asturiens-menschen-und-mythen-kalksteingebirge-picos-de-europa-g-102.html' + const minimal = createWebBookmarkDraftEvent({ url }) + expect(minimal.kind).toBe(ExtendedKind.WEB_BOOKMARK) + expect(minimal.content).toBe('') + expect(minimal.tags).toEqual([ + [ + 'd', + 'www.br.de/radio/bayern2/sendungen/radioreisen/nordspanien-asturiens-menschen-und-mythen-kalksteingebirge-picos-de-europa-g-102.html' + ] + ]) + expect(minimal.tags.some((t) => t[0] === 'i' || t[0] === 'I')).toBe(false) + expect(minimal.tags.some((t) => t[0] === 'published_at')).toBe(false) + + const full = createWebBookmarkDraftEvent({ + url, + title: 'Nordspanien', + note: 'Detailed description', + topicTags: ['travel'], + publishedAtUnix: '1738863000' + }) + expect(full.content).toBe('Detailed description') + expect(full.tags).toEqual([ + [ + 'd', + 'www.br.de/radio/bayern2/sendungen/radioreisen/nordspanien-asturiens-menschen-und-mythen-kalksteingebirge-picos-de-europa-g-102.html' + ], + ['title', 'Nordspanien'], + ['published_at', '1738863000'], + ['t', 'travel'] + ]) + }) +}) diff --git a/src/lib/web-bookmark-nip.ts b/src/lib/web-bookmark-nip.ts index ef12bd50..aecf75c8 100644 --- a/src/lib/web-bookmark-nip.ts +++ b/src/lib/web-bookmark-nip.ts @@ -1,4 +1,8 @@ -import { canonicalizeRssArticleUrl } from '@/lib/rss-article' +import { + canonicalizeRssArticleUrl, + expandArticleUrlThreadQueryValues, + normalizeHttpArticleUrl +} from '@/lib/rss-article' /** * NIP-B0: `d` tag is the URL without the scheme (`https://` / `http://` assumed). @@ -10,3 +14,20 @@ export function urlToWebBookmarkDTag(url: string): string { t.startsWith('http://') || t.startsWith('https://') ? canonicalizeRssArticleUrl(t) : `https://${t}` return withScheme.replace(/^https?:\/\//i, '') } + +/** Parse NIP-B0 `d` tag (scheme-less URL) back to a canonical http(s) URL. */ +export function webBookmarkDTagToUrl(dTag: string): string | null { + return normalizeHttpArticleUrl(dTag.trim()) +} + +/** `d`-tag values for REQ `#d` filters when resolving bookmarks for one article URL. */ +export function expandWebBookmarkDTagQueryValues(canonicalUrl: string): string[] { + const out = new Set() + for (const u of expandArticleUrlThreadQueryValues(canonicalUrl)) { + const d = urlToWebBookmarkDTag(u) + if (d) out.add(d) + } + const direct = urlToWebBookmarkDTag(canonicalUrl) + if (direct) out.add(direct) + return [...out] +} diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 180347f5..66d19706 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -20,6 +20,7 @@ import { getWebExternalReactionTargetUrl, rssArticleStableEventId } from '@/lib/rss-article' +import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip' import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags' import type { TThreadRootRef } from '@/lib/thread-reply-root-match' import { filterRelaysToUserAllowlist, isRelayInUserAllowlist } from '@/lib/relay-allowlist' @@ -733,12 +734,16 @@ class NoteStatsService { const canonical = canonicalizeRssArticleUrl(url) const tagVals = expandArticleUrlThreadQueryValues(canonical) const iVals = tagVals.length > 0 ? tagVals : [canonical] + const dVals = expandWebBookmarkDTagQueryValues(canonical) const nonSocial: Filter[] = [ { '#i': iVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, { '#I': iVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, { '#i': iVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit: 200 }, { '#I': iVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit: 200 } ] + if (dVals.length > 0) { + nonSocial.push({ '#d': dVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit: 200 }) + } if (tagVals.length > 0) { nonSocial.push( { '#r': tagVals, kinds: [kinds.Highlights], limit: interactionLimit },