You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

206 lines
7.6 KiB

import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import { ExtendedKind } from '@/constants'
import { cleanUrl } from '@/lib/url'
import { kinds, type Event } from 'nostr-tools'
/** NIP-22: `K` / `k` value for http(s) URL comment scopes (web pages, articles). */
export const NIP22_URL_SCOPE_KIND = 'web'
/** Encode article URL for a single path segment (UTF-8 → base64url, no padding). */
export function encodeRssArticlePathSegment(articleUrl: string): string {
const bytes = new TextEncoder().encode(articleUrl)
let binary = ''
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!)
const b64 = btoa(binary)
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function decodeRssArticlePathSegment(segment: string): string {
const b64 = segment.replace(/-/g, '+').replace(/_/g, '/')
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4))
const binary = atob(b64 + pad)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
return new TextDecoder().decode(out)
}
/** Stable fake event id for caching / stats keys (not a published note id). */
export function rssArticleStableEventId(articleUrl: string): string {
return bytesToHex(sha256(new TextEncoder().encode(`rss-thread-root:${articleUrl}`)))
}
/** Strip tracking params from http(s) article URLs; leave other values unchanged. */
export function canonicalizeRssArticleUrl(url: string): string {
const t = url.trim()
if (!t.startsWith('http://') && !t.startsWith('https://')) return t
return cleanUrl(t) || t
}
/** Normalize user input to an http(s) URL for manual article threads; returns null if invalid. */
export function normalizeHttpArticleUrl(raw: string): string | null {
let s = raw.trim()
if (!s) return null
if (!/^https?:\/\//i.test(s)) {
s = `https://${s}`
}
try {
const u = new URL(s)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
return canonicalizeRssArticleUrl(u.href)
} catch {
return null
}
}
/**
* Synthetic parent event for kind 1111 comments on an RSS article.
* Thread is keyed by the article URL in both `i` and `I` tags (no e/a root).
*/
export function createRssThreadRootEvent(articleUrl: string): Event {
const canonical = canonicalizeRssArticleUrl(articleUrl)
return {
id: rssArticleStableEventId(canonical),
pubkey: '0'.repeat(64),
created_at: 0,
kind: ExtendedKind.RSS_THREAD_ROOT,
tags: [
['I', canonical],
['i', canonical],
['K', NIP22_URL_SCOPE_KIND],
['k', NIP22_URL_SCOPE_KIND]
],
content: '',
sig: ''
}
}
export function getArticleUrlFromCommentITags(event: Event): string | undefined {
const upper = event.tags.find((t) => t[0] === 'I')?.[1]
if (upper) return upper
return event.tags.find((t) => t[0] === 'i')?.[1]
}
/** HTTP(S) URL from kind 39701 web bookmarks (`i`/`I`/`r` tags). */
export function getWebBookmarkArticleUrl(event: Pick<Event, 'kind' | 'tags'>): string | undefined {
if (event.kind !== ExtendedKind.WEB_BOOKMARK) return undefined
const fromII = getArticleUrlFromCommentITags(event as Event)
if (fromII && (fromII.startsWith('http://') || fromII.startsWith('https://'))) {
return canonicalizeRssArticleUrl(fromII)
}
const fromR = getHighlightSourceHttpUrl(event as Event)
if (fromR) return fromR
for (const t of event.tags) {
if (t[0] === 'r' && t[1]?.trim()) {
const u = t[1].trim()
if (u.startsWith('http://') || u.startsWith('https://')) return canonicalizeRssArticleUrl(u)
}
}
return undefined
}
/** HTTP(S) page URL from kind 9802 `r` tags. */
export function getHighlightSourceHttpUrl(event: Pick<Event, 'tags'>): string | undefined {
for (const t of event.tags) {
if (!t[0] || String(t[0]).toLowerCase() !== 'r' || !t[1]) continue
const u = t[1].trim()
if (!u.startsWith('http://') && !u.startsWith('https://')) continue
const marker = (t[2] ?? '').trim().toLowerCase()
// NIP-84: only `mention` marks a non-source URL; everything else (bare `r`, `source`, `-`, unknown) is the page.
if (marker === 'mention') continue
return canonicalizeRssArticleUrl(u)
}
return undefined
}
/** NIP-73: kind 7 reaction targeting an http(s) page via `r` tags (same disambiguation as highlights). */
export function getReactionPageUrlFromRTags(event: Pick<Event, 'kind' | 'tags'>): string | undefined {
if (event.kind !== kinds.Reaction) return undefined
return getHighlightSourceHttpUrl(event)
}
/**
* Canonical article URL plus common string variants for REQ filters (`i` / `I` / `r`).
* Relay matching is exact on tag values, so trailing slashes, query stripping, etc. are included.
*/
export function expandArticleUrlThreadQueryValues(canonicalUrl: string): string[] {
const s = canonicalUrl.trim()
if (!s.startsWith('http://') && !s.startsWith('https://')) return []
const out = new Set<string>([s])
try {
const u = new URL(s)
if (u.search) {
out.add(`${u.origin}${u.pathname}`)
}
const p = u.pathname
if (p.length > 1 && p.endsWith('/')) {
out.add(`${u.origin}${p.slice(0, -1)}${u.search}`)
} else if (p.length > 0 && !p.endsWith('/')) {
out.add(`${u.origin}${p}/${u.search}`)
}
} catch {
/* ignore */
}
return [...out]
}
/** True if `urlFromEvent` refers to the same article as `canonicalThreadKey` (after normalization + variant match). */
export function articleUrlMatchesThreadScope(urlFromEvent: string, canonicalThreadKey: string): boolean {
const key = canonicalizeRssArticleUrl(canonicalThreadKey)
const cand = canonicalizeRssArticleUrl(urlFromEvent)
if (key === cand) return true
const keyVariants = new Set(expandArticleUrlThreadQueryValues(key))
if (keyVariants.has(cand)) return true
for (const v of expandArticleUrlThreadQueryValues(cand)) {
if (keyVariants.has(v)) return true
}
return false
}
/** True for http(s) URLs whose host is clawstr.com (incl. subdomains; supports protocol-relative `//…`). */
export function isClawstrDotComHttpUrl(url: string): boolean {
const t = url.trim()
if (!t) return false
try {
const u = t.startsWith('//') ? new URL(`https:${t}`) : new URL(t)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
const host = u.hostname.toLowerCase()
return host === 'clawstr.com' || host.endsWith('.clawstr.com')
} catch {
return false
}
}
/** Same as {@link isClawstrDotComHttpUrl} — use for `href` attributes in HTML. */
export function isClawstrDotComHttpHref(href: string): boolean {
return isClawstrDotComHttpUrl(href)
}
/**
* NIP-25 kind 17 + NIP-73: resolve http(s) target URL for a `k: web` external reaction.
* Stops at the next `k` tag so podcast-style multi-scope reactions are not mis-parsed as web.
*/
export function getWebExternalReactionTargetUrl(event: Pick<Event, 'kind' | 'tags'>): string | undefined {
if (event.kind !== ExtendedKind.EXTERNAL_REACTION) return undefined
const tags = event.tags
for (let i = 0; i < tags.length; i++) {
const row = tags[i]
if (row[0] !== 'k' || row[1] !== NIP22_URL_SCOPE_KIND) continue
for (let j = i + 1; j < tags.length; j++) {
const t = tags[j]
if (t[0] === 'k') break
if (t[0] === 'i' && t[1]) {
const url = t[1]
if (url.startsWith('http://') || url.startsWith('https://')) {
return canonicalizeRssArticleUrl(url)
}
}
}
}
return undefined
}
/** Client-only RSS thread parent (non-standard kind); not a real relay event. */
export function isRssThreadSyntheticParentEvent(event: Pick<Event, 'kind'>): boolean {
return event.kind === ExtendedKind.RSS_THREAD_ROOT
}