mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 06:34:24 +01:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
45
src/hooks/useProfileLabels.ts
Normal file
45
src/hooks/useProfileLabels.ts
Normal 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])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user