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 { nip19 } from 'nostr-tools'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Models, Helpers } from 'applesauce-core'
const { getPubkeyFromDecodeResult } = Helpers
interface NostrMentionLinkProps {
nostrUri: string
@@ -20,22 +22,17 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
}) => {
// Decode the nostr URI first
let decoded: ReturnType<typeof nip19.decode> | null = null
let pubkey: string | undefined
try {
const identifier = nostrUri.replace(/^nostr:/, '')
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) {
// 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)
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)

View File

@@ -1,5 +1,6 @@
import React from 'react'
import NostrMentionLink from './NostrMentionLink'
import { Tokens } from 'applesauce-content/helpers'
interface RichContentProps {
content: string
@@ -19,17 +20,24 @@ const RichContent: React.FC<RichContentProps> = ({
className = 'bookmark-content'
}) => {
// Pattern to match:
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
// 3. http(s) URLs
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
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink
// 2. http(s) URLs
const nostrPattern = Tokens.nostrLink
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 (
<div className={className}>
{parts.map((part, index) => {
// Handle nostr: URIs
// Handle nostr: URIs - Tokens.nostrLink matches both formats
if (part.startsWith('nostr:')) {
return (
<NostrMentionLink
@@ -39,10 +47,8 @@ const RichContent: React.FC<RichContentProps> = ({
)
}
// Handle plain nostr identifiers (add nostr: prefix)
if (
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
) {
// Handle plain nostr identifiers (Tokens.nostrLink matches these too)
if (isNostrIdentifier(part)) {
return (
<NostrMentionLink
key={index}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'
import { RelayPool } from 'applesauce-relay'
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '../utils/nostrUriResolver'
import { fetchArticleTitles } from '../services/articleTitleResolver'
import { useProfileLabels } from './useProfileLabels'
/**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
@@ -18,7 +19,43 @@ export const useMarkdownToHTML = (
const previewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = 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(() => {
// Always clear previous render immediately to avoid showing stale content while processing
setRenderedHtml('')
@@ -30,36 +67,18 @@ export const useMarkdownToHTML = (
let isCancelled = false
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)
} 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)
}
const processMarkdown = () => {
// Replace nostr URIs with profile labels (progressive) and article titles
const processed = replaceNostrUrisInMarkdownWithProfileLabels(
markdown,
profileLabels,
articleTitles
)
if (isCancelled) return
setProcessedMarkdown(processed)
const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML
@@ -77,7 +96,7 @@ export const useMarkdownToHTML = (
return () => {
isCancelled = true
}
}, [markdown, relayPool])
}, [markdown, profileLabels, articleTitles])
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'
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 interface UrlClassification {

View File

@@ -1,40 +1,33 @@
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
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
* Uses applesauce Tokens.nostrLink which includes word boundary checks
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
* 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[] {
const matches = text.match(NOSTR_URI_REGEX)
if (!matches) return []
// Extract just the NIP-19 identifier (without nostr: prefix)
return matches.map(match => {
const cleanMatch = match.replace(/^nostr:/, '')
return cleanMatch
})
const pointers = getContentPointers(text)
return pointers.map(pointer => encodeDecodeResult(pointer))
}
/**
* Extract all naddr (article) identifiers from text
* Extract all naddr (article) identifiers from text using applesauce helpers
*/
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
}
})
const pointers = getContentPointers(text)
return pointers
.filter(pointer => pointer.type === 'naddr')
.map(pointer => encodeDecodeResult(pointer))
}
/**
@@ -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
* This is used when processing HTML content directly
*/
export function replaceNostrUrisInHTML(html: string): string {
return html.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
return html.replace(NOSTR_URI_REGEX, (_match, encoded) => {
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)