19 changed files with 641 additions and 144 deletions
@ -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 @@ |
|||||||
|
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