diff --git a/api/article-og.ts b/api/article-og.ts index 3f8d3002..6494217d 100644 --- a/api/article-og.ts +++ b/api/article-og.ts @@ -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 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 } } diff --git a/src/components/AuthorCard.tsx b/src/components/AuthorCard.tsx index b008397c..d55e8a96 100644 --- a/src/components/AuthorCard.tsx +++ b/src/components/AuthorCard.tsx @@ -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 = ({ 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 diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index 9801a7af..2787890f 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -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 = ({ 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 diff --git a/src/components/BookmarkItem.tsx b/src/components/BookmarkItem.tsx index f4d0d289..1f6e8e04 100644 --- a/src/components/BookmarkItem.tsx +++ b/src/components/BookmarkItem.tsx @@ -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 = ({ 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 diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index 194179ec..279aaaa6 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -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 = ({ // 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) } diff --git a/src/components/ContentWithResolvedProfiles.tsx b/src/components/ContentWithResolvedProfiles.tsx deleted file mode 100644 index 53481eb2..00000000 --- a/src/components/ContentWithResolvedProfiles.tsx +++ /dev/null @@ -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 = ({ content }) => { - const matches = extractNprofilePubkeys(content) - const decoded = matches - .map((m) => { - try { return decode(m) } catch { return undefined as undefined } - }) - .filter((v): v is ReturnType => 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
{rendered}
-} - -export default ContentWithResolvedProfiles - - diff --git a/src/components/HighlightCitation.tsx b/src/components/HighlightCitation.tsx index 564f5f43..f065a9fd 100644 --- a/src/components/HighlightCitation.tsx +++ b/src/components/HighlightCitation.tsx @@ -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 = ({ 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)) { diff --git a/src/components/HighlightItem.tsx b/src/components/HighlightItem.tsx index 67db5cd4..90d4e9e5 100644 --- a/src/components/HighlightItem.tsx +++ b/src/components/HighlightItem.tsx @@ -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 = ({ // 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) } diff --git a/src/components/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index ca16fc5f..2fe2623f 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -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 = ({ }) => { // Decode the nostr URI first let decoded: ReturnType | 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 = ({ ) } + // 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 ( + + @{displayName} + + ) + } + // 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 'npub': { - const pk = decoded.data - const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...` - - return ( - - @{displayName} - - ) - } - 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) - - return ( - - @{displayName} - - ) - } case 'naddr': { const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data // Check if it's a blog post (kind:30023) diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index a665149f..44d7a71a 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -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 = ({ 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 ( @{display} diff --git a/src/components/RichContent.tsx b/src/components/RichContent.tsx index 7bd55d12..946aa554 100644 --- a/src/components/RichContent.tsx +++ b/src/components/RichContent.tsx @@ -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 = ({ content, 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 + try { + // Pattern to match: + // 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(combinedPattern) - const parts = content.split(pattern) - - return ( + // 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 (
{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 ( = ({ ) } - // 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 ( = ({ return {part} })}
- ) + ) + } catch (err) { + console.error(`[${ts()}] [npub-resolve] RichContent: Error rendering:`, err) + return
Error rendering content
+ } } export default RichContent diff --git a/src/components/SidebarHeader.tsx b/src/components/SidebarHeader.tsx index 405351ec..fdf1334c 100644 --- a/src/components/SidebarHeader.tsx +++ b/src/components/SidebarHeader.tsx @@ -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 = ({ 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() diff --git a/src/components/Support.tsx b/src/components/Support.tsx index 228d05cd..a11e956b 100644 --- a/src/components/Support.tsx +++ b/src/components/Support.tsx @@ -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 = ({ 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) diff --git a/src/hooks/useEventLoader.ts b/src/hooks/useEventLoader.ts index ecd70ee4..181e9c44 100644 --- a/src/hooks/useEventLoader.ts +++ b/src/hooks/useEventLoader.ts @@ -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,15 +78,15 @@ 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 } } } - + if (resolved) { const updatedTitle = `Note by @${resolved}` setCurrentTitle(updatedTitle) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 80b6f1e4..f32df3b4 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -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(null) const [renderedHtml, setRenderedHtml] = useState('') const [processedMarkdown, setProcessedMarkdown] = useState('') + const [articleTitles, setArticleTitles] = useState>(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(null) + useEffect(() => { - // Always clear previous render immediately to avoid showing stale content while processing - setRenderedHtml('') - setProcessedMarkdown('') - - if (!markdown) { + profileLabelsRef.current = profileLabels + profileLoadingRef.current = profileLoading + articleTitlesRef.current = articleTitles + }, [profileLabels, profileLoading, articleTitles]) + + // 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) - - 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) + if (naddrs.length === 0) { + setArticleTitles(new Map()) + return } - - 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') + try { + const titlesMap = await fetchArticleTitles(relayPool!, naddrs) + if (!isCancelled) { + setArticleTitles(titlesMap) } - }) + } catch { + if (!isCancelled) setArticleTitles(new Map()) + } + } - return () => cancelAnimationFrame(rafId) + fetchTitles() + return () => { isCancelled = true } + }, [markdown, relayPool]) + + // Track previous markdown and processed state to detect actual content changes + const previousMarkdownRef = useRef(markdown) + const processedMarkdownRef = useRef(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) + 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 + } + } + } + + // 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 } } diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts new file mode 100644 index 00000000..8b5cf9a5 --- /dev/null +++ b/src/hooks/useProfileLabels.ts @@ -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; loading: Map } { + 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() + } + + const allPubkeys = profileData.map(({ pubkey }) => pubkey) + const cachedProfiles = loadCachedProfiles(allPubkeys) + const labels = new Map() + + 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>(initialLabels) + const [profileLoading, setProfileLoading] = useState>(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>(new Map()) + const rafScheduledRef = useRef(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(initialLabels) + const loading = new Map() + + 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 } +} + diff --git a/src/services/profileService.ts b/src/services/profileService.ts index f3f912f2..721e58da 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -1,82 +1,325 @@ 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>() + +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 { + const cached = new Map() + + 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 => { try { if (pubkeys.length === 0) { return [] } - const uniquePubkeys = Array.from(new Set(pubkeys)) - - 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() - - const processEvent = (event: NostrEvent) => { - 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) + 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() + + // 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()) } - } - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [0], authors: uniquePubkeys }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => processEvent(event)), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) + // 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) + 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() - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [0], authors: uniquePubkeys }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => processEvent(event)), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) + 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) + } + } - await lastValueFrom(merge(local$, remote$).pipe(toArray())) + const local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [0], authors: pubkeysToFetch }) + .pipe( + onlyEvents(), + onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}), + tap((event: NostrEvent) => processEvent(event)), + completeOnEose() + ) + : new Observable((sub) => sub.complete()) - const profiles = Array.from(profilesByPubkey.values()) + const remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [0], authors: pubkeysToFetch }) + .pipe( + onlyEvents(), + onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}), + tap((event: NostrEvent) => processEvent(event)), + completeOnEose() + ) + : new Observable((sub) => sub.complete()) - // 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). + await lastValueFrom(merge(local$, remote$).pipe(toArray())) - // Rebroadcast profiles to local/all relays based on settings - if (profiles.length > 0) { - await rebroadcastEvents(profiles, relayPool, settings) - } + 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) + '...')) + } - return profiles + // 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 + // 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 [] } } diff --git a/src/styles/components/reader.css b/src/styles/components/reader.css index 8da954b2..76cd34ed 100644 --- a/src/styles/components/reader.css +++ b/src/styles/components/reader.css @@ -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; } +} + diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 805fa473..738ab475 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -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(matches) - return Array.from(unique) -} - export type UrlType = 'video' | 'image' | 'youtube' | 'article' export interface UrlClassification { diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 8a4db4d3..51f609e1 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -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) + // 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 'npub': - return `@${encoded.slice(0, 12)}...` - case 'nprofile': { - const npub = npubEncode(decoded.data.pubkey) - return `@${npub.slice(0, 12)}...` - } 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 = new Map(), + articleTitles: Map = new Map(), + profileLoading: Map = 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 { + if (profileLoading.size === 0) { + return html + } + + // Find all tags with href starting with /p/ (profile links) + const result = html.replace(/]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub: string) => { + try { + // Decode npub or nprofile to get pubkey using applesauce helper + const decoded: ReturnType = 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(/(]*?)>/, '$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) diff --git a/src/utils/profileLoadingUtils.ts b/src/utils/profileLoadingUtils.ts new file mode 100644 index 00000000..093b2c0f --- /dev/null +++ b/src/utils/profileLoadingUtils.ts @@ -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 +} + diff --git a/src/utils/profileUtils.ts b/src/utils/profileUtils.ts new file mode 100644 index 00000000..0007f5d0 --- /dev/null +++ b/src/utils/profileUtils.ts @@ -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 '' + } + } +} +