mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 22:54:30 +01:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
40
src/utils/profileUtils.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user