mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 06:34:24 +01:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user