feat: resolve and display article titles for naddr references

- Add articleTitleResolver service to fetch article titles from relays
- Extract naddr identifiers from markdown content
- Fetch article titles in parallel using relay pool
- Replace naddr references with actual article titles
- Fallback to identifier if title fetch fails
- Update markdown processing to be async for title resolution
- Pass relayPool through component tree to enable resolution

Example: nostr:naddr1... now shows as "My Article Title" instead of "article:identifier"

Improves readability by showing human-friendly article titles in cross-references
This commit is contained in:
Gigi
2025-10-11 01:47:11 +01:00
parent 078a13c45d
commit ffe848883e
5 changed files with 196 additions and 18 deletions

View File

@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { Highlight } from '../types/highlights'
import { readingTime } from 'reading-time-estimator'
import { hexToRgb } from '../utils/colorHelpers'
@@ -32,6 +33,7 @@ interface ContentPanelProps {
currentUserPubkey?: string
followedPubkeys?: Set<string>
settings?: UserSettings
relayPool?: RelayPool | null
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
@@ -51,6 +53,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlightStyle = 'marker',
highlightColor = '#ffff00',
settings,
relayPool,
onHighlightClick,
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -59,7 +62,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection
}) => {
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
html,

View File

@@ -302,6 +302,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
/>
)}
</div>

View File

@@ -1,11 +1,16 @@
import React, { useState, useEffect, useRef } from 'react'
import { replaceNostrUrisInMarkdown } from '../utils/nostrUriResolver'
import { RelayPool } from 'applesauce-relay'
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
import { fetchArticleTitles } from '../services/articleTitleResolver'
/**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
* Also processes nostr: URIs in the markdown
* Also processes nostr: URIs in the markdown and resolves article titles
*/
export const useMarkdownToHTML = (markdown?: string): {
export const useMarkdownToHTML = (
markdown?: string,
relayPool?: RelayPool | null
): {
renderedHtml: string
previewRef: React.RefObject<HTMLDivElement>
processedMarkdown: string
@@ -21,24 +26,59 @@ export const useMarkdownToHTML = (markdown?: string): {
return
}
// Process nostr: URIs in markdown before rendering
const processed = replaceNostrUrisInMarkdown(markdown)
setProcessedMarkdown(processed)
let isCancelled = false
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
const processMarkdown = async () => {
// Extract all naddr references
const naddrs = extractNaddrUris(markdown)
let processed: string
if (naddrs.length > 0 && relayPool) {
// Fetch article titles for all naddrs
try {
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
if (isCancelled) return
// Replace nostr URIs with resolved titles
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
console.log(`📚 Resolved ${articleTitles.size} article titles`)
} catch (error) {
console.warn('Failed to fetch article titles:', error)
// Fall back to basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
} else {
console.warn('⚠️ markdownPreviewRef.current is null')
// No articles to resolve, use basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
})
if (isCancelled) return
setProcessedMarkdown(processed)
return () => cancelAnimationFrame(rafId)
}, [markdown])
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
} else if (!isCancelled) {
console.warn('⚠️ markdownPreviewRef.current is null')
}
})
return () => cancelAnimationFrame(rafId)
}
processMarkdown()
return () => {
isCancelled = true
}
}, [markdown, relayPool])
return { renderedHtml, previewRef, processedMarkdown }
}

View File

@@ -0,0 +1,87 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
const { getArticleTitle } = Helpers
/**
* Fetch article title for a single naddr
* Returns the title or null if not found/error
*/
export async function fetchArticleTitle(
relayPool: RelayPool,
naddr: string
): Promise<string | null> {
try {
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
// Fetch the article event
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
)
if (events.length === 0) {
return null
}
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
return getArticleTitle(article) || null
} catch (err) {
console.warn('Failed to fetch article title for', naddr, err)
return null
}
}
/**
* Fetch titles for multiple naddrs in parallel
* Returns a map of naddr -> title
*/
export async function fetchArticleTitles(
relayPool: RelayPool,
naddrs: string[]
): Promise<Map<string, string>> {
const titleMap = new Map<string, string>()
// Fetch all titles in parallel
const results = await Promise.allSettled(
naddrs.map(async (naddr) => {
const title = await fetchArticleTitle(relayPool, naddr)
return { naddr, title }
})
)
// Process results
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value.title) {
titleMap.set(result.value.naddr, result.value.title)
}
})
return titleMap
}

View File

@@ -23,6 +23,21 @@ export function extractNostrUris(text: string): string[] {
})
}
/**
* Extract all naddr (article) identifiers from text
*/
export function extractNaddrUris(text: string): string[] {
const allUris = extractNostrUris(text)
return allUris.filter(uri => {
try {
const decoded = decode(uri)
return decoded.type === 'naddr'
} catch {
return false
}
})
}
/**
* Decode a NIP-19 identifier and return a human-readable link
* For articles (naddr), returns an internal app link
@@ -99,6 +114,38 @@ export function replaceNostrUrisInMarkdown(markdown: string): string {
})
}
/**
* Replace nostr: URIs in markdown with proper markdown links, using resolved titles for articles
* This converts: nostr:naddr1... to [Article Title](link)
* @param markdown The markdown content to process
* @param articleTitles Map of naddr -> title for resolved articles
*/
export function replaceNostrUrisInMarkdownWithTitles(
markdown: string,
articleTitles: Map<string, string>
): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
// For articles, use the resolved title if available
try {
const decoded = decode(encoded)
if (decoded.type === 'naddr' && articleTitles.has(encoded)) {
const title = articleTitles.get(encoded)!
return `[${title}](${link})`
}
} catch (error) {
// Ignore decode errors, fall through to default label
}
// For other types or if title not resolved, use default label
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/**
* Replace nostr: URIs in HTML with clickable links
* This is used when processing HTML content directly