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
This commit is contained in:
Gigi
2025-11-02 22:21:43 +01:00
parent 15c016ad5e
commit ee7df54d87
9 changed files with 269 additions and 241 deletions

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,11 @@ const RichContent: React.FC<RichContentProps> = ({
return (
<div className={className}>
{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 (

View File

@@ -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
}
}
}

View File

@@ -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<Map<string, string>>(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<Map<string, string>>(new Map())
const rafScheduledRef = useRef<number | null>(null)
/**
* Helper to apply pending batched updates to state
* Cancels any scheduled RAF and applies updates synchronously
*/
const applyPendingUpdates = () => {
const pendingUpdates = pendingUpdatesRef.current
if (pendingUpdates.size === 0) return
// Cancel scheduled RAF since we're applying synchronously
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
// Apply all pending updates in one batch
setProfileLabels(prevLabels => {
const updatedLabels = new Map(prevLabels)
for (const [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
}

View File

@@ -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<string, Promise<NostrEvent[]>>()
function getProfileCacheKey(pubkey: string): string {
return `${PROFILE_CACHE_PREFIX}${pubkey}`
}
@@ -185,6 +189,7 @@ export function loadCachedProfiles(pubkeys: string[]): Map<string, NostrEvent> {
* 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<string, NostrEvent>()
// Add cached profiles to the map and EventStore
for (const [pubkey, profile] of cachedProfiles.entries()) {
profilesByPubkey.set(pubkey, profile)
// Ensure cached profiles are also in EventStore for consistency
eventStore.add(profile)
// 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<string, NostrEvent>()
// Add cached profiles to the map and EventStore
for (const [pubkey, profile] of cachedProfiles.entries()) {
profilesByPubkey.set(pubkey, profile)
// Ensure cached profiles are also in EventStore for consistency
eventStore.add(profile)
}
// 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<string>()
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<string>()
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<NostrEvent>((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<NostrEvent>((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<NostrEvent>((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<NostrEvent>((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 []

View File

@@ -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

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

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