17 changed files with 741 additions and 148 deletions
@ -0,0 +1,48 @@ |
|||||||
|
import client from '@/services/client.service' |
||||||
|
import { useNoteStatsById } from '@/hooks/useNoteStatsById' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import { useEffect, useMemo, useState } from 'react' |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches full kind-9802 events for {@link useNoteStatsById}(noteId).highlights so the note body can paint NIP-84 marks. |
||||||
|
*/ |
||||||
|
export function useNip84HighlightTargetEvents(note: Event | null | undefined): Event[] { |
||||||
|
const id = note?.id ?? '' |
||||||
|
const noteStats = useNoteStatsById(id) |
||||||
|
const [events, setEvents] = useState<Event[]>([]) |
||||||
|
const highlightIdsKey = useMemo( |
||||||
|
() => (noteStats?.highlights ?? []).map((h) => h.id).join(','), |
||||||
|
[noteStats?.highlights] |
||||||
|
) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!note || note.kind !== kinds.ShortTextNote) { |
||||||
|
setEvents([]) |
||||||
|
return |
||||||
|
} |
||||||
|
const ids = highlightIdsKey.split(',').filter(Boolean) |
||||||
|
if (ids.length === 0) { |
||||||
|
setEvents([]) |
||||||
|
return |
||||||
|
} |
||||||
|
let cancelled = false |
||||||
|
void (async () => { |
||||||
|
const loaded: Event[] = [] |
||||||
|
for (const hid of ids) { |
||||||
|
try { |
||||||
|
const ev = await client.fetchEvent(hid) |
||||||
|
if (ev && ev.kind === kinds.Highlights) loaded.push(ev) |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} |
||||||
|
if (cancelled) return |
||||||
|
} |
||||||
|
if (!cancelled) setEvents(loaded) |
||||||
|
})() |
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, [note?.id, note?.kind, highlightIdsKey]) |
||||||
|
|
||||||
|
return events |
||||||
|
} |
||||||
@ -0,0 +1,75 @@ |
|||||||
|
import { Fragment, type ReactNode } from 'react' |
||||||
|
import { kinds, type Event } from 'nostr-tools' |
||||||
|
import { resolveNip84HighlightDisplay } from '@/lib/nip84-highlight-display' |
||||||
|
|
||||||
|
/** Same classes as {@link Highlight} NIP-84 span marks (keep in sync manually). */ |
||||||
|
export const NIP84_HIGHLIGHT_MARK_CLASSNAME = |
||||||
|
'bg-green-200 dark:bg-green-600 dark:text-white px-1 rounded font-medium' |
||||||
|
|
||||||
|
function stripOuterQuotes(s: string): string { |
||||||
|
let t = s.trim() |
||||||
|
if (t.startsWith('"') && t.endsWith('"')) { |
||||||
|
t = t.slice(1, -1).trim() |
||||||
|
} |
||||||
|
return t |
||||||
|
} |
||||||
|
|
||||||
|
function highlightTargetsNoteHex(h: Event, opHex: string): boolean { |
||||||
|
const want = opHex.trim().toLowerCase() |
||||||
|
if (!/^[0-9a-f]{64}$/i.test(want)) return false |
||||||
|
return h.tags.some( |
||||||
|
(t) => (t[0] === 'e' || t[0] === 'E') && typeof t[1] === 'string' && t[1].trim().toLowerCase() === want |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
/** Non-overlapping merged intervals for first occurrence of each highlight’s marked span in `baseText`. */ |
||||||
|
export function mergeNip84MarkedIntervals( |
||||||
|
baseText: string, |
||||||
|
highlightEvents: Event[], |
||||||
|
opEventHexId: string |
||||||
|
): { start: number; end: number }[] { |
||||||
|
const intervals: { start: number; end: number }[] = [] |
||||||
|
for (const ev of highlightEvents) { |
||||||
|
if (ev.kind !== kinds.Highlights) continue |
||||||
|
if (!highlightTargetsNoteHex(ev, opEventHexId)) continue |
||||||
|
const { markedSpan } = resolveNip84HighlightDisplay(ev) |
||||||
|
const needle = stripOuterQuotes(markedSpan) |
||||||
|
if (!needle) continue |
||||||
|
const idx = baseText.indexOf(needle) |
||||||
|
if (idx >= 0) intervals.push({ start: idx, end: idx + needle.length }) |
||||||
|
} |
||||||
|
if (intervals.length === 0) return [] |
||||||
|
intervals.sort((a, b) => a.start - b.start) |
||||||
|
const merged: { start: number; end: number }[] = [] |
||||||
|
for (const cur of intervals) { |
||||||
|
const prev = merged[merged.length - 1] |
||||||
|
if (!prev || cur.start > prev.end) merged.push({ ...cur }) |
||||||
|
else prev.end = Math.max(prev.end, cur.end) |
||||||
|
} |
||||||
|
return merged |
||||||
|
} |
||||||
|
|
||||||
|
export function renderPlaintextWithNip84MergedMarks( |
||||||
|
baseText: string, |
||||||
|
merged: { start: number; end: number }[] |
||||||
|
): ReactNode { |
||||||
|
if (merged.length === 0) return baseText |
||||||
|
const nodes: ReactNode[] = [] |
||||||
|
let cursor = 0 |
||||||
|
let i = 0 |
||||||
|
for (const m of merged) { |
||||||
|
if (cursor < m.start) nodes.push(<Fragment key={`t-${i++}`}>{baseText.slice(cursor, m.start)}</Fragment>) |
||||||
|
nodes.push( |
||||||
|
<mark |
||||||
|
key={`m-${m.start}-${m.end}`} |
||||||
|
className={NIP84_HIGHLIGHT_MARK_CLASSNAME} |
||||||
|
data-nip84-highlight="span" |
||||||
|
> |
||||||
|
{baseText.slice(m.start, m.end)} |
||||||
|
</mark> |
||||||
|
) |
||||||
|
cursor = m.end |
||||||
|
} |
||||||
|
if (cursor < baseText.length) nodes.push(<Fragment key={`t-${i++}`}>{baseText.slice(cursor)}</Fragment>) |
||||||
|
return <>{nodes}</> |
||||||
|
} |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
getReplaceableCoordinateFromEvent, |
||||||
|
isReplaceableEvent, |
||||||
|
normalizeReplaceableCoordinateString |
||||||
|
} from '@/lib/event' |
||||||
|
import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' |
||||||
|
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' |
||||||
|
import type { TThreadRootRef } from '@/lib/thread-reply-root-match' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
const REF_TAG_NAMES = new Set(['e', 'E', 'a', 'A', 'q', 'Q']) |
||||||
|
|
||||||
|
/** |
||||||
|
* True if any `e` / `E` / `a` / `A` / `q` / `Q` tag on `evt` references the thread root described by `root` |
||||||
|
* (hex id, replaceable coordinate, or canonical article URL). Used for broad “references OP” discovery |
||||||
|
* where {@link getRootEventHexId} does not apply (e.g. long-form on the OP). |
||||||
|
*/ |
||||||
|
export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): boolean { |
||||||
|
if (root.type === 'I') { |
||||||
|
return isRssArticleUrlThreadInteraction(evt, root.id) |
||||||
|
} |
||||||
|
if (root.type === 'A') { |
||||||
|
const coordNorm = normalizeReplaceableCoordinateString(root.id) |
||||||
|
const eventHex = root.eventId.trim().toLowerCase() |
||||||
|
for (const t of evt.tags) { |
||||||
|
const name = t[0] |
||||||
|
if (!REF_TAG_NAMES.has(name)) continue |
||||||
|
const v = typeof t[1] === 'string' ? t[1].trim() : '' |
||||||
|
if (!v) continue |
||||||
|
if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === eventHex) return true |
||||||
|
if (name === 'a' || name === 'A') { |
||||||
|
if (normalizeReplaceableCoordinateString(v) === coordNorm) return true |
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
const hex = root.id.trim().toLowerCase() |
||||||
|
if (!/^[0-9a-f]{64}$/i.test(hex)) return false |
||||||
|
for (const t of evt.tags) { |
||||||
|
const name = t[0] |
||||||
|
if (!REF_TAG_NAMES.has(name)) continue |
||||||
|
const v = typeof t[1] === 'string' ? t[1].trim() : '' |
||||||
|
if (!v) continue |
||||||
|
if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === hex) return true |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
/** Build thread root ref from the note/article stats root (same shapes as {@link ReplyNoteList} `rootInfo`). */ |
||||||
|
export function threadRootRefFromStatsRootEvent(event: Event): TThreadRootRef | undefined { |
||||||
|
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { |
||||||
|
const url = getArticleUrlFromCommentITags(event) |
||||||
|
if (!url) return undefined |
||||||
|
return { type: 'I', id: canonicalizeRssArticleUrl(url) } |
||||||
|
} |
||||||
|
if (isReplaceableEvent(event.kind)) { |
||||||
|
return { |
||||||
|
type: 'A', |
||||||
|
id: getReplaceableCoordinateFromEvent(event), |
||||||
|
eventId: event.id, |
||||||
|
pubkey: event.pubkey |
||||||
|
} |
||||||
|
} |
||||||
|
return { type: 'E', id: event.id.trim().toLowerCase(), pubkey: event.pubkey } |
||||||
|
} |
||||||
Loading…
Reference in new issue