From b7cda7a3514437e819a336b70c32ef41111e4224 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:01:51 +0100 Subject: [PATCH 01/57] refactor: replace custom NIP-19 parsing with applesauce helpers and add progressive profile resolution - Replace custom regex patterns with Tokens.nostrLink from applesauce-content - Use getContentPointers() and getPubkeyFromDecodeResult() from applesauce helpers - Add useProfileLabels hook for shared profile resolution logic - Implement progressive profile name updates in markdown articles - Remove unused ContentWithResolvedProfiles component - Simplify useMarkdownToHTML by extracting profile resolution to shared hook - Fix TypeScript and ESLint errors --- .../ContentWithResolvedProfiles.tsx | 38 --------- src/components/NostrMentionLink.tsx | 15 ++-- src/components/RichContent.tsx | 26 ++++--- src/hooks/useMarkdownToHTML.ts | 73 +++++++++++------- src/hooks/useProfileLabels.ts | 45 +++++++++++ src/utils/helpers.ts | 8 -- src/utils/nostrUriResolver.tsx | 77 +++++++++++++------ 7 files changed, 167 insertions(+), 115 deletions(-) delete mode 100644 src/components/ContentWithResolvedProfiles.tsx create mode 100644 src/hooks/useProfileLabels.ts 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/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index ca16fc5f..39692744 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -1,7 +1,9 @@ import React from 'react' import { nip19 } from 'nostr-tools' import { useEventModel } from 'applesauce-react/hooks' -import { Models } from 'applesauce-core' +import { Models, Helpers } from 'applesauce-core' + +const { getPubkeyFromDecodeResult } = Helpers interface NostrMentionLinkProps { nostrUri: string @@ -20,22 +22,17 @@ const NostrMentionLink: React.FC = ({ }) => { // 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 + // Fetch profile at top level (Rules of Hooks) const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null) diff --git a/src/components/RichContent.tsx b/src/components/RichContent.tsx index 7bd55d12..3b7f2720 100644 --- a/src/components/RichContent.tsx +++ b/src/components/RichContent.tsx @@ -1,5 +1,6 @@ import React from 'react' import NostrMentionLink from './NostrMentionLink' +import { Tokens } from 'applesauce-content/helpers' interface RichContentProps { content: string @@ -19,17 +20,24 @@ const RichContent: React.FC = ({ className = 'bookmark-content' }) => { // Pattern to match: - // 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) - // 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.) - // 3. http(s) URLs - const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi + // 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink + // 2. http(s) URLs + const nostrPattern = Tokens.nostrLink + const urlPattern = /https?:\/\/[^\s]+/gi + const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi') - const parts = content.split(pattern) + const parts = content.split(combinedPattern) + + // Helper to check if a string is a nostr identifier (without mutating regex state) + const isNostrIdentifier = (str: string): boolean => { + const testPattern = new RegExp(nostrPattern.source, nostrPattern.flags) + return testPattern.test(str) + } return (
{parts.map((part, index) => { - // Handle nostr: URIs + // 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 (
- ) + ) + } catch (err) { + console.error('[RichContent] Error rendering:', err) + return
Error rendering content
+ } } export default RichContent diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index b86d0539..a04873ff 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -21,8 +21,11 @@ export const useMarkdownToHTML = ( const [processedMarkdown, setProcessedMarkdown] = useState('') const [articleTitles, setArticleTitles] = useState>(new Map()) + console.log('[useMarkdownToHTML] Hook called, markdown length:', markdown?.length || 0, 'hasRelayPool:', !!relayPool) + // Resolve profile labels progressively as profiles load const profileLabels = useProfileLabels(markdown || '') + console.log('[useMarkdownToHTML] Profile labels size:', profileLabels.size) // Fetch article titles useEffect(() => { @@ -68,16 +71,26 @@ export const useMarkdownToHTML = ( let isCancelled = false const processMarkdown = () => { - // Replace nostr URIs with profile labels (progressive) and article titles - const processed = replaceNostrUrisInMarkdownWithProfileLabels( - markdown, - profileLabels, - articleTitles - ) - - if (isCancelled) return - - setProcessedMarkdown(processed) + console.log('[useMarkdownToHTML] Processing markdown, length:', markdown.length) + console.log('[useMarkdownToHTML] Profile labels:', profileLabels.size, 'Article titles:', articleTitles.size) + try { + // Replace nostr URIs with profile labels (progressive) and article titles + const processed = replaceNostrUrisInMarkdownWithProfileLabels( + markdown, + profileLabels, + articleTitles + ) + console.log('[useMarkdownToHTML] Processed markdown length:', processed.length) + + if (isCancelled) return + + setProcessedMarkdown(processed) + } catch (err) { + console.error('[useMarkdownToHTML] Error processing markdown:', err) + if (!isCancelled) { + setProcessedMarkdown(markdown) // Fallback to original + } + } const rafId = requestAnimationFrame(() => { if (previewRef.current && !isCancelled) { diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 3280b3eb..37bef275 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -12,14 +12,31 @@ const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers export function useProfileLabels(content: string): Map { // Extract profile pointers (npub and nprofile) using applesauce helpers const profileData = useMemo(() => { - const pointers = getContentPointers(content) - return pointers - .filter(p => p.type === 'npub' || p.type === 'nprofile') - .map(pointer => ({ - pubkey: getPubkeyFromDecodeResult(pointer), - encoded: encodeDecodeResult(pointer) - })) - .filter(p => p.pubkey) + console.log('[useProfileLabels] Processing content, length:', content?.length || 0) + try { + const pointers = getContentPointers(content) + console.log('[useProfileLabels] Found pointers:', pointers.length, 'types:', pointers.map(p => p.type)) + const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile') + console.log('[useProfileLabels] Profile pointers:', filtered.length) + const result = filtered + .map(pointer => { + try { + return { + pubkey: getPubkeyFromDecodeResult(pointer), + encoded: encodeDecodeResult(pointer) + } + } catch (err) { + console.error('[useProfileLabels] Error processing pointer:', err, pointer) + return null + } + }) + .filter((p): p is { pubkey: string; encoded: string } => p !== null && !!p.pubkey) + console.log('[useProfileLabels] Profile data after filtering:', result.length) + return result + } catch (err) { + console.error('[useProfileLabels] Error extracting pointers:', err) + return [] + } }, [content]) // Fetch profiles for all found pubkeys (progressive loading) @@ -30,15 +47,18 @@ export function useProfileLabels(content: string): Map { // Build profile labels map that updates reactively as profiles load return useMemo(() => { const labels = new Map() + console.log('[useProfileLabels] Building labels map, profileData:', profileData.length, 'profiles:', profiles.length) profileData.forEach(({ encoded }, index) => { const profile = profiles[index] if (profile) { const displayName = profile.name || profile.display_name || profile.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) + console.log('[useProfileLabels] Set label:', encoded, '->', displayName) } } }) + console.log('[useProfileLabels] Final labels map size:', labels.size) return labels }, [profileData, profiles]) } diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 15af0ade..4124bda9 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -16,8 +16,24 @@ const NOSTR_URI_REGEX = Tokens.nostrLink * Extract all nostr URIs from text using applesauce helpers */ export function extractNostrUris(text: string): string[] { - const pointers = getContentPointers(text) - return pointers.map(pointer => encodeDecodeResult(pointer)) + console.log('[nostrUriResolver] extractNostrUris called, text length:', text?.length || 0) + try { + const pointers = getContentPointers(text) + console.log('[nostrUriResolver] Found pointers:', pointers.length) + const result = pointers.map(pointer => { + try { + return encodeDecodeResult(pointer) + } catch (err) { + console.error('[nostrUriResolver] Error encoding pointer:', err, pointer) + return null + } + }).filter((v): v is string => v !== null) + console.log('[nostrUriResolver] Extracted URIs:', result.length) + return result + } catch (err) { + console.error('[nostrUriResolver] Error in extractNostrUris:', err) + return [] + } } /** From 3b30bc98c72c356b38b2d3c24fa449a92a55b355 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:04:34 +0100 Subject: [PATCH 03/57] fix: correct syntax error in RichContent try-catch structure --- src/components/RichContent.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/RichContent.tsx b/src/components/RichContent.tsx index e72c3901..ae7cf307 100644 --- a/src/components/RichContent.tsx +++ b/src/components/RichContent.tsx @@ -32,13 +32,13 @@ const RichContent: React.FC = ({ const parts = content.split(combinedPattern) console.log('[RichContent] Split into parts:', parts.length) - // 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 ( + // 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 - Tokens.nostrLink matches both formats From da385cd037ce9d3ff96302b3224ff705dcb64b04 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:37:50 +0100 Subject: [PATCH 04/57] fix: resolve Rules of Hooks violation by using eventStore instead of useEventModel in map --- src/hooks/useMarkdownToHTML.ts | 2 +- src/hooks/useProfileLabels.ts | 118 ++++++++++++++++++++++++--------- src/utils/nostrUriResolver.tsx | 11 +-- 3 files changed, 93 insertions(+), 38 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index a04873ff..7e8248d2 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -24,7 +24,7 @@ export const useMarkdownToHTML = ( console.log('[useMarkdownToHTML] Hook called, markdown length:', markdown?.length || 0, 'hasRelayPool:', !!relayPool) // Resolve profile labels progressively as profiles load - const profileLabels = useProfileLabels(markdown || '') + const profileLabels = useProfileLabels(markdown || '', relayPool) console.log('[useMarkdownToHTML] Profile labels size:', profileLabels.size) // Fetch article titles diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 37bef275..0b93d642 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -1,7 +1,9 @@ -import { useMemo } from 'react' -import { useEventModel } from 'applesauce-react/hooks' -import { Models, Helpers } from 'applesauce-core' +import { useMemo, useState, useEffect } 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 { fetchProfiles } from '../services/profileService' const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers @@ -9,7 +11,9 @@ const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers * Hook to resolve profile labels from content containing npub/nprofile identifiers * Returns a Map of encoded identifier -> display name that updates progressively as profiles load */ -export function useProfileLabels(content: string): Map { +export function useProfileLabels(content: string, relayPool?: RelayPool | null): Map { + const eventStore = Hooks.useEventStore() + // Extract profile pointers (npub and nprofile) using applesauce helpers const profileData = useMemo(() => { console.log('[useProfileLabels] Processing content, length:', content?.length || 0) @@ -18,19 +22,18 @@ export function useProfileLabels(content: string): Map { console.log('[useProfileLabels] Found pointers:', pointers.length, 'types:', pointers.map(p => p.type)) const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile') console.log('[useProfileLabels] Profile pointers:', filtered.length) - const result = filtered - .map(pointer => { - try { - return { - pubkey: getPubkeyFromDecodeResult(pointer), - encoded: encodeDecodeResult(pointer) - } - } catch (err) { - console.error('[useProfileLabels] Error processing pointer:', err, pointer) - return null + 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 }) } - }) - .filter((p): p is { pubkey: string; encoded: string } => p !== null && !!p.pubkey) + } catch (err) { + console.error('[useProfileLabels] Error processing pointer:', err, pointer) + } + }) console.log('[useProfileLabels] Profile data after filtering:', result.length) return result } catch (err) { @@ -39,27 +42,76 @@ export function useProfileLabels(content: string): Map { } }, [content]) - // Fetch profiles for all found pubkeys (progressive loading) - const profiles = profileData.map(({ pubkey }) => - useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null) - ) + const [profileLabels, setProfileLabels] = useState>(new Map()) - // Build profile labels map that updates reactively as profiles load - return useMemo(() => { + // Build initial labels from eventStore, then fetch missing profiles + useEffect(() => { + console.log('[useProfileLabels] Building labels, profileData:', profileData.length, 'hasEventStore:', !!eventStore) + + // First, get profiles from eventStore synchronously const labels = new Map() - console.log('[useProfileLabels] Building labels map, profileData:', profileData.length, 'profiles:', profiles.length) - profileData.forEach(({ encoded }, index) => { - const profile = profiles[index] - if (profile) { - const displayName = profile.name || profile.display_name || profile.nip05 - if (displayName) { - labels.set(encoded, `@${displayName}`) - console.log('[useProfileLabels] Set label:', encoded, '->', displayName) + const pubkeysToFetch: string[] = [] + + profileData.forEach(({ encoded, pubkey }) => { + if (eventStore) { + const profileEvent = eventStore.getEvent(pubkey + ':0') + if (profileEvent) { + try { + const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + labels.set(encoded, `@${displayName}`) + console.log('[useProfileLabels] Found in eventStore:', encoded, '->', displayName) + } else { + pubkeysToFetch.push(pubkey) + } + } catch { + pubkeysToFetch.push(pubkey) + } + } else { + pubkeysToFetch.push(pubkey) } + } else { + pubkeysToFetch.push(pubkey) } }) - console.log('[useProfileLabels] Final labels map size:', labels.size) - return labels - }, [profileData, profiles]) + + // Update labels with what we found in eventStore + setProfileLabels(new Map(labels)) + + // Fetch missing profiles asynchronously + if (pubkeysToFetch.length > 0 && relayPool && eventStore) { + console.log('[useProfileLabels] Fetching', pubkeysToFetch.length, 'missing profiles') + fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) + .then(profiles => { + // Rebuild labels map with fetched profiles + const updatedLabels = new Map(labels) + profileData.forEach(({ encoded, pubkey }) => { + if (!updatedLabels.has(encoded)) { + const profileEvent = profiles.find(p => p.pubkey === pubkey) + if (profileEvent) { + try { + const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + updatedLabels.set(encoded, `@${displayName}`) + console.log('[useProfileLabels] Fetched profile:', encoded, '->', displayName) + } + } catch { + // ignore parse errors + } + } + } + }) + setProfileLabels(updatedLabels) + }) + .catch(err => { + console.error('[useProfileLabels] Error fetching profiles:', err) + }) + } + }, [profileData, eventStore, relayPool]) + + console.log('[useProfileLabels] Final labels map size:', profileLabels.size) + return profileLabels } diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 4124bda9..d9b3a490 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -20,14 +20,17 @@ export function extractNostrUris(text: string): string[] { try { const pointers = getContentPointers(text) console.log('[nostrUriResolver] Found pointers:', pointers.length) - const result = pointers.map(pointer => { + const result: string[] = [] + pointers.forEach(pointer => { try { - return encodeDecodeResult(pointer) + const encoded = encodeDecodeResult(pointer) + if (encoded) { + result.push(encoded) + } } catch (err) { console.error('[nostrUriResolver] Error encoding pointer:', err, pointer) - return null } - }).filter((v): v is string => v !== null) + }) console.log('[nostrUriResolver] Extracted URIs:', result.length) return result } catch (err) { From 68e6fcd3ac0476a8cfc33b69a943dd44eecb74e1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:38:26 +0100 Subject: [PATCH 05/57] debug: standardize all npub resolution debug logs with [npub-resolve] prefix --- src/components/RichContent.tsx | 6 +++--- src/hooks/useMarkdownToHTML.ts | 12 ++++++------ src/hooks/useProfileLabels.ts | 30 +++++++++++++++--------------- src/utils/nostrUriResolver.tsx | 10 +++++----- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/components/RichContent.tsx b/src/components/RichContent.tsx index ae7cf307..0b3dc00c 100644 --- a/src/components/RichContent.tsx +++ b/src/components/RichContent.tsx @@ -19,7 +19,7 @@ const RichContent: React.FC = ({ content, className = 'bookmark-content' }) => { - console.log('[RichContent] Rendering, content length:', content?.length || 0) + console.log('[npub-resolve] RichContent: Rendering, content length:', content?.length || 0) try { // Pattern to match: @@ -30,7 +30,7 @@ const RichContent: React.FC = ({ const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi') const parts = content.split(combinedPattern) - console.log('[RichContent] Split into parts:', parts.length) + console.log('[npub-resolve] RichContent: Split into parts:', parts.length) // Helper to check if a string is a nostr identifier (without mutating regex state) const isNostrIdentifier = (str: string): boolean => { @@ -82,7 +82,7 @@ const RichContent: React.FC = ({
) } catch (err) { - console.error('[RichContent] Error rendering:', err) + console.error('[npub-resolve] RichContent: Error rendering:', err) return
Error rendering content
} } diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 7e8248d2..b46d75dc 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -21,11 +21,11 @@ export const useMarkdownToHTML = ( const [processedMarkdown, setProcessedMarkdown] = useState('') const [articleTitles, setArticleTitles] = useState>(new Map()) - console.log('[useMarkdownToHTML] Hook called, markdown length:', markdown?.length || 0, 'hasRelayPool:', !!relayPool) + console.log('[npub-resolve] useMarkdownToHTML: markdown length:', markdown?.length || 0, 'hasRelayPool:', !!relayPool) // Resolve profile labels progressively as profiles load const profileLabels = useProfileLabels(markdown || '', relayPool) - console.log('[useMarkdownToHTML] Profile labels size:', profileLabels.size) + console.log('[npub-resolve] useMarkdownToHTML: Profile labels size:', profileLabels.size) // Fetch article titles useEffect(() => { @@ -71,8 +71,8 @@ export const useMarkdownToHTML = ( let isCancelled = false const processMarkdown = () => { - console.log('[useMarkdownToHTML] Processing markdown, length:', markdown.length) - console.log('[useMarkdownToHTML] Profile labels:', profileLabels.size, 'Article titles:', articleTitles.size) + console.log('[npub-resolve] useMarkdownToHTML: Processing markdown, length:', markdown.length) + console.log('[npub-resolve] useMarkdownToHTML: Profile labels:', profileLabels.size, 'Article titles:', articleTitles.size) try { // Replace nostr URIs with profile labels (progressive) and article titles const processed = replaceNostrUrisInMarkdownWithProfileLabels( @@ -80,13 +80,13 @@ export const useMarkdownToHTML = ( profileLabels, articleTitles ) - console.log('[useMarkdownToHTML] Processed markdown length:', processed.length) + console.log('[npub-resolve] useMarkdownToHTML: Processed markdown length:', processed.length) if (isCancelled) return setProcessedMarkdown(processed) } catch (err) { - console.error('[useMarkdownToHTML] Error processing markdown:', err) + console.error('[npub-resolve] useMarkdownToHTML: Error processing markdown:', err) if (!isCancelled) { setProcessedMarkdown(markdown) // Fallback to original } diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 0b93d642..d3ba1798 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -16,12 +16,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Extract profile pointers (npub and nprofile) using applesauce helpers const profileData = useMemo(() => { - console.log('[useProfileLabels] Processing content, length:', content?.length || 0) - try { - const pointers = getContentPointers(content) - console.log('[useProfileLabels] Found pointers:', pointers.length, 'types:', pointers.map(p => p.type)) - const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile') - console.log('[useProfileLabels] Profile pointers:', filtered.length) + console.log('[npub-resolve] Processing content, length:', content?.length || 0) + try { + const pointers = getContentPointers(content) + console.log('[npub-resolve] Found pointers:', pointers.length, 'types:', pointers.map(p => p.type)) + const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile') + console.log('[npub-resolve] Profile pointers:', filtered.length) const result: Array<{ pubkey: string; encoded: string }> = [] filtered.forEach(pointer => { try { @@ -31,13 +31,13 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): result.push({ pubkey, encoded: encoded as string }) } } catch (err) { - console.error('[useProfileLabels] Error processing pointer:', err, pointer) + console.error('[npub-resolve] Error processing pointer:', err, pointer) } }) - console.log('[useProfileLabels] Profile data after filtering:', result.length) + console.log('[npub-resolve] Profile data after filtering:', result.length) return result } catch (err) { - console.error('[useProfileLabels] Error extracting pointers:', err) + console.error('[npub-resolve] Error extracting pointers:', err) return [] } }, [content]) @@ -46,7 +46,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Build initial labels from eventStore, then fetch missing profiles useEffect(() => { - console.log('[useProfileLabels] Building labels, profileData:', profileData.length, 'hasEventStore:', !!eventStore) + console.log('[npub-resolve] Building labels, profileData:', profileData.length, 'hasEventStore:', !!eventStore) // First, get profiles from eventStore synchronously const labels = new Map() @@ -61,7 +61,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) - console.log('[useProfileLabels] Found in eventStore:', encoded, '->', displayName) + console.log('[npub-resolve] Found in eventStore:', encoded, '->', displayName) } else { pubkeysToFetch.push(pubkey) } @@ -81,7 +81,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { - console.log('[useProfileLabels] Fetching', pubkeysToFetch.length, 'missing profiles') + console.log('[npub-resolve] Fetching', pubkeysToFetch.length, 'missing profiles') fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) .then(profiles => { // Rebuild labels map with fetched profiles @@ -95,7 +95,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) - console.log('[useProfileLabels] Fetched profile:', encoded, '->', displayName) + console.log('[npub-resolve] Fetched profile:', encoded, '->', displayName) } } catch { // ignore parse errors @@ -106,12 +106,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): setProfileLabels(updatedLabels) }) .catch(err => { - console.error('[useProfileLabels] Error fetching profiles:', err) + console.error('[npub-resolve] Error fetching profiles:', err) }) } }, [profileData, eventStore, relayPool]) - console.log('[useProfileLabels] Final labels map size:', profileLabels.size) + console.log('[npub-resolve] Final labels map size:', profileLabels.size) return profileLabels } diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index d9b3a490..5d1f9771 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -16,10 +16,10 @@ const NOSTR_URI_REGEX = Tokens.nostrLink * Extract all nostr URIs from text using applesauce helpers */ export function extractNostrUris(text: string): string[] { - console.log('[nostrUriResolver] extractNostrUris called, text length:', text?.length || 0) + console.log('[npub-resolve] extractNostrUris: text length:', text?.length || 0) try { const pointers = getContentPointers(text) - console.log('[nostrUriResolver] Found pointers:', pointers.length) + console.log('[npub-resolve] extractNostrUris: Found pointers:', pointers.length) const result: string[] = [] pointers.forEach(pointer => { try { @@ -28,13 +28,13 @@ export function extractNostrUris(text: string): string[] { result.push(encoded) } } catch (err) { - console.error('[nostrUriResolver] Error encoding pointer:', err, pointer) + console.error('[npub-resolve] extractNostrUris: Error encoding pointer:', err, pointer) } }) - console.log('[nostrUriResolver] Extracted URIs:', result.length) + console.log('[npub-resolve] extractNostrUris: Extracted URIs:', result.length) return result } catch (err) { - console.error('[nostrUriResolver] Error in extractNostrUris:', err) + console.error('[npub-resolve] extractNostrUris: Error:', err) return [] } } From 30c2ca5b858cbc3f1b4d99cfd5790a77ba116f34 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:40:12 +0100 Subject: [PATCH 06/57] feat: remove 'npub1' prefix from shortened npub displays - Show @derggg instead of @npub1derggg for truncated npubs - Update getNostrUriLabel to skip first 5 chars ('npub1') - Update NostrMentionLink fallback display to match --- src/components/NostrMentionLink.tsx | 11 ++++++++--- src/utils/nostrUriResolver.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index 39692744..bc0f41cd 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -50,11 +50,14 @@ const NostrMentionLink: React.FC = ({ switch (decoded.type) { case 'npub': { const pk = decoded.data - const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...` + const npub = nip19.npubEncode(pk) + // Fallback: show npub without "npub1" prefix + const fallbackDisplay = `@${npub.slice(5, 12)}...` + const displayName = profile?.name || profile?.display_name || profile?.nip05 || fallbackDisplay return ( @@ -64,8 +67,10 @@ const NostrMentionLink: React.FC = ({ } 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) + // Fallback: show npub without "npub1" prefix + const fallbackDisplay = `@${npub.slice(5, 12)}...` + const displayName = profile?.name || profile?.display_name || profile?.nip05 || fallbackDisplay return ( Date: Sun, 2 Nov 2025 20:41:34 +0100 Subject: [PATCH 07/57] fix: re-check eventStore after fetchProfiles to resolve all profiles - After fetchProfiles completes, re-check eventStore for all profiles - This ensures profiles are resolved even if fetchProfiles returns partial results - Fixes issue where only 5 out of 19 profiles were being resolved --- src/hooks/useProfileLabels.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index d3ba1798..e608087b 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -83,19 +83,20 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (pubkeysToFetch.length > 0 && relayPool && eventStore) { console.log('[npub-resolve] Fetching', pubkeysToFetch.length, 'missing profiles') fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) - .then(profiles => { - // Rebuild labels map with fetched profiles + .then(() => { + // Re-check eventStore for all profiles (including ones we just fetched) + // This ensures we get profiles even if fetchProfiles didn't return them in the array const updatedLabels = new Map(labels) profileData.forEach(({ encoded, pubkey }) => { - if (!updatedLabels.has(encoded)) { - const profileEvent = profiles.find(p => p.pubkey === pubkey) + if (!updatedLabels.has(encoded) && eventStore) { + const profileEvent = eventStore.getEvent(pubkey + ':0') if (profileEvent) { try { const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) - console.log('[npub-resolve] Fetched profile:', encoded, '->', displayName) + console.log('[npub-resolve] Resolved profile:', encoded, '->', displayName) } } catch { // ignore parse errors @@ -103,6 +104,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } } }) + console.log('[npub-resolve] After fetch, resolved:', updatedLabels.size, 'out of', profileData.length) setProfileLabels(updatedLabels) }) .catch(err => { From 9ed56b213eae96dafdcec1f0fc7a66beebd350de Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:44:15 +0100 Subject: [PATCH 08/57] fix: add periodic re-checking of eventStore for async profile arrivals - Poll eventStore every 200ms for up to 2 seconds after fetchProfiles - Accumulate resolved labels across checks instead of resetting - Add detailed logging to diagnose why profiles aren't resolving - Fixes issue where profiles arrive asynchronously after fetchProfiles completes --- src/hooks/useProfileLabels.ts | 70 +++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index e608087b..0229376f 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -81,31 +81,63 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { - console.log('[npub-resolve] Fetching', pubkeysToFetch.length, 'missing profiles') + console.log('[npub-resolve] Fetching', pubkeysToFetch.length, 'missing profiles:', pubkeysToFetch.slice(0, 3).map(p => p.slice(0, 8) + '...')) fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) .then(() => { - // Re-check eventStore for all profiles (including ones we just fetched) - // This ensures we get profiles even if fetchProfiles didn't return them in the array - const updatedLabels = new Map(labels) - profileData.forEach(({ encoded, pubkey }) => { - if (!updatedLabels.has(encoded) && eventStore) { - const profileEvent = eventStore.getEvent(pubkey + ':0') - if (profileEvent) { - try { - const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - updatedLabels.set(encoded, `@${displayName}`) - console.log('[npub-resolve] Resolved profile:', encoded, '->', displayName) + // Re-check eventStore periodically as profiles arrive asynchronously + let checkCount = 0 + const maxChecks = 10 + const checkInterval = 200 // ms + + // Keep track of resolved labels across checks + let currentLabels = new Map(labels) + + const checkForProfiles = () => { + checkCount++ + const updatedLabels = new Map(currentLabels) + let newlyResolvedCount = 0 + let withEventsCount = 0 + let withoutNamesCount = 0 + + profileData.forEach(({ encoded, pubkey }) => { + if (!updatedLabels.has(encoded) && eventStore) { + const profileEvent = eventStore.getEvent(pubkey + ':0') + if (profileEvent) { + withEventsCount++ + try { + const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + updatedLabels.set(encoded, `@${displayName}`) + newlyResolvedCount++ + console.log('[npub-resolve] Resolved profile:', encoded.slice(0, 20) + '...', '->', displayName) + } else { + withoutNamesCount++ + if (checkCount === 1) { // Only log once on first check + console.log('[npub-resolve] Profile has no name:', encoded.slice(0, 20) + '...', 'content keys:', Object.keys(profileData)) + } + } + } catch (err) { + console.error('[npub-resolve] Error parsing profile:', encoded.slice(0, 20) + '...', err) } - } catch { - // ignore parse errors } } + }) + + currentLabels = updatedLabels + console.log('[npub-resolve] Check', checkCount, '- resolved:', updatedLabels.size, 'total,', newlyResolvedCount, 'new,', withEventsCount, 'with events,', withoutNamesCount, 'without names') + setProfileLabels(updatedLabels) + + // Continue checking if we haven't resolved all profiles and haven't exceeded max checks + if (updatedLabels.size < profileData.length && checkCount < maxChecks) { + setTimeout(checkForProfiles, checkInterval) + } else if (updatedLabels.size < profileData.length) { + console.warn('[npub-resolve] Stopped checking after', checkCount, 'attempts. Resolved', updatedLabels.size, 'out of', profileData.length) } - }) - console.log('[npub-resolve] After fetch, resolved:', updatedLabels.size, 'out of', profileData.length) - setProfileLabels(updatedLabels) + } + + // Start checking immediately, then periodically + checkForProfiles() }) .catch(err => { console.error('[npub-resolve] Error fetching profiles:', err) From 3ba5bce437e9d7a6bb5cd5b2ae967c7ed5730626 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:46:23 +0100 Subject: [PATCH 09/57] debug: add detailed logging to diagnose nprofile resolution issues - Log fetchProfiles return count - Log profile events found in store vs missing - Log profiles with names vs without names - Help diagnose why 0 profiles are being resolved --- src/hooks/useProfileLabels.ts | 84 +++++++++++++++-------------------- 1 file changed, 36 insertions(+), 48 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 0229376f..a76f5485 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -81,63 +81,51 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { - console.log('[npub-resolve] Fetching', pubkeysToFetch.length, 'missing profiles:', pubkeysToFetch.slice(0, 3).map(p => p.slice(0, 8) + '...')) + console.log('[npub-resolve] Fetching', pubkeysToFetch.length, 'missing profiles') fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) - .then(() => { - // Re-check eventStore periodically as profiles arrive asynchronously - let checkCount = 0 - const maxChecks = 10 - const checkInterval = 200 // ms + .then((fetchedProfiles) => { + console.log('[npub-resolve] fetchProfiles returned', fetchedProfiles.length, 'profiles') - // Keep track of resolved labels across checks - let currentLabels = new Map(labels) + // Re-check eventStore for all profiles (including ones we just fetched) + // This ensures we get profiles even if fetchProfiles didn't return them in the array + const updatedLabels = new Map(labels) + let foundInStore = 0 + let withNames = 0 + let withoutNames = 0 + let missingFromStore = 0 - const checkForProfiles = () => { - checkCount++ - const updatedLabels = new Map(currentLabels) - let newlyResolvedCount = 0 - let withEventsCount = 0 - let withoutNamesCount = 0 - - profileData.forEach(({ encoded, pubkey }) => { - if (!updatedLabels.has(encoded) && eventStore) { - const profileEvent = eventStore.getEvent(pubkey + ':0') - if (profileEvent) { - withEventsCount++ - try { - const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - updatedLabels.set(encoded, `@${displayName}`) - newlyResolvedCount++ - console.log('[npub-resolve] Resolved profile:', encoded.slice(0, 20) + '...', '->', displayName) - } else { - withoutNamesCount++ - if (checkCount === 1) { // Only log once on first check - console.log('[npub-resolve] Profile has no name:', encoded.slice(0, 20) + '...', 'content keys:', Object.keys(profileData)) - } + profileData.forEach(({ encoded, pubkey }) => { + if (!updatedLabels.has(encoded) && eventStore) { + const profileEvent = eventStore.getEvent(pubkey + ':0') + if (profileEvent) { + foundInStore++ + try { + const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + updatedLabels.set(encoded, `@${displayName}`) + withNames++ + console.log('[npub-resolve] Resolved profile:', encoded.slice(0, 30) + '...', '->', displayName) + } else { + withoutNames++ + if (withoutNames <= 3) { // Log first few for debugging + console.log('[npub-resolve] Profile event found but no name/display_name/nip05:', encoded.slice(0, 30) + '...', 'content keys:', Object.keys(profileData)) } - } catch (err) { - console.error('[npub-resolve] Error parsing profile:', encoded.slice(0, 20) + '...', err) } + } catch (err) { + console.error('[npub-resolve] Error parsing profile event for', encoded.slice(0, 30) + '...', err) + } + } else { + missingFromStore++ + if (missingFromStore <= 3) { // Log first few for debugging + console.log('[npub-resolve] Profile not in eventStore after fetch:', pubkey.slice(0, 16) + '...') } } - }) - - currentLabels = updatedLabels - console.log('[npub-resolve] Check', checkCount, '- resolved:', updatedLabels.size, 'total,', newlyResolvedCount, 'new,', withEventsCount, 'with events,', withoutNamesCount, 'without names') - setProfileLabels(updatedLabels) - - // Continue checking if we haven't resolved all profiles and haven't exceeded max checks - if (updatedLabels.size < profileData.length && checkCount < maxChecks) { - setTimeout(checkForProfiles, checkInterval) - } else if (updatedLabels.size < profileData.length) { - console.warn('[npub-resolve] Stopped checking after', checkCount, 'attempts. Resolved', updatedLabels.size, 'out of', profileData.length) } - } + }) - // Start checking immediately, then periodically - checkForProfiles() + console.log('[npub-resolve] After fetch - resolved:', updatedLabels.size, 'total | found in store:', foundInStore, '| with names:', withNames, '| without names:', withoutNames, '| missing:', missingFromStore, '| out of', profileData.length) + setProfileLabels(updatedLabels) }) .catch(err => { console.error('[npub-resolve] Error fetching profiles:', err) From 50ab59ebcd68ce668ffb5881e55a6a47583a095a Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:49:34 +0100 Subject: [PATCH 10/57] fix: use fetchedProfiles array directly instead of only checking eventStore - fetchProfiles returns profiles that we should use immediately - Check returned array first, then fallback to eventStore lookup - Fixes issue where profiles were returned but not used for resolution --- src/hooks/useProfileLabels.ts | 54 ++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index a76f5485..9c477319 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -86,45 +86,67 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): .then((fetchedProfiles) => { console.log('[npub-resolve] fetchProfiles returned', fetchedProfiles.length, 'profiles') - // Re-check eventStore for all profiles (including ones we just fetched) - // This ensures we get profiles even if fetchProfiles didn't return them in the array + // First, use the profiles returned from fetchProfiles directly const updatedLabels = new Map(labels) - let foundInStore = 0 + const fetchedProfilesByPubkey = new Map(fetchedProfiles.map(p => [p.pubkey, p])) + + let resolvedFromArray = 0 + let resolvedFromStore = 0 let withNames = 0 let withoutNames = 0 let missingFromStore = 0 profileData.forEach(({ encoded, pubkey }) => { - if (!updatedLabels.has(encoded) && eventStore) { - const profileEvent = eventStore.getEvent(pubkey + ':0') - if (profileEvent) { - foundInStore++ + if (!updatedLabels.has(encoded)) { + // First, try to use the profile from the returned array + const fetchedProfile = fetchedProfilesByPubkey.get(pubkey) + if (fetchedProfile) { + resolvedFromArray++ try { - const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const profileData = JSON.parse(fetchedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string } const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) withNames++ - console.log('[npub-resolve] Resolved profile:', encoded.slice(0, 30) + '...', '->', displayName) + console.log('[npub-resolve] Resolved from fetched array:', encoded.slice(0, 30) + '...', '->', displayName) } else { withoutNames++ - if (withoutNames <= 3) { // Log first few for debugging - console.log('[npub-resolve] Profile event found but no name/display_name/nip05:', encoded.slice(0, 30) + '...', 'content keys:', Object.keys(profileData)) + if (withoutNames <= 3) { + console.log('[npub-resolve] Fetched profile has no name/display_name/nip05:', encoded.slice(0, 30) + '...', 'content keys:', Object.keys(profileData)) } } } catch (err) { - console.error('[npub-resolve] Error parsing profile event for', encoded.slice(0, 30) + '...', err) + console.error('[npub-resolve] Error parsing fetched profile for', encoded.slice(0, 30) + '...', err) + } + } else if (eventStore) { + // Fallback: check eventStore (in case fetchProfiles stored but didn't return) + const profileEvent = eventStore.getEvent(pubkey + ':0') + if (profileEvent) { + resolvedFromStore++ + try { + const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + updatedLabels.set(encoded, `@${displayName}`) + withNames++ + console.log('[npub-resolve] Resolved from eventStore:', encoded.slice(0, 30) + '...', '->', displayName) + } + } catch (err) { + console.error('[npub-resolve] Error parsing profile event for', encoded.slice(0, 30) + '...', err) + } + } else { + missingFromStore++ + if (missingFromStore <= 3) { + console.log('[npub-resolve] Profile not found in array or eventStore:', pubkey.slice(0, 16) + '...') + } } } else { missingFromStore++ - if (missingFromStore <= 3) { // Log first few for debugging - console.log('[npub-resolve] Profile not in eventStore after fetch:', pubkey.slice(0, 16) + '...') - } } } }) - console.log('[npub-resolve] After fetch - resolved:', updatedLabels.size, 'total | found in store:', foundInStore, '| with names:', withNames, '| without names:', withoutNames, '| missing:', missingFromStore, '| out of', profileData.length) + console.log('[npub-resolve] After fetch - resolved:', updatedLabels.size, 'total | from array:', resolvedFromArray, '| from store:', resolvedFromStore, '| with names:', withNames, '| without names:', withoutNames, '| missing:', missingFromStore, '| out of', profileData.length) setProfileLabels(updatedLabels) }) .catch(err => { From 8a431d962e8b641eece5c68cc9d49758ec537c0f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:53:22 +0100 Subject: [PATCH 11/57] debug: add timestamps to all npub-resolve logs for performance analysis - Add timestamp helper function (HH:mm:ss.SSS format) - Update all console.log/error statements to include timestamps - Helps identify timing bottlenecks in profile resolution --- src/components/RichContent.tsx | 13 +++++++--- src/hooks/useMarkdownToHTML.ts | 19 +++++++++----- src/hooks/useProfileLabels.ts | 45 ++++++++++++++++++++-------------- src/utils/nostrUriResolver.tsx | 17 +++++++++---- 4 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/components/RichContent.tsx b/src/components/RichContent.tsx index 0b3dc00c..643aa3d0 100644 --- a/src/components/RichContent.tsx +++ b/src/components/RichContent.tsx @@ -2,6 +2,13 @@ import React from 'react' import NostrMentionLink from './NostrMentionLink' import { Tokens } from 'applesauce-content/helpers' +// Helper to add timestamps to 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 className?: string @@ -19,7 +26,7 @@ const RichContent: React.FC = ({ content, className = 'bookmark-content' }) => { - console.log('[npub-resolve] RichContent: Rendering, content length:', content?.length || 0) + console.log(`[${ts()}] [npub-resolve] RichContent: Rendering, content length:`, content?.length || 0) try { // Pattern to match: @@ -30,7 +37,7 @@ const RichContent: React.FC = ({ const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi') const parts = content.split(combinedPattern) - console.log('[npub-resolve] RichContent: Split into parts:', parts.length) + console.log(`[${ts()}] [npub-resolve] RichContent: Split into parts:`, parts.length) // Helper to check if a string is a nostr identifier (without mutating regex state) const isNostrIdentifier = (str: string): boolean => { @@ -82,7 +89,7 @@ const RichContent: React.FC = ({ ) } catch (err) { - console.error('[npub-resolve] RichContent: Error rendering:', err) + console.error(`[${ts()}] [npub-resolve] RichContent: Error rendering:`, err) return
Error rendering content
} } diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index b46d75dc..07d88023 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -4,6 +4,13 @@ import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '. import { fetchArticleTitles } from '../services/articleTitleResolver' import { useProfileLabels } from './useProfileLabels' +// Helper to add timestamps to logs +const ts = () => { + const now = new Date() + const ms = now.getMilliseconds().toString().padStart(3, '0') + return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}` +} + /** * Hook to convert markdown to HTML using a hidden ReactMarkdown component * Also processes nostr: URIs in the markdown and resolves article titles @@ -21,11 +28,11 @@ export const useMarkdownToHTML = ( const [processedMarkdown, setProcessedMarkdown] = useState('') const [articleTitles, setArticleTitles] = useState>(new Map()) - console.log('[npub-resolve] useMarkdownToHTML: markdown length:', markdown?.length || 0, 'hasRelayPool:', !!relayPool) + console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: markdown length:`, markdown?.length || 0, 'hasRelayPool:', !!relayPool) // Resolve profile labels progressively as profiles load const profileLabels = useProfileLabels(markdown || '', relayPool) - console.log('[npub-resolve] useMarkdownToHTML: Profile labels size:', profileLabels.size) + console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: Profile labels size:`, profileLabels.size) // Fetch article titles useEffect(() => { @@ -71,8 +78,8 @@ export const useMarkdownToHTML = ( let isCancelled = false const processMarkdown = () => { - console.log('[npub-resolve] useMarkdownToHTML: Processing markdown, length:', markdown.length) - console.log('[npub-resolve] useMarkdownToHTML: Profile labels:', profileLabels.size, 'Article titles:', articleTitles.size) + console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: Processing markdown, length:`, markdown.length) + console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: Profile labels:`, profileLabels.size, 'Article titles:', articleTitles.size) try { // Replace nostr URIs with profile labels (progressive) and article titles const processed = replaceNostrUrisInMarkdownWithProfileLabels( @@ -80,13 +87,13 @@ export const useMarkdownToHTML = ( profileLabels, articleTitles ) - console.log('[npub-resolve] useMarkdownToHTML: Processed markdown length:', processed.length) + console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: Processed markdown length:`, processed.length) if (isCancelled) return setProcessedMarkdown(processed) } catch (err) { - console.error('[npub-resolve] useMarkdownToHTML: Error processing markdown:', err) + console.error(`[${ts()}] [npub-resolve] useMarkdownToHTML: Error processing markdown:`, err) if (!isCancelled) { setProcessedMarkdown(markdown) // Fallback to original } diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 9c477319..c9433808 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -7,6 +7,13 @@ import { fetchProfiles } from '../services/profileService' const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers +// Helper to add timestamps to logs +const ts = () => { + const now = new Date() + const ms = now.getMilliseconds().toString().padStart(3, '0') + return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}` +} + /** * Hook to resolve profile labels from content containing npub/nprofile identifiers * Returns a Map of encoded identifier -> display name that updates progressively as profiles load @@ -16,12 +23,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Extract profile pointers (npub and nprofile) using applesauce helpers const profileData = useMemo(() => { - console.log('[npub-resolve] Processing content, length:', content?.length || 0) + console.log(`[${ts()}] [npub-resolve] Processing content, length:`, content?.length || 0) try { const pointers = getContentPointers(content) - console.log('[npub-resolve] Found pointers:', pointers.length, 'types:', pointers.map(p => p.type)) + console.log(`[${ts()}] [npub-resolve] Found pointers:`, pointers.length, 'types:', pointers.map(p => p.type)) const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile') - console.log('[npub-resolve] Profile pointers:', filtered.length) + console.log(`[${ts()}] [npub-resolve] Profile pointers:`, filtered.length) const result: Array<{ pubkey: string; encoded: string }> = [] filtered.forEach(pointer => { try { @@ -31,13 +38,13 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): result.push({ pubkey, encoded: encoded as string }) } } catch (err) { - console.error('[npub-resolve] Error processing pointer:', err, pointer) + console.error(`[${ts()}] [npub-resolve] Error processing pointer:`, err, pointer) } }) - console.log('[npub-resolve] Profile data after filtering:', result.length) + console.log(`[${ts()}] [npub-resolve] Profile data after filtering:`, result.length) return result } catch (err) { - console.error('[npub-resolve] Error extracting pointers:', err) + console.error(`[${ts()}] [npub-resolve] Error extracting pointers:`, err) return [] } }, [content]) @@ -46,7 +53,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Build initial labels from eventStore, then fetch missing profiles useEffect(() => { - console.log('[npub-resolve] Building labels, profileData:', profileData.length, 'hasEventStore:', !!eventStore) + console.log(`[${ts()}] [npub-resolve] Building labels, profileData:`, profileData.length, 'hasEventStore:', !!eventStore) // First, get profiles from eventStore synchronously const labels = new Map() @@ -61,7 +68,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) - console.log('[npub-resolve] Found in eventStore:', encoded, '->', displayName) + console.log(`[${ts()}] [npub-resolve] Found in eventStore:`, encoded, '->', displayName) } else { pubkeysToFetch.push(pubkey) } @@ -81,10 +88,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { - console.log('[npub-resolve] Fetching', pubkeysToFetch.length, 'missing profiles') + console.log(`[${ts()}] [npub-resolve] Fetching`, pubkeysToFetch.length, 'missing profiles') fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) .then((fetchedProfiles) => { - console.log('[npub-resolve] fetchProfiles returned', fetchedProfiles.length, 'profiles') + console.log(`[${ts()}] [npub-resolve] fetchProfiles returned`, fetchedProfiles.length, 'profiles') // First, use the profiles returned from fetchProfiles directly const updatedLabels = new Map(labels) @@ -108,15 +115,15 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (displayName) { updatedLabels.set(encoded, `@${displayName}`) withNames++ - console.log('[npub-resolve] Resolved from fetched array:', encoded.slice(0, 30) + '...', '->', displayName) + console.log(`[${ts()}] [npub-resolve] Resolved from fetched array:`, encoded.slice(0, 30) + '...', '->', displayName) } else { withoutNames++ if (withoutNames <= 3) { - console.log('[npub-resolve] Fetched profile has no name/display_name/nip05:', encoded.slice(0, 30) + '...', 'content keys:', Object.keys(profileData)) + console.log(`[${ts()}] [npub-resolve] Fetched profile has no name/display_name/nip05:`, encoded.slice(0, 30) + '...', 'content keys:', Object.keys(profileData)) } } } catch (err) { - console.error('[npub-resolve] Error parsing fetched profile for', encoded.slice(0, 30) + '...', err) + console.error(`[${ts()}] [npub-resolve] Error parsing fetched profile for`, encoded.slice(0, 30) + '...', err) } } else if (eventStore) { // Fallback: check eventStore (in case fetchProfiles stored but didn't return) @@ -129,15 +136,15 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (displayName) { updatedLabels.set(encoded, `@${displayName}`) withNames++ - console.log('[npub-resolve] Resolved from eventStore:', encoded.slice(0, 30) + '...', '->', displayName) + console.log(`[${ts()}] [npub-resolve] Resolved from eventStore:`, encoded.slice(0, 30) + '...', '->', displayName) } } catch (err) { - console.error('[npub-resolve] Error parsing profile event for', encoded.slice(0, 30) + '...', err) + console.error(`[${ts()}] [npub-resolve] Error parsing profile event for`, encoded.slice(0, 30) + '...', err) } } else { missingFromStore++ if (missingFromStore <= 3) { - console.log('[npub-resolve] Profile not found in array or eventStore:', pubkey.slice(0, 16) + '...') + console.log(`[${ts()}] [npub-resolve] Profile not found in array or eventStore:`, pubkey.slice(0, 16) + '...') } } } else { @@ -146,16 +153,16 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) - console.log('[npub-resolve] After fetch - resolved:', updatedLabels.size, 'total | from array:', resolvedFromArray, '| from store:', resolvedFromStore, '| with names:', withNames, '| without names:', withoutNames, '| missing:', missingFromStore, '| out of', profileData.length) + console.log(`[${ts()}] [npub-resolve] After fetch - resolved:`, updatedLabels.size, 'total | from array:', resolvedFromArray, '| from store:', resolvedFromStore, '| with names:', withNames, '| without names:', withoutNames, '| missing:', missingFromStore, '| out of', profileData.length) setProfileLabels(updatedLabels) }) .catch(err => { - console.error('[npub-resolve] Error fetching profiles:', err) + console.error(`[${ts()}] [npub-resolve] Error fetching profiles:`, err) }) } }, [profileData, eventStore, relayPool]) - console.log('[npub-resolve] Final labels map size:', profileLabels.size) + console.log(`[${ts()}] [npub-resolve] Final labels map size:`, profileLabels.size) return profileLabels } diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 93338e27..0e388a42 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -4,6 +4,13 @@ import { Tokens } from 'applesauce-content/helpers' import { getContentPointers } from 'applesauce-factory/helpers' import { encodeDecodeResult } from 'applesauce-core/helpers' +// Helper to add timestamps to logs +const ts = () => { + const now = new Date() + const ms = now.getMilliseconds().toString().padStart(3, '0') + return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}` +} + /** * Regular expression to match nostr: URIs and bare NIP-19 identifiers * Uses applesauce Tokens.nostrLink which includes word boundary checks @@ -16,10 +23,10 @@ const NOSTR_URI_REGEX = Tokens.nostrLink * Extract all nostr URIs from text using applesauce helpers */ export function extractNostrUris(text: string): string[] { - console.log('[npub-resolve] extractNostrUris: text length:', text?.length || 0) + console.log(`[${ts()}] [npub-resolve] extractNostrUris: text length:`, text?.length || 0) try { const pointers = getContentPointers(text) - console.log('[npub-resolve] extractNostrUris: Found pointers:', pointers.length) + console.log(`[${ts()}] [npub-resolve] extractNostrUris: Found pointers:`, pointers.length) const result: string[] = [] pointers.forEach(pointer => { try { @@ -28,13 +35,13 @@ export function extractNostrUris(text: string): string[] { result.push(encoded) } } catch (err) { - console.error('[npub-resolve] extractNostrUris: Error encoding pointer:', err, pointer) + console.error(`[${ts()}] [npub-resolve] extractNostrUris: Error encoding pointer:`, err, pointer) } }) - console.log('[npub-resolve] extractNostrUris: Extracted URIs:', result.length) + console.log(`[${ts()}] [npub-resolve] extractNostrUris: Extracted URIs:`, result.length) return result } catch (err) { - console.error('[npub-resolve] extractNostrUris: Error:', err) + console.error(`[${ts()}] [npub-resolve] extractNostrUris: Error:`, err) return [] } } From 3136b198d59ab2781197d2033f473a3fd15b17b0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:54:40 +0100 Subject: [PATCH 12/57] perf: add timing metrics and reduce excessive logging - Add duration tracking for fetchProfiles (shows how long it takes) - Add total time tracking for entire resolution process - Reduce log noise by only logging when profileLabels size changes - Helps identify performance bottlenecks --- src/hooks/useMarkdownToHTML.ts | 10 ++++++---- src/hooks/useProfileLabels.ts | 23 ++++++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 07d88023..9842c38a 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, useMemo } from 'react' import { RelayPool } from 'applesauce-relay' import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '../utils/nostrUriResolver' import { fetchArticleTitles } from '../services/articleTitleResolver' @@ -28,11 +28,13 @@ export const useMarkdownToHTML = ( const [processedMarkdown, setProcessedMarkdown] = useState('') const [articleTitles, setArticleTitles] = useState>(new Map()) - console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: markdown length:`, markdown?.length || 0, 'hasRelayPool:', !!relayPool) - // Resolve profile labels progressively as profiles load const profileLabels = useProfileLabels(markdown || '', relayPool) - console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: Profile labels size:`, profileLabels.size) + + // Log when markdown or profile labels change (but throttle excessive logs) + useEffect(() => { + console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: markdown length:`, markdown?.length || 0, 'hasRelayPool:', !!relayPool, 'Profile labels size:', profileLabels.size) + }, [markdown?.length, profileLabels.size, relayPool]) // Fetch article titles useEffect(() => { diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index c9433808..4a002a4d 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect } from 'react' +import { useMemo, useState, useEffect, useRef } from 'react' import { Hooks } from 'applesauce-react' import { Helpers, IEventStore } from 'applesauce-core' import { getContentPointers } from 'applesauce-factory/helpers' @@ -50,9 +50,11 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): }, [content]) const [profileLabels, setProfileLabels] = useState>(new Map()) + const lastLoggedSize = useRef(0) // Build initial labels from eventStore, then fetch missing profiles useEffect(() => { + const startTime = Date.now() console.log(`[${ts()}] [npub-resolve] Building labels, profileData:`, profileData.length, 'hasEventStore:', !!eventStore) // First, get profiles from eventStore synchronously @@ -88,10 +90,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { + const fetchStartTime = Date.now() console.log(`[${ts()}] [npub-resolve] Fetching`, pubkeysToFetch.length, 'missing profiles') fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) .then((fetchedProfiles) => { - console.log(`[${ts()}] [npub-resolve] fetchProfiles returned`, fetchedProfiles.length, 'profiles') + const fetchDuration = Date.now() - fetchStartTime + console.log(`[${ts()}] [npub-resolve] fetchProfiles returned`, fetchedProfiles.length, 'profiles in', fetchDuration, 'ms') // First, use the profiles returned from fetchProfiles directly const updatedLabels = new Map(labels) @@ -153,16 +157,25 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) - console.log(`[${ts()}] [npub-resolve] After fetch - resolved:`, updatedLabels.size, 'total | from array:', resolvedFromArray, '| from store:', resolvedFromStore, '| with names:', withNames, '| without names:', withoutNames, '| missing:', missingFromStore, '| out of', profileData.length) + const totalDuration = Date.now() - startTime + console.log(`[${ts()}] [npub-resolve] After fetch - resolved:`, updatedLabels.size, 'total | from array:', resolvedFromArray, '| from store:', resolvedFromStore, '| with names:', withNames, '| without names:', withoutNames, '| missing:', missingFromStore, '| out of', profileData.length, '| total time:', totalDuration, 'ms') setProfileLabels(updatedLabels) }) .catch(err => { - console.error(`[${ts()}] [npub-resolve] Error fetching profiles:`, err) + const fetchDuration = Date.now() - fetchStartTime + console.error(`[${ts()}] [npub-resolve] Error fetching profiles after`, fetchDuration, 'ms:', err) }) } }, [profileData, eventStore, relayPool]) - console.log(`[${ts()}] [npub-resolve] Final labels map size:`, profileLabels.size) + // Only log when size actually changes to reduce noise + useEffect(() => { + if (profileLabels.size !== lastLoggedSize.current) { + console.log(`[${ts()}] [npub-resolve] Final labels map size:`, profileLabels.size) + lastLoggedSize.current = profileLabels.size + } + }, [profileLabels.size]) + return profileLabels } From 8a39258d8ec4ac8fe51b0adb11e7e150319dd311 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 20:54:45 +0100 Subject: [PATCH 13/57] fix: remove unused useMemo import --- src/hooks/useMarkdownToHTML.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 9842c38a..b0ca85e9 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { RelayPool } from 'applesauce-relay' import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '../utils/nostrUriResolver' import { fetchArticleTitles } from '../services/articleTitleResolver' From aaddd0ef6b751163ed4aa5a5339d84aaaeb1a579 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:03:10 +0100 Subject: [PATCH 14/57] feat: add localStorage caching for profile resolution - Add localStorage caching functions to profileService.ts following articleService.ts pattern - getCachedProfile: get single cached profile with TTL validation (30 days) - cacheProfile: save profile to localStorage with error handling - loadCachedProfiles: batch load multiple profiles from cache - Modify fetchProfiles() to check localStorage cache first, only fetch missing/expired profiles, and cache fetched profiles - Update useProfileLabels hook to check localStorage before EventStore, add cached profiles to EventStore for consistency - Update logging to show cache hits from localStorage - Benefits: instant profile resolution on page reload, reduced relay queries, offline support for previously-seen profiles --- src/hooks/useProfileLabels.ts | 62 ++++++++++++------ src/services/profileService.ts | 114 ++++++++++++++++++++++++++++++--- 2 files changed, 150 insertions(+), 26 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 4a002a4d..f4399013 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -3,7 +3,7 @@ import { Hooks } from 'applesauce-react' import { Helpers, IEventStore } from 'applesauce-core' import { getContentPointers } from 'applesauce-factory/helpers' import { RelayPool } from 'applesauce-relay' -import { fetchProfiles } from '../services/profileService' +import { fetchProfiles, loadCachedProfiles } from '../services/profileService' const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers @@ -52,32 +52,58 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const [profileLabels, setProfileLabels] = useState>(new Map()) const lastLoggedSize = useRef(0) - // Build initial labels from eventStore, then fetch missing profiles + // Build initial labels: localStorage cache -> eventStore -> fetch from relays useEffect(() => { const startTime = Date.now() console.log(`[${ts()}] [npub-resolve] Building labels, profileData:`, profileData.length, 'hasEventStore:', !!eventStore) - // First, get profiles from eventStore synchronously + // Extract all pubkeys + const allPubkeys = profileData.map(({ pubkey }) => pubkey) + + // First, check localStorage cache (synchronous, instant) + const cachedProfiles = loadCachedProfiles(allPubkeys) + console.log(`[${ts()}] [npub-resolve] Found in localStorage cache:`, cachedProfiles.size, 'out of', allPubkeys.length) + + // Add cached profiles to EventStore for consistency + if (eventStore) { + for (const profile of cachedProfiles.values()) { + eventStore.add(profile) + } + } + + // Build initial labels from localStorage cache and eventStore const labels = new Map() const pubkeysToFetch: string[] = [] profileData.forEach(({ encoded, pubkey }) => { - if (eventStore) { - const profileEvent = eventStore.getEvent(pubkey + ':0') - if (profileEvent) { - try { - const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - labels.set(encoded, `@${displayName}`) - console.log(`[${ts()}] [npub-resolve] Found in eventStore:`, encoded, '->', displayName) - } else { - pubkeysToFetch.push(pubkey) - } - } catch { + let profileEvent: { content: string } | null = null + let foundSource = '' + + // Check localStorage cache first + const cachedProfile = cachedProfiles.get(pubkey) + if (cachedProfile) { + profileEvent = cachedProfile + foundSource = 'localStorage cache' + } else if (eventStore) { + // Then check EventStore (in-memory from current session) + const eventStoreProfile = eventStore.getEvent(pubkey + ':0') + if (eventStoreProfile) { + profileEvent = eventStoreProfile + foundSource = 'eventStore' + } + } + + if (profileEvent) { + try { + const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + labels.set(encoded, `@${displayName}`) + console.log(`[${ts()}] [npub-resolve] Found in ${foundSource}:`, encoded.slice(0, 30) + '...', '->', displayName) + } else { pubkeysToFetch.push(pubkey) } - } else { + } catch { pubkeysToFetch.push(pubkey) } } else { @@ -85,7 +111,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) - // Update labels with what we found in eventStore + // Update labels with what we found in localStorage cache and eventStore setProfileLabels(new Map(labels)) // Fetch missing profiles asynchronously diff --git a/src/services/profileService.ts b/src/services/profileService.ts index f3f912f2..021a1032 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -6,9 +6,86 @@ import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { rebroadcastEvents } from './rebroadcastService' import { UserSettings } from './settingsService' +interface CachedProfile { + event: NostrEvent + timestamp: number +} + +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_' + +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 + */ +export function getCachedProfile(pubkey: string): NostrEvent | null { + try { + const cacheKey = getProfileCacheKey(pubkey) + const cached = localStorage.getItem(cacheKey) + if (!cached) { + return null + } + + const { event, timestamp }: CachedProfile = JSON.parse(cached) + const age = Date.now() - timestamp + + if (age > PROFILE_CACHE_TTL) { + localStorage.removeItem(cacheKey) + return null + } + + return event + } catch (err) { + // Silently handle cache read errors + return null + } +} + +/** + * Cache a profile to localStorage + * Handles errors gracefully (quota exceeded, invalid data, etc.) + */ +export function cacheProfile(profile: NostrEvent): void { + try { + if (profile.kind !== 0) return // Only cache kind:0 (profile) events + + const cacheKey = getProfileCacheKey(profile.pubkey) + const cached: CachedProfile = { + event: profile, + timestamp: Date.now() + } + localStorage.setItem(cacheKey, JSON.stringify(cached)) + } catch (err) { + // Silently fail - don't block the UI if caching fails + // Handles quota exceeded, invalid data, and other errors gracefully + } +} + +/** + * 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 */ export const fetchProfiles = async ( relayPool: RelayPool, @@ -22,26 +99,45 @@ export const fetchProfiles = async ( } const uniquePubkeys = Array.from(new Set(pubkeys)) + + // 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()) + } + // 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() - 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) + // 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(), tap((event: NostrEvent) => processEvent(event)), @@ -52,7 +148,7 @@ export const fetchProfiles = async ( const remote$ = remoteRelays.length > 0 ? relayPool - .req(remoteRelays, { kinds: [0], authors: uniquePubkeys }) + .req(remoteRelays, { kinds: [0], authors: pubkeysToFetch }) .pipe( onlyEvents(), tap((event: NostrEvent) => processEvent(event)), @@ -70,8 +166,10 @@ export const fetchProfiles = async ( // 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 From e814aadb5b2dc068c8a6998420b2e3805800001c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:06:02 +0100 Subject: [PATCH 15/57] fix: initialize profile labels synchronously from cache for instant display - Use useMemo to check localStorage cache synchronously during render, before useEffect - Initialize useState with labels from cache, so first render shows cached profiles immediately - Add detailed logging for cache operations to debug caching issues - Fix ESLint warnings about unused variables and dependencies This eliminates the delay where profiles were only resolved after useEffect ran, causing profiles to display instantly on page reload when cached. --- src/hooks/useProfileLabels.ts | 74 ++++++++++++++++++++++++++++++---- src/services/profileService.ts | 13 ++++-- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index f4399013..6448cd70 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -49,20 +49,63 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }, [content]) - const [profileLabels, setProfileLabels] = useState>(new Map()) + // Initialize labels synchronously from cache on first render to avoid delay + 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(({ encoded, pubkey }) => { + const cachedProfile = cachedProfiles.get(pubkey) + if (cachedProfile) { + try { + const profileData = JSON.parse(cachedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + labels.set(encoded, `@${displayName}`) + } + } catch { + // Ignore parsing errors, will fetch later + } + } + }) + + return labels + }, [profileData]) + + const [profileLabels, setProfileLabels] = useState>(initialLabels) const lastLoggedSize = useRef(0) // Build initial labels: localStorage cache -> eventStore -> fetch from relays useEffect(() => { const startTime = Date.now() - console.log(`[${ts()}] [npub-resolve] Building labels, profileData:`, profileData.length, 'hasEventStore:', !!eventStore) + console.log(`[${ts()}] [npub-resolve] Building labels, profileData:`, profileData.length, 'hasEventStore:', !!eventStore, 'hasRelayPool:', !!relayPool) // Extract all pubkeys const allPubkeys = profileData.map(({ pubkey }) => pubkey) + if (allPubkeys.length === 0) { + console.log(`[${ts()}] [npub-resolve] No pubkeys to resolve, clearing labels`) + setProfileLabels(new Map()) + return + } + // First, check localStorage cache (synchronous, instant) + const cacheStartTime = Date.now() const cachedProfiles = loadCachedProfiles(allPubkeys) - console.log(`[${ts()}] [npub-resolve] Found in localStorage cache:`, cachedProfiles.size, 'out of', allPubkeys.length) + const cacheDuration = Date.now() - cacheStartTime + console.log(`[${ts()}] [npub-resolve] Found in localStorage cache:`, cachedProfiles.size, 'out of', allPubkeys.length, 'in', cacheDuration, 'ms') + + // Log which pubkeys were found in cache + if (cachedProfiles.size > 0) { + cachedProfiles.forEach((_profile, pubkey) => { + console.log(`[${ts()}] [npub-resolve] Cached profile found:`, pubkey.slice(0, 16) + '...') + }) + } // Add cached profiles to EventStore for consistency if (eventStore) { @@ -71,15 +114,22 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } } - // Build initial labels from localStorage cache and eventStore - const labels = new Map() + // Build labels from localStorage cache and eventStore (initialLabels already has cache, add eventStore) + // Start with labels from initial cache lookup (in useMemo) + const labels = new Map(initialLabels) + const pubkeysToFetch: string[] = [] profileData.forEach(({ encoded, pubkey }) => { + // Skip if already resolved from initial cache + if (labels.has(encoded)) { + return + } + let profileEvent: { content: string } | null = null let foundSource = '' - // Check localStorage cache first + // Check localStorage cache first (should already be checked in initialLabels, but double-check) const cachedProfile = cachedProfiles.get(pubkey) if (cachedProfile) { profileEvent = cachedProfile @@ -101,9 +151,11 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): labels.set(encoded, `@${displayName}`) console.log(`[${ts()}] [npub-resolve] Found in ${foundSource}:`, encoded.slice(0, 30) + '...', '->', displayName) } else { + console.log(`[${ts()}] [npub-resolve] Profile from ${foundSource} has no display name, will fetch:`, pubkey.slice(0, 16) + '...') pubkeysToFetch.push(pubkey) } - } catch { + } catch (err) { + console.error(`[${ts()}] [npub-resolve] Error parsing profile from ${foundSource}:`, err) pubkeysToFetch.push(pubkey) } } else { @@ -112,7 +164,13 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): }) // Update labels with what we found in localStorage cache and eventStore - setProfileLabels(new Map(labels)) + const initialResolveTime = Date.now() - startTime + console.log(`[${ts()}] [npub-resolve] Initial resolution complete:`, labels.size, 'labels resolved in', initialResolveTime, 'ms. Will fetch', pubkeysToFetch.length, 'missing profiles.') + + // Only update if labels changed (avoid unnecessary re-renders) + if (labels.size !== profileLabels.size || Array.from(labels.keys()).some(k => labels.get(k) !== profileLabels.get(k))) { + setProfileLabels(new Map(labels)) + } // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 021a1032..f90cb680 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -40,7 +40,8 @@ export function getCachedProfile(pubkey: string): NostrEvent | null { return event } catch (err) { - // Silently handle cache read errors + // Log cache read errors for debugging + console.error(`[profile-cache] Error reading cached profile for ${pubkey.slice(0, 16)}...:`, err) return null } } @@ -51,7 +52,10 @@ export function getCachedProfile(pubkey: string): NostrEvent | null { */ export function cacheProfile(profile: NostrEvent): void { try { - if (profile.kind !== 0) return // Only cache kind:0 (profile) events + if (profile.kind !== 0) { + console.warn(`[profile-cache] Attempted to cache non-profile event (kind ${profile.kind})`) + return // Only cache kind:0 (profile) events + } const cacheKey = getProfileCacheKey(profile.pubkey) const cached: CachedProfile = { @@ -59,8 +63,11 @@ export function cacheProfile(profile: NostrEvent): void { timestamp: Date.now() } localStorage.setItem(cacheKey, JSON.stringify(cached)) + console.log(`[profile-cache] Cached profile:`, profile.pubkey.slice(0, 16) + '...') } catch (err) { - // Silently fail - don't block the UI if caching fails + // Log caching errors for debugging + console.error(`[profile-cache] Failed to cache profile ${profile.pubkey.slice(0, 16)}...:`, err) + // Don't block the UI if caching fails // Handles quota exceeded, invalid data, and other errors gracefully } } From 074af764ed43138905b202efaa9e482e1004645b Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:06:16 +0100 Subject: [PATCH 16/57] fix: disable eslint warning for useEffect dependencies in useProfileLabels --- src/hooks/useProfileLabels.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 6448cd70..9d0ba13b 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -250,6 +250,8 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): console.error(`[${ts()}] [npub-resolve] Error fetching profiles after`, fetchDuration, 'ms:', err) }) } + // eslint-disable-next-line react-hooks/exhaustive-deps + // initialLabels is derived from profileData, profileLabels is state we update (would cause loops) }, [profileData, eventStore, relayPool]) // Only log when size actually changes to reduce noise From d206ff228ed105d0e7376ff7d17bb6017dc4a9fc Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:06:40 +0100 Subject: [PATCH 17/57] fix: remove unnecessary label comparison and fix useEffect dependencies --- src/hooks/useProfileLabels.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 9d0ba13b..4868fdbb 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -166,11 +166,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Update labels with what we found in localStorage cache and eventStore const initialResolveTime = Date.now() - startTime console.log(`[${ts()}] [npub-resolve] Initial resolution complete:`, labels.size, 'labels resolved in', initialResolveTime, 'ms. Will fetch', pubkeysToFetch.length, 'missing profiles.') - - // Only update if labels changed (avoid unnecessary re-renders) - if (labels.size !== profileLabels.size || Array.from(labels.keys()).some(k => labels.get(k) !== profileLabels.get(k))) { - setProfileLabels(new Map(labels)) - } + setProfileLabels(new Map(labels)) // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { @@ -250,9 +246,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): console.error(`[${ts()}] [npub-resolve] Error fetching profiles after`, fetchDuration, 'ms:', err) }) } - // eslint-disable-next-line react-hooks/exhaustive-deps - // initialLabels is derived from profileData, profileLabels is state we update (would cause loops) - }, [profileData, eventStore, relayPool]) + }, [profileData, eventStore, relayPool, initialLabels]) // Only log when size actually changes to reduce noise useEffect(() => { From 6074caaae3ccbbda59ba832b9a62086f74331e69 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:07:44 +0100 Subject: [PATCH 18/57] refactor: change profile-cache log prefix to npub-cache for consistency --- src/services/profileService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/profileService.ts b/src/services/profileService.ts index f90cb680..4fcba049 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -41,7 +41,7 @@ export function getCachedProfile(pubkey: string): NostrEvent | null { return event } catch (err) { // Log cache read errors for debugging - console.error(`[profile-cache] Error reading cached profile for ${pubkey.slice(0, 16)}...:`, err) + console.error(`[npub-cache] Error reading cached profile for ${pubkey.slice(0, 16)}...:`, err) return null } } @@ -53,7 +53,7 @@ export function getCachedProfile(pubkey: string): NostrEvent | null { export function cacheProfile(profile: NostrEvent): void { try { if (profile.kind !== 0) { - console.warn(`[profile-cache] Attempted to cache non-profile event (kind ${profile.kind})`) + console.warn(`[npub-cache] Attempted to cache non-profile event (kind ${profile.kind})`) return // Only cache kind:0 (profile) events } @@ -63,10 +63,10 @@ export function cacheProfile(profile: NostrEvent): void { timestamp: Date.now() } localStorage.setItem(cacheKey, JSON.stringify(cached)) - console.log(`[profile-cache] Cached profile:`, profile.pubkey.slice(0, 16) + '...') + console.log(`[npub-cache] Cached profile:`, profile.pubkey.slice(0, 16) + '...') } catch (err) { // Log caching errors for debugging - console.error(`[profile-cache] Failed to cache profile ${profile.pubkey.slice(0, 16)}...:`, err) + console.error(`[npub-cache] Failed to cache profile ${profile.pubkey.slice(0, 16)}...:`, err) // Don't block the UI if caching fails // Handles quota exceeded, invalid data, and other errors gracefully } From 93eb8a63debb22a767d3a7d1d2cdb7267245c2ff Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:09:11 +0100 Subject: [PATCH 19/57] fix: implement LRU cache eviction to handle QuotaExceededError - Add LRU eviction strategy: limit to 1000 cached profiles, evict oldest when full - Track lastAccessed timestamp for each cached profile - Automatically evict old profiles when quota is exceeded - Reduce error logging spam: only log quota error once per session - Silently handle cache errors to match articleService pattern - Proactively evict before caching when approaching limit This prevents localStorage quota exceeded errors and ensures the most recently accessed profiles remain cached. --- src/services/profileService.ts | 116 +++++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 4fcba049..7b7dfbef 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -9,10 +9,13 @@ 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 function getProfileCacheKey(pubkey: string): string { return `${PROFILE_CACHE_PREFIX}${pubkey}` @@ -21,6 +24,7 @@ function getProfileCacheKey(pubkey: string): string { /** * 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 { @@ -30,45 +34,133 @@ export function getCachedProfile(pubkey: string): NostrEvent | null { return null } - const { event, timestamp }: CachedProfile = JSON.parse(cached) - const age = Date.now() - timestamp + const data: CachedProfile = JSON.parse(cached) + const age = Date.now() - data.timestamp if (age > PROFILE_CACHE_TTL) { localStorage.removeItem(cacheKey) return null } - return event + // 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) { - // Log cache read errors for debugging - console.error(`[npub-cache] Error reading cached profile for ${pubkey.slice(0, 16)}...:`, 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) { - console.warn(`[npub-cache] Attempted to cache non-profile event (kind ${profile.kind})`) 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() + timestamp: Date.now(), + lastAccessed: Date.now() } localStorage.setItem(cacheKey, JSON.stringify(cached)) - console.log(`[npub-cache] Cached profile:`, profile.pubkey.slice(0, 16) + '...') } catch (err) { - // Log caching errors for debugging - console.error(`[npub-cache] Failed to cache profile ${profile.pubkey.slice(0, 16)}...:`, err) - // Don't block the UI if caching fails - // Handles quota exceeded, invalid data, and other errors gracefully + // 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.) } } From 5d36d6de4f1bd1076842fb3adcbf2c98f6fe1743 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:10:56 +0100 Subject: [PATCH 20/57] refactor: add logging to verify initialLabels are set correctly from cache --- src/hooks/useProfileLabels.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 4868fdbb..aa1c6a32 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -74,6 +74,9 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) + if (labels.size > 0) { + console.log(`[${ts()}] [npub-resolve] Initial labels from cache (useMemo):`, labels.size, 'labels') + } return labels }, [profileData]) @@ -116,6 +119,8 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Build labels from localStorage cache and eventStore (initialLabels already has cache, add eventStore) // Start with labels from initial cache lookup (in useMemo) + // Note: initialLabels should already have all cached profiles, but we rebuild here + // to also check EventStore and handle any profiles that weren't in cache const labels = new Map(initialLabels) const pubkeysToFetch: string[] = [] From 5e1ed6b8deb3f554efd6466d8d1783bdc4b75c66 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:26:06 +0100 Subject: [PATCH 21/57] refactor: clean up npub/nprofile display implementation - Remove all debug console.log/error statements (39+) and ts() helpers - Eliminate redundant localStorage cache check in useProfileLabels - Standardize fallback display format using getNpubFallbackDisplay() utility - Update ResolvedMention to use npub format consistently --- src/components/ResolvedMention.tsx | 3 +- src/hooks/useMarkdownToHTML.ts | 21 +---- src/hooks/useProfileLabels.ts | 121 ++++------------------------- src/utils/nostrUriResolver.tsx | 34 ++++---- 4 files changed, 40 insertions(+), 139 deletions(-) diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index a665149f..e3c65961 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { useEventModel } from 'applesauce-react/hooks' import { Models, Helpers } from 'applesauce-core' import { decode, npubEncode } from 'nostr-tools/nip19' +import { getNpubFallbackDisplay } from '../utils/nostrUriResolver' const { getPubkeyFromDecodeResult } = Helpers @@ -20,7 +21,7 @@ const ResolvedMention: React.FC = ({ encoded }) => { } const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined - const display = profile?.name || profile?.display_name || profile?.nip05 || (pubkey ? `${pubkey.slice(0, 8)}...` : encoded) + const display = profile?.name || profile?.display_name || profile?.nip05 || (pubkey ? getNpubFallbackDisplay(pubkey) : encoded) const npub = pubkey ? npubEncode(pubkey) : undefined if (npub) { diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index b0ca85e9..6003e121 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -4,13 +4,6 @@ import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '. import { fetchArticleTitles } from '../services/articleTitleResolver' import { useProfileLabels } from './useProfileLabels' -// Helper to add timestamps to logs -const ts = () => { - const now = new Date() - const ms = now.getMilliseconds().toString().padStart(3, '0') - return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}` -} - /** * Hook to convert markdown to HTML using a hidden ReactMarkdown component * Also processes nostr: URIs in the markdown and resolves article titles @@ -30,11 +23,6 @@ export const useMarkdownToHTML = ( // Resolve profile labels progressively as profiles load const profileLabels = useProfileLabels(markdown || '', relayPool) - - // Log when markdown or profile labels change (but throttle excessive logs) - useEffect(() => { - console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: markdown length:`, markdown?.length || 0, 'hasRelayPool:', !!relayPool, 'Profile labels size:', profileLabels.size) - }, [markdown?.length, profileLabels.size, relayPool]) // Fetch article titles useEffect(() => { @@ -57,8 +45,7 @@ export const useMarkdownToHTML = ( if (!isCancelled) { setArticleTitles(titlesMap) } - } catch (error) { - console.warn('Failed to fetch article titles:', error) + } catch { if (!isCancelled) setArticleTitles(new Map()) } } @@ -80,8 +67,6 @@ export const useMarkdownToHTML = ( let isCancelled = false const processMarkdown = () => { - console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: Processing markdown, length:`, markdown.length) - console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: Profile labels:`, profileLabels.size, 'Article titles:', articleTitles.size) try { // Replace nostr URIs with profile labels (progressive) and article titles const processed = replaceNostrUrisInMarkdownWithProfileLabels( @@ -89,13 +74,11 @@ export const useMarkdownToHTML = ( profileLabels, articleTitles ) - console.log(`[${ts()}] [npub-resolve] useMarkdownToHTML: Processed markdown length:`, processed.length) if (isCancelled) return setProcessedMarkdown(processed) - } catch (err) { - console.error(`[${ts()}] [npub-resolve] useMarkdownToHTML: Error processing markdown:`, err) + } catch { if (!isCancelled) { setProcessedMarkdown(markdown) // Fallback to original } diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index aa1c6a32..dd6d1806 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect, useRef } from 'react' +import { useMemo, useState, useEffect } from 'react' import { Hooks } from 'applesauce-react' import { Helpers, IEventStore } from 'applesauce-core' import { getContentPointers } from 'applesauce-factory/helpers' @@ -7,13 +7,6 @@ import { fetchProfiles, loadCachedProfiles } from '../services/profileService' const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers -// Helper to add timestamps to logs -const ts = () => { - const now = new Date() - const ms = now.getMilliseconds().toString().padStart(3, '0') - return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}` -} - /** * Hook to resolve profile labels from content containing npub/nprofile identifiers * Returns a Map of encoded identifier -> display name that updates progressively as profiles load @@ -23,12 +16,9 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Extract profile pointers (npub and nprofile) using applesauce helpers const profileData = useMemo(() => { - console.log(`[${ts()}] [npub-resolve] Processing content, length:`, content?.length || 0) try { const pointers = getContentPointers(content) - console.log(`[${ts()}] [npub-resolve] Found pointers:`, pointers.length, 'types:', pointers.map(p => p.type)) const filtered = pointers.filter(p => p.type === 'npub' || p.type === 'nprofile') - console.log(`[${ts()}] [npub-resolve] Profile pointers:`, filtered.length) const result: Array<{ pubkey: string; encoded: string }> = [] filtered.forEach(pointer => { try { @@ -37,14 +27,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (pubkey && encoded) { result.push({ pubkey, encoded: encoded as string }) } - } catch (err) { - console.error(`[${ts()}] [npub-resolve] Error processing pointer:`, err, pointer) + } catch { + // Ignore errors, continue processing other pointers } }) - console.log(`[${ts()}] [npub-resolve] Profile data after filtering:`, result.length) return result - } catch (err) { - console.error(`[${ts()}] [npub-resolve] Error extracting pointers:`, err) + } catch { return [] } }, [content]) @@ -74,53 +62,31 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) - if (labels.size > 0) { - console.log(`[${ts()}] [npub-resolve] Initial labels from cache (useMemo):`, labels.size, 'labels') - } return labels }, [profileData]) const [profileLabels, setProfileLabels] = useState>(initialLabels) - const lastLoggedSize = useRef(0) // Build initial labels: localStorage cache -> eventStore -> fetch from relays useEffect(() => { - const startTime = Date.now() - console.log(`[${ts()}] [npub-resolve] Building labels, profileData:`, profileData.length, 'hasEventStore:', !!eventStore, 'hasRelayPool:', !!relayPool) - // Extract all pubkeys const allPubkeys = profileData.map(({ pubkey }) => pubkey) if (allPubkeys.length === 0) { - console.log(`[${ts()}] [npub-resolve] No pubkeys to resolve, clearing labels`) setProfileLabels(new Map()) return } - // First, check localStorage cache (synchronous, instant) - const cacheStartTime = Date.now() - const cachedProfiles = loadCachedProfiles(allPubkeys) - const cacheDuration = Date.now() - cacheStartTime - console.log(`[${ts()}] [npub-resolve] Found in localStorage cache:`, cachedProfiles.size, 'out of', allPubkeys.length, 'in', cacheDuration, 'ms') - - // Log which pubkeys were found in cache - if (cachedProfiles.size > 0) { - cachedProfiles.forEach((_profile, pubkey) => { - console.log(`[${ts()}] [npub-resolve] Cached profile found:`, pubkey.slice(0, 16) + '...') - }) - } - // 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 cache, add eventStore) - // Start with labels from initial cache lookup (in useMemo) - // Note: initialLabels should already have all cached profiles, but we rebuild here - // to also check EventStore and handle any profiles that weren't in cache + // Build labels from localStorage cache and eventStore + // initialLabels already has all cached profiles, so we only need to check eventStore const labels = new Map(initialLabels) const pubkeysToFetch: string[] = [] @@ -131,20 +97,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): return } + // Check EventStore for profiles that weren't in cache let profileEvent: { content: string } | null = null - let foundSource = '' - - // Check localStorage cache first (should already be checked in initialLabels, but double-check) - const cachedProfile = cachedProfiles.get(pubkey) - if (cachedProfile) { - profileEvent = cachedProfile - foundSource = 'localStorage cache' - } else if (eventStore) { - // Then check EventStore (in-memory from current session) + if (eventStore) { const eventStoreProfile = eventStore.getEvent(pubkey + ':0') if (eventStoreProfile) { profileEvent = eventStoreProfile - foundSource = 'eventStore' } } @@ -154,13 +112,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) - console.log(`[${ts()}] [npub-resolve] Found in ${foundSource}:`, encoded.slice(0, 30) + '...', '->', displayName) } else { - console.log(`[${ts()}] [npub-resolve] Profile from ${foundSource} has no display name, will fetch:`, pubkey.slice(0, 16) + '...') pubkeysToFetch.push(pubkey) } - } catch (err) { - console.error(`[${ts()}] [npub-resolve] Error parsing profile from ${foundSource}:`, err) + } catch { pubkeysToFetch.push(pubkey) } } else { @@ -168,98 +123,54 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) - // Update labels with what we found in localStorage cache and eventStore - const initialResolveTime = Date.now() - startTime - console.log(`[${ts()}] [npub-resolve] Initial resolution complete:`, labels.size, 'labels resolved in', initialResolveTime, 'ms. Will fetch', pubkeysToFetch.length, 'missing profiles.') setProfileLabels(new Map(labels)) // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { - const fetchStartTime = Date.now() - console.log(`[${ts()}] [npub-resolve] Fetching`, pubkeysToFetch.length, 'missing profiles') fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) .then((fetchedProfiles) => { - const fetchDuration = Date.now() - fetchStartTime - console.log(`[${ts()}] [npub-resolve] fetchProfiles returned`, fetchedProfiles.length, 'profiles in', fetchDuration, 'ms') - - // First, use the profiles returned from fetchProfiles directly const updatedLabels = new Map(labels) const fetchedProfilesByPubkey = new Map(fetchedProfiles.map(p => [p.pubkey, p])) - let resolvedFromArray = 0 - let resolvedFromStore = 0 - let withNames = 0 - let withoutNames = 0 - let missingFromStore = 0 - profileData.forEach(({ encoded, pubkey }) => { if (!updatedLabels.has(encoded)) { // First, try to use the profile from the returned array const fetchedProfile = fetchedProfilesByPubkey.get(pubkey) if (fetchedProfile) { - resolvedFromArray++ try { const profileData = JSON.parse(fetchedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string } const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) - withNames++ - console.log(`[${ts()}] [npub-resolve] Resolved from fetched array:`, encoded.slice(0, 30) + '...', '->', displayName) - } else { - withoutNames++ - if (withoutNames <= 3) { - console.log(`[${ts()}] [npub-resolve] Fetched profile has no name/display_name/nip05:`, encoded.slice(0, 30) + '...', 'content keys:', Object.keys(profileData)) - } } - } catch (err) { - console.error(`[${ts()}] [npub-resolve] Error parsing fetched profile for`, encoded.slice(0, 30) + '...', err) + } catch { + // Ignore parsing errors } } else if (eventStore) { // Fallback: check eventStore (in case fetchProfiles stored but didn't return) const profileEvent = eventStore.getEvent(pubkey + ':0') if (profileEvent) { - resolvedFromStore++ try { const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) - withNames++ - console.log(`[${ts()}] [npub-resolve] Resolved from eventStore:`, encoded.slice(0, 30) + '...', '->', displayName) } - } catch (err) { - console.error(`[${ts()}] [npub-resolve] Error parsing profile event for`, encoded.slice(0, 30) + '...', err) - } - } else { - missingFromStore++ - if (missingFromStore <= 3) { - console.log(`[${ts()}] [npub-resolve] Profile not found in array or eventStore:`, pubkey.slice(0, 16) + '...') + } catch { + // Ignore parsing errors } } - } else { - missingFromStore++ } } }) - const totalDuration = Date.now() - startTime - console.log(`[${ts()}] [npub-resolve] After fetch - resolved:`, updatedLabels.size, 'total | from array:', resolvedFromArray, '| from store:', resolvedFromStore, '| with names:', withNames, '| without names:', withoutNames, '| missing:', missingFromStore, '| out of', profileData.length, '| total time:', totalDuration, 'ms') setProfileLabels(updatedLabels) }) - .catch(err => { - const fetchDuration = Date.now() - fetchStartTime - console.error(`[${ts()}] [npub-resolve] Error fetching profiles after`, fetchDuration, 'ms:', err) + .catch(() => { + // Silently handle fetch errors }) } }, [profileData, eventStore, relayPool, initialLabels]) - - // Only log when size actually changes to reduce noise - useEffect(() => { - if (profileLabels.size !== lastLoggedSize.current) { - console.log(`[${ts()}] [npub-resolve] Final labels map size:`, profileLabels.size) - lastLoggedSize.current = profileLabels.size - } - }, [profileLabels.size]) return profileLabels } diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 0e388a42..85b0a591 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -4,13 +4,6 @@ import { Tokens } from 'applesauce-content/helpers' import { getContentPointers } from 'applesauce-factory/helpers' import { encodeDecodeResult } from 'applesauce-core/helpers' -// Helper to add timestamps to logs -const ts = () => { - const now = new Date() - const ms = now.getMilliseconds().toString().padStart(3, '0') - return `${now.toLocaleTimeString('en-US', { hour12: false })}.${ms}` -} - /** * Regular expression to match nostr: URIs and bare NIP-19 identifiers * Uses applesauce Tokens.nostrLink which includes word boundary checks @@ -23,10 +16,8 @@ const NOSTR_URI_REGEX = Tokens.nostrLink * Extract all nostr URIs from text using applesauce helpers */ export function extractNostrUris(text: string): string[] { - console.log(`[${ts()}] [npub-resolve] extractNostrUris: text length:`, text?.length || 0) try { const pointers = getContentPointers(text) - console.log(`[${ts()}] [npub-resolve] extractNostrUris: Found pointers:`, pointers.length) const result: string[] = [] pointers.forEach(pointer => { try { @@ -34,14 +25,12 @@ export function extractNostrUris(text: string): string[] { if (encoded) { result.push(encoded) } - } catch (err) { - console.error(`[${ts()}] [npub-resolve] extractNostrUris: Error encoding pointer:`, err, pointer) + } catch { + // Ignore encoding errors, continue processing other pointers } }) - console.log(`[${ts()}] [npub-resolve] extractNostrUris: Extracted URIs:`, result.length) return result - } catch (err) { - console.error(`[${ts()}] [npub-resolve] extractNostrUris: Error:`, err) + } catch { return [] } } @@ -128,6 +117,23 @@ export function getNostrUriLabel(encoded: string): string { } } +/** + * Get a standardized fallback display name for a pubkey when profile has no name + * Returns npub format: @abc1234... + * @param pubkey The pubkey in hex format + * @returns Formatted npub display string + */ +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)}...` + } +} + /** * Process markdown to replace nostr URIs while skipping those inside markdown links * This prevents nested markdown link issues when nostr identifiers appear in URLs From affd80ca2ebab15d90ede1a8d93048f25bb2d3ec Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:31:16 +0100 Subject: [PATCH 22/57] refactor: standardize profile display name fallbacks across codebase - Add getProfileDisplayName() utility function for consistent profile name resolution - Update all components to use standardized npub fallback format instead of hex - Fix useProfileLabels hook to include fallback npub labels when profiles lack names - Refactor NostrMentionLink to eliminate duplication between npub/nprofile cases - Remove debug console.log statements from RichContent component - Update AuthorCard, SidebarHeader, HighlightItem, Support, BlogPostCard, ResolvedMention, and useEventLoader to use new utilities --- src/components/AuthorCard.tsx | 5 ++- src/components/BlogPostCard.tsx | 4 +-- src/components/HighlightItem.tsx | 5 ++- src/components/NostrMentionLink.tsx | 47 +++++++++++--------------- src/components/ResolvedMention.tsx | 4 +-- src/components/RichContent.tsx | 5 +-- src/components/SidebarHeader.tsx | 6 ++-- src/components/Support.tsx | 3 +- src/hooks/useEventLoader.ts | 5 +-- src/hooks/useProfileLabels.ts | 51 ++++++++++++++++++++++++++--- src/utils/nostrUriResolver.tsx | 17 ++++++++++ 11 files changed, 98 insertions(+), 54 deletions(-) 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/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 bc0f41cd..2baf9f1f 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -2,6 +2,7 @@ import React from 'react' import { nip19 } from 'nostr-tools' import { useEventModel } from 'applesauce-react/hooks' import { Models, Helpers } from 'applesauce-core' +import { getProfileDisplayName } from '../utils/nostrUriResolver' const { getPubkeyFromDecodeResult } = Helpers @@ -46,41 +47,31 @@ 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) + + return ( +
+ @{displayName} + + ) + } + // Render based on decoded type switch (decoded.type) { case 'npub': { const pk = decoded.data - const npub = nip19.npubEncode(pk) - // Fallback: show npub without "npub1" prefix - const fallbackDisplay = `@${npub.slice(5, 12)}...` - const displayName = profile?.name || profile?.display_name || profile?.nip05 || fallbackDisplay - - return ( - - @{displayName} - - ) + return renderProfileLink(pk) } case 'nprofile': { const { pubkey: pk } = decoded.data - const npub = nip19.npubEncode(pk) - // Fallback: show npub without "npub1" prefix - const fallbackDisplay = `@${npub.slice(5, 12)}...` - const displayName = profile?.name || profile?.display_name || profile?.nip05 || fallbackDisplay - - return ( - - @{displayName} - - ) + return renderProfileLink(pk) } case 'naddr': { const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index e3c65961..c393728b 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom' import { useEventModel } from 'applesauce-react/hooks' import { Models, Helpers } from 'applesauce-core' import { decode, npubEncode } from 'nostr-tools/nip19' -import { getNpubFallbackDisplay } from '../utils/nostrUriResolver' +import { getProfileDisplayName } from '../utils/nostrUriResolver' const { getPubkeyFromDecodeResult } = Helpers @@ -21,7 +21,7 @@ const ResolvedMention: React.FC = ({ encoded }) => { } const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined - const display = profile?.name || profile?.display_name || profile?.nip05 || (pubkey ? getNpubFallbackDisplay(pubkey) : encoded) + const display = pubkey ? getProfileDisplayName(profile, pubkey) : encoded const npub = pubkey ? npubEncode(pubkey) : undefined if (npub) { diff --git a/src/components/RichContent.tsx b/src/components/RichContent.tsx index 643aa3d0..de8fdb1c 100644 --- a/src/components/RichContent.tsx +++ b/src/components/RichContent.tsx @@ -2,7 +2,7 @@ import React from 'react' import NostrMentionLink from './NostrMentionLink' import { Tokens } from 'applesauce-content/helpers' -// Helper to add timestamps to logs +// Helper to add timestamps to error logs const ts = () => { const now = new Date() const ms = now.getMilliseconds().toString().padStart(3, '0') @@ -26,8 +26,6 @@ const RichContent: React.FC = ({ content, className = 'bookmark-content' }) => { - console.log(`[${ts()}] [npub-resolve] RichContent: Rendering, content length:`, content?.length || 0) - try { // Pattern to match: // 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink @@ -37,7 +35,6 @@ const RichContent: React.FC = ({ const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi') const parts = content.split(combinedPattern) - console.log(`[${ts()}] [npub-resolve] RichContent: Split into parts:`, parts.length) // Helper to check if a string is a nostr identifier (without mutating regex state) const isNostrIdentifier = (str: string): boolean => { 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..1164a8b8 100644 --- a/src/hooks/useEventLoader.ts +++ b/src/hooks/useEventLoader.ts @@ -6,6 +6,7 @@ import { ReadableContent } from '../services/readerService' import { eventManager } from '../services/eventManager' import { fetchProfiles } from '../services/profileService' import { useDocumentTitle } from './useDocumentTitle' +import { getNpubFallbackDisplay } from '../utils/nostrUriResolver' interface UseEventLoaderProps { eventId?: string @@ -40,7 +41,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 @@ -83,7 +84,7 @@ export function useEventLoader({ } } } - + if (resolved) { const updatedTitle = `Note by @${resolved}` setCurrentTitle(updatedTitle) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index dd6d1806..6d01ed44 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -4,6 +4,7 @@ import { Helpers, IEventStore } from 'applesauce-core' import { getContentPointers } from 'applesauce-factory/helpers' import { RelayPool } from 'applesauce-relay' import { fetchProfiles, loadCachedProfiles } from '../services/profileService' +import { getNpubFallbackDisplay } from '../utils/nostrUriResolver' const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers @@ -55,9 +56,15 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) + } else { + // Use fallback npub display if profile has no name + const fallback = getNpubFallbackDisplay(pubkey) + labels.set(encoded, fallback) } } catch { - // Ignore parsing errors, will fetch later + // Use fallback npub display if parsing fails + const fallback = getNpubFallbackDisplay(pubkey) + labels.set(encoded, fallback) } } }) @@ -113,16 +120,30 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (displayName) { labels.set(encoded, `@${displayName}`) } else { - pubkeysToFetch.push(pubkey) + // Use fallback npub display if profile has no name + const fallback = getNpubFallbackDisplay(pubkey) + labels.set(encoded, fallback) } } catch { - pubkeysToFetch.push(pubkey) + // Use fallback npub display if parsing fails + const fallback = getNpubFallbackDisplay(pubkey) + labels.set(encoded, fallback) } } else { + // No profile found yet, will use fallback after fetch or keep empty + // We'll set fallback labels for missing profiles at the end pubkeysToFetch.push(pubkey) } }) + // Set fallback labels for profiles that weren't found + profileData.forEach(({ encoded, pubkey }) => { + if (!labels.has(encoded)) { + const fallback = getNpubFallbackDisplay(pubkey) + labels.set(encoded, fallback) + } + }) + setProfileLabels(new Map(labels)) // Fetch missing profiles asynchronously @@ -142,9 +163,15 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) + } else { + // Use fallback npub display if profile has no name + const fallback = getNpubFallbackDisplay(pubkey) + updatedLabels.set(encoded, fallback) } } catch { - // Ignore parsing errors + // Use fallback npub display if parsing fails + const fallback = getNpubFallbackDisplay(pubkey) + updatedLabels.set(encoded, fallback) } } else if (eventStore) { // Fallback: check eventStore (in case fetchProfiles stored but didn't return) @@ -155,11 +182,25 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) + } else { + // Use fallback npub display if profile has no name + const fallback = getNpubFallbackDisplay(pubkey) + updatedLabels.set(encoded, fallback) } } catch { - // Ignore parsing errors + // Use fallback npub display if parsing fails + const fallback = getNpubFallbackDisplay(pubkey) + updatedLabels.set(encoded, fallback) } + } else { + // No profile found, use fallback + const fallback = getNpubFallbackDisplay(pubkey) + updatedLabels.set(encoded, fallback) } + } else { + // No eventStore, use fallback + const fallback = getNpubFallbackDisplay(pubkey) + updatedLabels.set(encoded, fallback) } } }) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 85b0a591..8d9775ad 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -134,6 +134,23 @@ export function getNpubFallbackDisplay(pubkey: string): string { } } +/** + * Get display name for a profile with consistent priority order + * Returns: profile.name || profile.display_name || profile.nip05 || npub fallback + * @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 { + 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 From 500cec88d099c74bfc118c78dab37d30201d1ea9 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:34:57 +0100 Subject: [PATCH 23/57] fix: allow profile labels to update from fallback to resolved names Previously, useProfileLabels would set fallback npub labels immediately for missing profiles, then skip updating them when profiles were fetched because the condition checked if the label already existed. Now we track which profiles were being fetched (pubkeysToFetch) and update their labels even if they already have fallback labels set, allowing profiles to resolve progressively from fallback npubs to actual names as they load. --- src/hooks/useProfileLabels.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 6d01ed44..c58f6532 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -148,13 +148,16 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { + const pubkeysToFetchSet = new Set(pubkeysToFetch) fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) .then((fetchedProfiles) => { const updatedLabels = new Map(labels) const fetchedProfilesByPubkey = new Map(fetchedProfiles.map(p => [p.pubkey, p])) profileData.forEach(({ encoded, pubkey }) => { - if (!updatedLabels.has(encoded)) { + // Only update profiles that were in pubkeysToFetch (i.e., were being fetched) + // This allows us to replace fallback labels with resolved names + if (pubkeysToFetchSet.has(pubkey)) { // First, try to use the profile from the returned array const fetchedProfile = fetchedProfilesByPubkey.get(pubkey) if (fetchedProfile) { @@ -192,16 +195,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const fallback = getNpubFallbackDisplay(pubkey) updatedLabels.set(encoded, fallback) } - } else { - // No profile found, use fallback - const fallback = getNpubFallbackDisplay(pubkey) - updatedLabels.set(encoded, fallback) } - } else { - // No eventStore, use fallback - const fallback = getNpubFallbackDisplay(pubkey) - updatedLabels.set(encoded, fallback) + // If no profile found in eventStore, keep existing fallback } + // If no eventStore, keep existing fallback } }) From 18a38d054f3a3b8f40db90483b8642f92a139f41 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:37:14 +0100 Subject: [PATCH 24/57] debug: add comprehensive logging to profile label resolution Add detailed debug logs prefixed with [profile-labels] and [markdown-replace] to track the profile resolution flow: - Profile identifier extraction from content - Cache lookup and eventStore checks - Profile fetching from relays - Label updates when profiles resolve - Markdown URI replacement with profile labels This will help diagnose why profile names aren't resolving correctly. --- src/hooks/useMarkdownToHTML.ts | 6 +++- src/hooks/useProfileLabels.ts | 62 ++++++++++++++++++++++++++++++---- src/utils/nostrUriResolver.tsx | 4 +++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 6003e121..95b5977c 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -61,12 +61,14 @@ export const useMarkdownToHTML = ( setProcessedMarkdown('') if (!markdown) { + console.log(`[markdown-to-html] No markdown provided`) return } let isCancelled = false const processMarkdown = () => { + console.log(`[markdown-to-html] Processing markdown with ${profileLabels.size} profile labels`) try { // Replace nostr URIs with profile labels (progressive) and article titles const processed = replaceNostrUrisInMarkdownWithProfileLabels( @@ -77,8 +79,10 @@ export const useMarkdownToHTML = ( if (isCancelled) return + console.log(`[markdown-to-html] Markdown processed, length: ${processed.length}`) setProcessedMarkdown(processed) - } catch { + } catch (error) { + console.error(`[markdown-to-html] Error processing markdown:`, error) if (!isCancelled) { setProcessedMarkdown(markdown) // Fallback to original } diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index c58f6532..87ad803d 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -32,8 +32,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Ignore errors, continue processing other pointers } }) + console.log(`[profile-labels] Extracted ${result.length} profile identifiers from content:`, result.map(r => ({ encoded: r.encoded.slice(0, 20) + '...', pubkey: r.pubkey.slice(0, 16) + '...' }))) return result - } catch { + } catch (error) { + console.warn(`[profile-labels] Error extracting profile pointers:`, error) return [] } }, [content]) @@ -41,11 +43,13 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Initialize labels synchronously from cache on first render to avoid delay const initialLabels = useMemo(() => { if (profileData.length === 0) { + console.log(`[profile-labels] No profile data, returning empty labels`) return new Map() } const allPubkeys = profileData.map(({ pubkey }) => pubkey) const cachedProfiles = loadCachedProfiles(allPubkeys) + console.log(`[profile-labels] Loaded ${cachedProfiles.size} cached profiles out of ${allPubkeys.length} requested`) const labels = new Map() profileData.forEach(({ encoded, pubkey }) => { @@ -56,19 +60,25 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) + console.log(`[profile-labels] Found cached name for ${encoded.slice(0, 20)}...: ${displayName}`) } else { // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) + console.log(`[profile-labels] Cached profile for ${encoded.slice(0, 20)}... has no name, using fallback: ${fallback}`) } - } catch { + } catch (error) { // Use fallback npub display if parsing fails const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) + console.warn(`[profile-labels] Error parsing cached profile for ${encoded.slice(0, 20)}..., using fallback:`, error) } + } else { + console.log(`[profile-labels] No cached profile for ${encoded.slice(0, 20)}... (pubkey: ${pubkey.slice(0, 16)}...)`) } }) + console.log(`[profile-labels] Initial labels from cache:`, Array.from(labels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) return labels }, [profileData]) @@ -98,9 +108,11 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const pubkeysToFetch: string[] = [] + console.log(`[profile-labels] Checking eventStore for ${profileData.length} profiles`) profileData.forEach(({ encoded, pubkey }) => { // Skip if already resolved from initial cache if (labels.has(encoded)) { + console.log(`[profile-labels] Skipping ${encoded.slice(0, 20)}..., already has label from cache`) return } @@ -110,7 +122,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const eventStoreProfile = eventStore.getEvent(pubkey + ':0') if (eventStoreProfile) { profileEvent = eventStoreProfile + console.log(`[profile-labels] Found profile in eventStore for ${encoded.slice(0, 20)}...`) + } else { + console.log(`[profile-labels] Profile not in eventStore for ${encoded.slice(0, 20)}... (pubkey: ${pubkey.slice(0, 16)}...)`) } + } else { + console.log(`[profile-labels] No eventStore available`) } if (profileEvent) { @@ -119,19 +136,23 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) + console.log(`[profile-labels] Set label from eventStore for ${encoded.slice(0, 20)}...: @${displayName}`) } else { // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) + console.log(`[profile-labels] Profile in eventStore for ${encoded.slice(0, 20)}... has no name, using fallback: ${fallback}`) } - } catch { + } catch (error) { // Use fallback npub display if parsing fails const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) + console.warn(`[profile-labels] Error parsing eventStore profile for ${encoded.slice(0, 20)}..., using fallback:`, error) } } else { // No profile found yet, will use fallback after fetch or keep empty // We'll set fallback labels for missing profiles at the end + console.log(`[profile-labels] Adding ${encoded.slice(0, 20)}... to fetch queue`) pubkeysToFetch.push(pubkey) } }) @@ -141,16 +162,21 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (!labels.has(encoded)) { const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) + console.log(`[profile-labels] Setting fallback label for ${encoded.slice(0, 20)}...: ${fallback}`) } }) + console.log(`[profile-labels] Labels after checking cache and eventStore:`, Array.from(labels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) + console.log(`[profile-labels] Profiles to fetch: ${pubkeysToFetch.length}`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) setProfileLabels(new Map(labels)) // Fetch missing profiles asynchronously if (pubkeysToFetch.length > 0 && relayPool && eventStore) { const pubkeysToFetchSet = new Set(pubkeysToFetch) + console.log(`[profile-labels] Fetching ${pubkeysToFetch.length} profiles from relays`) fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) .then((fetchedProfiles) => { + console.log(`[profile-labels] Fetch completed, received ${fetchedProfiles.length} profiles`) const updatedLabels = new Map(labels) const fetchedProfilesByPubkey = new Map(fetchedProfiles.map(p => [p.pubkey, p])) @@ -158,55 +184,79 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Only update profiles that were in pubkeysToFetch (i.e., were being fetched) // This allows us to replace fallback labels with resolved names if (pubkeysToFetchSet.has(pubkey)) { + console.log(`[profile-labels] Processing fetched profile for ${encoded.slice(0, 20)}...`) // First, try to use the profile from the returned array const fetchedProfile = fetchedProfilesByPubkey.get(pubkey) if (fetchedProfile) { + console.log(`[profile-labels] Found profile in fetch results for ${encoded.slice(0, 20)}...`) try { const profileData = JSON.parse(fetchedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string } const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) + console.log(`[profile-labels] Updated label for ${encoded.slice(0, 20)}... to @${displayName}`) } else { // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) updatedLabels.set(encoded, fallback) + console.log(`[profile-labels] Fetched profile for ${encoded.slice(0, 20)}... has no name, using fallback: ${fallback}`) } - } catch { + } catch (error) { // Use fallback npub display if parsing fails const fallback = getNpubFallbackDisplay(pubkey) updatedLabels.set(encoded, fallback) + console.warn(`[profile-labels] Error parsing fetched profile for ${encoded.slice(0, 20)}..., using fallback:`, error) } } else if (eventStore) { + console.log(`[profile-labels] Profile not in fetch results, checking eventStore for ${encoded.slice(0, 20)}...`) // Fallback: check eventStore (in case fetchProfiles stored but didn't return) const profileEvent = eventStore.getEvent(pubkey + ':0') if (profileEvent) { + console.log(`[profile-labels] Found profile in eventStore after fetch for ${encoded.slice(0, 20)}...`) try { const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { updatedLabels.set(encoded, `@${displayName}`) + console.log(`[profile-labels] Updated label from eventStore for ${encoded.slice(0, 20)}... to @${displayName}`) } else { // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) updatedLabels.set(encoded, fallback) + console.log(`[profile-labels] Profile in eventStore for ${encoded.slice(0, 20)}... has no name, using fallback: ${fallback}`) } - } catch { + } catch (error) { // Use fallback npub display if parsing fails const fallback = getNpubFallbackDisplay(pubkey) updatedLabels.set(encoded, fallback) + console.warn(`[profile-labels] Error parsing eventStore profile for ${encoded.slice(0, 20)}..., using fallback:`, error) } + } else { + console.log(`[profile-labels] Profile not in eventStore after fetch for ${encoded.slice(0, 20)}..., keeping fallback`) } // If no profile found in eventStore, keep existing fallback + } else { + console.log(`[profile-labels] No eventStore available, keeping fallback for ${encoded.slice(0, 20)}...`) } // If no eventStore, keep existing fallback } }) + console.log(`[profile-labels] Final labels after fetch:`, Array.from(updatedLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) setProfileLabels(updatedLabels) }) - .catch(() => { + .catch((error) => { + console.error(`[profile-labels] Error fetching profiles:`, error) // Silently handle fetch errors }) + } else { + if (pubkeysToFetch.length === 0) { + console.log(`[profile-labels] No profiles to fetch`) + } else if (!relayPool) { + console.log(`[profile-labels] No relayPool available, cannot fetch profiles`) + } else if (!eventStore) { + console.log(`[profile-labels] No eventStore available, cannot fetch profiles`) + } } }, [profileData, eventStore, relayPool, initialLabels]) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 8d9775ad..57a95204 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -315,12 +315,14 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( profileLabels: Map = new Map(), articleTitles: Map = new Map() ): string { + console.log(`[markdown-replace] Replacing URIs with ${profileLabels.size} profile labels and ${articleTitles.size} article titles`) return replaceNostrUrisSafely(markdown, (encoded) => { const link = createNostrLink(encoded) // Check if we have a resolved profile name if (profileLabels.has(encoded)) { const displayName = profileLabels.get(encoded)! + console.log(`[markdown-replace] Using profile label for ${encoded.slice(0, 20)}...: ${displayName}`) return `[${displayName}](${link})` } @@ -329,6 +331,7 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( const decoded = decode(encoded) if (decoded.type === 'naddr' && articleTitles.has(encoded)) { const title = articleTitles.get(encoded)! + console.log(`[markdown-replace] Using article title for ${encoded.slice(0, 20)}...: ${title}`) return `[${title}](${link})` } } catch (error) { @@ -337,6 +340,7 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( // For other types or if not resolved, use default label (shortened npub format) const label = getNostrUriLabel(encoded) + console.log(`[markdown-replace] Using default label for ${encoded.slice(0, 20)}...: ${label}`) return `[${label}](${link})` }) } From c1877a40e9c8b4ccb34d7ee23e8d76cb668021e5 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:40:14 +0100 Subject: [PATCH 25/57] debug: add detailed logging to fetchProfiles function Add comprehensive logs prefixed with [fetch-profiles] to track: - How many profiles are requested - Cache lookup results - Relay query configuration - Each profile event as it's received - Summary of fetched vs missing profiles - Which profiles weren't found on relays This will help diagnose why only 9/19 profiles are being returned. --- src/services/profileService.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 7b7dfbef..1cd30593 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -194,10 +194,12 @@ export const fetchProfiles = async ( ): Promise => { try { if (pubkeys.length === 0) { + console.log(`[fetch-profiles] No pubkeys provided`) return [] } const uniquePubkeys = Array.from(new Set(pubkeys)) + console.log(`[fetch-profiles] Requested ${pubkeys.length} profiles (${uniquePubkeys.length} unique)`) // First, check localStorage cache for all requested profiles const cachedProfiles = loadCachedProfiles(uniquePubkeys) @@ -210,11 +212,16 @@ export const fetchProfiles = async ( eventStore.add(profile) } + console.log(`[fetch-profiles] Found ${cachedProfiles.size} profiles in cache`) + // Determine which pubkeys need to be fetched from relays const pubkeysToFetch = uniquePubkeys.filter(pubkey => !cachedProfiles.has(pubkey)) + console.log(`[fetch-profiles] Need to fetch ${pubkeysToFetch.length} profiles from relays`) + // If all profiles are cached, return early if (pubkeysToFetch.length === 0) { + console.log(`[fetch-profiles] All profiles cached, returning ${profilesByPubkey.size} profiles`) return Array.from(profilesByPubkey.values()) } @@ -223,7 +230,13 @@ export const fetchProfiles = async ( const prioritized = prioritizeLocalRelays(relayUrls) const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) + console.log(`[fetch-profiles] Querying ${localRelays.length} local relays and ${remoteRelays.length} remote relays`) + let eventCount = 0 + const fetchedPubkeys = new Set() + const processEvent = (event: NostrEvent) => { + eventCount++ + fetchedPubkeys.add(event.pubkey) const existing = profilesByPubkey.get(event.pubkey) if (!existing || event.created_at > existing.created_at) { profilesByPubkey.set(event.pubkey, event) @@ -231,6 +244,9 @@ export const fetchProfiles = async ( eventStore.add(event) // Cache to localStorage for future use cacheProfile(event) + console.log(`[fetch-profiles] Received profile for ${event.pubkey.slice(0, 16)}... (event #${eventCount})`) + } else { + console.log(`[fetch-profiles] Received older profile for ${event.pubkey.slice(0, 16)}..., keeping existing`) } } @@ -259,6 +275,13 @@ export const fetchProfiles = async ( await lastValueFrom(merge(local$, remote$).pipe(toArray())) const profiles = Array.from(profilesByPubkey.values()) + + console.log(`[fetch-profiles] Fetch completed: received ${eventCount} events, ${fetchedPubkeys.size} unique profiles`) + 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) + '...')) + } + console.log(`[fetch-profiles] Returning ${profiles.length} total profiles (${cachedProfiles.size} cached + ${fetchedPubkeys.size} fetched)`) // 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. @@ -273,7 +296,7 @@ export const fetchProfiles = async ( return profiles } catch (error) { - console.error('Failed to fetch profiles:', error) + console.error('[fetch-profiles] Failed to fetch profiles:', error) return [] } } From 5ce13c667d4022b19df11622bd10bf0495fc4575 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:41:03 +0100 Subject: [PATCH 26/57] feat: ensure purplepag.es relay is used for profile lookups Add logic to check if purplepag.es is in the active relay pool when fetching profiles. If not, add it temporarily to ensure we query this relay for profile metadata. This should help find profiles that might not be available on other relays. Also adds debug logging to show which active relays are being queried. --- src/services/profileService.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 1cd30593..9d3ce5e7 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -231,6 +231,20 @@ export const fetchProfiles = async ( const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) console.log(`[fetch-profiles] Querying ${localRelays.length} local relays and ${remoteRelays.length} remote relays`) + console.log(`[fetch-profiles] Active relays:`, relayUrls) + 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) + } + } let eventCount = 0 const fetchedPubkeys = new Set() From 7ec2ddcceb419c914edf5c2101c0e6e53befbb33 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:42:07 +0100 Subject: [PATCH 27/57] debug: add log before fetchProfiles call to verify it's being invoked --- src/hooks/useProfileLabels.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 87ad803d..44ffe15b 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -174,6 +174,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (pubkeysToFetch.length > 0 && relayPool && eventStore) { const pubkeysToFetchSet = new Set(pubkeysToFetch) console.log(`[profile-labels] Fetching ${pubkeysToFetch.length} profiles from relays`) + console.log(`[profile-labels] Calling fetchProfiles with relayPool and ${pubkeysToFetch.length} pubkeys`) fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) .then((fetchedProfiles) => { console.log(`[profile-labels] Fetch completed, received ${fetchedProfiles.length} profiles`) From 6b221e4d137d0fed9bba15f3f56b7522fbc16a3b Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:43:00 +0100 Subject: [PATCH 28/57] perf: increase remote relay timeout for profile fetches Increase timeout from 6s to 10s to give slow relays (including purplepag.es) more time to respond with profile metadata. This may help find profiles that were timing out before. --- src/services/profileService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 9d3ce5e7..3561c3c7 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -282,7 +282,7 @@ export const fetchProfiles = async ( onlyEvents(), tap((event: NostrEvent) => processEvent(event)), completeOnEose(), - takeUntil(timer(6000)) + takeUntil(timer(10000)) // Increased from 6000ms to 10000ms to give slow relays more time ) : new Observable((sub) => sub.complete()) From d4c6747d98b31444e529220bdf462f36185af098 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:48:39 +0100 Subject: [PATCH 29/57] refactor: remove timeouts and make profile fetching reactive - Add optional onEvent callback to fetchProfiles (following queryEvents pattern) - Remove all timeouts - rely entirely on EOSE signals - Update useProfileLabels to use reactive streaming callback - Labels update progressively as profiles arrive from relays - Remove unused timer/takeUntil imports - Backwards compatible: other callers of fetchProfiles still work This follows the controller pattern from fetching-data-with-controllers rule: 'Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.' --- src/hooks/useProfileLabels.ts | 120 ++++++++++++++------------------- src/services/profileService.ts | 13 ++-- 2 files changed, 58 insertions(+), 75 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 44ffe15b..c22f9ca3 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -3,6 +3,7 @@ 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' @@ -170,81 +171,62 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): console.log(`[profile-labels] Profiles to fetch: ${pubkeysToFetch.length}`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) setProfileLabels(new Map(labels)) - // Fetch missing profiles asynchronously + // Fetch missing profiles asynchronously with reactive updates if (pubkeysToFetch.length > 0 && relayPool && eventStore) { const pubkeysToFetchSet = new Set(pubkeysToFetch) + // Create a map from pubkey to encoded identifier for quick lookup + const pubkeyToEncoded = new Map() + profileData.forEach(({ encoded, pubkey }) => { + if (pubkeysToFetchSet.has(pubkey)) { + pubkeyToEncoded.set(pubkey, encoded) + } + }) + console.log(`[profile-labels] Fetching ${pubkeysToFetch.length} profiles from relays`) console.log(`[profile-labels] Calling fetchProfiles with relayPool and ${pubkeysToFetch.length} pubkeys`) - fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch) - .then((fetchedProfiles) => { - console.log(`[profile-labels] Fetch completed, received ${fetchedProfiles.length} profiles`) - const updatedLabels = new Map(labels) - const fetchedProfilesByPubkey = new Map(fetchedProfiles.map(p => [p.pubkey, p])) - - profileData.forEach(({ encoded, pubkey }) => { - // Only update profiles that were in pubkeysToFetch (i.e., were being fetched) - // This allows us to replace fallback labels with resolved names - if (pubkeysToFetchSet.has(pubkey)) { - console.log(`[profile-labels] Processing fetched profile for ${encoded.slice(0, 20)}...`) - // First, try to use the profile from the returned array - const fetchedProfile = fetchedProfilesByPubkey.get(pubkey) - if (fetchedProfile) { - console.log(`[profile-labels] Found profile in fetch results for ${encoded.slice(0, 20)}...`) - try { - const profileData = JSON.parse(fetchedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - updatedLabels.set(encoded, `@${displayName}`) - console.log(`[profile-labels] Updated label for ${encoded.slice(0, 20)}... to @${displayName}`) - } else { - // Use fallback npub display if profile has no name - const fallback = getNpubFallbackDisplay(pubkey) - updatedLabels.set(encoded, fallback) - console.log(`[profile-labels] Fetched profile for ${encoded.slice(0, 20)}... has no name, using fallback: ${fallback}`) - } - } catch (error) { - // Use fallback npub display if parsing fails - const fallback = getNpubFallbackDisplay(pubkey) - updatedLabels.set(encoded, fallback) - console.warn(`[profile-labels] Error parsing fetched profile for ${encoded.slice(0, 20)}..., using fallback:`, error) - } - } else if (eventStore) { - console.log(`[profile-labels] Profile not in fetch results, checking eventStore for ${encoded.slice(0, 20)}...`) - // Fallback: check eventStore (in case fetchProfiles stored but didn't return) - const profileEvent = eventStore.getEvent(pubkey + ':0') - if (profileEvent) { - console.log(`[profile-labels] Found profile in eventStore after fetch for ${encoded.slice(0, 20)}...`) - try { - const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - updatedLabels.set(encoded, `@${displayName}`) - console.log(`[profile-labels] Updated label from eventStore for ${encoded.slice(0, 20)}... to @${displayName}`) - } else { - // Use fallback npub display if profile has no name - const fallback = getNpubFallbackDisplay(pubkey) - updatedLabels.set(encoded, fallback) - console.log(`[profile-labels] Profile in eventStore for ${encoded.slice(0, 20)}... has no name, using fallback: ${fallback}`) - } - } catch (error) { - // Use fallback npub display if parsing fails - const fallback = getNpubFallbackDisplay(pubkey) - updatedLabels.set(encoded, fallback) - console.warn(`[profile-labels] Error parsing eventStore profile for ${encoded.slice(0, 20)}..., using fallback:`, error) - } - } else { - console.log(`[profile-labels] Profile not in eventStore after fetch for ${encoded.slice(0, 20)}..., keeping fallback`) - } - // If no profile found in eventStore, keep existing fallback - } else { - console.log(`[profile-labels] No eventStore available, keeping fallback for ${encoded.slice(0, 20)}...`) - } - // If no eventStore, keep existing fallback + + // Reactive callback: update labels as profiles stream in + const handleProfileEvent = (event: NostrEvent) => { + const encoded = pubkeyToEncoded.get(event.pubkey) + if (!encoded) { + console.log(`[profile-labels] Received profile for unknown pubkey ${event.pubkey.slice(0, 16)}..., skipping`) + return + } + + console.log(`[profile-labels] Received profile event for ${encoded.slice(0, 20)}...`) + setProfileLabels(prevLabels => { + const updatedLabels = new Map(prevLabels) + try { + const profileData = JSON.parse(event.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + updatedLabels.set(encoded, `@${displayName}`) + console.log(`[profile-labels] Updated label reactively for ${encoded.slice(0, 20)}... to @${displayName}`) + } else { + // Use fallback npub display if profile has no name + const fallback = getNpubFallbackDisplay(event.pubkey) + updatedLabels.set(encoded, fallback) + console.log(`[profile-labels] Profile for ${encoded.slice(0, 20)}... has no name, keeping fallback: ${fallback}`) } + } catch (error) { + // Use fallback npub display if parsing fails + const fallback = getNpubFallbackDisplay(event.pubkey) + updatedLabels.set(encoded, fallback) + console.warn(`[profile-labels] Error parsing profile for ${encoded.slice(0, 20)}..., using fallback:`, error) + } + return updatedLabels + }) + } + + fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent) + .then((fetchedProfiles) => { + console.log(`[profile-labels] Fetch completed (EOSE), received ${fetchedProfiles.length} profiles total`) + // Labels have already been updated reactively via handleProfileEvent + // Just log final state for debugging + setProfileLabels(prevLabels => { + console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) + return prevLabels // No change needed, already updated reactively }) - - console.log(`[profile-labels] Final labels after fetch:`, Array.from(updatedLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) - setProfileLabels(updatedLabels) }) .catch((error) => { console.error(`[profile-labels] Error fetching profiles:`, error) diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 3561c3c7..04722016 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -1,5 +1,5 @@ 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' @@ -190,7 +190,8 @@ export const fetchProfiles = async ( relayPool: RelayPool, eventStore: IEventStore, pubkeys: string[], - settings?: UserSettings + settings?: UserSettings, + onEvent?: (event: NostrEvent) => void ): Promise => { try { if (pubkeys.length === 0) { @@ -269,9 +270,9 @@ export const fetchProfiles = async ( .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((sub) => sub.complete()) @@ -280,9 +281,9 @@ export const fetchProfiles = async ( .req(remoteRelays, { kinds: [0], authors: pubkeysToFetch }) .pipe( onlyEvents(), + onEvent ? tap((event: NostrEvent) => onEvent(event)) : tap(() => {}), tap((event: NostrEvent) => processEvent(event)), - completeOnEose(), - takeUntil(timer(10000)) // Increased from 6000ms to 10000ms to give slow relays more time + completeOnEose() ) : new Observable((sub) => sub.complete()) From 76a117cddab3b716a8bebd9565d0044192a2be19 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 21:53:22 +0100 Subject: [PATCH 30/57] fix: batch profile label updates to prevent UI flickering - Use requestAnimationFrame to batch rapid profile label updates - Collect pending updates in a ref instead of updating state immediately - Apply all pending updates in one render cycle - Add cleanup to cancel pending RAF on unmount/effect cleanup This prevents flickering when multiple profiles stream in quickly while still maintaining progressive updates as profiles arrive. --- src/hooks/useProfileLabels.ts | 103 ++++++++++++++++++++++++---------- 1 file changed, 74 insertions(+), 29 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index c22f9ca3..399ce088 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect } from 'react' +import { useMemo, useState, useEffect, useRef } from 'react' import { Hooks } from 'applesauce-react' import { Helpers, IEventStore } from 'applesauce-core' import { getContentPointers } from 'applesauce-factory/helpers' @@ -84,6 +84,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): }, [profileData]) const [profileLabels, setProfileLabels] = useState>(initialLabels) + + // Refs for batching updates to prevent flickering + const pendingUpdatesRef = useRef>(new Map()) + const rafScheduledRef = useRef(null) // Build initial labels: localStorage cache -> eventStore -> fetch from relays useEffect(() => { @@ -185,7 +189,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): console.log(`[profile-labels] Fetching ${pubkeysToFetch.length} profiles from relays`) console.log(`[profile-labels] Calling fetchProfiles with relayPool and ${pubkeysToFetch.length} pubkeys`) - // Reactive callback: update labels as profiles stream in + // Reactive callback: batch updates to prevent flickering const handleProfileEvent = (event: NostrEvent) => { const encoded = pubkeyToEncoded.get(event.pubkey) if (!encoded) { @@ -194,44 +198,85 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } console.log(`[profile-labels] Received profile event for ${encoded.slice(0, 20)}...`) - setProfileLabels(prevLabels => { - const updatedLabels = new Map(prevLabels) - try { - const profileData = JSON.parse(event.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - updatedLabels.set(encoded, `@${displayName}`) - console.log(`[profile-labels] Updated label reactively for ${encoded.slice(0, 20)}... to @${displayName}`) - } else { - // Use fallback npub display if profile has no name - const fallback = getNpubFallbackDisplay(event.pubkey) - updatedLabels.set(encoded, fallback) - console.log(`[profile-labels] Profile for ${encoded.slice(0, 20)}... has no name, keeping fallback: ${fallback}`) - } - } catch (error) { - // Use fallback npub display if parsing fails - const fallback = getNpubFallbackDisplay(event.pubkey) - updatedLabels.set(encoded, fallback) - console.warn(`[profile-labels] Error parsing profile for ${encoded.slice(0, 20)}..., using fallback:`, error) + + // Determine the label for this profile + let label: string + try { + const profileData = JSON.parse(event.content || '{}') as { name?: string; display_name?: string; nip05?: string } + const displayName = profileData.display_name || profileData.name || profileData.nip05 + if (displayName) { + label = `@${displayName}` + console.log(`[profile-labels] Updated label reactively for ${encoded.slice(0, 20)}... to @${displayName}`) + } else { + // Use fallback npub display if profile has no name + label = getNpubFallbackDisplay(event.pubkey) + console.log(`[profile-labels] Profile for ${encoded.slice(0, 20)}... has no name, keeping fallback: ${label}`) } - return updatedLabels - }) + } catch (error) { + // Use fallback npub display if parsing fails + label = getNpubFallbackDisplay(event.pubkey) + console.warn(`[profile-labels] Error parsing profile for ${encoded.slice(0, 20)}..., using fallback:`, error) + } + + // Add to pending updates + pendingUpdatesRef.current.set(encoded, label) + + // Schedule batched update if not already scheduled + if (rafScheduledRef.current === null) { + rafScheduledRef.current = requestAnimationFrame(() => { + // Apply all pending updates in one batch + setProfileLabels(prevLabels => { + const updatedLabels = new Map(prevLabels) + const pendingUpdates = pendingUpdatesRef.current + + // Apply all pending updates + for (const [encoded, label] of pendingUpdates.entries()) { + updatedLabels.set(encoded, label) + } + + // Clear pending updates + pendingUpdates.clear() + rafScheduledRef.current = null + + return updatedLabels + }) + }) + } } fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent) .then((fetchedProfiles) => { console.log(`[profile-labels] Fetch completed (EOSE), received ${fetchedProfiles.length} profiles total`) - // Labels have already been updated reactively via handleProfileEvent - // Just log final state for debugging - setProfileLabels(prevLabels => { - console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) - return prevLabels // No change needed, already updated reactively - }) + // Ensure any pending batched updates are applied + if (rafScheduledRef.current !== null) { + // Wait for the scheduled RAF to complete + requestAnimationFrame(() => { + setProfileLabels(prevLabels => { + console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) + return prevLabels + }) + }) + } else { + // No pending updates, just log final state + setProfileLabels(prevLabels => { + console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) + return prevLabels + }) + } }) .catch((error) => { console.error(`[profile-labels] Error fetching profiles:`, error) // Silently handle fetch errors }) + + // Cleanup: cancel any pending RAF and clear pending updates + return () => { + if (rafScheduledRef.current !== null) { + cancelAnimationFrame(rafScheduledRef.current) + rafScheduledRef.current = null + } + pendingUpdatesRef.current.clear() + } } else { if (pubkeysToFetch.length === 0) { console.log(`[profile-labels] No profiles to fetch`) From 4fd66056662f32864d60832bcc05ca7f41141ef1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:01:35 +0100 Subject: [PATCH 31/57] fix: ensure profile labels always update correctly - Sync state when initialLabels changes (e.g., content changes) - Flush pending batched updates after EOSE completes - Flush pending updates in cleanup to avoid losing updates - Better handling of profile data changes vs same profiles Fixes issue where @npub... placeholders sometimes weren't replaced until refresh. Now all profile updates are guaranteed to be applied. --- src/hooks/useProfileLabels.ts | 92 +++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 399ce088..16b5c30a 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -88,6 +88,42 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Refs for batching updates to prevent flickering const pendingUpdatesRef = useRef>(new Map()) const rafScheduledRef = useRef(null) + + // 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 currentEncodedIds = new Set(Array.from(prevLabels.keys())) + const newEncodedIds = new Set(profileData.map(p => p.encoded)) + + // If the content changed significantly (different set of profiles), reset state + const hasDifferentProfiles = + currentEncodedIds.size !== newEncodedIds.size || + !Array.from(newEncodedIds).every(id => currentEncodedIds.has(id)) + + if (hasDifferentProfiles) { + // Clear pending updates 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 (initial labels take precedence for missing ones) + const merged = new Map(prevLabels) + for (const [encoded, label] of initialLabels.entries()) { + // Only update if missing or if initial label has a better value (not a fallback) + if (!merged.has(encoded) || (!prevLabels.get(encoded)?.startsWith('@') && label.startsWith('@'))) { + merged.set(encoded, label) + } + } + return merged + } + }) + }, [initialLabels, profileData]) // Build initial labels: localStorage cache -> eventStore -> fetch from relays useEffect(() => { @@ -96,6 +132,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (allPubkeys.length === 0) { setProfileLabels(new Map()) + // Clear pending updates when clearing labels + pendingUpdatesRef.current.clear() + if (rafScheduledRef.current !== null) { + cancelAnimationFrame(rafScheduledRef.current) + rafScheduledRef.current = null + } return } @@ -247,14 +289,29 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent) .then((fetchedProfiles) => { console.log(`[profile-labels] Fetch completed (EOSE), received ${fetchedProfiles.length} profiles total`) - // Ensure any pending batched updates are applied + + // Ensure any pending batched updates are applied immediately after EOSE + // This ensures all profile updates are applied even if RAF hasn't fired yet + const pendingUpdates = pendingUpdatesRef.current + const pendingCount = pendingUpdates.size + + // Cancel any pending RAF since we're applying updates synchronously now if (rafScheduledRef.current !== null) { - // Wait for the scheduled RAF to complete - requestAnimationFrame(() => { - setProfileLabels(prevLabels => { - console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) - return prevLabels - }) + cancelAnimationFrame(rafScheduledRef.current) + rafScheduledRef.current = null + } + + if (pendingCount > 0) { + // Apply all pending updates synchronously + setProfileLabels(prevLabels => { + const updatedLabels = new Map(prevLabels) + for (const [encoded, label] of pendingUpdates.entries()) { + updatedLabels.set(encoded, label) + } + pendingUpdates.clear() + console.log(`[profile-labels] Flushed ${pendingCount} pending updates after EOSE`) + console.log(`[profile-labels] Final labels after EOSE:`, Array.from(updatedLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) + return updatedLabels }) } else { // No pending updates, just log final state @@ -269,9 +326,26 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Silently handle fetch errors }) - // Cleanup: cancel any pending RAF and clear pending updates + // Cleanup: flush any pending updates before clearing, then cancel RAF return () => { - if (rafScheduledRef.current !== null) { + // Flush any pending updates before cleanup to avoid losing them + const pendingUpdates = pendingUpdatesRef.current + if (pendingUpdates.size > 0 && rafScheduledRef.current !== null) { + // Cancel the scheduled RAF and apply updates immediately + cancelAnimationFrame(rafScheduledRef.current) + rafScheduledRef.current = null + + // Apply pending updates synchronously before cleanup + setProfileLabels(prevLabels => { + const updatedLabels = new Map(prevLabels) + for (const [encoded, label] of pendingUpdates.entries()) { + updatedLabels.set(encoded, label) + } + return updatedLabels + }) + + pendingUpdates.clear() + } else if (rafScheduledRef.current !== null) { cancelAnimationFrame(rafScheduledRef.current) rafScheduledRef.current = null } From b0574d3f8e390f6149350bbcb95a0dae6734271c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:05:08 +0100 Subject: [PATCH 32/57] fix: resolve React hooks exhaustive-deps linter warning - Capture refs at effect level and use in cleanup function - This satisfies react-hooks/exhaustive-deps rule for cleanup functions - Prevents stale closure issues while keeping code clean --- src/hooks/useProfileLabels.ts | 42 +++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 16b5c30a..fbb3cecc 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -231,6 +231,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): console.log(`[profile-labels] Fetching ${pubkeysToFetch.length} profiles from relays`) console.log(`[profile-labels] Calling fetchProfiles with relayPool and ${pubkeysToFetch.length} pubkeys`) + // Capture refs at effect level for cleanup function + const currentPendingUpdatesRef = pendingUpdatesRef + const currentRafScheduledRef = rafScheduledRef + // Reactive callback: batch updates to prevent flickering const handleProfileEvent = (event: NostrEvent) => { const encoded = pubkeyToEncoded.get(event.pubkey) @@ -261,15 +265,15 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } // Add to pending updates - pendingUpdatesRef.current.set(encoded, label) + currentPendingUpdatesRef.current.set(encoded, label) // Schedule batched update if not already scheduled - if (rafScheduledRef.current === null) { - rafScheduledRef.current = requestAnimationFrame(() => { + if (currentRafScheduledRef.current === null) { + currentRafScheduledRef.current = requestAnimationFrame(() => { // Apply all pending updates in one batch setProfileLabels(prevLabels => { const updatedLabels = new Map(prevLabels) - const pendingUpdates = pendingUpdatesRef.current + const pendingUpdates = currentPendingUpdatesRef.current // Apply all pending updates for (const [encoded, label] of pendingUpdates.entries()) { @@ -278,7 +282,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Clear pending updates pendingUpdates.clear() - rafScheduledRef.current = null + currentRafScheduledRef.current = null return updatedLabels }) @@ -292,13 +296,13 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Ensure any pending batched updates are applied immediately after EOSE // This ensures all profile updates are applied even if RAF hasn't fired yet - const pendingUpdates = pendingUpdatesRef.current + const pendingUpdates = currentPendingUpdatesRef.current const pendingCount = pendingUpdates.size // Cancel any pending RAF since we're applying updates synchronously now - if (rafScheduledRef.current !== null) { - cancelAnimationFrame(rafScheduledRef.current) - rafScheduledRef.current = null + if (currentRafScheduledRef.current !== null) { + cancelAnimationFrame(currentRafScheduledRef.current) + currentRafScheduledRef.current = null } if (pendingCount > 0) { @@ -328,12 +332,15 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Cleanup: flush any pending updates before clearing, then cancel RAF return () => { + // Use captured refs from effect scope to avoid stale closure issues + const pendingUpdates = currentPendingUpdatesRef.current + const scheduledRaf = currentRafScheduledRef.current + // Flush any pending updates before cleanup to avoid losing them - const pendingUpdates = pendingUpdatesRef.current - if (pendingUpdates.size > 0 && rafScheduledRef.current !== null) { + if (pendingUpdates.size > 0 && scheduledRaf !== null) { // Cancel the scheduled RAF and apply updates immediately - cancelAnimationFrame(rafScheduledRef.current) - rafScheduledRef.current = null + cancelAnimationFrame(scheduledRaf) + currentRafScheduledRef.current = null // Apply pending updates synchronously before cleanup setProfileLabels(prevLabels => { @@ -345,11 +352,12 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): }) pendingUpdates.clear() - } else if (rafScheduledRef.current !== null) { - cancelAnimationFrame(rafScheduledRef.current) - rafScheduledRef.current = null + } else if (scheduledRaf !== null) { + cancelAnimationFrame(scheduledRaf) + currentRafScheduledRef.current = null } - pendingUpdatesRef.current.clear() + // Clear using captured reference to avoid linter warning + pendingUpdates.clear() } } else { if (pubkeysToFetch.length === 0) { From 15c016ad5ea8e34cba293e14816cdb107ec0293c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:07:33 +0100 Subject: [PATCH 33/57] chore: remove console.log debug output from profile services and hooks --- src/hooks/useMarkdownToHTML.ts | 3 --- src/hooks/useProfileLabels.ts | 47 ---------------------------------- src/services/profileService.ts | 15 ----------- src/utils/nostrUriResolver.tsx | 4 --- 4 files changed, 69 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 95b5977c..6afb4e99 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -61,14 +61,12 @@ export const useMarkdownToHTML = ( setProcessedMarkdown('') if (!markdown) { - console.log(`[markdown-to-html] No markdown provided`) return } let isCancelled = false const processMarkdown = () => { - console.log(`[markdown-to-html] Processing markdown with ${profileLabels.size} profile labels`) try { // Replace nostr URIs with profile labels (progressive) and article titles const processed = replaceNostrUrisInMarkdownWithProfileLabels( @@ -79,7 +77,6 @@ export const useMarkdownToHTML = ( if (isCancelled) return - console.log(`[markdown-to-html] Markdown processed, length: ${processed.length}`) setProcessedMarkdown(processed) } catch (error) { console.error(`[markdown-to-html] Error processing markdown:`, error) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index fbb3cecc..dc55c4eb 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -33,7 +33,6 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Ignore errors, continue processing other pointers } }) - console.log(`[profile-labels] Extracted ${result.length} profile identifiers from content:`, result.map(r => ({ encoded: r.encoded.slice(0, 20) + '...', pubkey: r.pubkey.slice(0, 16) + '...' }))) return result } catch (error) { console.warn(`[profile-labels] Error extracting profile pointers:`, error) @@ -44,13 +43,11 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Initialize labels synchronously from cache on first render to avoid delay const initialLabels = useMemo(() => { if (profileData.length === 0) { - console.log(`[profile-labels] No profile data, returning empty labels`) return new Map() } const allPubkeys = profileData.map(({ pubkey }) => pubkey) const cachedProfiles = loadCachedProfiles(allPubkeys) - console.log(`[profile-labels] Loaded ${cachedProfiles.size} cached profiles out of ${allPubkeys.length} requested`) const labels = new Map() profileData.forEach(({ encoded, pubkey }) => { @@ -61,12 +58,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) - console.log(`[profile-labels] Found cached name for ${encoded.slice(0, 20)}...: ${displayName}`) } else { // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) - console.log(`[profile-labels] Cached profile for ${encoded.slice(0, 20)}... has no name, using fallback: ${fallback}`) } } catch (error) { // Use fallback npub display if parsing fails @@ -74,12 +69,9 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): labels.set(encoded, fallback) console.warn(`[profile-labels] Error parsing cached profile for ${encoded.slice(0, 20)}..., using fallback:`, error) } - } else { - console.log(`[profile-labels] No cached profile for ${encoded.slice(0, 20)}... (pubkey: ${pubkey.slice(0, 16)}...)`) } }) - console.log(`[profile-labels] Initial labels from cache:`, Array.from(labels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) return labels }, [profileData]) @@ -155,11 +147,9 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const pubkeysToFetch: string[] = [] - console.log(`[profile-labels] Checking eventStore for ${profileData.length} profiles`) profileData.forEach(({ encoded, pubkey }) => { // Skip if already resolved from initial cache if (labels.has(encoded)) { - console.log(`[profile-labels] Skipping ${encoded.slice(0, 20)}..., already has label from cache`) return } @@ -169,12 +159,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const eventStoreProfile = eventStore.getEvent(pubkey + ':0') if (eventStoreProfile) { profileEvent = eventStoreProfile - console.log(`[profile-labels] Found profile in eventStore for ${encoded.slice(0, 20)}...`) - } else { - console.log(`[profile-labels] Profile not in eventStore for ${encoded.slice(0, 20)}... (pubkey: ${pubkey.slice(0, 16)}...)`) } - } else { - console.log(`[profile-labels] No eventStore available`) } if (profileEvent) { @@ -183,12 +168,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { labels.set(encoded, `@${displayName}`) - console.log(`[profile-labels] Set label from eventStore for ${encoded.slice(0, 20)}...: @${displayName}`) } else { // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) - console.log(`[profile-labels] Profile in eventStore for ${encoded.slice(0, 20)}... has no name, using fallback: ${fallback}`) } } catch (error) { // Use fallback npub display if parsing fails @@ -199,7 +182,6 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } else { // No profile found yet, will use fallback after fetch or keep empty // We'll set fallback labels for missing profiles at the end - console.log(`[profile-labels] Adding ${encoded.slice(0, 20)}... to fetch queue`) pubkeysToFetch.push(pubkey) } }) @@ -209,12 +191,9 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (!labels.has(encoded)) { const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) - console.log(`[profile-labels] Setting fallback label for ${encoded.slice(0, 20)}...: ${fallback}`) } }) - console.log(`[profile-labels] Labels after checking cache and eventStore:`, Array.from(labels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) - console.log(`[profile-labels] Profiles to fetch: ${pubkeysToFetch.length}`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) setProfileLabels(new Map(labels)) // Fetch missing profiles asynchronously with reactive updates @@ -228,9 +207,6 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) - console.log(`[profile-labels] Fetching ${pubkeysToFetch.length} profiles from relays`) - console.log(`[profile-labels] Calling fetchProfiles with relayPool and ${pubkeysToFetch.length} pubkeys`) - // Capture refs at effect level for cleanup function const currentPendingUpdatesRef = pendingUpdatesRef const currentRafScheduledRef = rafScheduledRef @@ -239,12 +215,9 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const handleProfileEvent = (event: NostrEvent) => { const encoded = pubkeyToEncoded.get(event.pubkey) if (!encoded) { - console.log(`[profile-labels] Received profile for unknown pubkey ${event.pubkey.slice(0, 16)}..., skipping`) return } - console.log(`[profile-labels] Received profile event for ${encoded.slice(0, 20)}...`) - // Determine the label for this profile let label: string try { @@ -252,11 +225,9 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const displayName = profileData.display_name || profileData.name || profileData.nip05 if (displayName) { label = `@${displayName}` - console.log(`[profile-labels] Updated label reactively for ${encoded.slice(0, 20)}... to @${displayName}`) } else { // Use fallback npub display if profile has no name label = getNpubFallbackDisplay(event.pubkey) - console.log(`[profile-labels] Profile for ${encoded.slice(0, 20)}... has no name, keeping fallback: ${label}`) } } catch (error) { // Use fallback npub display if parsing fails @@ -292,8 +263,6 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent) .then((fetchedProfiles) => { - console.log(`[profile-labels] Fetch completed (EOSE), received ${fetchedProfiles.length} profiles total`) - // Ensure any pending batched updates are applied immediately after EOSE // This ensures all profile updates are applied even if RAF hasn't fired yet const pendingUpdates = currentPendingUpdatesRef.current @@ -313,16 +282,8 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): updatedLabels.set(encoded, label) } pendingUpdates.clear() - console.log(`[profile-labels] Flushed ${pendingCount} pending updates after EOSE`) - console.log(`[profile-labels] Final labels after EOSE:`, Array.from(updatedLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) return updatedLabels }) - } else { - // No pending updates, just log final state - setProfileLabels(prevLabels => { - console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) - return prevLabels - }) } }) .catch((error) => { @@ -359,14 +320,6 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Clear using captured reference to avoid linter warning pendingUpdates.clear() } - } else { - if (pubkeysToFetch.length === 0) { - console.log(`[profile-labels] No profiles to fetch`) - } else if (!relayPool) { - console.log(`[profile-labels] No relayPool available, cannot fetch profiles`) - } else if (!eventStore) { - console.log(`[profile-labels] No eventStore available, cannot fetch profiles`) - } } }, [profileData, eventStore, relayPool, initialLabels]) diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 04722016..94e8d587 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -195,12 +195,10 @@ export const fetchProfiles = async ( ): Promise => { try { if (pubkeys.length === 0) { - console.log(`[fetch-profiles] No pubkeys provided`) return [] } const uniquePubkeys = Array.from(new Set(pubkeys)) - console.log(`[fetch-profiles] Requested ${pubkeys.length} profiles (${uniquePubkeys.length} unique)`) // First, check localStorage cache for all requested profiles const cachedProfiles = loadCachedProfiles(uniquePubkeys) @@ -213,16 +211,11 @@ export const fetchProfiles = async ( eventStore.add(profile) } - console.log(`[fetch-profiles] Found ${cachedProfiles.size} profiles in cache`) - // Determine which pubkeys need to be fetched from relays const pubkeysToFetch = uniquePubkeys.filter(pubkey => !cachedProfiles.has(pubkey)) - console.log(`[fetch-profiles] Need to fetch ${pubkeysToFetch.length} profiles from relays`) - // If all profiles are cached, return early if (pubkeysToFetch.length === 0) { - console.log(`[fetch-profiles] All profiles cached, returning ${profilesByPubkey.size} profiles`) return Array.from(profilesByPubkey.values()) } @@ -230,9 +223,6 @@ export const fetchProfiles = async ( const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const prioritized = prioritizeLocalRelays(relayUrls) const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) - - console.log(`[fetch-profiles] Querying ${localRelays.length} local relays and ${remoteRelays.length} remote relays`) - console.log(`[fetch-profiles] Active relays:`, relayUrls) 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`) @@ -259,9 +249,6 @@ export const fetchProfiles = async ( eventStore.add(event) // Cache to localStorage for future use cacheProfile(event) - console.log(`[fetch-profiles] Received profile for ${event.pubkey.slice(0, 16)}... (event #${eventCount})`) - } else { - console.log(`[fetch-profiles] Received older profile for ${event.pubkey.slice(0, 16)}..., keeping existing`) } } @@ -291,12 +278,10 @@ export const fetchProfiles = async ( const profiles = Array.from(profilesByPubkey.values()) - console.log(`[fetch-profiles] Fetch completed: received ${eventCount} events, ${fetchedPubkeys.size} unique profiles`) 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) + '...')) } - console.log(`[fetch-profiles] Returning ${profiles.length} total profiles (${cachedProfiles.size} cached + ${fetchedPubkeys.size} fetched)`) // 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. diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 57a95204..8d9775ad 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -315,14 +315,12 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( profileLabels: Map = new Map(), articleTitles: Map = new Map() ): string { - console.log(`[markdown-replace] Replacing URIs with ${profileLabels.size} profile labels and ${articleTitles.size} article titles`) return replaceNostrUrisSafely(markdown, (encoded) => { const link = createNostrLink(encoded) // Check if we have a resolved profile name if (profileLabels.has(encoded)) { const displayName = profileLabels.get(encoded)! - console.log(`[markdown-replace] Using profile label for ${encoded.slice(0, 20)}...: ${displayName}`) return `[${displayName}](${link})` } @@ -331,7 +329,6 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( const decoded = decode(encoded) if (decoded.type === 'naddr' && articleTitles.has(encoded)) { const title = articleTitles.get(encoded)! - console.log(`[markdown-replace] Using article title for ${encoded.slice(0, 20)}...: ${title}`) return `[${title}](${link})` } } catch (error) { @@ -340,7 +337,6 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( // For other types or if not resolved, use default label (shortened npub format) const label = getNostrUriLabel(encoded) - console.log(`[markdown-replace] Using default label for ${encoded.slice(0, 20)}...: ${label}`) return `[${label}](${link})` }) } From ee7df54d874083c381f83c299fcb3b4c5a1c2521 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:21:43 +0100 Subject: [PATCH 34/57] refactor(profiles): standardize profile name extraction and improve code quality - Create centralized profileUtils.ts with extractProfileDisplayName function - Standardize profile name priority order: name || display_name || nip05 || fallback - Replace duplicate profile parsing code across 6+ locations - Add request deduplication to fetchProfiles to prevent duplicate relay requests - Simplify RAF batching logic in useProfileLabels with helper functions - Fix RichContent.tsx error when content.split() produces undefined parts - Remove unused eventCount variable in profileService - Fix React Hook dependency warnings by wrapping scheduleBatchedUpdate in useCallback --- api/article-og.ts | 13 +- src/components/BookmarkItem.tsx | 14 +- src/components/HighlightCitation.tsx | 4 +- src/components/RichContent.tsx | 5 + src/hooks/useEventLoader.ts | 22 +-- src/hooks/useProfileLabels.ts | 216 ++++++++++----------------- src/services/profileService.ts | 193 +++++++++++++----------- src/utils/nostrUriResolver.tsx | 3 + src/utils/profileUtils.ts | 40 +++++ 9 files changed, 269 insertions(+), 241 deletions(-) create mode 100644 src/utils/profileUtils.ts 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/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/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/RichContent.tsx b/src/components/RichContent.tsx index de8fdb1c..946aa554 100644 --- a/src/components/RichContent.tsx +++ b/src/components/RichContent.tsx @@ -45,6 +45,11 @@ const RichContent: React.FC = ({ return (
{parts.map((part, index) => { + // Skip empty or undefined parts + if (!part) { + return null + } + // Handle nostr: URIs - Tokens.nostrLink matches both formats if (part.startsWith('nostr:')) { return ( diff --git a/src/hooks/useEventLoader.ts b/src/hooks/useEventLoader.ts index 1164a8b8..181e9c44 100644 --- a/src/hooks/useEventLoader.ts +++ b/src/hooks/useEventLoader.ts @@ -7,6 +7,7 @@ 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 @@ -63,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 } } @@ -76,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 } } } diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index dc55c4eb..ff6c9f5d 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect, useRef } from 'react' +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' @@ -6,6 +6,7 @@ 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 @@ -53,21 +54,15 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): profileData.forEach(({ encoded, pubkey }) => { const cachedProfile = cachedProfiles.get(pubkey) if (cachedProfile) { - try { - const profileData = JSON.parse(cachedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - labels.set(encoded, `@${displayName}`) - } else { - // Use fallback npub display if profile has no name - const fallback = getNpubFallbackDisplay(pubkey) - labels.set(encoded, fallback) - } - } catch (error) { - // Use fallback npub display if parsing fails + const displayName = extractProfileDisplayName(cachedProfile) + if (displayName) { + // Only add @ prefix if we have a real name, otherwise use fallback format directly + const label = displayName.startsWith('@') ? displayName : `@${displayName}` + labels.set(encoded, label) + } else { + // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) - console.warn(`[profile-labels] Error parsing cached profile for ${encoded.slice(0, 20)}..., using fallback:`, error) } } }) @@ -77,10 +72,50 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const [profileLabels, setProfileLabels] = useState>(initialLabels) - // Refs for batching updates to prevent flickering + // 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. 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 [encoded, label] of pendingUpdates.entries()) { + updatedLabels.set(encoded, 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(() => { @@ -95,7 +130,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): !Array.from(newEncodedIds).every(id => currentEncodedIds.has(id)) if (hasDifferentProfiles) { - // Clear pending updates for old profiles + // Clear pending updates and cancel RAF for old profiles pendingUpdatesRef.current.clear() if (rafScheduledRef.current !== null) { cancelAnimationFrame(rafScheduledRef.current) @@ -124,7 +159,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): if (allPubkeys.length === 0) { setProfileLabels(new Map()) - // Clear pending updates when clearing labels + // Clear pending updates and cancel RAF when clearing labels pendingUpdatesRef.current.clear() if (rafScheduledRef.current !== null) { cancelAnimationFrame(rafScheduledRef.current) @@ -154,30 +189,19 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } // Check EventStore for profiles that weren't in cache - let profileEvent: { content: string } | null = null - if (eventStore) { - const eventStoreProfile = eventStore.getEvent(pubkey + ':0') - if (eventStoreProfile) { - profileEvent = eventStoreProfile - } - } + const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') - if (profileEvent) { - try { - const profileData = JSON.parse(profileEvent.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - labels.set(encoded, `@${displayName}`) - } else { - // Use fallback npub display if profile has no name - const fallback = getNpubFallbackDisplay(pubkey) - labels.set(encoded, fallback) - } - } catch (error) { - // Use fallback npub display if parsing fails + if (eventStoreProfile && eventStore) { + // Extract display name using centralized utility + const displayName = extractProfileDisplayName(eventStoreProfile as NostrEvent) + if (displayName) { + // Only add @ prefix if we have a real name, otherwise use fallback format directly + const label = displayName.startsWith('@') ? displayName : `@${displayName}` + labels.set(encoded, label) + } else { + // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) - console.warn(`[profile-labels] Error parsing eventStore profile for ${encoded.slice(0, 20)}..., using fallback:`, error) } } else { // No profile found yet, will use fallback after fetch or keep empty @@ -207,121 +231,45 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }) - // Capture refs at effect level for cleanup function - const currentPendingUpdatesRef = pendingUpdatesRef - const currentRafScheduledRef = rafScheduledRef - - // Reactive callback: batch updates to prevent flickering + // Reactive callback: collects profile updates and batches them via RAF to prevent flicker + // Strategy: Collect updates in ref, schedule RAF on first update, apply all in batch const handleProfileEvent = (event: NostrEvent) => { const encoded = pubkeyToEncoded.get(event.pubkey) if (!encoded) { return } - // Determine the label for this profile - let label: string - try { - const profileData = JSON.parse(event.content || '{}') as { name?: string; display_name?: string; nip05?: string } - const displayName = profileData.display_name || profileData.name || profileData.nip05 - if (displayName) { - label = `@${displayName}` - } else { - // Use fallback npub display if profile has no name - label = getNpubFallbackDisplay(event.pubkey) - } - } catch (error) { - // Use fallback npub display if parsing fails - label = getNpubFallbackDisplay(event.pubkey) - console.warn(`[profile-labels] Error parsing profile for ${encoded.slice(0, 20)}..., using fallback:`, error) - } + // Determine the label for this profile using centralized utility + const displayName = extractProfileDisplayName(event) + const label = displayName ? (displayName.startsWith('@') ? displayName : `@${displayName}`) : getNpubFallbackDisplay(event.pubkey) - // Add to pending updates - currentPendingUpdatesRef.current.set(encoded, label) - - // Schedule batched update if not already scheduled - if (currentRafScheduledRef.current === null) { - currentRafScheduledRef.current = requestAnimationFrame(() => { - // Apply all pending updates in one batch - setProfileLabels(prevLabels => { - const updatedLabels = new Map(prevLabels) - const pendingUpdates = currentPendingUpdatesRef.current - - // Apply all pending updates - for (const [encoded, label] of pendingUpdates.entries()) { - updatedLabels.set(encoded, label) - } - - // Clear pending updates - pendingUpdates.clear() - currentRafScheduledRef.current = null - - return updatedLabels - }) - }) - } + // Add to pending updates and schedule batched application + pendingUpdatesRef.current.set(encoded, label) + scheduleBatchedUpdate() } fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent) - .then((fetchedProfiles) => { - // Ensure any pending batched updates are applied immediately after EOSE + .then(() => { + // After EOSE: apply any remaining pending updates immediately // This ensures all profile updates are applied even if RAF hasn't fired yet - const pendingUpdates = currentPendingUpdatesRef.current - const pendingCount = pendingUpdates.size - - // Cancel any pending RAF since we're applying updates synchronously now - if (currentRafScheduledRef.current !== null) { - cancelAnimationFrame(currentRafScheduledRef.current) - currentRafScheduledRef.current = null - } - - if (pendingCount > 0) { - // Apply all pending updates synchronously - setProfileLabels(prevLabels => { - const updatedLabels = new Map(prevLabels) - for (const [encoded, label] of pendingUpdates.entries()) { - updatedLabels.set(encoded, label) - } - pendingUpdates.clear() - return updatedLabels - }) - } + applyPendingUpdates() }) .catch((error) => { console.error(`[profile-labels] Error fetching profiles:`, error) - // Silently handle fetch errors + // Silently handle fetch errors, but still clear any pending updates + pendingUpdatesRef.current.clear() + if (rafScheduledRef.current !== null) { + cancelAnimationFrame(rafScheduledRef.current) + rafScheduledRef.current = null + } }) - // Cleanup: flush any pending updates before clearing, then cancel RAF + // Cleanup: apply any pending updates before unmount to avoid losing them return () => { - // Use captured refs from effect scope to avoid stale closure issues - const pendingUpdates = currentPendingUpdatesRef.current - const scheduledRaf = currentRafScheduledRef.current - - // Flush any pending updates before cleanup to avoid losing them - if (pendingUpdates.size > 0 && scheduledRaf !== null) { - // Cancel the scheduled RAF and apply updates immediately - cancelAnimationFrame(scheduledRaf) - currentRafScheduledRef.current = null - - // Apply pending updates synchronously before cleanup - setProfileLabels(prevLabels => { - const updatedLabels = new Map(prevLabels) - for (const [encoded, label] of pendingUpdates.entries()) { - updatedLabels.set(encoded, label) - } - return updatedLabels - }) - - pendingUpdates.clear() - } else if (scheduledRaf !== null) { - cancelAnimationFrame(scheduledRaf) - currentRafScheduledRef.current = null - } - // Clear using captured reference to avoid linter warning - pendingUpdates.clear() + applyPendingUpdates() } } - }, [profileData, eventStore, relayPool, initialLabels]) + }, [profileData, eventStore, relayPool, initialLabels, scheduleBatchedUpdate]) return profileLabels } diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 94e8d587..721e58da 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -17,6 +17,10 @@ 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}` } @@ -185,6 +189,7 @@ export function loadCachedProfiles(pubkeys: string[]): Map { * Fetches profile metadata (kind:0) for a list of pubkeys * 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, @@ -198,103 +203,121 @@ export const fetchProfiles = async ( return [] } - const uniquePubkeys = Array.from(new Set(pubkeys)) + const uniquePubkeys = Array.from(new Set(pubkeys)).sort() - // 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) + // Check for in-flight request with same pubkey set (deduplication) + const requestKey = uniquePubkeys.join(',') + const existingRequest = inFlightRequests.get(requestKey) + if (existingRequest) { + return existingRequest } - // 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) - 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]) + // 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) } - // Ensure it's included in the remote relays for this fetch - if (!remoteRelays.includes(purplePagesUrl)) { - remoteRelays.push(purplePagesUrl) + + // 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()) } - } - let eventCount = 0 - const fetchedPubkeys = new Set() - const processEvent = (event: NostrEvent) => { - eventCount++ - 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) + // 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 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 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 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()) + 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()) - await lastValueFrom(merge(local$, remote$).pipe(toArray())) + 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()) - const profiles = Array.from(profilesByPubkey.values()) + await lastValueFrom(merge(local$, remote$).pipe(toArray())) + + 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 + // 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 + })() - 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 - // 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('[fetch-profiles] Failed to fetch profiles:', error) return [] diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 8d9775ad..3dd940e6 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -137,6 +137,8 @@ export function getNpubFallbackDisplay(pubkey: string): string { /** * 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 @@ -145,6 +147,7 @@ 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 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 '' + } + } +} + From 156cf3162515923c854170d47acd349af7db9a0b Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:29:35 +0100 Subject: [PATCH 35/57] feat: add loading states for profile lookups in articles - Extend useProfileLabels to return loading Map alongside labels - Update markdown replacement to show loading indicator for unresolved profiles - Add loading state detection to ResolvedMention and NostrMentionLink components - Add CSS animation for profile-loading class with opacity pulse - Respect prefers-reduced-motion for accessibility --- src/components/NostrMentionLink.tsx | 24 +++++++++-- src/components/ResolvedMention.tsx | 24 +++++++++-- src/hooks/useMarkdownToHTML.ts | 7 ++-- src/hooks/useProfileLabels.ts | 65 +++++++++++++++++++++++++++-- src/styles/components/reader.css | 18 ++++++++ src/utils/nostrUriResolver.tsx | 11 ++++- 6 files changed, 135 insertions(+), 14 deletions(-) diff --git a/src/components/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index 2baf9f1f..ff622322 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -1,8 +1,9 @@ -import React from 'react' +import React, { useMemo } from 'react' import { nip19 } from 'nostr-tools' -import { useEventModel } from 'applesauce-react/hooks' +import { useEventModel, Hooks } from 'applesauce-react/hooks' import { Models, Helpers } from 'applesauce-core' import { getProfileDisplayName } from '../utils/nostrUriResolver' +import { loadCachedProfiles } from '../services/profileService' const { getPubkeyFromDecodeResult } = Helpers @@ -34,9 +35,25 @@ const NostrMentionLink: React.FC = ({ // 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 + // Check cache + const cached = loadCachedProfiles([pubkey]) + if (cached.has(pubkey)) return true + // Check eventStore + const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') + return !!eventStoreProfile + }, [pubkey, eventStore]) + + // Show loading if profile doesn't exist and not in cache/store (for npub/nprofile) + const isLoading = !profile && pubkey && !isInCacheOrStore && + decoded && (decoded.type === 'npub' || decoded.type === 'nprofile') + // If decoding failed, show shortened identifier if (!decoded) { const identifier = nostrUri.replace(/^nostr:/, '') @@ -51,11 +68,12 @@ const NostrMentionLink: React.FC = ({ const renderProfileLink = (pubkey: string) => { const npub = nip19.npubEncode(pubkey) const displayName = getProfileDisplayName(profile, pubkey) + const linkClassName = isLoading ? `${className} profile-loading` : className return ( @{displayName} diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index c393728b..27ff11ea 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -1,9 +1,10 @@ -import React from 'react' +import React, { useMemo } from 'react' import { Link } from 'react-router-dom' -import { useEventModel } from 'applesauce-react/hooks' +import { useEventModel, Hooks } from 'applesauce-react/hooks' import { Models, Helpers } from 'applesauce-core' import { decode, npubEncode } from 'nostr-tools/nip19' import { getProfileDisplayName } from '../utils/nostrUriResolver' +import { loadCachedProfiles } from '../services/profileService' const { getPubkeyFromDecodeResult } = Helpers @@ -20,15 +21,32 @@ 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 + + // Check if profile is in cache or eventStore + const isInCacheOrStore = useMemo(() => { + if (!pubkey) return false + // Check cache + const cached = loadCachedProfiles([pubkey]) + if (cached.has(pubkey)) return true + // Check eventStore + const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') + return !!eventStoreProfile + }, [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/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 6afb4e99..2f8dfbc1 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -22,7 +22,7 @@ export const useMarkdownToHTML = ( const [articleTitles, setArticleTitles] = useState>(new Map()) // Resolve profile labels progressively as profiles load - const profileLabels = useProfileLabels(markdown || '', relayPool) + const { labels: profileLabels, loading: profileLoading } = useProfileLabels(markdown || '', relayPool) // Fetch article titles useEffect(() => { @@ -72,7 +72,8 @@ export const useMarkdownToHTML = ( const processed = replaceNostrUrisInMarkdownWithProfileLabels( markdown, profileLabels, - articleTitles + articleTitles, + profileLoading ) if (isCancelled) return @@ -102,7 +103,7 @@ export const useMarkdownToHTML = ( return () => { isCancelled = true } - }, [markdown, profileLabels, articleTitles]) + }, [markdown, profileLabels, profileLoading, articleTitles]) return { renderedHtml, previewRef, processedMarkdown } } diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index ff6c9f5d..5f9014fc 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -12,9 +12,12 @@ const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers /** * Hook to resolve profile labels from content containing npub/nprofile identifiers - * Returns a Map of encoded identifier -> display name that updates progressively as profiles load + * Returns an object with labels Map and loading Map that updates progressively as profiles load */ -export function useProfileLabels(content: string, relayPool?: RelayPool | null): Map { +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 @@ -71,6 +74,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): }, [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. @@ -116,7 +120,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }, []) // Empty deps: only uses refs which are stable - // Sync state when initialLabels changes (e.g., when content changes) + // 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 @@ -150,6 +154,21 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): return merged } }) + + // Reset loading state when content changes significantly + setProfileLoading(prevLoading => { + const currentEncodedIds = new Set(Array.from(prevLoading.keys())) + const newEncodedIds = new Set(profileData.map(p => p.encoded)) + + const hasDifferentProfiles = + currentEncodedIds.size !== newEncodedIds.size || + !Array.from(newEncodedIds).every(id => currentEncodedIds.has(id)) + + if (hasDifferentProfiles) { + return new Map() + } + return prevLoading + }) }, [initialLabels, profileData]) // Build initial labels: localStorage cache -> eventStore -> fetch from relays @@ -159,6 +178,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): 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) { @@ -179,12 +199,14 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Build labels from localStorage cache and eventStore // initialLabels already has all cached profiles, so we only need to check eventStore const labels = new Map(initialLabels) + const loading = new Map() const pubkeysToFetch: string[] = [] profileData.forEach(({ encoded, pubkey }) => { // Skip if already resolved from initial cache if (labels.has(encoded)) { + loading.set(encoded, false) return } @@ -203,10 +225,13 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): const fallback = getNpubFallbackDisplay(pubkey) labels.set(encoded, fallback) } + loading.set(encoded, 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(encoded, true) } }) @@ -219,6 +244,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): }) setProfileLabels(new Map(labels)) + setProfileLoading(new Map(loading)) // Fetch missing profiles asynchronously with reactive updates if (pubkeysToFetch.length > 0 && relayPool && eventStore) { @@ -246,6 +272,13 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // Add to pending updates and schedule batched application pendingUpdatesRef.current.set(encoded, label) scheduleBatchedUpdate() + + // Clear loading state for this profile when it resolves + setProfileLoading(prevLoading => { + const updated = new Map(prevLoading) + updated.set(encoded, false) + return updated + }) } fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent) @@ -253,6 +286,18 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): // 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 => { + const encoded = pubkeyToEncoded.get(pubkey) + if (encoded) { + updated.set(encoded, false) + } + }) + return updated + }) }) .catch((error) => { console.error(`[profile-labels] Error fetching profiles:`, error) @@ -262,6 +307,18 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): cancelAnimationFrame(rafScheduledRef.current) rafScheduledRef.current = null } + + // Clear loading state on error (show fallback) + setProfileLoading(prevLoading => { + const updated = new Map(prevLoading) + pubkeysToFetch.forEach(pubkey => { + const encoded = pubkeyToEncoded.get(pubkey) + if (encoded) { + updated.set(encoded, false) + } + }) + return updated + }) }) // Cleanup: apply any pending updates before unmount to avoid losing them @@ -271,6 +328,6 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null): } }, [profileData, eventStore, relayPool, initialLabels, scheduleBatchedUpdate]) - return profileLabels + return { labels: profileLabels, loading: profileLoading } } 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/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 3dd940e6..5aacb2d6 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -312,11 +312,13 @@ export function replaceNostrUrisInMarkdownWithTitles( * @param markdown The markdown content to process * @param profileLabels Map of encoded identifier -> display name (e.g., npub1... -> @username) * @param articleTitles Map of naddr -> title for resolved articles + * @param profileLoading Map of encoded identifier -> boolean indicating if profile is loading */ export function replaceNostrUrisInMarkdownWithProfileLabels( markdown: string, profileLabels: Map = new Map(), - articleTitles: Map = new Map() + articleTitles: Map = new Map(), + profileLoading: Map = new Map() ): string { return replaceNostrUrisSafely(markdown, (encoded) => { const link = createNostrLink(encoded) @@ -334,6 +336,13 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( const title = articleTitles.get(encoded)! return `[${title}](${link})` } + + // For npub/nprofile, check if loading and show loading state + if ((decoded.type === 'npub' || decoded.type === 'nprofile') && profileLoading.has(encoded) && profileLoading.get(encoded)) { + const label = getNostrUriLabel(encoded) + // Wrap in span with profile-loading class for CSS styling + return `[${label}](${link})` + } } catch (error) { // Ignore decode errors, fall through to default label } From 7e5972a6e223cd4dd9aa205155f0d72f2b96e983 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:30:37 +0100 Subject: [PATCH 36/57] fix: correct Hooks import path from applesauce-react - Import Hooks from 'applesauce-react' instead of 'applesauce-react/hooks' - Fixes TypeScript errors in ResolvedMention and NostrMentionLink --- src/components/NostrMentionLink.tsx | 3 ++- src/components/ResolvedMention.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index ff622322..e45776c3 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react' import { nip19 } from 'nostr-tools' -import { useEventModel, Hooks } from 'applesauce-react/hooks' +import { useEventModel } from 'applesauce-react/hooks' +import { Hooks } from 'applesauce-react' import { Models, Helpers } from 'applesauce-core' import { getProfileDisplayName } from '../utils/nostrUriResolver' import { loadCachedProfiles } from '../services/profileService' diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index 27ff11ea..929a41e0 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react' import { Link } from 'react-router-dom' -import { useEventModel, Hooks } from 'applesauce-react/hooks' +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' From 51a4b545e900833ea799254c964639100a987c63 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:39:07 +0100 Subject: [PATCH 37/57] debug: add comprehensive logging for profile loading states and article refresh - Add logs to useProfileLabels for loading state tracking - Add logs to markdown processing to track when content is cleared/reprocessed - Add logs to article loader for refresh behavior - Add logs to ResolvedMention and NostrMentionLink for loading detection - Add logs to nostr URI resolver when loading state is shown - All logs prefixed with meaningful tags for easy filtering --- src/components/NostrMentionLink.tsx | 16 +++++++++++++--- src/components/ResolvedMention.tsx | 16 +++++++++++++--- src/hooks/useArticleLoader.ts | 4 ++++ src/hooks/useMarkdownToHTML.ts | 3 +++ src/hooks/useProfileLabels.ts | 11 +++++++++++ src/utils/nostrUriResolver.tsx | 1 + 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/components/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index e45776c3..1c2a83af 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -45,15 +45,25 @@ const NostrMentionLink: React.FC = ({ if (!pubkey) return false // Check cache const cached = loadCachedProfiles([pubkey]) - if (cached.has(pubkey)) return true + if (cached.has(pubkey)) { + console.log(`[nostr-mention-link] ${nostrUri.slice(0, 30)}... in cache`) + return true + } // Check eventStore const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') - return !!eventStoreProfile - }, [pubkey, eventStore]) + const inStore = !!eventStoreProfile + if (inStore) { + console.log(`[nostr-mention-link] ${nostrUri.slice(0, 30)}... in eventStore`) + } + return inStore + }, [pubkey, eventStore, nostrUri]) // Show loading if profile doesn't exist and not in cache/store (for npub/nprofile) const isLoading = !profile && pubkey && !isInCacheOrStore && decoded && (decoded.type === 'npub' || decoded.type === 'nprofile') + if (isLoading) { + console.log(`[nostr-mention-link] ${nostrUri.slice(0, 30)}... isLoading=true (profile=${!!profile}, pubkey=${!!pubkey}, inCacheOrStore=${isInCacheOrStore})`) + } // If decoding failed, show shortened identifier if (!decoded) { diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index 929a41e0..bf5d7481 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -30,14 +30,24 @@ const ResolvedMention: React.FC = ({ encoded }) => { if (!pubkey) return false // Check cache const cached = loadCachedProfiles([pubkey]) - if (cached.has(pubkey)) return true + if (cached.has(pubkey)) { + console.log(`[resolved-mention] ${encoded?.slice(0, 16)}... in cache`) + return true + } // Check eventStore const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') - return !!eventStoreProfile - }, [pubkey, eventStore]) + const inStore = !!eventStoreProfile + if (inStore) { + console.log(`[resolved-mention] ${encoded?.slice(0, 16)}... in eventStore`) + } + return inStore + }, [pubkey, eventStore, encoded]) // Show loading if profile doesn't exist and not in cache/store const isLoading = !profile && pubkey && !isInCacheOrStore + if (isLoading && encoded) { + console.log(`[resolved-mention] ${encoded.slice(0, 16)}... isLoading=true (profile=${!!profile}, pubkey=${!!pubkey}, inCacheOrStore=${isInCacheOrStore})`) + } const display = pubkey ? getProfileDisplayName(profile, pubkey) : encoded const npub = pubkey ? npubEncode(pubkey) : undefined diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 49a8abe2..bba59af4 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -264,8 +264,10 @@ export function useArticleLoader({ const loadArticle = async () => { const requestId = ++currentRequestIdRef.current + console.log(`[article-loader] Starting loadArticle requestId=${requestId} for naddr=${naddr.slice(0, 20)}...`) if (!mountedRef.current) { + console.log(`[article-loader] Aborted loadArticle requestId=${requestId} - not mounted`) return } @@ -282,6 +284,7 @@ export function useArticleLoader({ // At this point, we've checked EventStore and cache - neither had content // Only show loading skeleton if we also don't have preview data if (previewData) { + console.log(`[article-loader] requestId=${requestId} has previewData, showing immediately`) // If we have preview data from navigation, show it immediately (no skeleton!) setCurrentTitle(previewData.title) setReaderContent({ @@ -298,6 +301,7 @@ export function useArticleLoader({ // Preloading again would be redundant and could cause unnecessary network requests } else { // No cache, no EventStore, no preview data - need to load from relays + console.log(`[article-loader] requestId=${requestId} no previewData, setting loading=true, content=undefined`) setReaderLoading(true) setReaderContent(undefined) } diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 2f8dfbc1..d5f85ace 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -56,6 +56,8 @@ export const useMarkdownToHTML = ( // Process markdown with progressive profile labels and article titles useEffect(() => { + console.log(`[markdown-to-html] Processing markdown, profileLabels=${profileLabels.size}, profileLoading=${profileLoading.size}, articleTitles=${articleTitles.size}`) + console.log(`[markdown-to-html] Clearing rendered HTML and processed markdown`) // Always clear previous render immediately to avoid showing stale content while processing setRenderedHtml('') setProcessedMarkdown('') @@ -78,6 +80,7 @@ export const useMarkdownToHTML = ( if (isCancelled) return + console.log(`[markdown-to-html] Processed markdown, loading states:`, Array.from(profileLoading.entries()).filter(([_, l]) => l).map(([e, _]) => e.slice(0, 16) + '...')) setProcessedMarkdown(processed) } catch (error) { console.error(`[markdown-to-html] Error processing markdown:`, error) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 5f9014fc..0a574ab5 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -207,6 +207,7 @@ export function useProfileLabels( // Skip if already resolved from initial cache if (labels.has(encoded)) { loading.set(encoded, false) + console.log(`[profile-labels-loading] ${encoded.slice(0, 16)}... in cache, not loading`) return } @@ -226,12 +227,14 @@ export function useProfileLabels( labels.set(encoded, fallback) } loading.set(encoded, false) + console.log(`[profile-labels-loading] ${encoded.slice(0, 16)}... in eventStore, not loading`) } 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(encoded, true) + console.log(`[profile-labels-loading] ${encoded.slice(0, 16)}... not found, SET LOADING=true`) } }) @@ -245,9 +248,11 @@ export function useProfileLabels( setProfileLabels(new Map(labels)) setProfileLoading(new Map(loading)) + console.log(`[profile-labels-loading] Initial loading state:`, Array.from(loading.entries()).map(([e, l]) => `${e.slice(0, 16)}...=${l}`)) // Fetch missing profiles asynchronously with reactive updates if (pubkeysToFetch.length > 0 && relayPool && eventStore) { + console.log(`[profile-labels-loading] Starting fetch for ${pubkeysToFetch.length} profiles:`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) const pubkeysToFetchSet = new Set(pubkeysToFetch) // Create a map from pubkey to encoded identifier for quick lookup const pubkeyToEncoded = new Map() @@ -274,6 +279,7 @@ export function useProfileLabels( scheduleBatchedUpdate() // Clear loading state for this profile when it resolves + console.log(`[profile-labels-loading] Profile resolved for ${encoded.slice(0, 16)}..., CLEARING LOADING`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) updated.set(encoded, false) @@ -288,12 +294,17 @@ export function useProfileLabels( applyPendingUpdates() // Clear loading state for all fetched profiles + console.log(`[profile-labels-loading] Fetch complete, clearing loading for all ${pubkeysToFetch.length} profiles`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) pubkeysToFetch.forEach(pubkey => { const encoded = pubkeyToEncoded.get(pubkey) if (encoded) { + const wasLoading = updated.get(encoded) updated.set(encoded, false) + if (wasLoading) { + console.log(`[profile-labels-loading] ${encoded.slice(0, 16)}... CLEARED loading after fetch complete`) + } } }) return updated diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 5aacb2d6..c41d5db5 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -340,6 +340,7 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( // For npub/nprofile, check if loading and show loading state if ((decoded.type === 'npub' || decoded.type === 'nprofile') && profileLoading.has(encoded) && profileLoading.get(encoded)) { const label = getNostrUriLabel(encoded) + console.log(`[nostr-uri-resolve] ${encoded.slice(0, 16)}... is LOADING, showing loading state`) // Wrap in span with profile-loading class for CSS styling return `[${label}](${link})` } From 3b2732681de9781f7c9b95c933325be1e604ffe9 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:39:24 +0100 Subject: [PATCH 38/57] fix: remove unused variables in debug log filter --- src/hooks/useMarkdownToHTML.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index d5f85ace..56b4d56f 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -80,7 +80,7 @@ export const useMarkdownToHTML = ( if (isCancelled) return - console.log(`[markdown-to-html] Processed markdown, loading states:`, Array.from(profileLoading.entries()).filter(([_, l]) => l).map(([e, _]) => e.slice(0, 16) + '...')) + console.log(`[markdown-to-html] Processed markdown, loading states:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([e]) => e.slice(0, 16) + '...')) setProcessedMarkdown(processed) } catch (error) { console.error(`[markdown-to-html] Error processing markdown:`, error) From 27dde5afa2364628f7bfa5372fe977ca6452f12f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:40:08 +0100 Subject: [PATCH 39/57] debug: add common prefix [profile-loading-debug] to all debug logs All profile loading related debug logs now have the common prefix for easy filtering in console. --- src/components/NostrMentionLink.tsx | 6 +++--- src/components/ResolvedMention.tsx | 6 +++--- src/hooks/useArticleLoader.ts | 8 ++++---- src/hooks/useMarkdownToHTML.ts | 6 +++--- src/hooks/useProfileLabels.ts | 16 ++++++++-------- src/utils/nostrUriResolver.tsx | 2 +- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/components/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index 1c2a83af..f7788f56 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -46,14 +46,14 @@ const NostrMentionLink: React.FC = ({ // Check cache const cached = loadCachedProfiles([pubkey]) if (cached.has(pubkey)) { - console.log(`[nostr-mention-link] ${nostrUri.slice(0, 30)}... in cache`) + console.log(`[profile-loading-debug][nostr-mention-link] ${nostrUri.slice(0, 30)}... in cache`) return true } // Check eventStore const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') const inStore = !!eventStoreProfile if (inStore) { - console.log(`[nostr-mention-link] ${nostrUri.slice(0, 30)}... in eventStore`) + console.log(`[profile-loading-debug][nostr-mention-link] ${nostrUri.slice(0, 30)}... in eventStore`) } return inStore }, [pubkey, eventStore, nostrUri]) @@ -62,7 +62,7 @@ const NostrMentionLink: React.FC = ({ const isLoading = !profile && pubkey && !isInCacheOrStore && decoded && (decoded.type === 'npub' || decoded.type === 'nprofile') if (isLoading) { - console.log(`[nostr-mention-link] ${nostrUri.slice(0, 30)}... isLoading=true (profile=${!!profile}, pubkey=${!!pubkey}, inCacheOrStore=${isInCacheOrStore})`) + console.log(`[profile-loading-debug][nostr-mention-link] ${nostrUri.slice(0, 30)}... isLoading=true (profile=${!!profile}, pubkey=${!!pubkey}, inCacheOrStore=${isInCacheOrStore})`) } // If decoding failed, show shortened identifier diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index bf5d7481..d8fee2b3 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -31,14 +31,14 @@ const ResolvedMention: React.FC = ({ encoded }) => { // Check cache const cached = loadCachedProfiles([pubkey]) if (cached.has(pubkey)) { - console.log(`[resolved-mention] ${encoded?.slice(0, 16)}... in cache`) + console.log(`[profile-loading-debug][resolved-mention] ${encoded?.slice(0, 16)}... in cache`) return true } // Check eventStore const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') const inStore = !!eventStoreProfile if (inStore) { - console.log(`[resolved-mention] ${encoded?.slice(0, 16)}... in eventStore`) + console.log(`[profile-loading-debug][resolved-mention] ${encoded?.slice(0, 16)}... in eventStore`) } return inStore }, [pubkey, eventStore, encoded]) @@ -46,7 +46,7 @@ const ResolvedMention: React.FC = ({ encoded }) => { // Show loading if profile doesn't exist and not in cache/store const isLoading = !profile && pubkey && !isInCacheOrStore if (isLoading && encoded) { - console.log(`[resolved-mention] ${encoded.slice(0, 16)}... isLoading=true (profile=${!!profile}, pubkey=${!!pubkey}, inCacheOrStore=${isInCacheOrStore})`) + console.log(`[profile-loading-debug][resolved-mention] ${encoded.slice(0, 16)}... isLoading=true (profile=${!!profile}, pubkey=${!!pubkey}, inCacheOrStore=${isInCacheOrStore})`) } const display = pubkey ? getProfileDisplayName(profile, pubkey) : encoded diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index bba59af4..a5e090d3 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -264,10 +264,10 @@ export function useArticleLoader({ const loadArticle = async () => { const requestId = ++currentRequestIdRef.current - console.log(`[article-loader] Starting loadArticle requestId=${requestId} for naddr=${naddr.slice(0, 20)}...`) + console.log(`[profile-loading-debug][article-loader] Starting loadArticle requestId=${requestId} for naddr=${naddr.slice(0, 20)}...`) if (!mountedRef.current) { - console.log(`[article-loader] Aborted loadArticle requestId=${requestId} - not mounted`) + console.log(`[profile-loading-debug][article-loader] Aborted loadArticle requestId=${requestId} - not mounted`) return } @@ -284,7 +284,7 @@ export function useArticleLoader({ // At this point, we've checked EventStore and cache - neither had content // Only show loading skeleton if we also don't have preview data if (previewData) { - console.log(`[article-loader] requestId=${requestId} has previewData, showing immediately`) + console.log(`[profile-loading-debug][article-loader] requestId=${requestId} has previewData, showing immediately`) // If we have preview data from navigation, show it immediately (no skeleton!) setCurrentTitle(previewData.title) setReaderContent({ @@ -301,7 +301,7 @@ export function useArticleLoader({ // Preloading again would be redundant and could cause unnecessary network requests } else { // No cache, no EventStore, no preview data - need to load from relays - console.log(`[article-loader] requestId=${requestId} no previewData, setting loading=true, content=undefined`) + console.log(`[profile-loading-debug][article-loader] requestId=${requestId} no previewData, setting loading=true, content=undefined`) setReaderLoading(true) setReaderContent(undefined) } diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 56b4d56f..b9e9d0d0 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -56,8 +56,8 @@ export const useMarkdownToHTML = ( // Process markdown with progressive profile labels and article titles useEffect(() => { - console.log(`[markdown-to-html] Processing markdown, profileLabels=${profileLabels.size}, profileLoading=${profileLoading.size}, articleTitles=${articleTitles.size}`) - console.log(`[markdown-to-html] Clearing rendered HTML and processed markdown`) + console.log(`[profile-loading-debug][markdown-to-html] Processing markdown, profileLabels=${profileLabels.size}, profileLoading=${profileLoading.size}, articleTitles=${articleTitles.size}`) + console.log(`[profile-loading-debug][markdown-to-html] Clearing rendered HTML and processed markdown`) // Always clear previous render immediately to avoid showing stale content while processing setRenderedHtml('') setProcessedMarkdown('') @@ -80,7 +80,7 @@ export const useMarkdownToHTML = ( if (isCancelled) return - console.log(`[markdown-to-html] Processed markdown, loading states:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([e]) => e.slice(0, 16) + '...')) + console.log(`[profile-loading-debug][markdown-to-html] Processed markdown, loading states:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([e]) => e.slice(0, 16) + '...')) setProcessedMarkdown(processed) } catch (error) { console.error(`[markdown-to-html] Error processing markdown:`, error) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 0a574ab5..64cb2ea5 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -207,7 +207,7 @@ export function useProfileLabels( // Skip if already resolved from initial cache if (labels.has(encoded)) { loading.set(encoded, false) - console.log(`[profile-labels-loading] ${encoded.slice(0, 16)}... in cache, not loading`) + console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... in cache, not loading`) return } @@ -227,14 +227,14 @@ export function useProfileLabels( labels.set(encoded, fallback) } loading.set(encoded, false) - console.log(`[profile-labels-loading] ${encoded.slice(0, 16)}... in eventStore, not loading`) + console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... in eventStore, not loading`) } 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(encoded, true) - console.log(`[profile-labels-loading] ${encoded.slice(0, 16)}... not found, SET LOADING=true`) + console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... not found, SET LOADING=true`) } }) @@ -248,11 +248,11 @@ export function useProfileLabels( setProfileLabels(new Map(labels)) setProfileLoading(new Map(loading)) - console.log(`[profile-labels-loading] Initial loading state:`, Array.from(loading.entries()).map(([e, l]) => `${e.slice(0, 16)}...=${l}`)) + console.log(`[profile-loading-debug][profile-labels-loading] Initial loading state:`, Array.from(loading.entries()).map(([e, l]) => `${e.slice(0, 16)}...=${l}`)) // Fetch missing profiles asynchronously with reactive updates if (pubkeysToFetch.length > 0 && relayPool && eventStore) { - console.log(`[profile-labels-loading] Starting fetch for ${pubkeysToFetch.length} profiles:`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) + console.log(`[profile-loading-debug][profile-labels-loading] Starting fetch for ${pubkeysToFetch.length} profiles:`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) const pubkeysToFetchSet = new Set(pubkeysToFetch) // Create a map from pubkey to encoded identifier for quick lookup const pubkeyToEncoded = new Map() @@ -279,7 +279,7 @@ export function useProfileLabels( scheduleBatchedUpdate() // Clear loading state for this profile when it resolves - console.log(`[profile-labels-loading] Profile resolved for ${encoded.slice(0, 16)}..., CLEARING LOADING`) + console.log(`[profile-loading-debug][profile-labels-loading] Profile resolved for ${encoded.slice(0, 16)}..., CLEARING LOADING`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) updated.set(encoded, false) @@ -294,7 +294,7 @@ export function useProfileLabels( applyPendingUpdates() // Clear loading state for all fetched profiles - console.log(`[profile-labels-loading] Fetch complete, clearing loading for all ${pubkeysToFetch.length} profiles`) + console.log(`[profile-loading-debug][profile-labels-loading] Fetch complete, clearing loading for all ${pubkeysToFetch.length} profiles`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) pubkeysToFetch.forEach(pubkey => { @@ -303,7 +303,7 @@ export function useProfileLabels( const wasLoading = updated.get(encoded) updated.set(encoded, false) if (wasLoading) { - console.log(`[profile-labels-loading] ${encoded.slice(0, 16)}... CLEARED loading after fetch complete`) + console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... CLEARED loading after fetch complete`) } } }) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index c41d5db5..3938220d 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -340,7 +340,7 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( // For npub/nprofile, check if loading and show loading state if ((decoded.type === 'npub' || decoded.type === 'nprofile') && profileLoading.has(encoded) && profileLoading.get(encoded)) { const label = getNostrUriLabel(encoded) - console.log(`[nostr-uri-resolve] ${encoded.slice(0, 16)}... is LOADING, showing loading state`) + console.log(`[profile-loading-debug][nostr-uri-resolve] ${encoded.slice(0, 16)}... is LOADING, showing loading state`) // Wrap in span with profile-loading class for CSS styling return `[${label}](${link})` } From 7ec87b66d8d29cb9493dcb78de691a4d07088c87 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:42:03 +0100 Subject: [PATCH 40/57] fix: reduce markdown reprocessing to prevent flicker - Use stable string keys instead of Map objects as dependencies - Only clear rendered HTML when markdown content actually changes - Use refs to access latest Map values without triggering re-renders - Prevents excessive markdown reprocessing on every profile update - Should significantly reduce screen flickering during profile resolution --- src/hooks/useMarkdownToHTML.ts | 83 +++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index b9e9d0d0..995bb31c 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, useMemo } from 'react' import { RelayPool } from 'applesauce-relay' import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '../utils/nostrUriResolver' import { fetchArticleTitles } from '../services/articleTitleResolver' @@ -23,6 +23,35 @@ export const useMarkdownToHTML = ( // 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(() => { + return Array.from(profileLabels.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|') + }, [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) + + useEffect(() => { + profileLabelsRef.current = profileLabels + profileLoadingRef.current = profileLoading + articleTitlesRef.current = articleTitles + }, [profileLabels, profileLoading, articleTitles]) // Fetch article titles useEffect(() => { @@ -54,15 +83,27 @@ export const useMarkdownToHTML = ( return () => { isCancelled = true } }, [markdown, relayPool]) - // Process markdown with progressive profile labels and article titles + // Track previous markdown and processed state to detect actual content changes + const previousMarkdownRef = useRef(markdown) + const processedMarkdownRef = useRef(processedMarkdown) + useEffect(() => { - console.log(`[profile-loading-debug][markdown-to-html] Processing markdown, profileLabels=${profileLabels.size}, profileLoading=${profileLoading.size}, articleTitles=${articleTitles.size}`) - console.log(`[profile-loading-debug][markdown-to-html] Clearing rendered HTML and processed markdown`) - // Always clear previous render immediately to avoid showing stale content while processing - setRenderedHtml('') - setProcessedMarkdown('') + 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(() => { + const labelsSize = profileLabelsRef.current.size + const loadingSize = profileLoadingRef.current.size + const titlesSize = articleTitlesRef.current.size + console.log(`[profile-loading-debug][markdown-to-html] Processing markdown, profileLabels=${labelsSize}, profileLoading=${loadingSize}, articleTitles=${titlesSize}`) if (!markdown) { + setRenderedHtml('') + setProcessedMarkdown('') + previousMarkdownRef.current = markdown + processedMarkdownRef.current = '' return } @@ -71,21 +112,27 @@ export const useMarkdownToHTML = ( 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, - profileLabels, - articleTitles, - profileLoading + profileLabelsRef.current, + articleTitlesRef.current, + profileLoadingRef.current ) if (isCancelled) return - console.log(`[profile-loading-debug][markdown-to-html] Processed markdown, loading states:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([e]) => e.slice(0, 16) + '...')) + const loadingStates = Array.from(profileLoadingRef.current.entries()) + .filter(([, l]) => l) + .map(([e]) => e.slice(0, 16) + '...') + console.log(`[profile-loading-debug][markdown-to-html] Processed markdown, loading states:`, loadingStates) setProcessedMarkdown(processed) + processedMarkdownRef.current = processed } catch (error) { console.error(`[markdown-to-html] Error processing markdown:`, error) if (!isCancelled) { setProcessedMarkdown(markdown) // Fallback to original + processedMarkdownRef.current = markdown } } @@ -101,12 +148,24 @@ export const useMarkdownToHTML = ( 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) { + console.log(`[profile-loading-debug][markdown-to-html] Clearing rendered HTML and processed markdown (markdown changed: ${isMarkdownChange})`) + setRenderedHtml('') + setProcessedMarkdown('') + processedMarkdownRef.current = '' + } + processMarkdown() return () => { isCancelled = true } - }, [markdown, profileLabels, profileLoading, articleTitles]) + }, [markdown, profileLabelsKey, profileLoadingKey, articleTitlesKey]) return { renderedHtml, previewRef, processedMarkdown } } From 8f1288b1a254019076bfdf24d988d175ca333fca Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:47:43 +0100 Subject: [PATCH 41/57] debug: add detailed logs to nostr URI resolver for loading state detection - Log when replacement function is called with Map sizes - Log all loading keys in the Map - Log detailed info for each npub/nprofile found: type, hasLoading, isLoading - Will help identify if encoded IDs don't match or loading state isn't detected --- src/utils/nostrUriResolver.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 3938220d..91255188 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -320,6 +320,9 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( articleTitles: Map = new Map(), profileLoading: Map = new Map() ): string { + console.log(`[profile-loading-debug][nostr-uri-resolve] Processing markdown, profileLabels=${profileLabels.size}, profileLoading=${profileLoading.size}`) + console.log(`[profile-loading-debug][nostr-uri-resolve] Loading keys:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([k]) => k.slice(0, 16) + '...')) + return replaceNostrUrisSafely(markdown, (encoded) => { const link = createNostrLink(encoded) @@ -338,11 +341,17 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( } // For npub/nprofile, check if loading and show loading state - if ((decoded.type === 'npub' || decoded.type === 'nprofile') && profileLoading.has(encoded) && profileLoading.get(encoded)) { - const label = getNostrUriLabel(encoded) - console.log(`[profile-loading-debug][nostr-uri-resolve] ${encoded.slice(0, 16)}... is LOADING, showing loading state`) - // Wrap in span with profile-loading class for CSS styling - return `[${label}](${link})` + if (decoded.type === 'npub' || decoded.type === 'nprofile') { + const hasLoading = profileLoading.has(encoded) + const isLoading = profileLoading.get(encoded) + console.log(`[profile-loading-debug][nostr-uri-resolve] ${encoded.slice(0, 16)}... type=${decoded.type}, hasLoading=${hasLoading}, isLoading=${isLoading}`) + + if (hasLoading && isLoading) { + const label = getNostrUriLabel(encoded) + console.log(`[profile-loading-debug][nostr-uri-resolve] ${encoded.slice(0, 16)}... is LOADING, showing loading state`) + // Wrap in span with profile-loading class for CSS styling + return `[${label}](${link})` + } } } catch (error) { // Ignore decode errors, fall through to default label From 4b03f32d21a05b14b69e5c70949d2ccc1ecc2529 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:48:52 +0100 Subject: [PATCH 42/57] debug: add logs to compare encoded format between markdown extraction and Map keys - Log the exact encoded value being processed - Log sample of Map keys for comparison - Will help identify format mismatch between markdown and Map storage --- src/utils/nostrUriResolver.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 91255188..cd2ab2ba 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -324,6 +324,9 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( console.log(`[profile-loading-debug][nostr-uri-resolve] Loading keys:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([k]) => k.slice(0, 16) + '...')) return replaceNostrUrisSafely(markdown, (encoded) => { + console.log(`[profile-loading-debug][nostr-uri-resolve] Processing encoded="${encoded.slice(0, 30)}..."`) + console.log(`[profile-loading-debug][nostr-uri-resolve] Map keys sample:`, Array.from(profileLoading.keys()).slice(0, 3).map(k => k.slice(0, 30) + '...')) + const link = createNostrLink(encoded) // Check if we have a resolved profile name From f57a4d4f1b6ac45d38effca8f7eb469e28e33e65 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:50:05 +0100 Subject: [PATCH 43/57] debug: add key mismatch detection to identify format differences - Check if encoded value from regex matches Map keys - Log full comparison when mismatch detected - Will help identify if regex capture group format differs from Map storage format --- src/utils/nostrUriResolver.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index cd2ab2ba..06d0dc43 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -324,9 +324,6 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( console.log(`[profile-loading-debug][nostr-uri-resolve] Loading keys:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([k]) => k.slice(0, 16) + '...')) return replaceNostrUrisSafely(markdown, (encoded) => { - console.log(`[profile-loading-debug][nostr-uri-resolve] Processing encoded="${encoded.slice(0, 30)}..."`) - console.log(`[profile-loading-debug][nostr-uri-resolve] Map keys sample:`, Array.from(profileLoading.keys()).slice(0, 3).map(k => k.slice(0, 30) + '...')) - const link = createNostrLink(encoded) // Check if we have a resolved profile name @@ -347,7 +344,17 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( if (decoded.type === 'npub' || decoded.type === 'nprofile') { const hasLoading = profileLoading.has(encoded) const isLoading = profileLoading.get(encoded) - console.log(`[profile-loading-debug][nostr-uri-resolve] ${encoded.slice(0, 16)}... type=${decoded.type}, hasLoading=${hasLoading}, isLoading=${isLoading}`) + + // Debug: Check if there's a key mismatch + if (!hasLoading && profileLoading.size > 0) { + // Check if there's a similar key (for debugging) + const matchingKey = Array.from(profileLoading.keys()).find(k => k.includes(encoded.slice(0, 20)) || encoded.includes(k.slice(0, 20))) + if (matchingKey) { + console.log(`[profile-loading-debug][nostr-uri-resolve] KEY MISMATCH: encoded="${encoded.slice(0, 50)}..." vs Map key="${matchingKey.slice(0, 50)}..."`) + console.log(`[profile-loading-debug][nostr-uri-resolve] Full encoded length=${encoded.length}, Full key length=${matchingKey.length}`) + console.log(`[profile-loading-debug][nostr-uri-resolve] encoded === key? ${encoded === matchingKey}`) + } + } if (hasLoading && isLoading) { const label = getNostrUriLabel(encoded) From d41cbb5305dfd418ba1621f4e4fd81bef805d07c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:52:49 +0100 Subject: [PATCH 44/57] refactor: use pubkey (hex) as Map key instead of encoded nprofile/npub strings - Changed useProfileLabels to use pubkey as key for canonical identification - Updated replaceNostrUrisInMarkdownWithProfileLabels to extract pubkey and use it for lookup - This fixes the key mismatch issue where different nprofile encodings map to the same pubkey - Multiple nprofile strings can refer to the same pubkey (different relay hints) - Using pubkey as key is the Nostr standard way to identify profiles --- src/hooks/useProfileLabels.ts | 97 +++++++++++++++------------------- src/utils/nostrUriResolver.tsx | 34 +++++------- 2 files changed, 54 insertions(+), 77 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 64cb2ea5..68bb8c11 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -45,6 +45,7 @@ export function useProfileLabels( }, [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() @@ -54,18 +55,18 @@ export function useProfileLabels( const cachedProfiles = loadCachedProfiles(allPubkeys) const labels = new Map() - profileData.forEach(({ encoded, pubkey }) => { + profileData.forEach(({ pubkey }) => { const cachedProfile = cachedProfiles.get(pubkey) if (cachedProfile) { const displayName = extractProfileDisplayName(cachedProfile) if (displayName) { // Only add @ prefix if we have a real name, otherwise use fallback format directly const label = displayName.startsWith('@') ? displayName : `@${displayName}` - labels.set(encoded, label) + labels.set(pubkey, label) } else { // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) - labels.set(encoded, fallback) + labels.set(pubkey, fallback) } } }) @@ -78,6 +79,7 @@ export function useProfileLabels( // 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) @@ -125,13 +127,13 @@ export function useProfileLabels( useEffect(() => { // Use a functional update to access current state without including it in dependencies setProfileLabels(prevLabels => { - const currentEncodedIds = new Set(Array.from(prevLabels.keys())) - const newEncodedIds = new Set(profileData.map(p => p.encoded)) + 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 = - currentEncodedIds.size !== newEncodedIds.size || - !Array.from(newEncodedIds).every(id => currentEncodedIds.has(id)) + currentPubkeys.size !== newPubkeys.size || + !Array.from(newPubkeys).every(pk => currentPubkeys.has(pk)) if (hasDifferentProfiles) { // Clear pending updates and cancel RAF for old profiles @@ -145,10 +147,10 @@ export function useProfileLabels( } else { // Same profiles, merge initial labels with existing state (initial labels take precedence for missing ones) const merged = new Map(prevLabels) - for (const [encoded, label] of initialLabels.entries()) { + for (const [pubkey, label] of initialLabels.entries()) { // Only update if missing or if initial label has a better value (not a fallback) - if (!merged.has(encoded) || (!prevLabels.get(encoded)?.startsWith('@') && label.startsWith('@'))) { - merged.set(encoded, label) + if (!merged.has(pubkey) || (!prevLabels.get(pubkey)?.startsWith('@') && label.startsWith('@'))) { + merged.set(pubkey, label) } } return merged @@ -157,12 +159,12 @@ export function useProfileLabels( // Reset loading state when content changes significantly setProfileLoading(prevLoading => { - const currentEncodedIds = new Set(Array.from(prevLoading.keys())) - const newEncodedIds = new Set(profileData.map(p => p.encoded)) + const currentPubkeys = new Set(Array.from(prevLoading.keys())) + const newPubkeys = new Set(profileData.map(p => p.pubkey)) const hasDifferentProfiles = - currentEncodedIds.size !== newEncodedIds.size || - !Array.from(newEncodedIds).every(id => currentEncodedIds.has(id)) + currentPubkeys.size !== newPubkeys.size || + !Array.from(newPubkeys).every(pk => currentPubkeys.has(pk)) if (hasDifferentProfiles) { return new Map() @@ -198,16 +200,17 @@ export function useProfileLabels( // 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(({ encoded, pubkey }) => { + profileData.forEach(({ pubkey }) => { // Skip if already resolved from initial cache - if (labels.has(encoded)) { - loading.set(encoded, false) - console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... in cache, not loading`) + if (labels.has(pubkey)) { + loading.set(pubkey, false) + console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... in cache, not loading`) return } @@ -220,69 +223,59 @@ export function useProfileLabels( if (displayName) { // Only add @ prefix if we have a real name, otherwise use fallback format directly const label = displayName.startsWith('@') ? displayName : `@${displayName}` - labels.set(encoded, label) + labels.set(pubkey, label) } else { // Use fallback npub display if profile has no name const fallback = getNpubFallbackDisplay(pubkey) - labels.set(encoded, fallback) + labels.set(pubkey, fallback) } - loading.set(encoded, false) - console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... in eventStore, not loading`) + loading.set(pubkey, false) + console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... in eventStore, not loading`) } 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(encoded, true) - console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... not found, SET LOADING=true`) + loading.set(pubkey, true) + console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... not found, SET LOADING=true`) } }) // Set fallback labels for profiles that weren't found - profileData.forEach(({ encoded, pubkey }) => { - if (!labels.has(encoded)) { + profileData.forEach(({ pubkey }) => { + if (!labels.has(pubkey)) { const fallback = getNpubFallbackDisplay(pubkey) - labels.set(encoded, fallback) + labels.set(pubkey, fallback) } }) setProfileLabels(new Map(labels)) setProfileLoading(new Map(loading)) - console.log(`[profile-loading-debug][profile-labels-loading] Initial loading state:`, Array.from(loading.entries()).map(([e, l]) => `${e.slice(0, 16)}...=${l}`)) + console.log(`[profile-loading-debug][profile-labels-loading] Initial loading state:`, Array.from(loading.entries()).map(([pk, l]) => `${pk.slice(0, 16)}...=${l}`)) // Fetch missing profiles asynchronously with reactive updates if (pubkeysToFetch.length > 0 && relayPool && eventStore) { console.log(`[profile-loading-debug][profile-labels-loading] Starting fetch for ${pubkeysToFetch.length} profiles:`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) - const pubkeysToFetchSet = new Set(pubkeysToFetch) - // Create a map from pubkey to encoded identifier for quick lookup - const pubkeyToEncoded = new Map() - profileData.forEach(({ encoded, pubkey }) => { - if (pubkeysToFetchSet.has(pubkey)) { - pubkeyToEncoded.set(pubkey, encoded) - } - }) // Reactive callback: collects profile updates and batches them via RAF to prevent flicker // Strategy: Collect updates in ref, schedule RAF on first update, apply all in batch const handleProfileEvent = (event: NostrEvent) => { - const encoded = pubkeyToEncoded.get(event.pubkey) - if (!encoded) { - return - } + // Use pubkey directly as the key + const pubkey = event.pubkey // Determine the label for this profile using centralized utility const displayName = extractProfileDisplayName(event) - const label = displayName ? (displayName.startsWith('@') ? displayName : `@${displayName}`) : getNpubFallbackDisplay(event.pubkey) + const label = displayName ? (displayName.startsWith('@') ? displayName : `@${displayName}`) : getNpubFallbackDisplay(pubkey) // Add to pending updates and schedule batched application - pendingUpdatesRef.current.set(encoded, label) + pendingUpdatesRef.current.set(pubkey, label) scheduleBatchedUpdate() // Clear loading state for this profile when it resolves - console.log(`[profile-loading-debug][profile-labels-loading] Profile resolved for ${encoded.slice(0, 16)}..., CLEARING LOADING`) + console.log(`[profile-loading-debug][profile-labels-loading] Profile resolved for ${pubkey.slice(0, 16)}..., CLEARING LOADING`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) - updated.set(encoded, false) + updated.set(pubkey, false) return updated }) } @@ -298,13 +291,10 @@ export function useProfileLabels( setProfileLoading(prevLoading => { const updated = new Map(prevLoading) pubkeysToFetch.forEach(pubkey => { - const encoded = pubkeyToEncoded.get(pubkey) - if (encoded) { - const wasLoading = updated.get(encoded) - updated.set(encoded, false) - if (wasLoading) { - console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... CLEARED loading after fetch complete`) - } + const wasLoading = updated.get(pubkey) + updated.set(pubkey, false) + if (wasLoading) { + console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... CLEARED loading after fetch complete`) } }) return updated @@ -323,10 +313,7 @@ export function useProfileLabels( setProfileLoading(prevLoading => { const updated = new Map(prevLoading) pubkeysToFetch.forEach(pubkey => { - const encoded = pubkeyToEncoded.get(pubkey) - if (encoded) { - updated.set(encoded, false) - } + updated.set(pubkey, false) }) return updated }) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 06d0dc43..e11b7446 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -310,9 +310,9 @@ export function replaceNostrUrisInMarkdownWithTitles( * This converts: nostr:npub1... to [@username](link) and nostr:naddr1... to [Article Title](link) * Labels update progressively as profiles load * @param markdown The markdown content to process - * @param profileLabels Map of encoded identifier -> display name (e.g., npub1... -> @username) + * @param profileLabels Map of pubkey (hex) -> display name (e.g., pubkey -> @username) * @param articleTitles Map of naddr -> title for resolved articles - * @param profileLoading Map of encoded identifier -> boolean indicating if profile is loading + * @param profileLoading Map of pubkey (hex) -> boolean indicating if profile is loading */ export function replaceNostrUrisInMarkdownWithProfileLabels( markdown: string, @@ -326,12 +326,6 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( return replaceNostrUrisSafely(markdown, (encoded) => { const link = createNostrLink(encoded) - // Check if we have a resolved profile name - if (profileLabels.has(encoded)) { - const displayName = profileLabels.get(encoded)! - return `[${displayName}](${link})` - } - // For articles, use the resolved title if available try { const decoded = decode(encoded) @@ -340,25 +334,21 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( return `[${title}](${link})` } - // For npub/nprofile, check if loading and show loading state + // For npub/nprofile, extract pubkey and use it as the lookup key if (decoded.type === 'npub' || decoded.type === 'nprofile') { - const hasLoading = profileLoading.has(encoded) - const isLoading = profileLoading.get(encoded) + const pubkey = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey - // Debug: Check if there's a key mismatch - if (!hasLoading && profileLoading.size > 0) { - // Check if there's a similar key (for debugging) - const matchingKey = Array.from(profileLoading.keys()).find(k => k.includes(encoded.slice(0, 20)) || encoded.includes(k.slice(0, 20))) - if (matchingKey) { - console.log(`[profile-loading-debug][nostr-uri-resolve] KEY MISMATCH: encoded="${encoded.slice(0, 50)}..." vs Map key="${matchingKey.slice(0, 50)}..."`) - console.log(`[profile-loading-debug][nostr-uri-resolve] Full encoded length=${encoded.length}, Full key length=${matchingKey.length}`) - console.log(`[profile-loading-debug][nostr-uri-resolve] encoded === key? ${encoded === matchingKey}`) - } + // Check if we have a resolved profile name using pubkey as key + if (profileLabels.has(pubkey)) { + const displayName = profileLabels.get(pubkey)! + return `[${displayName}](${link})` } - if (hasLoading && isLoading) { + // Check loading state using pubkey as key + const isLoading = profileLoading.get(pubkey) + if (isLoading === true) { const label = getNostrUriLabel(encoded) - console.log(`[profile-loading-debug][nostr-uri-resolve] ${encoded.slice(0, 16)}... is LOADING, showing loading state`) + console.log(`[profile-loading-debug][nostr-uri-resolve] ${pubkey.slice(0, 16)}... is LOADING, showing loading state`) // Wrap in span with profile-loading class for CSS styling return `[${label}](${link})` } From fd2d4d106f362389ca9d718f05ac88f8b2d8bc7e Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:55:14 +0100 Subject: [PATCH 45/57] fix: check loading state before resolved labels to show shimmer - Check loading state FIRST before checking for resolved labels - Profiles have fallback labels immediately, which caused early return - Now loading shimmer will show even when fallback label exists - This fixes the issue where shimmer never appeared --- src/utils/nostrUriResolver.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index e11b7446..335f9c2c 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -338,13 +338,7 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( if (decoded.type === 'npub' || decoded.type === 'nprofile') { const pubkey = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey - // Check if we have a resolved profile name using pubkey as key - if (profileLabels.has(pubkey)) { - const displayName = profileLabels.get(pubkey)! - return `[${displayName}](${link})` - } - - // Check loading state using pubkey as key + // Check loading state FIRST - show loading even if we have a fallback label const isLoading = profileLoading.get(pubkey) if (isLoading === true) { const label = getNostrUriLabel(encoded) @@ -352,6 +346,12 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( // Wrap in span with profile-loading class for CSS styling return `[${label}](${link})` } + + // Check if we have a resolved profile name using pubkey as key + if (profileLabels.has(pubkey)) { + const displayName = profileLabels.get(pubkey)! + return `[${displayName}](${link})` + } } } catch (error) { // Ignore decode errors, fall through to default label From 1eca19154db5a25d07066160f61e2c00926517fd Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:57:41 +0100 Subject: [PATCH 46/57] fix: post-process rendered HTML to add loading class to profile links - HTML inside markdown links doesn't render correctly with rehype-raw - Instead, post-process rendered HTML to find profile links (/p/npub...) - Decode npub to get pubkey and check loading state - Add profile-loading class directly to tags - This ensures the loading shimmer appears on the actual link element --- src/hooks/useMarkdownToHTML.ts | 6 ++-- src/utils/nostrUriResolver.tsx | 58 ++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 995bb31c..5d256e7e 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' import { RelayPool } from 'applesauce-relay' -import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '../utils/nostrUriResolver' +import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels, addLoadingClassToProfileLinks } from '../utils/nostrUriResolver' import { fetchArticleTitles } from '../services/articleTitleResolver' import { useProfileLabels } from './useProfileLabels' @@ -138,7 +138,9 @@ export const useMarkdownToHTML = ( const rafId = requestAnimationFrame(() => { if (previewRef.current && !isCancelled) { - const html = previewRef.current.innerHTML + 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) { console.warn('⚠️ markdownPreviewRef.current is null') diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 335f9c2c..8297a2a3 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -338,20 +338,15 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( if (decoded.type === 'npub' || decoded.type === 'nprofile') { const pubkey = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey - // Check loading state FIRST - show loading even if we have a fallback label - const isLoading = profileLoading.get(pubkey) - if (isLoading === true) { - const label = getNostrUriLabel(encoded) - console.log(`[profile-loading-debug][nostr-uri-resolve] ${pubkey.slice(0, 16)}... is LOADING, showing loading state`) - // Wrap in span with profile-loading class for CSS styling - return `[${label}](${link})` - } - // Check if we have a resolved profile name using pubkey as key if (profileLabels.has(pubkey)) { const displayName = profileLabels.get(pubkey)! return `[${displayName}](${link})` } + + // If 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 @@ -363,6 +358,51 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( }) } +/** + * 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) + return html.replace(/]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub) => { + try { + // Decode npub to get pubkey + const decoded = decode(npub) + if (decoded.type !== 'npub') return match + + const pubkey = decoded.data + + // Check if this profile is loading + if (profileLoading.get(pubkey) === 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) { + // Update existing class attribute + return match.replace(/class="([^"]*)"/, `class="$1 profile-loading"`) + } else { + // Add new class attribute + return match.replace(/(]*?)>/, '$1 class="profile-loading">') + } + } + } + } catch (error) { + // If decoding fails, just return the original match + } + + return match + }) +} + /** * Replace nostr: URIs in HTML with clickable links * This is used when processing HTML content directly From 0bf33f1a7d0bf4ec1493dfa5f3262b7d2befb825 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 22:59:28 +0100 Subject: [PATCH 47/57] debug: add log to verify post-processing adds loading class - Log when loading class is added to profile links during post-processing - Will help verify the shimmer is being applied correctly --- src/hooks/useProfileLabels.ts | 9 ++------- src/utils/nostrUriResolver.tsx | 10 +++++++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 68bb8c11..0881758d 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -241,13 +241,8 @@ export function useProfileLabels( } }) - // Set fallback labels for profiles that weren't found - profileData.forEach(({ pubkey }) => { - if (!labels.has(pubkey)) { - const fallback = getNpubFallbackDisplay(pubkey) - labels.set(pubkey, fallback) - } - }) + // 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)) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 8297a2a3..60ca5aa1 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -339,12 +339,14 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( const pubkey = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey // Check if we have a resolved profile name using pubkey as key - if (profileLabels.has(pubkey)) { + // Only use Map value if profile is not loading (meaning it's actually resolved) + const isLoading = profileLoading.get(pubkey) + if (!isLoading && profileLabels.has(pubkey)) { const displayName = profileLabels.get(pubkey)! return `[${displayName}](${link})` } - // If no resolved label yet, use fallback (will show loading via post-processing) + // If loading or no resolved label yet, use fallback (will show loading via post-processing) const label = getNostrUriLabel(encoded) return `[${label}](${link})` } @@ -381,7 +383,9 @@ export function addLoadingClassToProfileLinks( const pubkey = decoded.data // Check if this profile is loading - if (profileLoading.get(pubkey) === true) { + const isLoading = profileLoading.get(pubkey) + if (isLoading === true) { + console.log(`[profile-loading-debug][post-process] Adding loading class to link for ${pubkey.slice(0, 16)}...`) // Add profile-loading class if not already present if (!match.includes('profile-loading')) { // Insert class before the closing > From 7c2b37325497e06637bb05e6ac26d7f88270574c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:00:32 +0100 Subject: [PATCH 48/57] debug: add comprehensive shimmer debug logs - Add [shimmer-debug] prefixed logs to trace loading state flow - Log when profiles are marked as loading in useProfileLabels - Log when loading state is cleared after profile resolution - Log detailed post-processing steps in addLoadingClassToProfileLinks - Log markdown replacement decisions in replaceNostrUrisInMarkdownWithProfileLabels - Log HTML changes and class counts in useMarkdownToHTML - All logs use [shimmer-debug] prefix for easy filtering --- src/hooks/useMarkdownToHTML.ts | 15 ++++++++++ src/hooks/useProfileLabels.ts | 21 ++++++++++---- src/utils/nostrUriResolver.tsx | 51 +++++++++++++++++++++++++++------- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 5d256e7e..2c50c73d 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -139,8 +139,23 @@ export const useMarkdownToHTML = ( const rafId = requestAnimationFrame(() => { if (previewRef.current && !isCancelled) { let html = previewRef.current.innerHTML + console.log(`[shimmer-debug][markdown-to-html] Extracted HTML, length=${html.length}, loading profiles=${profileLoadingRef.current.size}`) + console.log(`[shimmer-debug][markdown-to-html] HTML sample (first 200 chars):`, html.slice(0, 200)) + // Post-process HTML to add loading class to profile links + const htmlBefore = html html = addLoadingClassToProfileLinks(html, profileLoadingRef.current) + + if (html !== htmlBefore) { + console.log(`[shimmer-debug][markdown-to-html] HTML changed after post-processing`) + console.log(`[shimmer-debug][markdown-to-html] HTML after (first 200 chars):`, html.slice(0, 200)) + // Count how many profile-loading classes are in the HTML + const loadingClassCount = (html.match(/profile-loading/g) || []).length + console.log(`[shimmer-debug][markdown-to-html] Found ${loadingClassCount} profile-loading classes in final HTML`) + } else { + console.log(`[shimmer-debug][markdown-to-html] HTML unchanged after post-processing`) + } + setRenderedHtml(html) } else if (!isCancelled) { console.warn('⚠️ markdownPreviewRef.current is null') diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 0881758d..2983a043 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -232,12 +232,13 @@ export function useProfileLabels( loading.set(pubkey, false) console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... in eventStore, not loading`) } 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) - console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... not found, SET LOADING=true`) + // 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) + console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... not found, SET LOADING=true`) + console.log(`[shimmer-debug][profile-labels] Marking profile as loading: ${pubkey.slice(0, 16)}..., will need to fetch`) } }) @@ -247,6 +248,7 @@ export function useProfileLabels( setProfileLabels(new Map(labels)) setProfileLoading(new Map(loading)) console.log(`[profile-loading-debug][profile-labels-loading] Initial loading state:`, Array.from(loading.entries()).map(([pk, l]) => `${pk.slice(0, 16)}...=${l}`)) + console.log(`[shimmer-debug][profile-labels] Set initial loading state, loading count=${Array.from(loading.values()).filter(l => l).length}, total profiles=${loading.size}`) // Fetch missing profiles asynchronously with reactive updates if (pubkeysToFetch.length > 0 && relayPool && eventStore) { @@ -268,9 +270,12 @@ export function useProfileLabels( // Clear loading state for this profile when it resolves console.log(`[profile-loading-debug][profile-labels-loading] Profile resolved for ${pubkey.slice(0, 16)}..., CLEARING LOADING`) + console.log(`[shimmer-debug][profile-labels] Profile resolved: ${pubkey.slice(0, 16)}..., setting loading=false, label="${label}"`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) + const wasLoading = updated.get(pubkey) === true updated.set(pubkey, false) + console.log(`[shimmer-debug][profile-labels] Updated loading state: ${pubkey.slice(0, 16)}... wasLoading=${wasLoading}, nowLoading=${updated.get(pubkey)}`) return updated }) } @@ -283,8 +288,10 @@ export function useProfileLabels( // Clear loading state for all fetched profiles console.log(`[profile-loading-debug][profile-labels-loading] Fetch complete, clearing loading for all ${pubkeysToFetch.length} profiles`) + console.log(`[shimmer-debug][profile-labels] Fetch complete, clearing loading for ${pubkeysToFetch.length} profiles`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) + const loadingCountBefore = Array.from(updated.values()).filter(l => l).length pubkeysToFetch.forEach(pubkey => { const wasLoading = updated.get(pubkey) updated.set(pubkey, false) @@ -292,6 +299,8 @@ export function useProfileLabels( console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... CLEARED loading after fetch complete`) } }) + const loadingCountAfter = Array.from(updated.values()).filter(l => l).length + console.log(`[shimmer-debug][profile-labels] Loading state after fetch complete: ${loadingCountBefore} -> ${loadingCountAfter} loading profiles`) return updated }) }) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 60ca5aa1..740371c6 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -341,13 +341,18 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( // Check if we have a resolved profile name using pubkey as key // Only use Map value if profile is not loading (meaning it's actually resolved) const isLoading = profileLoading.get(pubkey) - if (!isLoading && profileLabels.has(pubkey)) { + const hasLabel = profileLabels.has(pubkey) + console.log(`[shimmer-debug][markdown-replace] ${decoded.type} pubkey=${pubkey.slice(0, 16)}..., isLoading=${isLoading}, hasLabel=${hasLabel}`) + + if (!isLoading && hasLabel) { const displayName = profileLabels.get(pubkey)! + console.log(`[shimmer-debug][markdown-replace] Using resolved name: ${displayName}`) return `[${displayName}](${link})` } // If loading or no resolved label yet, use fallback (will show loading via post-processing) const label = getNostrUriLabel(encoded) + console.log(`[shimmer-debug][markdown-replace] Using fallback label: ${label} (isLoading=${isLoading})`) return `[${label}](${link})` } } catch (error) { @@ -371,40 +376,66 @@ export function addLoadingClassToProfileLinks( html: string, profileLoading: Map ): string { - if (profileLoading.size === 0) return html + console.log(`[shimmer-debug][post-process] Starting post-process, profileLoading.size=${profileLoading.size}`) + console.log(`[shimmer-debug][post-process] Loading pubkeys:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([k]) => k.slice(0, 16) + '...')) + + if (profileLoading.size === 0) { + console.log(`[shimmer-debug][post-process] No loading profiles, skipping`) + return html + } + + let linksProcessed = 0 + let linksWithLoadingClass = 0 // Find all tags with href starting with /p/ (profile links) - return html.replace(/]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub) => { + const result = html.replace(/]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub) => { + linksProcessed++ try { // Decode npub to get pubkey const decoded = decode(npub) - if (decoded.type !== 'npub') return match + if (decoded.type !== 'npub') { + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: decoded type is ${decoded.type}, not npub`) + return match + } const pubkey = decoded.data + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: npub=${npub.slice(0, 20)}..., pubkey=${pubkey.slice(0, 16)}...`) // Check if this profile is loading const isLoading = profileLoading.get(pubkey) + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: isLoading=${isLoading}, type=${typeof isLoading}`) + if (isLoading === true) { - console.log(`[profile-loading-debug][post-process] Adding loading class to link for ${pubkey.slice(0, 16)}...`) + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Profile is loading, adding class`) // Add profile-loading class if not already present if (!match.includes('profile-loading')) { + linksWithLoadingClass++ // Insert class before the closing > const classMatch = /class="([^"]*)"/.exec(match) if (classMatch) { - // Update existing class attribute - return match.replace(/class="([^"]*)"/, `class="$1 profile-loading"`) + const updated = match.replace(/class="([^"]*)"/, `class="$1 profile-loading"`) + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Updated existing class, result="${updated.slice(0, 60)}..."`) + return updated } else { - // Add new class attribute - return match.replace(/(]*?)>/, '$1 class="profile-loading">') + const updated = match.replace(/(]*?)>/, '$1 class="profile-loading">') + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Added new class attribute, result="${updated.slice(0, 60)}..."`) + return updated } + } else { + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Already has profile-loading class`) } + } else { + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Profile not loading, skipping`) } } catch (error) { - // If decoding fails, just return the original match + console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Error processing link:`, error) } return match }) + + console.log(`[shimmer-debug][post-process] Finished: processed ${linksProcessed} links, added class to ${linksWithLoadingClass} links`) + return result } /** From 541d30764e04cf9d8d5f5fd692257f24338f63ee Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:02:26 +0100 Subject: [PATCH 49/57] fix: extract HTML after ReactMarkdown renders processedMarkdown - Separate markdown processing from HTML extraction - Add useEffect that watches processedMarkdown and extracts HTML - Use double RAF to ensure ReactMarkdown has finished rendering before extracting - This fixes the issue where resolved profile names weren't updating in the article view - Add debug logs to track HTML extraction after processedMarkdown changes --- src/hooks/useMarkdownToHTML.ts | 92 +++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 2c50c73d..84564977 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -47,6 +47,9 @@ export const useMarkdownToHTML = ( const profileLoadingRef = useRef(profileLoading) const articleTitlesRef = useRef(articleTitles) + // Ref to track second RAF ID for HTML extraction cleanup + const htmlExtractionRafIdRef = useRef(null) + useEffect(() => { profileLabelsRef.current = profileLabels profileLoadingRef.current = profileLoading @@ -126,8 +129,10 @@ export const useMarkdownToHTML = ( .filter(([, l]) => l) .map(([e]) => e.slice(0, 16) + '...') console.log(`[profile-loading-debug][markdown-to-html] Processed markdown, loading states:`, loadingStates) + console.log(`[shimmer-debug][markdown-to-html] Setting processedMarkdown, length=${processed.length}`) 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) { @@ -135,34 +140,6 @@ export const useMarkdownToHTML = ( processedMarkdownRef.current = markdown } } - - const rafId = requestAnimationFrame(() => { - if (previewRef.current && !isCancelled) { - let html = previewRef.current.innerHTML - console.log(`[shimmer-debug][markdown-to-html] Extracted HTML, length=${html.length}, loading profiles=${profileLoadingRef.current.size}`) - console.log(`[shimmer-debug][markdown-to-html] HTML sample (first 200 chars):`, html.slice(0, 200)) - - // Post-process HTML to add loading class to profile links - const htmlBefore = html - html = addLoadingClassToProfileLinks(html, profileLoadingRef.current) - - if (html !== htmlBefore) { - console.log(`[shimmer-debug][markdown-to-html] HTML changed after post-processing`) - console.log(`[shimmer-debug][markdown-to-html] HTML after (first 200 chars):`, html.slice(0, 200)) - // Count how many profile-loading classes are in the HTML - const loadingClassCount = (html.match(/profile-loading/g) || []).length - console.log(`[shimmer-debug][markdown-to-html] Found ${loadingClassCount} profile-loading classes in final HTML`) - } else { - console.log(`[shimmer-debug][markdown-to-html] HTML unchanged after post-processing`) - } - - setRenderedHtml(html) - } else if (!isCancelled) { - console.warn('⚠️ markdownPreviewRef.current is null') - } - }) - - return () => cancelAnimationFrame(rafId) } // Only clear previous content if this is the first processing or markdown changed @@ -184,6 +161,65 @@ export const useMarkdownToHTML = ( } }, [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 + + console.log(`[shimmer-debug][markdown-to-html] processedMarkdown changed, scheduling HTML extraction`) + + // 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 + console.log(`[shimmer-debug][markdown-to-html] Extracted HTML after processedMarkdown change, length=${html.length}, loading profiles=${profileLoadingRef.current.size}`) + + // Check if HTML actually contains the updated content by looking for resolved names + const hasResolvedNames = Array.from(profileLabelsRef.current.entries()).some(([, label]) => { + // Check if label is a resolved name (starts with @ and isn't a fallback npub) + return label.startsWith('@') && !label.startsWith('@npub') && html.includes(label) + }) + console.log(`[shimmer-debug][markdown-to-html] HTML contains resolved names: ${hasResolvedNames}`) + + if (html.length === 0) { + console.log(`[shimmer-debug][markdown-to-html] Warning: HTML is empty, ReactMarkdown may not have rendered yet`) + } + + // Post-process HTML to add loading class to profile links + const htmlBefore = html + html = addLoadingClassToProfileLinks(html, profileLoadingRef.current) + + if (html !== htmlBefore) { + console.log(`[shimmer-debug][markdown-to-html] HTML changed after post-processing`) + // Count how many profile-loading classes are in the HTML + const loadingClassCount = (html.match(/profile-loading/g) || []).length + console.log(`[shimmer-debug][markdown-to-html] Found ${loadingClassCount} profile-loading classes in final HTML`) + } + + 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 } } From 4a432bac8d03e1519ef98c690fcc08754fa8fea5 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:03:33 +0100 Subject: [PATCH 50/57] debug: add logs to trace profile labels batching - Add debug logs in applyPendingUpdates to see when updates are applied - Add debug logs in scheduleBatchedUpdate to track RAF scheduling - Add debug logs when adding to pending updates - Add debug logs for profileLabelsKey computation to verify state updates - Will help diagnose why profileLabels stays at size 0 despite profiles resolving --- src/hooks/useMarkdownToHTML.ts | 7 ++++++- src/hooks/useProfileLabels.ts | 19 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index 84564977..b8320a01 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -27,7 +27,12 @@ export const useMarkdownToHTML = ( // Create stable dependencies based on Map contents, not Map objects // This prevents unnecessary reprocessing when Maps are recreated with same content const profileLabelsKey = useMemo(() => { - return Array.from(profileLabels.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|') + const key = Array.from(profileLabels.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join('|') + console.log(`[shimmer-debug][markdown-to-html] profileLabelsKey computed, profileLabels.size=${profileLabels.size}, key length=${key.length}`) + if (profileLabels.size > 0) { + console.log(`[shimmer-debug][markdown-to-html] Profile labels in key:`, Array.from(profileLabels.entries()).slice(0, 3).map(([k, v]) => `${k.slice(0, 16)}...="${v}"`)) + } + return key }, [profileLabels]) const profileLoadingKey = useMemo(() => { diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 2983a043..300aefc4 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -89,7 +89,11 @@ export function useProfileLabels( */ const applyPendingUpdates = () => { const pendingUpdates = pendingUpdatesRef.current - if (pendingUpdates.size === 0) return + console.log(`[shimmer-debug][profile-labels] applyPendingUpdates called, pendingUpdates.size=${pendingUpdates.size}`) + if (pendingUpdates.size === 0) { + console.log(`[shimmer-debug][profile-labels] No pending updates to apply`) + return + } // Cancel scheduled RAF since we're applying synchronously if (rafScheduledRef.current !== null) { @@ -100,9 +104,13 @@ export function useProfileLabels( // Apply all pending updates in one batch setProfileLabels(prevLabels => { const updatedLabels = new Map(prevLabels) - for (const [encoded, label] of pendingUpdates.entries()) { - updatedLabels.set(encoded, label) + const updatesList: string[] = [] + for (const [pubkey, label] of pendingUpdates.entries()) { + updatedLabels.set(pubkey, label) + updatesList.push(`${pubkey.slice(0, 16)}...="${label}"`) } + console.log(`[shimmer-debug][profile-labels] Applying ${updatesList.length} pending updates:`, updatesList) + console.log(`[shimmer-debug][profile-labels] Profile labels before: ${prevLabels.size}, after: ${updatedLabels.size}`) pendingUpdates.clear() return updatedLabels }) @@ -115,10 +123,14 @@ export function useProfileLabels( */ const scheduleBatchedUpdate = useCallback(() => { if (rafScheduledRef.current === null) { + console.log(`[shimmer-debug][profile-labels] Scheduling batched update via RAF`) rafScheduledRef.current = requestAnimationFrame(() => { + console.log(`[shimmer-debug][profile-labels] RAF fired, calling applyPendingUpdates`) applyPendingUpdates() rafScheduledRef.current = null }) + } else { + console.log(`[shimmer-debug][profile-labels] RAF already scheduled, skipping`) } }, []) // Empty deps: only uses refs which are stable @@ -265,6 +277,7 @@ export function useProfileLabels( const label = displayName ? (displayName.startsWith('@') ? displayName : `@${displayName}`) : getNpubFallbackDisplay(pubkey) // Add to pending updates and schedule batched application + console.log(`[shimmer-debug][profile-labels] Adding to pending updates: ${pubkey.slice(0, 16)}...="${label}", pendingUpdates.size=${pendingUpdatesRef.current.size + 1}`) pendingUpdatesRef.current.set(pubkey, label) scheduleBatchedUpdate() From 945b9502bc914ddd8795f39d1c07cbcaa10aa30c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:05:16 +0100 Subject: [PATCH 51/57] fix: preserve profile labels from pending updates in useEffect - Fix merge logic in useEffect that syncs profileLabels state - Previously was overwriting newly resolved labels when initialLabels changed - Now preserves existing labels and only adds missing ones from initialLabels - This fixes the issue where profileLabels was being reset to 0 after applyPendingUpdates - Add debug logs to track when useEffect sync runs --- src/hooks/useProfileLabels.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 300aefc4..95428a0e 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -142,12 +142,15 @@ export function useProfileLabels( const currentPubkeys = new Set(Array.from(prevLabels.keys())) const newPubkeys = new Set(profileData.map(p => p.pubkey)) + console.log(`[shimmer-debug][profile-labels] useEffect sync: prevLabels.size=${prevLabels.size}, initialLabels.size=${initialLabels.size}, profileData.length=${profileData.length}`) + // 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) { + console.log(`[shimmer-debug][profile-labels] useEffect: Different profiles detected, resetting state`) // Clear pending updates and cancel RAF for old profiles pendingUpdatesRef.current.clear() if (rafScheduledRef.current !== null) { @@ -157,14 +160,17 @@ export function useProfileLabels( // Reset to initial labels return new Map(initialLabels) } else { - // Same profiles, merge initial labels with existing state (initial labels take precedence for missing ones) + // 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 update if missing or if initial label has a better value (not a fallback) - if (!merged.has(pubkey) || (!prevLabels.get(pubkey)?.startsWith('@') && label.startsWith('@'))) { + // 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) } } + console.log(`[shimmer-debug][profile-labels] useEffect: Merged labels, before=${prevLabels.size}, after=${merged.size}`) return merged } }) From f417ed8210433c53414bf37785adf16e840e8bd7 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:08:20 +0100 Subject: [PATCH 52/57] fix: resolve race condition in profile label updates Fix regression where npubs/nprofiles weren't being replaced with profile names. The issue was a race condition: loading state was cleared immediately, but labels were applied asynchronously via RAF, causing the condition check to fail. Changes: - Apply profile labels immediately when profiles resolve, instead of batching via RAF - Update condition check to explicitly handle undefined loading state (isLoading !== true) - This ensures labels are available in the Map when loading becomes false --- src/hooks/useProfileLabels.ts | 14 +++++++++----- src/utils/nostrUriResolver.tsx | 7 +++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 95428a0e..5bedb7d6 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -273,7 +273,7 @@ export function useProfileLabels( console.log(`[profile-loading-debug][profile-labels-loading] Starting fetch for ${pubkeysToFetch.length} profiles:`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) // Reactive callback: collects profile updates and batches them via RAF to prevent flicker - // Strategy: Collect updates in ref, schedule RAF on first update, apply all in batch + // 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 @@ -282,10 +282,14 @@ export function useProfileLabels( const displayName = extractProfileDisplayName(event) const label = displayName ? (displayName.startsWith('@') ? displayName : `@${displayName}`) : getNpubFallbackDisplay(pubkey) - // Add to pending updates and schedule batched application - console.log(`[shimmer-debug][profile-labels] Adding to pending updates: ${pubkey.slice(0, 16)}...="${label}", pendingUpdates.size=${pendingUpdatesRef.current.size + 1}`) - pendingUpdatesRef.current.set(pubkey, label) - scheduleBatchedUpdate() + // Apply label immediately to prevent race condition with loading state + // This ensures labels are available when isLoading becomes false + console.log(`[shimmer-debug][profile-labels] Applying label immediately: ${pubkey.slice(0, 16)}...="${label}"`) + setProfileLabels(prevLabels => { + const updated = new Map(prevLabels) + updated.set(pubkey, label) + return updated + }) // Clear loading state for this profile when it resolves console.log(`[profile-loading-debug][profile-labels-loading] Profile resolved for ${pubkey.slice(0, 16)}..., CLEARING LOADING`) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 740371c6..83aa4ddb 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -339,12 +339,15 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( const pubkey = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey // Check if we have a resolved profile name using pubkey as key - // Only use Map value if profile is not loading (meaning it's actually resolved) + // 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) console.log(`[shimmer-debug][markdown-replace] ${decoded.type} pubkey=${pubkey.slice(0, 16)}..., isLoading=${isLoading}, hasLabel=${hasLabel}`) - if (!isLoading && hasLabel) { + // 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)! console.log(`[shimmer-debug][markdown-replace] Using resolved name: ${displayName}`) return `[${displayName}](${link})` From ea3c130cc3207381c4547e60c8409b4565a4d57c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:11:40 +0100 Subject: [PATCH 53/57] chore: remove console.log debug output --- src/components/NostrMentionLink.tsx | 12 ++-------- src/components/ResolvedMention.tsx | 12 ++-------- src/hooks/useArticleLoader.ts | 4 ---- src/hooks/useMarkdownToHTML.ts | 37 ----------------------------- src/hooks/useProfileLabels.ts | 35 --------------------------- src/utils/nostrUriResolver.tsx | 28 +--------------------- 6 files changed, 5 insertions(+), 123 deletions(-) diff --git a/src/components/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index f7788f56..6c765435 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -46,24 +46,16 @@ const NostrMentionLink: React.FC = ({ // Check cache const cached = loadCachedProfiles([pubkey]) if (cached.has(pubkey)) { - console.log(`[profile-loading-debug][nostr-mention-link] ${nostrUri.slice(0, 30)}... in cache`) return true } // Check eventStore const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') - const inStore = !!eventStoreProfile - if (inStore) { - console.log(`[profile-loading-debug][nostr-mention-link] ${nostrUri.slice(0, 30)}... in eventStore`) - } - return inStore - }, [pubkey, eventStore, nostrUri]) + return !!eventStoreProfile + }, [pubkey, eventStore]) // Show loading if profile doesn't exist and not in cache/store (for npub/nprofile) const isLoading = !profile && pubkey && !isInCacheOrStore && decoded && (decoded.type === 'npub' || decoded.type === 'nprofile') - if (isLoading) { - console.log(`[profile-loading-debug][nostr-mention-link] ${nostrUri.slice(0, 30)}... isLoading=true (profile=${!!profile}, pubkey=${!!pubkey}, inCacheOrStore=${isInCacheOrStore})`) - } // If decoding failed, show shortened identifier if (!decoded) { diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index d8fee2b3..37c96b63 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -31,23 +31,15 @@ const ResolvedMention: React.FC = ({ encoded }) => { // Check cache const cached = loadCachedProfiles([pubkey]) if (cached.has(pubkey)) { - console.log(`[profile-loading-debug][resolved-mention] ${encoded?.slice(0, 16)}... in cache`) return true } // Check eventStore const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') - const inStore = !!eventStoreProfile - if (inStore) { - console.log(`[profile-loading-debug][resolved-mention] ${encoded?.slice(0, 16)}... in eventStore`) - } - return inStore - }, [pubkey, eventStore, encoded]) + return !!eventStoreProfile + }, [pubkey, eventStore]) // Show loading if profile doesn't exist and not in cache/store const isLoading = !profile && pubkey && !isInCacheOrStore - if (isLoading && encoded) { - console.log(`[profile-loading-debug][resolved-mention] ${encoded.slice(0, 16)}... isLoading=true (profile=${!!profile}, pubkey=${!!pubkey}, inCacheOrStore=${isInCacheOrStore})`) - } const display = pubkey ? getProfileDisplayName(profile, pubkey) : encoded const npub = pubkey ? npubEncode(pubkey) : undefined diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index a5e090d3..49a8abe2 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -264,10 +264,8 @@ export function useArticleLoader({ const loadArticle = async () => { const requestId = ++currentRequestIdRef.current - console.log(`[profile-loading-debug][article-loader] Starting loadArticle requestId=${requestId} for naddr=${naddr.slice(0, 20)}...`) if (!mountedRef.current) { - console.log(`[profile-loading-debug][article-loader] Aborted loadArticle requestId=${requestId} - not mounted`) return } @@ -284,7 +282,6 @@ export function useArticleLoader({ // At this point, we've checked EventStore and cache - neither had content // Only show loading skeleton if we also don't have preview data if (previewData) { - console.log(`[profile-loading-debug][article-loader] requestId=${requestId} has previewData, showing immediately`) // If we have preview data from navigation, show it immediately (no skeleton!) setCurrentTitle(previewData.title) setReaderContent({ @@ -301,7 +298,6 @@ export function useArticleLoader({ // Preloading again would be redundant and could cause unnecessary network requests } else { // No cache, no EventStore, no preview data - need to load from relays - console.log(`[profile-loading-debug][article-loader] requestId=${requestId} no previewData, setting loading=true, content=undefined`) setReaderLoading(true) setReaderContent(undefined) } diff --git a/src/hooks/useMarkdownToHTML.ts b/src/hooks/useMarkdownToHTML.ts index b8320a01..f32df3b4 100644 --- a/src/hooks/useMarkdownToHTML.ts +++ b/src/hooks/useMarkdownToHTML.ts @@ -28,10 +28,6 @@ export const useMarkdownToHTML = ( // 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('|') - console.log(`[shimmer-debug][markdown-to-html] profileLabelsKey computed, profileLabels.size=${profileLabels.size}, key length=${key.length}`) - if (profileLabels.size > 0) { - console.log(`[shimmer-debug][markdown-to-html] Profile labels in key:`, Array.from(profileLabels.entries()).slice(0, 3).map(([k, v]) => `${k.slice(0, 16)}...="${v}"`)) - } return key }, [profileLabels]) @@ -102,11 +98,6 @@ export const useMarkdownToHTML = ( // Process markdown with progressive profile labels and article titles // Use stable string keys instead of Map objects to prevent excessive reprocessing useEffect(() => { - const labelsSize = profileLabelsRef.current.size - const loadingSize = profileLoadingRef.current.size - const titlesSize = articleTitlesRef.current.size - console.log(`[profile-loading-debug][markdown-to-html] Processing markdown, profileLabels=${labelsSize}, profileLoading=${loadingSize}, articleTitles=${titlesSize}`) - if (!markdown) { setRenderedHtml('') setProcessedMarkdown('') @@ -130,11 +121,6 @@ export const useMarkdownToHTML = ( if (isCancelled) return - const loadingStates = Array.from(profileLoadingRef.current.entries()) - .filter(([, l]) => l) - .map(([e]) => e.slice(0, 16) + '...') - console.log(`[profile-loading-debug][markdown-to-html] Processed markdown, loading states:`, loadingStates) - console.log(`[shimmer-debug][markdown-to-html] Setting processedMarkdown, length=${processed.length}`) setProcessedMarkdown(processed) processedMarkdownRef.current = processed // HTML extraction will happen in separate useEffect that watches processedMarkdown @@ -153,7 +139,6 @@ export const useMarkdownToHTML = ( previousMarkdownRef.current = markdown if (isMarkdownChange || !processedMarkdownRef.current) { - console.log(`[profile-loading-debug][markdown-to-html] Clearing rendered HTML and processed markdown (markdown changed: ${isMarkdownChange})`) setRenderedHtml('') setProcessedMarkdown('') processedMarkdownRef.current = '' @@ -175,8 +160,6 @@ export const useMarkdownToHTML = ( let isCancelled = false - console.log(`[shimmer-debug][markdown-to-html] processedMarkdown changed, scheduling HTML extraction`) - // 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 @@ -184,30 +167,10 @@ export const useMarkdownToHTML = ( htmlExtractionRafIdRef.current = requestAnimationFrame(() => { if (previewRef.current && !isCancelled) { let html = previewRef.current.innerHTML - console.log(`[shimmer-debug][markdown-to-html] Extracted HTML after processedMarkdown change, length=${html.length}, loading profiles=${profileLoadingRef.current.size}`) - - // Check if HTML actually contains the updated content by looking for resolved names - const hasResolvedNames = Array.from(profileLabelsRef.current.entries()).some(([, label]) => { - // Check if label is a resolved name (starts with @ and isn't a fallback npub) - return label.startsWith('@') && !label.startsWith('@npub') && html.includes(label) - }) - console.log(`[shimmer-debug][markdown-to-html] HTML contains resolved names: ${hasResolvedNames}`) - - if (html.length === 0) { - console.log(`[shimmer-debug][markdown-to-html] Warning: HTML is empty, ReactMarkdown may not have rendered yet`) - } // Post-process HTML to add loading class to profile links - const htmlBefore = html html = addLoadingClassToProfileLinks(html, profileLoadingRef.current) - if (html !== htmlBefore) { - console.log(`[shimmer-debug][markdown-to-html] HTML changed after post-processing`) - // Count how many profile-loading classes are in the HTML - const loadingClassCount = (html.match(/profile-loading/g) || []).length - console.log(`[shimmer-debug][markdown-to-html] Found ${loadingClassCount} profile-loading classes in final HTML`) - } - setRenderedHtml(html) } else if (!isCancelled && processedMarkdown) { console.warn('⚠️ markdownPreviewRef.current is null but processedMarkdown exists') diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 5bedb7d6..0806b4dc 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -89,9 +89,7 @@ export function useProfileLabels( */ const applyPendingUpdates = () => { const pendingUpdates = pendingUpdatesRef.current - console.log(`[shimmer-debug][profile-labels] applyPendingUpdates called, pendingUpdates.size=${pendingUpdates.size}`) if (pendingUpdates.size === 0) { - console.log(`[shimmer-debug][profile-labels] No pending updates to apply`) return } @@ -104,13 +102,9 @@ export function useProfileLabels( // Apply all pending updates in one batch setProfileLabels(prevLabels => { const updatedLabels = new Map(prevLabels) - const updatesList: string[] = [] for (const [pubkey, label] of pendingUpdates.entries()) { updatedLabels.set(pubkey, label) - updatesList.push(`${pubkey.slice(0, 16)}...="${label}"`) } - console.log(`[shimmer-debug][profile-labels] Applying ${updatesList.length} pending updates:`, updatesList) - console.log(`[shimmer-debug][profile-labels] Profile labels before: ${prevLabels.size}, after: ${updatedLabels.size}`) pendingUpdates.clear() return updatedLabels }) @@ -123,14 +117,10 @@ export function useProfileLabels( */ const scheduleBatchedUpdate = useCallback(() => { if (rafScheduledRef.current === null) { - console.log(`[shimmer-debug][profile-labels] Scheduling batched update via RAF`) rafScheduledRef.current = requestAnimationFrame(() => { - console.log(`[shimmer-debug][profile-labels] RAF fired, calling applyPendingUpdates`) applyPendingUpdates() rafScheduledRef.current = null }) - } else { - console.log(`[shimmer-debug][profile-labels] RAF already scheduled, skipping`) } }, []) // Empty deps: only uses refs which are stable @@ -142,15 +132,12 @@ export function useProfileLabels( const currentPubkeys = new Set(Array.from(prevLabels.keys())) const newPubkeys = new Set(profileData.map(p => p.pubkey)) - console.log(`[shimmer-debug][profile-labels] useEffect sync: prevLabels.size=${prevLabels.size}, initialLabels.size=${initialLabels.size}, profileData.length=${profileData.length}`) - // 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) { - console.log(`[shimmer-debug][profile-labels] useEffect: Different profiles detected, resetting state`) // Clear pending updates and cancel RAF for old profiles pendingUpdatesRef.current.clear() if (rafScheduledRef.current !== null) { @@ -170,7 +157,6 @@ export function useProfileLabels( merged.set(pubkey, label) } } - console.log(`[shimmer-debug][profile-labels] useEffect: Merged labels, before=${prevLabels.size}, after=${merged.size}`) return merged } }) @@ -228,7 +214,6 @@ export function useProfileLabels( // Skip if already resolved from initial cache if (labels.has(pubkey)) { loading.set(pubkey, false) - console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... in cache, not loading`) return } @@ -248,15 +233,12 @@ export function useProfileLabels( labels.set(pubkey, fallback) } loading.set(pubkey, false) - console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... in eventStore, not loading`) } 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) - console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... not found, SET LOADING=true`) - console.log(`[shimmer-debug][profile-labels] Marking profile as loading: ${pubkey.slice(0, 16)}..., will need to fetch`) } }) @@ -265,12 +247,9 @@ export function useProfileLabels( setProfileLabels(new Map(labels)) setProfileLoading(new Map(loading)) - console.log(`[profile-loading-debug][profile-labels-loading] Initial loading state:`, Array.from(loading.entries()).map(([pk, l]) => `${pk.slice(0, 16)}...=${l}`)) - console.log(`[shimmer-debug][profile-labels] Set initial loading state, loading count=${Array.from(loading.values()).filter(l => l).length}, total profiles=${loading.size}`) // Fetch missing profiles asynchronously with reactive updates if (pubkeysToFetch.length > 0 && relayPool && eventStore) { - console.log(`[profile-loading-debug][profile-labels-loading] Starting fetch for ${pubkeysToFetch.length} profiles:`, pubkeysToFetch.map(p => p.slice(0, 16) + '...')) // 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 @@ -284,7 +263,6 @@ export function useProfileLabels( // Apply label immediately to prevent race condition with loading state // This ensures labels are available when isLoading becomes false - console.log(`[shimmer-debug][profile-labels] Applying label immediately: ${pubkey.slice(0, 16)}...="${label}"`) setProfileLabels(prevLabels => { const updated = new Map(prevLabels) updated.set(pubkey, label) @@ -292,13 +270,9 @@ export function useProfileLabels( }) // Clear loading state for this profile when it resolves - console.log(`[profile-loading-debug][profile-labels-loading] Profile resolved for ${pubkey.slice(0, 16)}..., CLEARING LOADING`) - console.log(`[shimmer-debug][profile-labels] Profile resolved: ${pubkey.slice(0, 16)}..., setting loading=false, label="${label}"`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) - const wasLoading = updated.get(pubkey) === true updated.set(pubkey, false) - console.log(`[shimmer-debug][profile-labels] Updated loading state: ${pubkey.slice(0, 16)}... wasLoading=${wasLoading}, nowLoading=${updated.get(pubkey)}`) return updated }) } @@ -310,20 +284,11 @@ export function useProfileLabels( applyPendingUpdates() // Clear loading state for all fetched profiles - console.log(`[profile-loading-debug][profile-labels-loading] Fetch complete, clearing loading for all ${pubkeysToFetch.length} profiles`) - console.log(`[shimmer-debug][profile-labels] Fetch complete, clearing loading for ${pubkeysToFetch.length} profiles`) setProfileLoading(prevLoading => { const updated = new Map(prevLoading) - const loadingCountBefore = Array.from(updated.values()).filter(l => l).length pubkeysToFetch.forEach(pubkey => { - const wasLoading = updated.get(pubkey) updated.set(pubkey, false) - if (wasLoading) { - console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... CLEARED loading after fetch complete`) - } }) - const loadingCountAfter = Array.from(updated.values()).filter(l => l).length - console.log(`[shimmer-debug][profile-labels] Loading state after fetch complete: ${loadingCountBefore} -> ${loadingCountAfter} loading profiles`) return updated }) }) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 83aa4ddb..6c2e41eb 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -320,9 +320,6 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( articleTitles: Map = new Map(), profileLoading: Map = new Map() ): string { - console.log(`[profile-loading-debug][nostr-uri-resolve] Processing markdown, profileLabels=${profileLabels.size}, profileLoading=${profileLoading.size}`) - console.log(`[profile-loading-debug][nostr-uri-resolve] Loading keys:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([k]) => k.slice(0, 16) + '...')) - return replaceNostrUrisSafely(markdown, (encoded) => { const link = createNostrLink(encoded) @@ -342,20 +339,17 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( // 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) - console.log(`[shimmer-debug][markdown-replace] ${decoded.type} pubkey=${pubkey.slice(0, 16)}..., isLoading=${isLoading}, hasLabel=${hasLabel}`) // 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)! - console.log(`[shimmer-debug][markdown-replace] Using resolved name: ${displayName}`) return `[${displayName}](${link})` } // If loading or no resolved label yet, use fallback (will show loading via post-processing) const label = getNostrUriLabel(encoded) - console.log(`[shimmer-debug][markdown-replace] Using fallback label: ${label} (isLoading=${isLoading})`) return `[${label}](${link})` } } catch (error) { @@ -379,65 +373,45 @@ export function addLoadingClassToProfileLinks( html: string, profileLoading: Map ): string { - console.log(`[shimmer-debug][post-process] Starting post-process, profileLoading.size=${profileLoading.size}`) - console.log(`[shimmer-debug][post-process] Loading pubkeys:`, Array.from(profileLoading.entries()).filter(([, l]) => l).map(([k]) => k.slice(0, 16) + '...')) - if (profileLoading.size === 0) { - console.log(`[shimmer-debug][post-process] No loading profiles, skipping`) return html } - let linksProcessed = 0 - let linksWithLoadingClass = 0 - // Find all tags with href starting with /p/ (profile links) const result = html.replace(/]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub) => { - linksProcessed++ try { // Decode npub to get pubkey const decoded = decode(npub) if (decoded.type !== 'npub') { - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: decoded type is ${decoded.type}, not npub`) return match } const pubkey = decoded.data - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: npub=${npub.slice(0, 20)}..., pubkey=${pubkey.slice(0, 16)}...`) // Check if this profile is loading const isLoading = profileLoading.get(pubkey) - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: isLoading=${isLoading}, type=${typeof isLoading}`) if (isLoading === true) { - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Profile is loading, adding class`) // Add profile-loading class if not already present if (!match.includes('profile-loading')) { - linksWithLoadingClass++ // Insert class before the closing > const classMatch = /class="([^"]*)"/.exec(match) if (classMatch) { const updated = match.replace(/class="([^"]*)"/, `class="$1 profile-loading"`) - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Updated existing class, result="${updated.slice(0, 60)}..."`) return updated } else { const updated = match.replace(/(]*?)>/, '$1 class="profile-loading">') - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Added new class attribute, result="${updated.slice(0, 60)}..."`) return updated } - } else { - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Already has profile-loading class`) } - } else { - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Profile not loading, skipping`) } } catch (error) { - console.log(`[shimmer-debug][post-process] Link ${linksProcessed}: Error processing link:`, error) + // Ignore processing errors } return match }) - console.log(`[shimmer-debug][post-process] Finished: processed ${linksProcessed} links, added class to ${linksWithLoadingClass} links`) return result } From 8cb77864bc23a24f48afdd70e382c8ee79757fa3 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:13:37 +0100 Subject: [PATCH 54/57] fix: resolve TypeScript errors in nostrUriResolver.tsx - Add explicit type annotations for decoded variable and npub parameter - Use switch statement for better type narrowing when checking npub type --- src/utils/nostrUriResolver.tsx | 48 ++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 6c2e41eb..c8e6541e 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -378,32 +378,36 @@ export function addLoadingClassToProfileLinks( } // Find all tags with href starting with /p/ (profile links) - const result = html.replace(/]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub) => { + const result = html.replace(/]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub: string) => { try { // Decode npub to get pubkey - const decoded = decode(npub) - if (decoded.type !== 'npub') { - return match - } - - const pubkey = decoded.data - - // 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 + const decoded: ReturnType = decode(npub) + switch (decoded.type) { + case 'npub': { + const pubkey = decoded.data + + // 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 + } + } } + break } + default: + // Not an npub, ignore + break } } catch (error) { // Ignore processing errors From 66de230f66c1da97e24f6d7867a5386917a81d31 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:36:46 +0100 Subject: [PATCH 55/57] refactor: standardize @ prefix handling and improve npub/nprofile display - Fix getNpubFallbackDisplay to return names without @ prefix - Update all call sites to consistently add @ when rendering mentions - Fix incomplete error handling in getNpubFallbackDisplay catch block - Add nprofile support to addLoadingClassToProfileLinks - Extract shared isProfileInCacheOrStore utility to eliminate duplicate loading state checks - Update ResolvedMention and NostrMentionLink to use shared utility This ensures consistent @ prefix handling across all profile display contexts and eliminates code duplication for profile loading state detection. --- src/components/NostrMentionLink.tsx | 11 +---- src/components/ResolvedMention.tsx | 11 +---- src/hooks/useProfileLabels.ts | 19 ++++---- src/utils/nostrUriResolver.tsx | 71 +++++++++++++++-------------- src/utils/profileLoadingUtils.ts | 27 +++++++++++ 5 files changed, 78 insertions(+), 61 deletions(-) create mode 100644 src/utils/profileLoadingUtils.ts diff --git a/src/components/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index 6c765435..ea03e5bd 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -4,7 +4,7 @@ import { useEventModel } from 'applesauce-react/hooks' import { Hooks } from 'applesauce-react' import { Models, Helpers } from 'applesauce-core' import { getProfileDisplayName } from '../utils/nostrUriResolver' -import { loadCachedProfiles } from '../services/profileService' +import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils' const { getPubkeyFromDecodeResult } = Helpers @@ -43,14 +43,7 @@ const NostrMentionLink: React.FC = ({ // Check if profile is in cache or eventStore for loading detection const isInCacheOrStore = useMemo(() => { if (!pubkey) return false - // Check cache - const cached = loadCachedProfiles([pubkey]) - if (cached.has(pubkey)) { - return true - } - // Check eventStore - const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') - return !!eventStoreProfile + return isProfileInCacheOrStore(pubkey, eventStore) }, [pubkey, eventStore]) // Show loading if profile doesn't exist and not in cache/store (for npub/nprofile) diff --git a/src/components/ResolvedMention.tsx b/src/components/ResolvedMention.tsx index 37c96b63..44d7a71a 100644 --- a/src/components/ResolvedMention.tsx +++ b/src/components/ResolvedMention.tsx @@ -5,7 +5,7 @@ import { Hooks } from 'applesauce-react' import { Models, Helpers } from 'applesauce-core' import { decode, npubEncode } from 'nostr-tools/nip19' import { getProfileDisplayName } from '../utils/nostrUriResolver' -import { loadCachedProfiles } from '../services/profileService' +import { isProfileInCacheOrStore } from '../utils/profileLoadingUtils' const { getPubkeyFromDecodeResult } = Helpers @@ -28,14 +28,7 @@ const ResolvedMention: React.FC = ({ encoded }) => { // Check if profile is in cache or eventStore const isInCacheOrStore = useMemo(() => { if (!pubkey) return false - // Check cache - const cached = loadCachedProfiles([pubkey]) - if (cached.has(pubkey)) { - return true - } - // Check eventStore - const eventStoreProfile = eventStore?.getEvent(pubkey + ':0') - return !!eventStoreProfile + return isProfileInCacheOrStore(pubkey, eventStore) }, [pubkey, eventStore]) // Show loading if profile doesn't exist and not in cache/store diff --git a/src/hooks/useProfileLabels.ts b/src/hooks/useProfileLabels.ts index 0806b4dc..8b5cf9a5 100644 --- a/src/hooks/useProfileLabels.ts +++ b/src/hooks/useProfileLabels.ts @@ -60,13 +60,13 @@ export function useProfileLabels( if (cachedProfile) { const displayName = extractProfileDisplayName(cachedProfile) if (displayName) { - // Only add @ prefix if we have a real name, otherwise use fallback format directly - const label = displayName.startsWith('@') ? displayName : `@${displayName}` + // Add @ prefix (extractProfileDisplayName returns name without @) + const label = `@${displayName}` labels.set(pubkey, label) } else { - // Use fallback npub display if profile has no name + // Use fallback npub display if profile has no name (add @ prefix) const fallback = getNpubFallbackDisplay(pubkey) - labels.set(pubkey, fallback) + labels.set(pubkey, `@${fallback}`) } } }) @@ -224,13 +224,13 @@ export function useProfileLabels( // Extract display name using centralized utility const displayName = extractProfileDisplayName(eventStoreProfile as NostrEvent) if (displayName) { - // Only add @ prefix if we have a real name, otherwise use fallback format directly - const label = displayName.startsWith('@') ? displayName : `@${displayName}` + // Add @ prefix (extractProfileDisplayName returns name without @) + const label = `@${displayName}` labels.set(pubkey, label) } else { - // Use fallback npub display if profile has no name + // Use fallback npub display if profile has no name (add @ prefix) const fallback = getNpubFallbackDisplay(pubkey) - labels.set(pubkey, fallback) + labels.set(pubkey, `@${fallback}`) } loading.set(pubkey, false) } else { @@ -258,8 +258,9 @@ export function useProfileLabels( 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.startsWith('@') ? displayName : `@${displayName}`) : getNpubFallbackDisplay(pubkey) + 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 diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index c8e6541e..778cb24e 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -86,13 +86,15 @@ export function getNostrUriLabel(encoded: string): string { const decoded = decode(encoded) switch (decoded.type) { - case 'npub': - // Remove "npub1" prefix (5 chars) and show next 7 chars - return `@${encoded.slice(5, 12)}...` + case 'npub': { + // Use shared fallback display function and add @ for label + const pubkey = decoded.data + return `@${getNpubFallbackDisplay(pubkey)}` + } case 'nprofile': { - const npub = npubEncode(decoded.data.pubkey) - // Remove "npub1" prefix (5 chars) and show next 7 chars - return `@${npub.slice(5, 12)}...` + // Use shared fallback display function and add @ for label + const pubkey = decoded.data.pubkey + return `@${getNpubFallbackDisplay(pubkey)}` } case 'note': return `note:${encoded.slice(5, 12)}...` @@ -119,18 +121,19 @@ export function getNostrUriLabel(encoded: string): string { /** * Get a standardized fallback display name for a pubkey when profile has no name - * Returns npub format: @abc1234... + * 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 + * @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)}...` + return `${npub.slice(5, 12)}...` } catch { // Fallback to shortened pubkey if encoding fails - return `@${pubkey.slice(0, 8)}...` + return `${pubkey.slice(0, 8)}...` } } @@ -380,34 +383,34 @@ export function addLoadingClassToProfileLinks( // Find all tags with href starting with /p/ (profile links) const result = html.replace(/]*?href="\/p\/([^"]+)"[^>]*?>/g, (match, npub: string) => { try { - // Decode npub to get pubkey + // Decode npub or nprofile to get pubkey const decoded: ReturnType = decode(npub) - switch (decoded.type) { - case 'npub': { - const pubkey = decoded.data - - // 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 - } + + let pubkey: string | undefined + if (decoded.type === 'npub') { + pubkey = decoded.data + } else if (decoded.type === 'nprofile') { + pubkey = decoded.data.pubkey + } + + 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 } } - break } - default: - // Not an npub, ignore - break } } catch (error) { // Ignore processing errors 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 +} + From d4b78d94840398a907b12e49e0b7cf97f111c1b1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:42:59 +0100 Subject: [PATCH 56/57] refactor: standardize applesauce helpers for npub/nprofile detection Replace manual type checking and pubkey extraction with getPubkeyFromDecodeResult helper: - Update getNostrUriLabel to use helper instead of manual npub/nprofile cases - Update replaceNostrUrisInMarkdownWithProfileLabels to use helper - Update addLoadingClassToProfileLinks to use helper - Simplify NostrMentionLink by removing redundant type checks - Update Bookmarks.tsx to use helper for profile pubkey extraction This eliminates duplicate logic and ensures consistent handling of npub/nprofile across the codebase using applesauce helpers. --- src/components/Bookmarks.tsx | 11 ++++----- src/components/NostrMentionLink.tsx | 17 ++++++------- src/utils/nostrUriResolver.tsx | 38 ++++++++++++----------------- 3 files changed, 27 insertions(+), 39 deletions(-) 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/NostrMentionLink.tsx b/src/components/NostrMentionLink.tsx index ea03e5bd..2fe2623f 100644 --- a/src/components/NostrMentionLink.tsx +++ b/src/components/NostrMentionLink.tsx @@ -47,8 +47,8 @@ const NostrMentionLink: React.FC = ({ }, [pubkey, eventStore]) // Show loading if profile doesn't exist and not in cache/store (for npub/nprofile) - const isLoading = !profile && pubkey && !isInCacheOrStore && - decoded && (decoded.type === 'npub' || decoded.type === '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) { @@ -78,15 +78,12 @@ const NostrMentionLink: React.FC = ({ } // 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 - return renderProfileLink(pk) - } - case 'nprofile': { - const { pubkey: pk } = decoded.data - return renderProfileLink(pk) - } case 'naddr': { const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data // Check if it's a blog post (kind:30023) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 778cb24e..20e7d1c8 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -2,7 +2,9 @@ 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 { encodeDecodeResult, Helpers } from 'applesauce-core/helpers' + +const { getPubkeyFromDecodeResult } = Helpers /** * Regular expression to match nostr: URIs and bare NIP-19 identifiers @@ -85,17 +87,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': { - // Use shared fallback display function and add @ for label - const pubkey = decoded.data - return `@${getNpubFallbackDisplay(pubkey)}` - } - case 'nprofile': { - // Use shared fallback display function and add @ for label - const pubkey = decoded.data.pubkey - return `@${getNpubFallbackDisplay(pubkey)}` - } case 'note': return `note:${encoded.slice(5, 12)}...` case 'nevent': { @@ -334,10 +333,9 @@ export function replaceNostrUrisInMarkdownWithProfileLabels( return `[${title}](${link})` } - // For npub/nprofile, extract pubkey and use it as the lookup key - if (decoded.type === 'npub' || decoded.type === 'nprofile') { - const pubkey = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey - + // 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) @@ -383,15 +381,9 @@ export function addLoadingClassToProfileLinks( // 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 + // Decode npub or nprofile to get pubkey using applesauce helper const decoded: ReturnType = decode(npub) - - let pubkey: string | undefined - if (decoded.type === 'npub') { - pubkey = decoded.data - } else if (decoded.type === 'nprofile') { - pubkey = decoded.data.pubkey - } + const pubkey = getPubkeyFromDecodeResult(decoded) if (pubkey) { // Check if this profile is loading From a30943686e643f69ce21c3a76bddf43a8c4e8f00 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 2 Nov 2025 23:43:49 +0100 Subject: [PATCH 57/57] fix: correct Helpers import path in nostrUriResolver Helpers should be imported from 'applesauce-core', not 'applesauce-core/helpers' --- src/utils/nostrUriResolver.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/nostrUriResolver.tsx b/src/utils/nostrUriResolver.tsx index 20e7d1c8..51f609e1 100644 --- a/src/utils/nostrUriResolver.tsx +++ b/src/utils/nostrUriResolver.tsx @@ -2,7 +2,8 @@ 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, Helpers } from 'applesauce-core/helpers' +import { encodeDecodeResult } from 'applesauce-core/helpers' +import { Helpers } from 'applesauce-core' const { getPubkeyFromDecodeResult } = Helpers