Browse Source

support non-standard web bookmarks

imwald
Silberengel 1 week ago
parent
commit
941cfab5d0
  1. 9
      src/components/RssArticleWebBookmarks/index.tsx
  2. 16
      src/lib/draft-event.ts
  3. 7
      src/lib/rss-article.ts
  4. 85
      src/lib/web-bookmark-nip.test.ts
  5. 23
      src/lib/web-bookmark-nip.ts
  6. 5
      src/services/note-stats.service.ts

9
src/components/RssArticleWebBookmarks/index.tsx

@ -14,6 +14,7 @@ import {
expandArticleUrlThreadQueryValues, expandArticleUrlThreadQueryValues,
getWebBookmarkArticleUrl getWebBookmarkArticleUrl
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -38,6 +39,7 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str
const v = expandArticleUrlThreadQueryValues(canonical) const v = expandArticleUrlThreadQueryValues(canonical)
return v.length > 0 ? v : [canonical] return v.length > 0 ? v : [canonical]
}, [canonical]) }, [canonical])
const dVals = useMemo(() => expandWebBookmarkDTagQueryValues(canonical), [canonical])
const relayUrls = useMemo(() => { const relayUrls = useMemo(() => {
const read = userReadInboxUrls(relayList, cacheRelayListEvent) const read = userReadInboxUrls(relayList, cacheRelayListEvent)
@ -61,7 +63,10 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str
try { try {
const filters = [ 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 } { 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( const batches = await Promise.all(
filters.map((f) => client.fetchEvents(relayUrls, f, { cache: false }).catch(() => [] as Event[])) filters.map((f) => client.fetchEvents(relayUrls, f, { cache: false }).catch(() => [] as Event[]))
@ -83,7 +88,7 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [pubkey, relayUrls, iVals, canonical]) }, [pubkey, relayUrls, iVals, dVals, canonical])
useEffect(() => { useEffect(() => {
void reload() void reload()

16
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: { export function createWebBookmarkDraftEvent(options: {
url: string url: string
title?: string title?: string
/** NIP-B0 `.content` — detailed description (optional). */
note?: string note?: string
/** Preserve first publication time when editing (unix seconds string). */ /** Preserve first publication time when editing (unix seconds string). */
publishedAtUnix?: string publishedAtUnix?: string
@ -1060,26 +1061,23 @@ export function createWebBookmarkDraftEvent(options: {
const raw = options.url.trim() const raw = options.url.trim()
if (!raw) throw new Error('Web bookmark URL is required') if (!raw) throw new Error('Web bookmark URL is required')
const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}` const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`
const canonical = canonicalizeHttpUrlForITags(canonicalizeRssArticleUrl(href)) const canonical = canonicalizeRssArticleUrl(href)
const d = urlToWebBookmarkDTag(canonical) const d = urlToWebBookmarkDTag(canonical)
if (!d) throw new Error('Invalid web bookmark URL') if (!d) throw new Error('Invalid web bookmark URL')
const tags: string[][] = [ const tags: string[][] = [['d', d]]
['d', d],
['I', canonical],
['i', canonical]
]
const title = options.title?.trim() const title = options.title?.trim()
if (title) tags.push(['title', title]) if (title) tags.push(['title', title])
const now = dayjs().unix() const publishedAt = options.publishedAtUnix?.trim()
tags.push(['published_at', options.publishedAtUnix ?? String(now)]) if (publishedAt) tags.push(['published_at', publishedAt])
for (const topic of options.topicTags ?? []) { for (const topic of options.topicTags ?? []) {
const n = normalizeTopic(topic) const n = normalizeTopic(topic)
if (n) tags.push(['t', n]) if (n) tags.push(['t', n])
} }
const now = dayjs().unix()
return { return {
kind: ExtendedKind.WEB_BOOKMARK, kind: ExtendedKind.WEB_BOOKMARK,
content: options.note?.trim() ?? '', content: options.note?.trim() ?? '',

7
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] 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<Event, 'kind' | 'tags'>): string | undefined { export function getWebBookmarkArticleUrl(event: Pick<Event, 'kind' | 'tags'>): string | undefined {
if (event.kind !== ExtendedKind.WEB_BOOKMARK) return undefined if (event.kind !== ExtendedKind.WEB_BOOKMARK) return undefined
const fromII = getArticleUrlFromCommentITags(event as Event) const fromII = getArticleUrlFromCommentITags(event as Event)
@ -96,6 +96,11 @@ export function getWebBookmarkArticleUrl(event: Pick<Event, 'kind' | 'tags'>): s
if (u.startsWith('http://') || u.startsWith('https://')) return canonicalizeRssArticleUrl(u) 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 return undefined
} }

85
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<Event, 'kind' | 'tags'> = {
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<Event, 'kind' | 'tags'> = {
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']
])
})
})

23
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). * 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}` t.startsWith('http://') || t.startsWith('https://') ? canonicalizeRssArticleUrl(t) : `https://${t}`
return withScheme.replace(/^https?:\/\//i, '') 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<string>()
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]
}

5
src/services/note-stats.service.ts

@ -20,6 +20,7 @@ import {
getWebExternalReactionTargetUrl, getWebExternalReactionTargetUrl,
rssArticleStableEventId rssArticleStableEventId
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip'
import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags' import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags'
import type { TThreadRootRef } from '@/lib/thread-reply-root-match' import type { TThreadRootRef } from '@/lib/thread-reply-root-match'
import { filterRelaysToUserAllowlist, isRelayInUserAllowlist } from '@/lib/relay-allowlist' import { filterRelaysToUserAllowlist, isRelayInUserAllowlist } from '@/lib/relay-allowlist'
@ -733,12 +734,16 @@ class NoteStatsService {
const canonical = canonicalizeRssArticleUrl(url) const canonical = canonicalizeRssArticleUrl(url)
const tagVals = expandArticleUrlThreadQueryValues(canonical) const tagVals = expandArticleUrlThreadQueryValues(canonical)
const iVals = tagVals.length > 0 ? tagVals : [canonical] const iVals = tagVals.length > 0 ? tagVals : [canonical]
const dVals = expandWebBookmarkDTagQueryValues(canonical)
const nonSocial: Filter[] = [ const nonSocial: Filter[] = [
{ '#i': iVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, { '#i': iVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit },
{ '#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 },
{ '#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) { if (tagVals.length > 0) {
nonSocial.push( nonSocial.push(
{ '#r': tagVals, kinds: [kinds.Highlights], limit: interactionLimit }, { '#r': tagVals, kinds: [kinds.Highlights], limit: interactionLimit },

Loading…
Cancel
Save