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
This commit is contained in:
Gigi
2025-11-02 22:52:49 +01:00
parent f57a4d4f1b
commit d41cbb5305
2 changed files with 54 additions and 77 deletions

View File

@@ -45,6 +45,7 @@ export function useProfileLabels(
}, [content]) }, [content])
// Initialize labels synchronously from cache on first render to avoid delay // 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(() => { const initialLabels = useMemo(() => {
if (profileData.length === 0) { if (profileData.length === 0) {
return new Map<string, string>() return new Map<string, string>()
@@ -54,18 +55,18 @@ export function useProfileLabels(
const cachedProfiles = loadCachedProfiles(allPubkeys) const cachedProfiles = loadCachedProfiles(allPubkeys)
const labels = new Map<string, string>() const labels = new Map<string, string>()
profileData.forEach(({ encoded, pubkey }) => { profileData.forEach(({ pubkey }) => {
const cachedProfile = cachedProfiles.get(pubkey) const cachedProfile = cachedProfiles.get(pubkey)
if (cachedProfile) { if (cachedProfile) {
const displayName = extractProfileDisplayName(cachedProfile) const displayName = extractProfileDisplayName(cachedProfile)
if (displayName) { if (displayName) {
// Only add @ prefix if we have a real name, otherwise use fallback format directly // Only add @ prefix if we have a real name, otherwise use fallback format directly
const label = displayName.startsWith('@') ? displayName : `@${displayName}` const label = displayName.startsWith('@') ? displayName : `@${displayName}`
labels.set(encoded, label) labels.set(pubkey, label)
} else { } else {
// Use fallback npub display if profile has no name // Use fallback npub display if profile has no name
const fallback = getNpubFallbackDisplay(pubkey) 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 // 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. // when many profiles resolve simultaneously. We use refs to avoid stale closures in async callbacks.
// Use pubkey (hex) as the key for canonical identification
const pendingUpdatesRef = useRef<Map<string, string>>(new Map()) const pendingUpdatesRef = useRef<Map<string, string>>(new Map())
const rafScheduledRef = useRef<number | null>(null) const rafScheduledRef = useRef<number | null>(null)
@@ -125,13 +127,13 @@ export function useProfileLabels(
useEffect(() => { useEffect(() => {
// Use a functional update to access current state without including it in dependencies // Use a functional update to access current state without including it in dependencies
setProfileLabels(prevLabels => { setProfileLabels(prevLabels => {
const currentEncodedIds = new Set(Array.from(prevLabels.keys())) const currentPubkeys = new Set(Array.from(prevLabels.keys()))
const newEncodedIds = new Set(profileData.map(p => p.encoded)) const newPubkeys = new Set(profileData.map(p => p.pubkey))
// If the content changed significantly (different set of profiles), reset state // If the content changed significantly (different set of profiles), reset state
const hasDifferentProfiles = const hasDifferentProfiles =
currentEncodedIds.size !== newEncodedIds.size || currentPubkeys.size !== newPubkeys.size ||
!Array.from(newEncodedIds).every(id => currentEncodedIds.has(id)) !Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
if (hasDifferentProfiles) { if (hasDifferentProfiles) {
// Clear pending updates and cancel RAF for old profiles // Clear pending updates and cancel RAF for old profiles
@@ -145,10 +147,10 @@ export function useProfileLabels(
} else { } else {
// Same profiles, merge initial labels with existing state (initial labels take precedence for missing ones) // Same profiles, merge initial labels with existing state (initial labels take precedence for missing ones)
const merged = new Map(prevLabels) 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) // 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('@'))) { if (!merged.has(pubkey) || (!prevLabels.get(pubkey)?.startsWith('@') && label.startsWith('@'))) {
merged.set(encoded, label) merged.set(pubkey, label)
} }
} }
return merged return merged
@@ -157,12 +159,12 @@ export function useProfileLabels(
// Reset loading state when content changes significantly // Reset loading state when content changes significantly
setProfileLoading(prevLoading => { setProfileLoading(prevLoading => {
const currentEncodedIds = new Set(Array.from(prevLoading.keys())) const currentPubkeys = new Set(Array.from(prevLoading.keys()))
const newEncodedIds = new Set(profileData.map(p => p.encoded)) const newPubkeys = new Set(profileData.map(p => p.pubkey))
const hasDifferentProfiles = const hasDifferentProfiles =
currentEncodedIds.size !== newEncodedIds.size || currentPubkeys.size !== newPubkeys.size ||
!Array.from(newEncodedIds).every(id => currentEncodedIds.has(id)) !Array.from(newPubkeys).every(pk => currentPubkeys.has(pk))
if (hasDifferentProfiles) { if (hasDifferentProfiles) {
return new Map() return new Map()
@@ -198,16 +200,17 @@ export function useProfileLabels(
// Build labels from localStorage cache and eventStore // Build labels from localStorage cache and eventStore
// initialLabels already has all cached profiles, so we only need to check eventStore // initialLabels already has all cached profiles, so we only need to check eventStore
// Use pubkey (hex) as the key for canonical identification
const labels = new Map<string, string>(initialLabels) const labels = new Map<string, string>(initialLabels)
const loading = new Map<string, boolean>() const loading = new Map<string, boolean>()
const pubkeysToFetch: string[] = [] const pubkeysToFetch: string[] = []
profileData.forEach(({ encoded, pubkey }) => { profileData.forEach(({ pubkey }) => {
// Skip if already resolved from initial cache // Skip if already resolved from initial cache
if (labels.has(encoded)) { if (labels.has(pubkey)) {
loading.set(encoded, false) loading.set(pubkey, false)
console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... in cache, not loading`) console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... in cache, not loading`)
return return
} }
@@ -220,69 +223,59 @@ export function useProfileLabels(
if (displayName) { if (displayName) {
// Only add @ prefix if we have a real name, otherwise use fallback format directly // Only add @ prefix if we have a real name, otherwise use fallback format directly
const label = displayName.startsWith('@') ? displayName : `@${displayName}` const label = displayName.startsWith('@') ? displayName : `@${displayName}`
labels.set(encoded, label) labels.set(pubkey, label)
} else { } else {
// Use fallback npub display if profile has no name // Use fallback npub display if profile has no name
const fallback = getNpubFallbackDisplay(pubkey) const fallback = getNpubFallbackDisplay(pubkey)
labels.set(encoded, fallback) labels.set(pubkey, fallback)
} }
loading.set(encoded, false) loading.set(pubkey, false)
console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... in eventStore, not loading`) console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... in eventStore, not loading`)
} else { } else {
// No profile found yet, will use fallback after fetch or keep empty // No profile found yet, will use fallback after fetch or keep empty
// We'll set fallback labels for missing profiles at the end // We'll set fallback labels for missing profiles at the end
// Mark as loading since we'll fetch it // Mark as loading since we'll fetch it
pubkeysToFetch.push(pubkey) pubkeysToFetch.push(pubkey)
loading.set(encoded, true) loading.set(pubkey, true)
console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... not found, SET LOADING=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 // Set fallback labels for profiles that weren't found
profileData.forEach(({ encoded, pubkey }) => { profileData.forEach(({ pubkey }) => {
if (!labels.has(encoded)) { if (!labels.has(pubkey)) {
const fallback = getNpubFallbackDisplay(pubkey) const fallback = getNpubFallbackDisplay(pubkey)
labels.set(encoded, fallback) labels.set(pubkey, fallback)
} }
}) })
setProfileLabels(new Map(labels)) setProfileLabels(new Map(labels))
setProfileLoading(new Map(loading)) 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 // Fetch missing profiles asynchronously with reactive updates
if (pubkeysToFetch.length > 0 && relayPool && eventStore) { 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) + '...')) 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<string, string>()
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 // 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: Collect updates in ref, schedule RAF on first update, apply all in batch
const handleProfileEvent = (event: NostrEvent) => { const handleProfileEvent = (event: NostrEvent) => {
const encoded = pubkeyToEncoded.get(event.pubkey) // Use pubkey directly as the key
if (!encoded) { const pubkey = event.pubkey
return
}
// Determine the label for this profile using centralized utility // Determine the label for this profile using centralized utility
const displayName = extractProfileDisplayName(event) 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 // Add to pending updates and schedule batched application
pendingUpdatesRef.current.set(encoded, label) pendingUpdatesRef.current.set(pubkey, label)
scheduleBatchedUpdate() scheduleBatchedUpdate()
// Clear loading state for this profile when it resolves // 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 => { setProfileLoading(prevLoading => {
const updated = new Map(prevLoading) const updated = new Map(prevLoading)
updated.set(encoded, false) updated.set(pubkey, false)
return updated return updated
}) })
} }
@@ -298,13 +291,10 @@ export function useProfileLabels(
setProfileLoading(prevLoading => { setProfileLoading(prevLoading => {
const updated = new Map(prevLoading) const updated = new Map(prevLoading)
pubkeysToFetch.forEach(pubkey => { pubkeysToFetch.forEach(pubkey => {
const encoded = pubkeyToEncoded.get(pubkey) const wasLoading = updated.get(pubkey)
if (encoded) { updated.set(pubkey, false)
const wasLoading = updated.get(encoded) if (wasLoading) {
updated.set(encoded, false) console.log(`[profile-loading-debug][profile-labels-loading] ${pubkey.slice(0, 16)}... CLEARED loading after fetch complete`)
if (wasLoading) {
console.log(`[profile-loading-debug][profile-labels-loading] ${encoded.slice(0, 16)}... CLEARED loading after fetch complete`)
}
} }
}) })
return updated return updated
@@ -323,10 +313,7 @@ export function useProfileLabels(
setProfileLoading(prevLoading => { setProfileLoading(prevLoading => {
const updated = new Map(prevLoading) const updated = new Map(prevLoading)
pubkeysToFetch.forEach(pubkey => { pubkeysToFetch.forEach(pubkey => {
const encoded = pubkeyToEncoded.get(pubkey) updated.set(pubkey, false)
if (encoded) {
updated.set(encoded, false)
}
}) })
return updated return updated
}) })

View File

@@ -310,9 +310,9 @@ export function replaceNostrUrisInMarkdownWithTitles(
* This converts: nostr:npub1... to [@username](link) and nostr:naddr1... to [Article Title](link) * This converts: nostr:npub1... to [@username](link) and nostr:naddr1... to [Article Title](link)
* Labels update progressively as profiles load * Labels update progressively as profiles load
* @param markdown The markdown content to process * @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 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( export function replaceNostrUrisInMarkdownWithProfileLabels(
markdown: string, markdown: string,
@@ -326,12 +326,6 @@ export function replaceNostrUrisInMarkdownWithProfileLabels(
return replaceNostrUrisSafely(markdown, (encoded) => { return replaceNostrUrisSafely(markdown, (encoded) => {
const link = createNostrLink(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 // For articles, use the resolved title if available
try { try {
const decoded = decode(encoded) const decoded = decode(encoded)
@@ -340,25 +334,21 @@ export function replaceNostrUrisInMarkdownWithProfileLabels(
return `[${title}](${link})` 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') { if (decoded.type === 'npub' || decoded.type === 'nprofile') {
const hasLoading = profileLoading.has(encoded) const pubkey = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey
const isLoading = profileLoading.get(encoded)
// Debug: Check if there's a key mismatch // Check if we have a resolved profile name using pubkey as key
if (!hasLoading && profileLoading.size > 0) { if (profileLabels.has(pubkey)) {
// Check if there's a similar key (for debugging) const displayName = profileLabels.get(pubkey)!
const matchingKey = Array.from(profileLoading.keys()).find(k => k.includes(encoded.slice(0, 20)) || encoded.includes(k.slice(0, 20))) return `[${displayName}](${link})`
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) { // Check loading state using pubkey as key
const isLoading = profileLoading.get(pubkey)
if (isLoading === true) {
const label = getNostrUriLabel(encoded) 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 // Wrap in span with profile-loading class for CSS styling
return `[<span class="profile-loading">${label}</span>](${link})` return `[<span class="profile-loading">${label}</span>](${link})`
} }