Merge pull request #34 from dergigi/show-npubs-and-nprofiles-as-names

Standardize npub/nprofile display and improve profile resolution
This commit is contained in:
Gigi
2025-11-02 23:53:11 +01:00
committed by GitHub
22 changed files with 1186 additions and 267 deletions

View File

@@ -4,6 +4,7 @@ import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { extractProfileDisplayName } from '../src/utils/profileUtils'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
@@ -117,14 +118,14 @@ async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | nu
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
// Extract author name from profile
// Extract author name from profile using centralized utility
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
try {
const profileData = JSON.parse(profileEvents[0].content)
authorName = profileData.display_name || profileData.name || authorName
} catch {
// Use fallback
const displayName = extractProfileDisplayName(profileEvents[0])
if (displayName && !displayName.startsWith('@')) {
authorName = displayName
} else if (displayName) {
authorName = displayName.substring(1) // Remove @ prefix
}
}

View File

@@ -5,6 +5,7 @@ import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface AuthorCardProps {
authorPubkey: string
@@ -16,9 +17,7 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
const getAuthorName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
return getProfileDisplayName(profile, authorPubkey)
}
const authorImage = profile?.picture || profile?.image

View File

@@ -7,6 +7,7 @@ import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { isKnownBot } from '../config/bots'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface BlogPostCardProps {
post: BlogPostPreview
@@ -24,8 +25,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
// when there are many blog posts.
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
const displayName = getProfileDisplayName(profile, post.author)
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
// Hide bot authors by name/display_name

View File

@@ -11,6 +11,7 @@ import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from '../utils/helpers'
import { ViewMode } from './Bookmarks'
import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
import { CompactView } from './BookmarkViews/CompactView'
import { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView'
@@ -62,12 +63,15 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
const authorNpub = npubEncode(bookmark.pubkey)
// Get display name for author
// Get display name for author using centralized utility
const getAuthorDisplayName = () => {
if (authorProfile?.name) return authorProfile.name
if (authorProfile?.display_name) return authorProfile.display_name
if (authorProfile?.nip05) return authorProfile.nip05
return short(bookmark.pubkey) // fallback to short pubkey
const displayName = getProfileDisplayName(authorProfile, bookmark.pubkey)
// getProfileDisplayName returns npub format for fallback, but we want short pubkey format
// So check if it's the fallback format and use short() instead
if (displayName.startsWith('@') && displayName.includes('...')) {
return short(bookmark.pubkey)
}
return displayName
}
// Get content type icon based on bookmark kind and URL classification

View File

@@ -2,8 +2,11 @@ import React, { useMemo, useEffect, useRef } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { Helpers } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
const { getPubkeyFromDecodeResult } = Helpers
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -79,16 +82,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
// Decode npub or nprofile to pubkey for profile view
// Decode npub or nprofile to pubkey for profile view using applesauce helper
let profilePubkey: string | undefined
if (npub && showProfile) {
try {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
profilePubkey = decoded.data
} else if (decoded.type === 'nprofile') {
profilePubkey = decoded.data.pubkey
}
profilePubkey = getPubkeyFromDecodeResult(decoded)
} catch (err) {
console.error('Failed to decode npub/nprofile:', err)
}

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

@@ -5,6 +5,7 @@ import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { fetchArticleTitle } from '../services/articleTitleResolver'
import { Highlight } from '../types/highlights'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface HighlightCitationProps {
highlight: Highlight
@@ -79,7 +80,8 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
loadTitle()
}, [highlight.eventReference, relayPool])
const authorName = authorProfile?.name || authorProfile?.display_name
// Use centralized profile display name utility
const authorName = authorPubkey ? getProfileDisplayName(authorProfile, authorPubkey) : undefined
// For nostr-native content with article reference
if (highlight.eventReference && (authorName || articleTitle)) {

View File

@@ -18,6 +18,7 @@ import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
import { useNavigate } from 'react-router-dom'
import NostrMentionLink from './NostrMentionLink'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
@@ -127,9 +128,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Get display name for the user
const getUserDisplayName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
return getProfileDisplayName(profile, highlight.pubkey)
}

View File

@@ -1,7 +1,12 @@
import React from 'react'
import React, { useMemo } from 'react'
import { nip19 } from 'nostr-tools'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Hooks } from 'applesauce-react'
import { Models, Helpers } from 'applesauce-core'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
const { getPubkeyFromDecodeResult } = Helpers
interface NostrMentionLinkProps {
nostrUri: string
@@ -20,25 +25,31 @@ 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
const eventStore = Hooks.useEventStore()
// Fetch profile at top level (Rules of Hooks)
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
// Check if profile is in cache or eventStore for loading detection
const isInCacheOrStore = useMemo(() => {
if (!pubkey) return false
return isProfileInCacheOrStore(pubkey, eventStore)
}, [pubkey, eventStore])
// Show loading if profile doesn't exist and not in cache/store (for npub/nprofile)
// pubkey will be undefined for non-profile types, so no need for explicit type check
const isLoading = !profile && pubkey && !isInCacheOrStore
// If decoding failed, show shortened identifier
if (!decoded) {
const identifier = nostrUri.replace(/^nostr:/, '')
@@ -49,37 +60,30 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
)
}
// Render based on decoded type
switch (decoded.type) {
case 'npub': {
const pk = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
return (
<a
href={`/p/${nip19.npubEncode(pk)}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'nprofile': {
const { pubkey: pk } = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
const npub = nip19.npubEncode(pk)
// Helper function to render profile links (used for both npub and nprofile)
const renderProfileLink = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
const displayName = getProfileDisplayName(profile, pubkey)
const linkClassName = isLoading ? `${className} profile-loading` : className
return (
<a
href={`/p/${npub}`}
className={className}
className={linkClassName}
onClick={onClick}
>
@{displayName}
</a>
)
}
// Render based on decoded type
// If we have a pubkey (from npub/nprofile), render profile link directly
if (pubkey) {
return renderProfileLink(pubkey)
}
switch (decoded.type) {
case 'naddr': {
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
// Check if it's a blog post (kind:30023)

View File

@@ -1,8 +1,11 @@
import React from 'react'
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useEventModel } from 'applesauce-react/hooks'
import { Hooks } from 'applesauce-react'
import { Models, Helpers } from 'applesauce-core'
import { decode, npubEncode } from 'nostr-tools/nip19'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils'
const { getPubkeyFromDecodeResult } = Helpers
@@ -19,15 +22,27 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
// ignore; will fallback to showing the encoded value
}
const eventStore = Hooks.useEventStore()
const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined
const display = profile?.name || profile?.display_name || profile?.nip05 || (pubkey ? `${pubkey.slice(0, 8)}...` : encoded)
// Check if profile is in cache or eventStore
const isInCacheOrStore = useMemo(() => {
if (!pubkey) return false
return isProfileInCacheOrStore(pubkey, eventStore)
}, [pubkey, eventStore])
// Show loading if profile doesn't exist and not in cache/store
const isLoading = !profile && pubkey && !isInCacheOrStore
const display = pubkey ? getProfileDisplayName(profile, pubkey) : encoded
const npub = pubkey ? npubEncode(pubkey) : undefined
if (npub) {
const className = isLoading ? 'nostr-mention profile-loading' : 'nostr-mention'
return (
<Link
to={`/p/${npub}`}
className="nostr-mention"
className={className}
>
@{display}
</Link>

View File

@@ -1,5 +1,13 @@
import React from 'react'
import NostrMentionLink from './NostrMentionLink'
import { Tokens } from 'applesauce-content/helpers'
// Helper to add timestamps to error logs
const ts = () => {
const now = new Date()
const ms = now.getMilliseconds().toString().padStart(3, '0')
return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}`
}
interface RichContentProps {
content: string
@@ -18,18 +26,31 @@ const RichContent: React.FC<RichContentProps> = ({
content,
className = 'bookmark-content'
}) => {
try {
// 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
// Skip empty or undefined parts
if (!part) {
return null
}
// Handle nostr: URIs - Tokens.nostrLink matches both formats
if (part.startsWith('nostr:')) {
return (
<NostrMentionLink
@@ -39,10 +60,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}
@@ -71,6 +90,10 @@ const RichContent: React.FC<RichContentProps> = ({
})}
</div>
)
} catch (err) {
console.error(`[${ts()}] [npub-resolve] RichContent: Error rendering:`, err)
return <div className={className}>Error rendering content</div>
}
}
export default RichContent

View File

@@ -8,6 +8,7 @@ import { Models } from 'applesauce-core'
import IconButton from './IconButton'
import { faBooks } from '../icons/customIcons'
import { preloadImage } from '../hooks/useImageCache'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface SidebarHeaderProps {
onToggleCollapse: () => void
@@ -29,10 +30,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const getUserDisplayName = () => {
if (!activeAccount) return 'Unknown User'
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
return getProfileDisplayName(profile, activeAccount.pubkey)
}
const profileImage = getProfileImage()

View File

@@ -10,6 +10,7 @@ import { Models } from 'applesauce-core'
import { useEventModel } from 'applesauce-react/hooks'
import { useNavigate } from 'react-router-dom'
import { nip19 } from 'nostr-tools'
import { getProfileDisplayName } from '../utils/nostrUriResolver'
interface SupportProps {
relayPool: RelayPool
@@ -182,7 +183,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
const picture = profile?.picture
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
const name = getProfileDisplayName(profile, supporter.pubkey)
const handleClick = () => {
const npub = nip19.npubEncode(supporter.pubkey)

View File

@@ -6,6 +6,8 @@ import { ReadableContent } from '../services/readerService'
import { eventManager } from '../services/eventManager'
import { fetchProfiles } from '../services/profileService'
import { useDocumentTitle } from './useDocumentTitle'
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
import { extractProfileDisplayName } from '../utils/profileUtils'
interface UseEventLoaderProps {
eventId?: string
@@ -40,7 +42,7 @@ export function useEventLoader({
// Initial title
let title = `Note (${event.kind})`
if (event.kind === 1) {
title = `Note by @${event.pubkey.slice(0, 8)}...`
title = `Note by ${getNpubFallbackDisplay(event.pubkey)}`
}
// Emit immediately
@@ -62,11 +64,12 @@ export function useEventLoader({
// First, try to get from event store cache
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
if (storedProfile) {
try {
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
const displayName = extractProfileDisplayName(storedProfile as NostrEvent)
if (displayName && !displayName.startsWith('@')) {
// Remove @ prefix if present (we'll add it when displaying)
resolved = displayName
} else if (displayName) {
resolved = displayName.substring(1) // Remove @ prefix
}
}
@@ -75,11 +78,11 @@ export function useEventLoader({
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
if (profiles && profiles.length > 0) {
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
try {
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
const displayName = extractProfileDisplayName(latest)
if (displayName && !displayName.startsWith('@')) {
resolved = displayName
} else if (displayName) {
resolved = displayName.substring(1) // Remove @ prefix
}
}
}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels, addLoadingClassToProfileLinks } from '../utils/nostrUriResolver'
import { fetchArticleTitles } from '../services/articleTitleResolver'
import { useProfileLabels } from './useProfileLabels'
/**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
@@ -18,58 +19,129 @@ 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 { labels: profileLabels, loading: profileLoading } = useProfileLabels(markdown || '', relayPool)
// Create stable dependencies based on Map contents, not Map objects
// This prevents unnecessary reprocessing when Maps are recreated with same content
const profileLabelsKey = useMemo(() => {
const key = Array.from(profileLabels.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|')
return key
}, [profileLabels])
const profileLoadingKey = useMemo(() => {
return Array.from(profileLoading.entries())
.filter(([, loading]) => loading)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k]) => k)
.join('|')
}, [profileLoading])
const articleTitlesKey = useMemo(() => {
return Array.from(articleTitles.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|')
}, [articleTitles])
// Keep refs to latest Maps for processing without causing re-renders
const profileLabelsRef = useRef(profileLabels)
const profileLoadingRef = useRef(profileLoading)
const articleTitlesRef = useRef(articleTitles)
// Ref to track second RAF ID for HTML extraction cleanup
const htmlExtractionRafIdRef = useRef<number | null>(null)
useEffect(() => {
// Always clear previous render immediately to avoid showing stale content while processing
setRenderedHtml('')
setProcessedMarkdown('')
profileLabelsRef.current = profileLabels
profileLoadingRef.current = profileLoading
articleTitlesRef.current = articleTitles
}, [profileLabels, profileLoading, articleTitles])
if (!markdown) {
// Fetch article titles
useEffect(() => {
if (!markdown || !relayPool) {
setArticleTitles(new Map())
return
}
let isCancelled = false
const processMarkdown = async () => {
// Extract all naddr references
const fetchTitles = async () => {
const naddrs = extractNaddrUris(markdown)
if (naddrs.length === 0) {
setArticleTitles(new Map())
return
}
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)
const titlesMap = await fetchArticleTitles(relayPool!, naddrs)
if (!isCancelled) {
setArticleTitles(titlesMap)
}
} else {
// No articles to resolve, use basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
} catch {
if (!isCancelled) setArticleTitles(new Map())
}
}
fetchTitles()
return () => { isCancelled = true }
}, [markdown, relayPool])
// Track previous markdown and processed state to detect actual content changes
const previousMarkdownRef = useRef<string | undefined>(markdown)
const processedMarkdownRef = useRef<string>(processedMarkdown)
useEffect(() => {
processedMarkdownRef.current = processedMarkdown
}, [processedMarkdown])
// Process markdown with progressive profile labels and article titles
// Use stable string keys instead of Map objects to prevent excessive reprocessing
useEffect(() => {
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
previousMarkdownRef.current = markdown
processedMarkdownRef.current = ''
return
}
let isCancelled = false
const processMarkdown = () => {
try {
// Replace nostr URIs with profile labels (progressive) and article titles
// Use refs to get latest values without causing dependency changes
const processed = replaceNostrUrisInMarkdownWithProfileLabels(
markdown,
profileLabelsRef.current,
articleTitlesRef.current,
profileLoadingRef.current
)
if (isCancelled) return
setProcessedMarkdown(processed)
const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML
setRenderedHtml(html)
} else if (!isCancelled) {
console.warn('⚠️ markdownPreviewRef.current is null')
processedMarkdownRef.current = processed
// HTML extraction will happen in separate useEffect that watches processedMarkdown
} catch (error) {
console.error(`[markdown-to-html] Error processing markdown:`, error)
if (!isCancelled) {
setProcessedMarkdown(markdown) // Fallback to original
processedMarkdownRef.current = markdown
}
}
}
})
return () => cancelAnimationFrame(rafId)
// Only clear previous content if this is the first processing or markdown changed
// For profile updates, just reprocess without clearing to avoid flicker
const isMarkdownChange = previousMarkdownRef.current !== markdown
previousMarkdownRef.current = markdown
if (isMarkdownChange || !processedMarkdownRef.current) {
setRenderedHtml('')
setProcessedMarkdown('')
processedMarkdownRef.current = ''
}
processMarkdown()
@@ -77,7 +149,44 @@ export const useMarkdownToHTML = (
return () => {
isCancelled = true
}
}, [markdown, relayPool])
}, [markdown, profileLabelsKey, profileLoadingKey, articleTitlesKey])
// Extract HTML after processedMarkdown renders
// This useEffect watches processedMarkdown and extracts HTML once ReactMarkdown has rendered it
useEffect(() => {
if (!processedMarkdown || !markdown) {
return
}
let isCancelled = false
// Use double RAF to ensure ReactMarkdown has finished rendering:
// First RAF: let React complete its render cycle
// Second RAF: extract HTML after DOM has updated
const rafId1 = requestAnimationFrame(() => {
htmlExtractionRafIdRef.current = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
let html = previewRef.current.innerHTML
// Post-process HTML to add loading class to profile links
html = addLoadingClassToProfileLinks(html, profileLoadingRef.current)
setRenderedHtml(html)
} else if (!isCancelled && processedMarkdown) {
console.warn('⚠️ markdownPreviewRef.current is null but processedMarkdown exists')
}
})
})
return () => {
isCancelled = true
cancelAnimationFrame(rafId1)
if (htmlExtractionRafIdRef.current !== null) {
cancelAnimationFrame(htmlExtractionRafIdRef.current)
htmlExtractionRafIdRef.current = null
}
}
}, [processedMarkdown, markdown])
return { renderedHtml, previewRef, processedMarkdown }
}

View File

@@ -0,0 +1,324 @@
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
import { Hooks } from 'applesauce-react'
import { Helpers, IEventStore } from 'applesauce-core'
import { getContentPointers } from 'applesauce-factory/helpers'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { fetchProfiles, loadCachedProfiles } from '../services/profileService'
import { getNpubFallbackDisplay } from '../utils/nostrUriResolver'
import { extractProfileDisplayName } from '../utils/profileUtils'
const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers
/**
* Hook to resolve profile labels from content containing npub/nprofile identifiers
* Returns an object with labels Map and loading Map that updates progressively as profiles load
*/
export function useProfileLabels(
content: string,
relayPool?: RelayPool | null
): { labels: Map<string, string>; loading: Map<string, boolean> } {
const eventStore = Hooks.useEventStore()
// Extract profile pointers (npub and nprofile) using applesauce helpers
const profileData = useMemo(() => {
try {
const pointers = getContentPointers(content)
const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile')
const result: Array<{ pubkey: string; encoded: string }> = []
filtered.forEach(pointer => {
try {
const pubkey = getPubkeyFromDecodeResult(pointer)
const encoded = encodeDecodeResult(pointer)
if (pubkey && encoded) {
result.push({ pubkey, encoded: encoded as string })
}
} catch {
// Ignore errors, continue processing other pointers
}
})
return result
} catch (error) {
console.warn(`[profile-labels] Error extracting profile pointers:`, error)
return []
}
}, [content])
// Initialize labels synchronously from cache on first render to avoid delay
// Use pubkey (hex) as the key instead of encoded string for canonical identification
const initialLabels = useMemo(() => {
if (profileData.length === 0) {
return new Map<string, string>()
}
const allPubkeys = profileData.map(({ pubkey }) => pubkey)
const cachedProfiles = loadCachedProfiles(allPubkeys)
const labels = new Map<string, string>()
profileData.forEach(({ pubkey }) => {
const cachedProfile = cachedProfiles.get(pubkey)
if (cachedProfile) {
const displayName = extractProfileDisplayName(cachedProfile)
if (displayName) {
// Add @ prefix (extractProfileDisplayName returns name without @)
const label = `@${displayName}`
labels.set(pubkey, label)
} else {
// Use fallback npub display if profile has no name (add @ prefix)
const fallback = getNpubFallbackDisplay(pubkey)
labels.set(pubkey, `@${fallback}`)
}
}
})
return labels
}, [profileData])
const [profileLabels, setProfileLabels] = useState<Map<string, string>>(initialLabels)
const [profileLoading, setProfileLoading] = useState<Map<string, boolean>>(new Map())
// Batching strategy: Collect profile updates and apply them in batches via RAF to prevent UI flicker
// when many profiles resolve simultaneously. We use refs to avoid stale closures in async callbacks.
// Use pubkey (hex) as the key for canonical identification
const pendingUpdatesRef = useRef<Map<string, string>>(new Map())
const rafScheduledRef = useRef<number | null>(null)
/**
* Helper to apply pending batched updates to state
* Cancels any scheduled RAF and applies updates synchronously
*/
const applyPendingUpdates = () => {
const pendingUpdates = pendingUpdatesRef.current
if (pendingUpdates.size === 0) {
return
}
// Cancel scheduled RAF since we're applying synchronously
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
// Apply all pending updates in one batch
setProfileLabels(prevLabels => {
const updatedLabels = new Map(prevLabels)
for (const [pubkey, label] of pendingUpdates.entries()) {
updatedLabels.set(pubkey, label)
}
pendingUpdates.clear()
return updatedLabels
})
}
/**
* Helper to schedule a batched update via RAF (if not already scheduled)
* This prevents multiple RAF calls when many profiles resolve at once
* Wrapped in useCallback for stable reference in dependency arrays
*/
const scheduleBatchedUpdate = useCallback(() => {
if (rafScheduledRef.current === null) {
rafScheduledRef.current = requestAnimationFrame(() => {
applyPendingUpdates()
rafScheduledRef.current = null
})
}
}, []) // Empty deps: only uses refs which are stable
// Sync state when initialLabels changes (e.g., when content changes)
// This ensures we start with the correct cached labels even if profiles haven't loaded yet
useEffect(() => {
// Use a functional update to access current state without including it in dependencies
setProfileLabels(prevLabels => {
const currentPubkeys = new Set(Array.from(prevLabels.keys()))
const newPubkeys = new Set(profileData.map(p => p.pubkey))
// If the content changed significantly (different set of profiles), reset state
const hasDifferentProfiles =
currentPubkeys.size !== newPubkeys.size ||
!Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
if (hasDifferentProfiles) {
// Clear pending updates and cancel RAF for old profiles
pendingUpdatesRef.current.clear()
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
// Reset to initial labels
return new Map(initialLabels)
} else {
// Same profiles, merge initial labels with existing state
// IMPORTANT: Preserve existing labels (from pending updates) and only add initial labels if missing
const merged = new Map(prevLabels)
for (const [pubkey, label] of initialLabels.entries()) {
// Only add initial label if we don't already have a label for this pubkey
// This preserves labels that were added via applyPendingUpdates
if (!merged.has(pubkey)) {
merged.set(pubkey, label)
}
}
return merged
}
})
// Reset loading state when content changes significantly
setProfileLoading(prevLoading => {
const currentPubkeys = new Set(Array.from(prevLoading.keys()))
const newPubkeys = new Set(profileData.map(p => p.pubkey))
const hasDifferentProfiles =
currentPubkeys.size !== newPubkeys.size ||
!Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
if (hasDifferentProfiles) {
return new Map()
}
return prevLoading
})
}, [initialLabels, profileData])
// Build initial labels: localStorage cache -> eventStore -> fetch from relays
useEffect(() => {
// Extract all pubkeys
const allPubkeys = profileData.map(({ pubkey }) => pubkey)
if (allPubkeys.length === 0) {
setProfileLabels(new Map())
setProfileLoading(new Map())
// Clear pending updates and cancel RAF when clearing labels
pendingUpdatesRef.current.clear()
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
return
}
// Add cached profiles to EventStore for consistency
const cachedProfiles = loadCachedProfiles(allPubkeys)
if (eventStore) {
for (const profile of cachedProfiles.values()) {
eventStore.add(profile)
}
}
// Build labels from localStorage cache and eventStore
// initialLabels already has all cached profiles, so we only need to check eventStore
// Use pubkey (hex) as the key for canonical identification
const labels = new Map<string, string>(initialLabels)
const loading = new Map<string, boolean>()
const pubkeysToFetch: string[] = []
profileData.forEach(({ pubkey }) => {
// Skip if already resolved from initial cache
if (labels.has(pubkey)) {
loading.set(pubkey, false)
return
}
// Check EventStore for profiles that weren't in cache
const eventStoreProfile = eventStore?.getEvent(pubkey + ':0')
if (eventStoreProfile && eventStore) {
// Extract display name using centralized utility
const displayName = extractProfileDisplayName(eventStoreProfile as NostrEvent)
if (displayName) {
// Add @ prefix (extractProfileDisplayName returns name without @)
const label = `@${displayName}`
labels.set(pubkey, label)
} else {
// Use fallback npub display if profile has no name (add @ prefix)
const fallback = getNpubFallbackDisplay(pubkey)
labels.set(pubkey, `@${fallback}`)
}
loading.set(pubkey, false)
} else {
// No profile found yet, will use fallback after fetch or keep empty
// We'll set fallback labels for missing profiles at the end
// Mark as loading since we'll fetch it
pubkeysToFetch.push(pubkey)
loading.set(pubkey, true)
}
})
// Don't set fallback labels in the Map - we'll use fallback directly when rendering
// This allows us to distinguish between "no label yet" (use fallback) vs "resolved label" (use Map value)
setProfileLabels(new Map(labels))
setProfileLoading(new Map(loading))
// Fetch missing profiles asynchronously with reactive updates
if (pubkeysToFetch.length > 0 && relayPool && eventStore) {
// Reactive callback: collects profile updates and batches them via RAF to prevent flicker
// Strategy: Apply label immediately when profile resolves, but still batch for multiple profiles
const handleProfileEvent = (event: NostrEvent) => {
// Use pubkey directly as the key
const pubkey = event.pubkey
// Determine the label for this profile using centralized utility
// Add @ prefix (both extractProfileDisplayName and getNpubFallbackDisplay return names without @)
const displayName = extractProfileDisplayName(event)
const label = displayName ? `@${displayName}` : `@${getNpubFallbackDisplay(pubkey)}`
// Apply label immediately to prevent race condition with loading state
// This ensures labels are available when isLoading becomes false
setProfileLabels(prevLabels => {
const updated = new Map(prevLabels)
updated.set(pubkey, label)
return updated
})
// Clear loading state for this profile when it resolves
setProfileLoading(prevLoading => {
const updated = new Map(prevLoading)
updated.set(pubkey, false)
return updated
})
}
fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent)
.then(() => {
// After EOSE: apply any remaining pending updates immediately
// This ensures all profile updates are applied even if RAF hasn't fired yet
applyPendingUpdates()
// Clear loading state for all fetched profiles
setProfileLoading(prevLoading => {
const updated = new Map(prevLoading)
pubkeysToFetch.forEach(pubkey => {
updated.set(pubkey, false)
})
return updated
})
})
.catch((error) => {
console.error(`[profile-labels] Error fetching profiles:`, error)
// Silently handle fetch errors, but still clear any pending updates
pendingUpdatesRef.current.clear()
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
// Clear loading state on error (show fallback)
setProfileLoading(prevLoading => {
const updated = new Map(prevLoading)
pubkeysToFetch.forEach(pubkey => {
updated.set(pubkey, false)
})
return updated
})
})
// Cleanup: apply any pending updates before unmount to avoid losing them
return () => {
applyPendingUpdates()
}
}
}, [profileData, eventStore, relayPool, initialLabels, scheduleBatchedUpdate])
return { labels: profileLabels, loading: profileLoading }
}

View File

@@ -1,63 +1,288 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray, tap } from 'rxjs'
import { lastValueFrom, merge, Observable, toArray, tap } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { rebroadcastEvents } from './rebroadcastService'
import { UserSettings } from './settingsService'
interface CachedProfile {
event: NostrEvent
timestamp: number
lastAccessed: number // For LRU eviction
}
const PROFILE_CACHE_TTL = 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds (profiles change less frequently than articles)
const PROFILE_CACHE_PREFIX = 'profile_cache_'
const MAX_CACHED_PROFILES = 1000 // Limit number of cached profiles to prevent quota issues
let quotaExceededLogged = false // Only log quota error once per session
// Request deduplication: track in-flight fetch requests by sorted pubkey array
// Key: sorted, comma-separated pubkeys, Value: Promise for that fetch
const inFlightRequests = new Map<string, Promise<NostrEvent[]>>()
function getProfileCacheKey(pubkey: string): string {
return `${PROFILE_CACHE_PREFIX}${pubkey}`
}
/**
* Get a cached profile from localStorage
* Returns null if not found, expired, or on error
* Updates lastAccessed timestamp for LRU eviction
*/
export function getCachedProfile(pubkey: string): NostrEvent | null {
try {
const cacheKey = getProfileCacheKey(pubkey)
const cached = localStorage.getItem(cacheKey)
if (!cached) {
return null
}
const data: CachedProfile = JSON.parse(cached)
const age = Date.now() - data.timestamp
if (age > PROFILE_CACHE_TTL) {
localStorage.removeItem(cacheKey)
return null
}
// Update lastAccessed for LRU eviction (but don't fail if update fails)
try {
data.lastAccessed = Date.now()
localStorage.setItem(cacheKey, JSON.stringify(data))
} catch {
// Ignore update errors, still return the profile
}
return data.event
} catch (err) {
// Silently handle cache read errors (quota, invalid data, etc.)
return null
}
}
/**
* Get all cached profile keys for eviction
*/
function getAllCachedProfileKeys(): Array<{ key: string; lastAccessed: number }> {
const keys: Array<{ key: string; lastAccessed: number }> = []
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(PROFILE_CACHE_PREFIX)) {
try {
const cached = localStorage.getItem(key)
if (cached) {
const data: CachedProfile = JSON.parse(cached)
keys.push({
key,
lastAccessed: data.lastAccessed || data.timestamp || 0
})
}
} catch {
// Skip invalid entries
}
}
}
} catch {
// Ignore errors during enumeration
}
return keys
}
/**
* Evict oldest profiles (LRU) to free up space
* Removes the oldest accessed profiles until we're under the limit
*/
function evictOldProfiles(targetCount: number): void {
try {
const keys = getAllCachedProfileKeys()
if (keys.length <= targetCount) {
return
}
// Sort by lastAccessed (oldest first) and remove oldest
keys.sort((a, b) => a.lastAccessed - b.lastAccessed)
const toRemove = keys.slice(0, keys.length - targetCount)
for (const { key } of toRemove) {
localStorage.removeItem(key)
}
} catch {
// Silently fail eviction
}
}
/**
* Cache a profile to localStorage
* Handles errors gracefully (quota exceeded, invalid data, etc.)
* Implements LRU eviction when cache is full
*/
export function cacheProfile(profile: NostrEvent): void {
try {
if (profile.kind !== 0) {
return // Only cache kind:0 (profile) events
}
const cacheKey = getProfileCacheKey(profile.pubkey)
// Check if we need to evict before caching
const existingKeys = getAllCachedProfileKeys()
if (existingKeys.length >= MAX_CACHED_PROFILES) {
// Check if this profile is already cached
const alreadyCached = existingKeys.some(k => k.key === cacheKey)
if (!alreadyCached) {
// Evict oldest profiles to make room (keep 90% of max)
evictOldProfiles(Math.floor(MAX_CACHED_PROFILES * 0.9))
}
}
const cached: CachedProfile = {
event: profile,
timestamp: Date.now(),
lastAccessed: Date.now()
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
} catch (err) {
// Handle quota exceeded by evicting and retrying once
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
if (!quotaExceededLogged) {
console.warn(`[npub-cache] localStorage quota exceeded, evicting old profiles...`)
quotaExceededLogged = true
}
// Try evicting more aggressively and retry
try {
evictOldProfiles(Math.floor(MAX_CACHED_PROFILES * 0.5))
const cached: CachedProfile = {
event: profile,
timestamp: Date.now(),
lastAccessed: Date.now()
}
localStorage.setItem(getProfileCacheKey(profile.pubkey), JSON.stringify(cached))
} catch {
// Silently fail if still can't cache - don't block the UI
}
}
// Silently handle other caching errors (invalid data, etc.)
}
}
/**
* Batch load multiple profiles from localStorage cache
* Returns a Map of pubkey -> NostrEvent for all found profiles
*/
export function loadCachedProfiles(pubkeys: string[]): Map<string, NostrEvent> {
const cached = new Map<string, NostrEvent>()
for (const pubkey of pubkeys) {
const profile = getCachedProfile(pubkey)
if (profile) {
cached.set(pubkey, profile)
}
}
return cached
}
/**
* Fetches profile metadata (kind:0) for a list of pubkeys
* Stores profiles in the event store and optionally to local relays
* Checks localStorage cache first, then fetches from relays for missing/expired profiles
* Stores profiles in the event store and caches to localStorage
* Implements request deduplication to prevent duplicate relay requests for the same pubkey sets
*/
export const fetchProfiles = async (
relayPool: RelayPool,
eventStore: IEventStore,
pubkeys: string[],
settings?: UserSettings
settings?: UserSettings,
onEvent?: (event: NostrEvent) => void
): Promise<NostrEvent[]> => {
try {
if (pubkeys.length === 0) {
return []
}
const uniquePubkeys = Array.from(new Set(pubkeys))
const uniquePubkeys = Array.from(new Set(pubkeys)).sort()
// Check for in-flight request with same pubkey set (deduplication)
const requestKey = uniquePubkeys.join(',')
const existingRequest = inFlightRequests.get(requestKey)
if (existingRequest) {
return existingRequest
}
// Create the fetch promise and track it
const fetchPromise = (async () => {
// First, check localStorage cache for all requested profiles
const cachedProfiles = loadCachedProfiles(uniquePubkeys)
const profilesByPubkey = new Map<string, NostrEvent>()
// Add cached profiles to the map and EventStore
for (const [pubkey, profile] of cachedProfiles.entries()) {
profilesByPubkey.set(pubkey, profile)
// Ensure cached profiles are also in EventStore for consistency
eventStore.add(profile)
}
// Determine which pubkeys need to be fetched from relays
const pubkeysToFetch = uniquePubkeys.filter(pubkey => !cachedProfiles.has(pubkey))
// If all profiles are cached, return early
if (pubkeysToFetch.length === 0) {
return Array.from(profilesByPubkey.values())
}
// Fetch missing profiles from relays
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Keep only the most recent profile for each pubkey
const profilesByPubkey = new Map<string, NostrEvent>()
const hasPurplePages = relayUrls.some(url => url.includes('purplepag.es'))
if (!hasPurplePages) {
console.warn(`[fetch-profiles] purplepag.es not in active relay pool, adding it temporarily`)
// Add purplepag.es if it's not in the pool (it might not have connected yet)
const purplePagesUrl = 'wss://purplepag.es'
if (!relayPool.relays.has(purplePagesUrl)) {
relayPool.group([purplePagesUrl])
}
// Ensure it's included in the remote relays for this fetch
if (!remoteRelays.includes(purplePagesUrl)) {
remoteRelays.push(purplePagesUrl)
}
}
const fetchedPubkeys = new Set<string>()
const processEvent = (event: NostrEvent) => {
fetchedPubkeys.add(event.pubkey)
const existing = profilesByPubkey.get(event.pubkey)
if (!existing || event.created_at > existing.created_at) {
profilesByPubkey.set(event.pubkey, event)
// Store in event store immediately
eventStore.add(event)
// Cache to localStorage for future use
cacheProfile(event)
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [0], authors: uniquePubkeys })
.req(localRelays, { kinds: [0], authors: pubkeysToFetch })
.pipe(
onlyEvents(),
onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose(),
takeUntil(timer(1200))
completeOnEose()
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [0], authors: uniquePubkeys })
.req(remoteRelays, { kinds: [0], authors: pubkeysToFetch })
.pipe(
onlyEvents(),
onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose(),
takeUntil(timer(6000))
completeOnEose()
)
: new Observable<NostrEvent>((sub) => sub.complete())
@@ -65,18 +290,36 @@ export const fetchProfiles = async (
const profiles = Array.from(profilesByPubkey.values())
const missingPubkeys = pubkeysToFetch.filter(p => !fetchedPubkeys.has(p))
if (missingPubkeys.length > 0) {
console.warn(`[fetch-profiles] ${missingPubkeys.length} profiles not found on relays:`, missingPubkeys.map(p => p.slice(0, 16) + '...'))
}
// Note: We don't preload all profile images here to avoid ERR_INSUFFICIENT_RESOURCES
// Profile images will be cached by Service Worker when they're actually displayed.
// Only the logged-in user's profile image is preloaded (in SidebarHeader).
// Rebroadcast profiles to local/all relays based on settings
if (profiles.length > 0) {
await rebroadcastEvents(profiles, relayPool, settings)
// Only rebroadcast newly fetched profiles, not cached ones
const newlyFetchedProfiles = profiles.filter(p => pubkeysToFetch.includes(p.pubkey))
if (newlyFetchedProfiles.length > 0) {
await rebroadcastEvents(newlyFetchedProfiles, relayPool, settings)
}
return profiles
})()
// Track the request
inFlightRequests.set(requestKey, fetchPromise)
// Clean up when request completes (success or failure)
fetchPromise.finally(() => {
inFlightRequests.delete(requestKey)
})
return fetchPromise
} catch (error) {
console.error('Failed to fetch profiles:', error)
console.error('[fetch-profiles] Failed to fetch profiles:', error)
return []
}
}

View File

@@ -273,3 +273,21 @@
/* Reading Progress Indicator - now using Tailwind utilities in component */
/* Profile loading state - subtle opacity pulse animation */
.profile-loading {
opacity: 0.6;
animation: profile-loading-pulse 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.profile-loading {
animation: none;
opacity: 0.7;
}
}
@keyframes profile-loading-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}

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,51 @@
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'
import { Helpers } from 'applesauce-core'
const { getPubkeyFromDecodeResult } = 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
try {
const pointers = getContentPointers(text)
const result: string[] = []
pointers.forEach(pointer => {
try {
const encoded = encodeDecodeResult(pointer)
if (encoded) {
result.push(encoded)
}
} catch {
// Ignore encoding errors, continue processing other pointers
}
})
return result
} catch {
return []
}
}
/**
* 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))
}
/**
@@ -77,13 +88,14 @@ export function getNostrUriLabel(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'npub':
return `@${encoded.slice(0, 12)}...`
case 'nprofile': {
const npub = npubEncode(decoded.data.pubkey)
return `@${npub.slice(0, 12)}...`
// Use applesauce helper to extract pubkey for npub/nprofile
const pubkey = getPubkeyFromDecodeResult(decoded)
if (pubkey) {
// Use shared fallback display function and add @ for label
return `@${getNpubFallbackDisplay(pubkey)}`
}
switch (decoded.type) {
case 'note':
return `note:${encoded.slice(5, 12)}...`
case 'nevent': {
@@ -107,6 +119,44 @@ export function getNostrUriLabel(encoded: string): string {
}
}
/**
* Get a standardized fallback display name for a pubkey when profile has no name
* Returns npub format: abc1234... (without @ prefix)
* Components should add @ prefix when rendering mentions/links
* @param pubkey The pubkey in hex format
* @returns Formatted npub display string without @ prefix
*/
export function getNpubFallbackDisplay(pubkey: string): string {
try {
const npub = npubEncode(pubkey)
// Remove "npub1" prefix (5 chars) and show next 7 chars
return `${npub.slice(5, 12)}...`
} catch {
// Fallback to shortened pubkey if encoding fails
return `${pubkey.slice(0, 8)}...`
}
}
/**
* Get display name for a profile with consistent priority order
* Returns: profile.name || profile.display_name || profile.nip05 || npub fallback
* This function works with parsed profile objects (from useEventModel)
* For NostrEvent objects, use extractProfileDisplayName from profileUtils
* @param profile Profile object with optional name, display_name, and nip05 fields
* @param pubkey The pubkey in hex format (required for fallback)
* @returns Display name string
*/
export function getProfileDisplayName(
profile: { name?: string; display_name?: string; nip05?: string } | null | undefined,
pubkey: string
): string {
// Consistent priority order: name || display_name || nip05 || fallback
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return getNpubFallbackDisplay(pubkey)
}
/**
* Process markdown to replace nostr URIs while skipping those inside markdown links
* This prevents nested markdown link issues when nostr identifiers appear in URLs
@@ -258,14 +308,120 @@ 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 pubkey (hex) -> display name (e.g., pubkey -> @username)
* @param articleTitles Map of naddr -> title for resolved articles
* @param profileLoading Map of pubkey (hex) -> boolean indicating if profile is loading
*/
export function replaceNostrUrisInMarkdownWithProfileLabels(
markdown: string,
profileLabels: Map<string, string> = new Map(),
articleTitles: Map<string, string> = new Map(),
profileLoading: Map<string, boolean> = new Map()
): string {
return replaceNostrUrisSafely(markdown, (encoded) => {
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})`
}
// For npub/nprofile, extract pubkey using applesauce helper
const pubkey = getPubkeyFromDecodeResult(decoded)
if (pubkey) {
// Check if we have a resolved profile name using pubkey as key
// Use the label if: 1) we have a label, AND 2) profile is not currently loading (false or undefined)
const isLoading = profileLoading.get(pubkey)
const hasLabel = profileLabels.has(pubkey)
// Use resolved label if we have one and profile is not loading
// isLoading can be: true (loading), false (loaded), or undefined (never was loading)
// We only avoid using the label if isLoading === true
if (isLoading !== true && hasLabel) {
const displayName = profileLabels.get(pubkey)!
return `[${displayName}](${link})`
}
// If loading or no resolved label yet, use fallback (will show loading via post-processing)
const label = getNostrUriLabel(encoded)
return `[${label}](${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})`
})
}
/**
* Post-process rendered HTML to add loading class to profile links that are still loading
* This is necessary because HTML inside markdown links doesn't render correctly
* @param html The rendered HTML string
* @param profileLoading Map of pubkey (hex) -> boolean indicating if profile is loading
* @returns HTML with profile-loading class added to loading profile links
*/
export function addLoadingClassToProfileLinks(
html: string,
profileLoading: Map<string, boolean>
): string {
if (profileLoading.size === 0) {
return html
}
// Find all <a> tags with href starting with /p/ (profile links)
const result = html.replace(/<a\s+[^>]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub: string) => {
try {
// Decode npub or nprofile to get pubkey using applesauce helper
const decoded: ReturnType<typeof decode> = decode(npub)
const pubkey = getPubkeyFromDecodeResult(decoded)
if (pubkey) {
// Check if this profile is loading
const isLoading = profileLoading.get(pubkey)
if (isLoading === true) {
// Add profile-loading class if not already present
if (!match.includes('profile-loading')) {
// Insert class before the closing >
const classMatch = /class="([^"]*)"/.exec(match)
if (classMatch) {
const updated = match.replace(/class="([^"]*)"/, `class="$1 profile-loading"`)
return updated
} else {
const updated = match.replace(/(<a\s+[^>]*?)>/, '$1 class="profile-loading">')
return updated
}
}
}
}
} catch (error) {
// Ignore processing errors
}
return match
})
return result
}
/**
* 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)

View File

@@ -0,0 +1,27 @@
import { IEventStore } from 'applesauce-core'
import { loadCachedProfiles } from '../services/profileService'
/**
* Check if a profile exists in cache or eventStore
* Used to determine if profile loading state should be shown
* @param pubkey The pubkey in hex format
* @param eventStore Optional eventStore instance
* @returns true if profile exists in cache or eventStore, false otherwise
*/
export function isProfileInCacheOrStore(
pubkey: string,
eventStore?: IEventStore | null
): boolean {
if (!pubkey) return false
// Check cache first
const cached = loadCachedProfiles([pubkey])
if (cached.has(pubkey)) {
return true
}
// Check eventStore
const eventStoreProfile = eventStore?.getEvent(pubkey + ':0')
return !!eventStoreProfile
}

40
src/utils/profileUtils.ts Normal file
View File

@@ -0,0 +1,40 @@
import { NostrEvent } from 'nostr-tools'
import { getNpubFallbackDisplay } from './nostrUriResolver'
/**
* Extract display name from a profile event (kind:0) with consistent priority order
* Priority: name || display_name || nip05 || npub fallback
*
* @param profileEvent The profile event (kind:0) to extract name from
* @returns Display name string, or empty string if event is invalid
*/
export function extractProfileDisplayName(profileEvent: NostrEvent | null | undefined): string {
if (!profileEvent || profileEvent.kind !== 0) {
return ''
}
try {
const profileData = JSON.parse(profileEvent.content || '{}') as {
name?: string
display_name?: string
nip05?: string
}
// Consistent priority: name || display_name || nip05
if (profileData.name) return profileData.name
if (profileData.display_name) return profileData.display_name
if (profileData.nip05) return profileData.nip05
// Fallback to npub if no name fields
return getNpubFallbackDisplay(profileEvent.pubkey)
} catch (error) {
// If JSON parsing fails, use npub fallback
try {
return getNpubFallbackDisplay(profileEvent.pubkey)
} catch {
// If npub encoding also fails, return empty string
return ''
}
}
}