refactor: replace custom NIP-19 parsing with applesauce helpers and add progressive profile resolution

- Replace custom regex patterns with Tokens.nostrLink from applesauce-content
- Use getContentPointers() and getPubkeyFromDecodeResult() from applesauce helpers
- Add useProfileLabels hook for shared profile resolution logic
- Implement progressive profile name updates in markdown articles
- Remove unused ContentWithResolvedProfiles component
- Simplify useMarkdownToHTML by extracting profile resolution to shared hook
- Fix TypeScript and ESLint errors
This commit is contained in:
Gigi
2025-11-02 20:01:51 +01:00
parent 5896a5d6db
commit b7cda7a351
7 changed files with 167 additions and 115 deletions

View File

@@ -1,38 +0,0 @@
import React from 'react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, Helpers } from 'applesauce-core'
import { decode } from 'nostr-tools/nip19'
import { extractNprofilePubkeys } from '../utils/helpers'
const { getPubkeyFromDecodeResult } = Helpers
interface Props { content: string }
const ContentWithResolvedProfiles: React.FC<Props> = ({ content }) => {
const matches = extractNprofilePubkeys(content)
const decoded = matches
.map((m) => {
try { return decode(m) } catch { return undefined as undefined }
})
.filter((v): v is ReturnType<typeof decode> => Boolean(v))
const lookups = decoded
.map((res) => getPubkeyFromDecodeResult(res))
.filter((v): v is string => typeof v === 'string')
const profiles = lookups.map((pubkey) => ({ pubkey, profile: useEventModel(Models.ProfileModel, [pubkey]) }))
let rendered = content
matches.forEach((m, i) => {
const pk = getPubkeyFromDecodeResult(decoded[i])
const found = profiles.find((p) => p.pubkey === pk)
const name = found?.profile?.name || found?.profile?.display_name || found?.profile?.nip05 || `${pk?.slice(0,8)}...`
if (name) rendered = rendered.replace(m, `@${name}`)
})
return <div className="bookmark-content">{rendered}</div>
}
export default ContentWithResolvedProfiles

View File

@@ -1,7 +1,9 @@
import React from 'react' import React from 'react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' import { Models, Helpers } from 'applesauce-core'
const { getPubkeyFromDecodeResult } = Helpers
interface NostrMentionLinkProps { interface NostrMentionLinkProps {
nostrUri: string nostrUri: string
@@ -20,22 +22,17 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
}) => { }) => {
// Decode the nostr URI first // Decode the nostr URI first
let decoded: ReturnType<typeof nip19.decode> | null = null let decoded: ReturnType<typeof nip19.decode> | null = null
let pubkey: string | undefined
try { try {
const identifier = nostrUri.replace(/^nostr:/, '') const identifier = nostrUri.replace(/^nostr:/, '')
decoded = nip19.decode(identifier) decoded = nip19.decode(identifier)
// Extract pubkey for profile fetching (works for npub and nprofile)
if (decoded.type === 'npub') {
pubkey = decoded.data
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey
}
} catch (error) { } catch (error) {
// Decoding failed, will fallback to shortened identifier // Decoding failed, will fallback to shortened identifier
} }
// Extract pubkey for profile fetching using applesauce helper (works for npub and nprofile)
const pubkey = decoded ? getPubkeyFromDecodeResult(decoded) : undefined
// Fetch profile at top level (Rules of Hooks) // Fetch profile at top level (Rules of Hooks)
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null) const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)

View File

@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import NostrMentionLink from './NostrMentionLink' import NostrMentionLink from './NostrMentionLink'
import { Tokens } from 'applesauce-content/helpers'
interface RichContentProps { interface RichContentProps {
content: string content: string
@@ -19,17 +20,24 @@ const RichContent: React.FC<RichContentProps> = ({
className = 'bookmark-content' className = 'bookmark-content'
}) => { }) => {
// Pattern to match: // Pattern to match:
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) // 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.) // 2. http(s) URLs
// 3. http(s) URLs const nostrPattern = Tokens.nostrLink
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi const urlPattern = /https?:\/\/[^\s]+/gi
const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi')
const parts = content.split(pattern) const parts = content.split(combinedPattern)
// Helper to check if a string is a nostr identifier (without mutating regex state)
const isNostrIdentifier = (str: string): boolean => {
const testPattern = new RegExp(nostrPattern.source, nostrPattern.flags)
return testPattern.test(str)
}
return ( return (
<div className={className}> <div className={className}>
{parts.map((part, index) => { {parts.map((part, index) => {
// Handle nostr: URIs // Handle nostr: URIs - Tokens.nostrLink matches both formats
if (part.startsWith('nostr:')) { if (part.startsWith('nostr:')) {
return ( return (
<NostrMentionLink <NostrMentionLink
@@ -39,10 +47,8 @@ const RichContent: React.FC<RichContentProps> = ({
) )
} }
// Handle plain nostr identifiers (add nostr: prefix) // Handle plain nostr identifiers (Tokens.nostrLink matches these too)
if ( if (isNostrIdentifier(part)) {
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
) {
return ( return (
<NostrMentionLink <NostrMentionLink
key={index} key={index}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver' import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '../utils/nostrUriResolver'
import { fetchArticleTitles } from '../services/articleTitleResolver' import { fetchArticleTitles } from '../services/articleTitleResolver'
import { useProfileLabels } from './useProfileLabels'
/** /**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component * Hook to convert markdown to HTML using a hidden ReactMarkdown component
@@ -18,7 +19,43 @@ export const useMarkdownToHTML = (
const previewRef = useRef<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('') const [renderedHtml, setRenderedHtml] = useState<string>('')
const [processedMarkdown, setProcessedMarkdown] = useState<string>('') const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
const [articleTitles, setArticleTitles] = useState<Map<string, string>>(new Map())
// Resolve profile labels progressively as profiles load
const profileLabels = useProfileLabels(markdown || '')
// Fetch article titles
useEffect(() => {
if (!markdown || !relayPool) {
setArticleTitles(new Map())
return
}
let isCancelled = false
const fetchTitles = async () => {
const naddrs = extractNaddrUris(markdown)
if (naddrs.length === 0) {
setArticleTitles(new Map())
return
}
try {
const titlesMap = await fetchArticleTitles(relayPool!, naddrs)
if (!isCancelled) {
setArticleTitles(titlesMap)
}
} catch (error) {
console.warn('Failed to fetch article titles:', error)
if (!isCancelled) setArticleTitles(new Map())
}
}
fetchTitles()
return () => { isCancelled = true }
}, [markdown, relayPool])
// Process markdown with progressive profile labels and article titles
useEffect(() => { useEffect(() => {
// Always clear previous render immediately to avoid showing stale content while processing // Always clear previous render immediately to avoid showing stale content while processing
setRenderedHtml('') setRenderedHtml('')
@@ -30,36 +67,18 @@ export const useMarkdownToHTML = (
let isCancelled = false let isCancelled = false
const processMarkdown = async () => { const processMarkdown = () => {
// Extract all naddr references // Replace nostr URIs with profile labels (progressive) and article titles
const naddrs = extractNaddrUris(markdown) const processed = replaceNostrUrisInMarkdownWithProfileLabels(
markdown,
let processed: string profileLabels,
articleTitles
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)
} catch (error) {
console.warn('Failed to fetch article titles:', error)
// Fall back to basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
} else {
// No articles to resolve, use basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
if (isCancelled) return if (isCancelled) return
setProcessedMarkdown(processed) setProcessedMarkdown(processed)
const rafId = requestAnimationFrame(() => { const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) { if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML const html = previewRef.current.innerHTML
@@ -77,7 +96,7 @@ export const useMarkdownToHTML = (
return () => { return () => {
isCancelled = true isCancelled = true
} }
}, [markdown, relayPool]) }, [markdown, profileLabels, articleTitles])
return { renderedHtml, previewRef, processedMarkdown } return { renderedHtml, previewRef, processedMarkdown }
} }

View File

@@ -0,0 +1,45 @@
import { useMemo } from 'react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, Helpers } from 'applesauce-core'
import { getContentPointers } from 'applesauce-factory/helpers'
const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers
/**
* Hook to resolve profile labels from content containing npub/nprofile identifiers
* Returns a Map of encoded identifier -> display name that updates progressively as profiles load
*/
export function useProfileLabels(content: string): Map<string, string> {
// Extract profile pointers (npub and nprofile) using applesauce helpers
const profileData = useMemo(() => {
const pointers = getContentPointers(content)
return pointers
.filter(p => p.type === 'npub' || p.type === 'nprofile')
.map(pointer => ({
pubkey: getPubkeyFromDecodeResult(pointer),
encoded: encodeDecodeResult(pointer)
}))
.filter(p => p.pubkey)
}, [content])
// Fetch profiles for all found pubkeys (progressive loading)
const profiles = profileData.map(({ pubkey }) =>
useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
)
// Build profile labels map that updates reactively as profiles load
return useMemo(() => {
const labels = new Map<string, string>()
profileData.forEach(({ encoded }, index) => {
const profile = profiles[index]
if (profile) {
const displayName = profile.name || profile.display_name || profile.nip05
if (displayName) {
labels.set(encoded, `@${displayName}`)
}
}
})
return labels
}, [profileData, profiles])
}

View File

@@ -1,13 +1,5 @@
// Extract pubkeys from nprofile strings in content
import { READING_PROGRESS } from '../config/kinds' import { READING_PROGRESS } from '../config/kinds'
export const extractNprofilePubkeys = (content: string): string[] => {
const nprofileRegex = /nprofile1[a-z0-9]+/gi
const matches = content.match(nprofileRegex) || []
const unique = new Set<string>(matches)
return Array.from(unique)
}
export type UrlType = 'video' | 'image' | 'youtube' | 'article' export type UrlType = 'video' | 'image' | 'youtube' | 'article'
export interface UrlClassification { export interface UrlClassification {

View File

@@ -1,40 +1,33 @@
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19' import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
import { getNostrUrl } from '../config/nostrGateways' import { getNostrUrl } from '../config/nostrGateways'
import { Tokens } from 'applesauce-content/helpers'
import { getContentPointers } from 'applesauce-factory/helpers'
import { encodeDecodeResult } from 'applesauce-core/helpers'
/** /**
* Regular expression to match nostr: URIs and bare NIP-19 identifiers * Regular expression to match nostr: URIs and bare NIP-19 identifiers
* Uses applesauce Tokens.nostrLink which includes word boundary checks
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1... * Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
* Also matches bare identifiers without the nostr: prefix * Also matches bare identifiers without the nostr: prefix
*/ */
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi const NOSTR_URI_REGEX = Tokens.nostrLink
/** /**
* Extract all nostr URIs from text * Extract all nostr URIs from text using applesauce helpers
*/ */
export function extractNostrUris(text: string): string[] { export function extractNostrUris(text: string): string[] {
const matches = text.match(NOSTR_URI_REGEX) const pointers = getContentPointers(text)
if (!matches) return [] return pointers.map(pointer => encodeDecodeResult(pointer))
// Extract just the NIP-19 identifier (without nostr: prefix)
return matches.map(match => {
const cleanMatch = match.replace(/^nostr:/, '')
return cleanMatch
})
} }
/** /**
* Extract all naddr (article) identifiers from text * Extract all naddr (article) identifiers from text using applesauce helpers
*/ */
export function extractNaddrUris(text: string): string[] { export function extractNaddrUris(text: string): string[] {
const allUris = extractNostrUris(text) const pointers = getContentPointers(text)
return allUris.filter(uri => { return pointers
try { .filter(pointer => pointer.type === 'naddr')
const decoded = decode(uri) .map(pointer => encodeDecodeResult(pointer))
return decoded.type === 'naddr'
} catch {
return false
}
})
} }
/** /**
@@ -258,14 +251,52 @@ export function replaceNostrUrisInMarkdownWithTitles(
}) })
} }
/**
* Replace nostr: URIs in markdown with proper markdown links, using resolved profile names and article titles
* This converts: nostr:npub1... to [@username](link) and nostr:naddr1... to [Article Title](link)
* Labels update progressively as profiles load
* @param markdown The markdown content to process
* @param profileLabels Map of encoded identifier -> display name (e.g., npub1... -> @username)
* @param articleTitles Map of naddr -> title for resolved articles
*/
export function replaceNostrUrisInMarkdownWithProfileLabels(
markdown: string,
profileLabels: Map<string, string> = new Map(),
articleTitles: Map<string, string> = new Map()
): string {
return replaceNostrUrisSafely(markdown, (encoded) => {
const link = createNostrLink(encoded)
// Check if we have a resolved profile name
if (profileLabels.has(encoded)) {
const displayName = profileLabels.get(encoded)!
return `[${displayName}](${link})`
}
// 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 not resolved, use default label (shortened npub format)
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/** /**
* Replace nostr: URIs in HTML with clickable links * Replace nostr: URIs in HTML with clickable links
* This is used when processing HTML content directly * This is used when processing HTML content directly
*/ */
export function replaceNostrUrisInHTML(html: string): string { export function replaceNostrUrisInHTML(html: string): string {
return html.replace(NOSTR_URI_REGEX, (match) => { return html.replace(NOSTR_URI_REGEX, (_match, encoded) => {
// Extract just the NIP-19 identifier (without nostr: prefix) // encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded) const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded) const label = getNostrUriLabel(encoded)