17 changed files with 741 additions and 148 deletions
@ -0,0 +1,48 @@
@@ -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 @@
@@ -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 @@
@@ -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