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
This commit is contained in:
Gigi
2025-11-02 22:29:35 +01:00
parent ee7df54d87
commit 156cf31625
6 changed files with 135 additions and 14 deletions

View File

@@ -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<NostrMentionLinkProps> = ({
// 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<NostrMentionLinkProps> = ({
const renderProfileLink = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
const displayName = getProfileDisplayName(profile, pubkey)
const linkClassName = isLoading ? `${className} profile-loading` : className
return (
<a
href={`/p/${npub}`}
className={className}
className={linkClassName}
onClick={onClick}
>
@{displayName}

View File

@@ -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<ResolvedMentionProps> = ({ 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 (
<Link
to={`/p/${npub}`}
className="nostr-mention"
className={className}
>
@{display}
</Link>

View File

@@ -22,7 +22,7 @@ export const useMarkdownToHTML = (
const [articleTitles, setArticleTitles] = useState<Map<string, string>>(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 }
}

View File

@@ -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<string, string> {
export function useProfileLabels(
content: string,
relayPool?: RelayPool | null
): { labels: Map<string, string>; loading: Map<string, boolean> } {
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<Map<string, string>>(initialLabels)
const [profileLoading, setProfileLoading] = useState<Map<string, boolean>>(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<string, string>(initialLabels)
const loading = new Map<string, boolean>()
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 }
}

View File

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

View File

@@ -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<string, string> = new Map(),
articleTitles: Map<string, string> = new Map()
articleTitles: Map<string, string> = new Map(),
profileLoading: Map<string, boolean> = 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 `[<span class="profile-loading">${label}</span>](${link})`
}
} catch (error) {
// Ignore decode errors, fall through to default label
}