19 changed files with 641 additions and 144 deletions
@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
|
||||
import NoteCard from '@/components/NoteCard' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' |
||||
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' |
||||
import { |
||||
buildRssArticleUrlThreadInteractionFilters, |
||||
isRssArticleUrlThreadInteraction |
||||
} from '@/lib/rss-web-feed' |
||||
import { queryService } from '@/services/client.service' |
||||
import type { Event } from 'nostr-tools' |
||||
import { useEffect, useMemo, useState } from 'react' |
||||
|
||||
const PREVIEW_LIMIT = 5 |
||||
const FETCH_LIMIT = 24 |
||||
|
||||
/** |
||||
* Compact Nostr thread rows (comments + highlights) for an article URL card in the RSS+Web feed. |
||||
*/ |
||||
export default function RssUrlThreadEventsPreview({ canonicalUrl }: { canonicalUrl: string }) { |
||||
const { relays, key: relayHintsKey } = useNoteStatsRelayHints() |
||||
const relayUrls = useMemo( |
||||
() => [...new Set([...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...relays])], |
||||
[relays] |
||||
) |
||||
const [events, setEvents] = useState<Event[]>([]) |
||||
const [loading, setLoading] = useState(true) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
setLoading(true) |
||||
const filters = buildRssArticleUrlThreadInteractionFilters(canonicalUrl, FETCH_LIMIT) |
||||
void queryService |
||||
.fetchEvents(relayUrls, filters) |
||||
.then((all) => { |
||||
if (cancelled) return |
||||
const seen = new Set<string>() |
||||
const merged: Event[] = [] |
||||
for (const e of [...all].sort((a, b) => b.created_at - a.created_at)) { |
||||
if (seen.has(e.id)) continue |
||||
if (!isRssArticleUrlThreadInteraction(e, canonicalUrl)) continue |
||||
seen.add(e.id) |
||||
merged.push(e) |
||||
} |
||||
setEvents(merged.slice(0, PREVIEW_LIMIT)) |
||||
}) |
||||
.catch(() => { |
||||
if (!cancelled) setEvents([]) |
||||
}) |
||||
.finally(() => { |
||||
if (!cancelled) setLoading(false) |
||||
}) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [canonicalUrl, relayHintsKey, relayUrls]) |
||||
|
||||
if (loading) { |
||||
return ( |
||||
<div |
||||
className="border-t border-border/50 bg-muted/10 px-3 py-2 pointer-events-auto space-y-2" |
||||
onClick={(e) => e.stopPropagation()} |
||||
onKeyDown={(e) => e.stopPropagation()} |
||||
> |
||||
<Skeleton className="h-14 w-full rounded-md" /> |
||||
<Skeleton className="h-14 w-full rounded-md" /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (events.length === 0) return null |
||||
|
||||
return ( |
||||
<div |
||||
className="border-t border-border/50 bg-muted/10 pointer-events-auto max-h-72 overflow-y-auto" |
||||
onClick={(e) => e.stopPropagation()} |
||||
onKeyDown={(e) => e.stopPropagation()} |
||||
> |
||||
<div className="divide-y divide-border/40"> |
||||
{events.map((evt) => ( |
||||
<div key={evt.id} className="px-2 py-1.5"> |
||||
<NoteCard event={evt} className="border-0 bg-transparent shadow-none" hideParentNotePreview /> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
/** |
||||
* NIP-84 / Web Annotation style `textquoteselector` (prefix + exact + suffix). |
||||
* `exact` is always {@link Event.content}; prefix/suffix are adjacent source text. |
||||
* |
||||
* Common shapes: |
||||
* - `["textquoteselector", prefix, suffix]` (3 items) |
||||
* - `["textquoteselector", "-", prefix, suffix]` — leading "-" = empty slot (Hypothesis-style) |
||||
*/ |
||||
export function parseTextQuoteSelectorParts(tag: readonly string[]): { prefix: string; suffix: string } { |
||||
if (tag.length < 2 || tag[0] !== 'textquoteselector') { |
||||
return { prefix: '', suffix: '' } |
||||
} |
||||
if (tag.length >= 4 && tag[1] === '-') { |
||||
return { |
||||
prefix: (tag[2] ?? '').trim(), |
||||
suffix: (tag[3] ?? '').trim() |
||||
} |
||||
} |
||||
if (tag.length >= 3) { |
||||
return { |
||||
prefix: (tag[1] ?? '').trim(), |
||||
suffix: (tag[2] ?? '').trim() |
||||
} |
||||
} |
||||
return { prefix: '', suffix: '' } |
||||
} |
||||
|
||||
/** `["textpositionselector", start, end]` — character offsets into a full document string. */ |
||||
export function parseTextPositionSelector(tag: readonly string[]): { start: number; end: number } | null { |
||||
if (tag.length < 3 || tag[0] !== 'textpositionselector') return null |
||||
const start = parseInt(tag[1] ?? '', 10) |
||||
const end = parseInt(tag[2] ?? '', 10) |
||||
if (Number.isNaN(start) || Number.isNaN(end) || start < 0 || end <= start) return null |
||||
return { start, end } |
||||
} |
||||
|
||||
export type Nip84HighlightDisplay = { |
||||
/** Full passage to show in the quote box */ |
||||
fullText: string |
||||
/** Substring of fullText to wrap in <mark> */ |
||||
markedSpan: string |
||||
} |
||||
|
||||
/** |
||||
* Resolve which span to mark inside which full text, using `context`, `textquoteselector`, |
||||
* and optionally `textpositionselector` (only when offsets fit the base string). |
||||
*/ |
||||
export function resolveNip84HighlightDisplay(event: Pick<Event, 'content' | 'tags'>): Nip84HighlightDisplay { |
||||
const highlightedText = event.content ?? '' |
||||
const tags = event.tags |
||||
|
||||
const contextTag = tags.find((t) => t[0] === 'context') |
||||
const contextBody = contextTag?.[1]?.trim() ? contextTag[1] : undefined |
||||
|
||||
const posTag = tags.find((t) => t[0] === 'textpositionselector') |
||||
const pos = posTag ? parseTextPositionSelector(posTag) : null |
||||
|
||||
if (contextBody && pos) { |
||||
const { start, end } = pos |
||||
if (end <= contextBody.length) { |
||||
const slice = contextBody.slice(start, end) |
||||
if (slice.length > 0) { |
||||
return { fullText: contextBody, markedSpan: slice } |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (contextBody) { |
||||
return { fullText: contextBody, markedSpan: highlightedText } |
||||
} |
||||
|
||||
const tqs = tags.find((t) => t[0] === 'textquoteselector') |
||||
if (tqs) { |
||||
const { prefix, suffix } = parseTextQuoteSelectorParts(tqs) |
||||
const fullText = `${prefix}${highlightedText}${suffix}` |
||||
return { fullText, markedSpan: highlightedText } |
||||
} |
||||
|
||||
return { fullText: highlightedText, markedSpan: highlightedText } |
||||
} |
||||
Loading…
Reference in new issue