From ffe848883eb71a8da6babfc14705913f7534a77c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 11 Oct 2025 01:47:11 +0100 Subject: [PATCH] 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 --- src/components/ContentPanel.tsx | 5 +- src/components/ThreePaneLayout.tsx | 1 + src/hooks/useMarkdownToHTML.ts | 74 +++++++++++++++++------ src/services/articleTitleResolver.ts | 87 ++++++++++++++++++++++++++++ src/utils/nostrUriResolver.tsx | 47 +++++++++++++++ 5 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 src/services/articleTitleResolver.ts diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 7fb12347..d4a5a19c 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -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 settings?: UserSettings + relayPool?: RelayPool | null // For highlight creation onTextSelection?: (text: string) => void onClearSelection?: () => void @@ -51,6 +53,7 @@ const ContentPanel: React.FC = ({ highlightStyle = 'marker', highlightColor = '#ffff00', settings, + relayPool, onHighlightClick, selectedHighlightId, highlightVisibility = { nostrverse: true, friends: true, mine: true }, @@ -59,7 +62,7 @@ const ContentPanel: React.FC = ({ onTextSelection, onClearSelection }) => { - const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown) + const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool) const { finalHtml, relevantHighlights } = useHighlightedContent({ html, diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index 64053547..19f517c3 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -302,6 +302,7 @@ const ThreePaneLayout: React.FC = (props) => { currentUserPubkey={props.currentUserPubkey} followedPubkeys={props.followedPubkeys} settings={props.settings} + relayPool={props.relayPool} /> )} diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 8d97dd3c..7cc68a2b 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -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 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 } } diff --git a/src/services/articleTitleResolver.ts b/src/services/articleTitleResolver.ts new file mode 100644 index 00000000..f990bb35 --- /dev/null +++ b/src/services/articleTitleResolver.ts @@ -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 { + 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> { + const titleMap = new Map() + + // 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 +} + diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 284b02b0..804faddb 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -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 { + 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