-
-
- {source.type === 'event' ? 'nevent' : 'naddr'}: {source.value.slice(0, 20)}...
-
-
-
+
+ {content}
)
}
diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx
index 7df4949..47937aa 100644
--- a/src/components/UserAvatar/index.tsx
+++ b/src/components/UserAvatar/index.tsx
@@ -5,7 +5,8 @@ import { generateImageByPubkey } from '@/lib/pubkey'
import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSmartProfileNavigation } from '@/PageManager'
-import { useMemo } from 'react'
+import { nip19 } from 'nostr-tools'
+import { useMemo, useState, useEffect } from 'react'
const UserAvatarSizeCnMap = {
large: 'w-24 h-24',
@@ -69,25 +70,111 @@ export function SimpleUserAvatar({
className?: string
}) {
const { profile } = useFetchProfile(userId)
+ // Always generate default avatar from userId/pubkey, even if profile isn't loaded yet
+ const pubkey = useMemo(() => {
+ if (!userId) return ''
+ try {
+ // Try to extract pubkey from userId (handles npub, nprofile, or hex pubkey)
+ if (userId.length === 64 && /^[0-9a-f]+$/i.test(userId)) {
+ return userId
+ }
+ // Try to decode npub/nprofile to get pubkey
+ try {
+ const decoded = nip19.decode(userId)
+ if (decoded.type === 'npub') {
+ return decoded.data
+ } else if (decoded.type === 'nprofile') {
+ return decoded.data.pubkey
+ }
+ } catch {
+ // Not a valid npub/nprofile, continue
+ }
+ // Use profile pubkey if available
+ if (profile?.pubkey) {
+ return profile.pubkey
+ }
+ return ''
+ } catch {
+ return ''
+ }
+ }, [userId, profile?.pubkey])
+
const defaultAvatar = useMemo(
- () => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
- [profile]
+ () => (pubkey ? generateImageByPubkey(pubkey) : ''),
+ [pubkey]
)
- if (!profile) {
+ // Use profile avatar if available, otherwise use default avatar
+ const avatarSrc = profile?.avatar || defaultAvatar || ''
+
+ // All hooks must be called before any early returns
+ const [imgError, setImgError] = useState(false)
+ const [currentSrc, setCurrentSrc] = useState(avatarSrc)
+ const [imageLoaded, setImageLoaded] = useState(false)
+
+ // Reset error state when src changes
+ useEffect(() => {
+ setImgError(false)
+ setImageLoaded(false)
+ setCurrentSrc(avatarSrc)
+ }, [avatarSrc])
+
+ const handleImageError = () => {
+ if (profile?.avatar && defaultAvatar && currentSrc === profile.avatar) {
+ // Try default avatar if profile avatar fails
+ setCurrentSrc(defaultAvatar)
+ setImgError(false)
+ } else {
+ // Both failed, show placeholder
+ setImgError(true)
+ setImageLoaded(true)
+ }
+ }
+
+ const handleImageLoad = () => {
+ setImageLoaded(true)
+ setImgError(false)
+ }
+
+ // If we have a pubkey (from decoding npub/nprofile or profile), show avatar even without profile
+ // Otherwise show skeleton while loading
+ if (!profile && !pubkey) {
return (
)
}
- const { avatar, pubkey } = profile
-
+ // Use pubkey from decoded userId if profile isn't loaded yet
+ const displayPubkey = profile?.pubkey || pubkey || ''
+
+ // Render image directly instead of using Radix UI Avatar for better reliability
return (
-
-
-
-
-
-
+
+ {!imgError && currentSrc ? (
+ <>
+ {!imageLoaded && (
+
+ )}
+

+ >
+ ) : (
+ // Show initials or placeholder when image fails
+
+ {displayPubkey ? displayPubkey.slice(0, 2).toUpperCase() : ''}
+
+ )}
+
)
}
\ No newline at end of file
diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx
index 950fb95..eb084e3 100644
--- a/src/components/WebPreview/index.tsx
+++ b/src/components/WebPreview/index.tsx
@@ -1,9 +1,80 @@
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
+import { useFetchEvent } from '@/hooks/useFetchEvent'
+import { useFetchProfile } from '@/hooks/useFetchProfile'
+import { ExtendedKind } from '@/constants'
+import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
+import { ExternalLink } from 'lucide-react'
+import { nip19, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import Image from '../Image'
+import { SimpleUserAvatar } from '../UserAvatar'
+import Username from '../Username'
+
+// Helper function to get event type name
+function getEventTypeName(kind: number): string {
+ switch (kind) {
+ case kinds.ShortTextNote:
+ return 'Text Post'
+ case kinds.LongFormArticle:
+ return 'Longform Article'
+ case ExtendedKind.PICTURE:
+ return 'Picture'
+ case ExtendedKind.VIDEO:
+ return 'Video'
+ case ExtendedKind.SHORT_VIDEO:
+ return 'Short Video'
+ case ExtendedKind.POLL:
+ return 'Poll'
+ case ExtendedKind.COMMENT:
+ return 'Comment'
+ case ExtendedKind.VOICE:
+ return 'Voice Post'
+ case ExtendedKind.VOICE_COMMENT:
+ return 'Voice Comment'
+ case kinds.Highlights:
+ return 'Highlight'
+ case ExtendedKind.PUBLICATION:
+ return 'Publication'
+ case ExtendedKind.PUBLICATION_CONTENT:
+ return 'Publication Content'
+ case ExtendedKind.WIKI_ARTICLE:
+ return 'Wiki Article'
+ case ExtendedKind.WIKI_ARTICLE_MARKDOWN:
+ return 'Wiki Article'
+ case ExtendedKind.DISCUSSION:
+ return 'Discussion'
+ default:
+ return `Event (kind ${kind})`
+ }
+}
+
+// Helper function to extract and strip markdown/asciidoc for preview
+function stripMarkdown(content: string): string {
+ let text = content
+ // Remove markdown headers
+ text = text.replace(/^#{1,6}\s+/gm, '')
+ // Remove markdown bold/italic
+ text = text.replace(/\*\*([^*]+)\*\*/g, '$1')
+ text = text.replace(/\*([^*]+)\*/g, '$1')
+ // Remove markdown links
+ text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
+ // Remove asciidoc headers
+ text = text.replace(/^=+\s+/gm, '')
+ // Remove asciidoc bold/italic
+ text = text.replace(/\*\*([^*]+)\*\*/g, '$1')
+ text = text.replace(/_([^_]+)_/g, '$1')
+ // Remove code blocks
+ text = text.replace(/```[\s\S]*?```/g, '')
+ text = text.replace(/`([^`]+)`/g, '$1')
+ // Remove HTML tags
+ text = text.replace(/<[^>]+>/g, '')
+ // Clean up whitespace
+ text = text.replace(/\n{3,}/g, '\n\n')
+ return text.trim()
+}
export default function WebPreview({ url, className }: { url: string; className?: string }) {
const { autoLoadMedia } = useContentPolicy()
@@ -18,12 +89,164 @@ export default function WebPreview({ url, className }: { url: string; className?
}
}, [url])
+ // Extract nostr identifier from URL
+ const nostrIdentifier = useMemo(() => {
+ const naddrMatch = url.match(/(naddr1[a-z0-9]+)/i)
+ const neventMatch = url.match(/(nevent1[a-z0-9]+)/i)
+ const noteMatch = url.match(/(note1[a-z0-9]{58})/i)
+ const npubMatch = url.match(/(npub1[a-z0-9]{58})/i)
+ const nprofileMatch = url.match(/(nprofile1[a-z0-9]+)/i)
+
+ return naddrMatch?.[1] || neventMatch?.[1] || noteMatch?.[1] || npubMatch?.[1] || nprofileMatch?.[1] || null
+ }, [url])
+
+ // Determine nostr type
+ const nostrType = useMemo(() => {
+ if (!nostrIdentifier) return null
+ try {
+ const decoded = nip19.decode(nostrIdentifier)
+ return decoded.type
+ } catch {
+ return null
+ }
+ }, [nostrIdentifier])
+
+ // Fetch profile for npub/nprofile
+ const profileId = nostrType === 'npub' || nostrType === 'nprofile' ? (nostrIdentifier || undefined) : undefined
+ const { profile: fetchedProfile, isFetching: isFetchingProfile } = useFetchProfile(profileId)
+
+ // Fetch event for naddr/nevent/note
+ const eventId = (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') ? (nostrIdentifier || undefined) : undefined
+ const { event: fetchedEvent, isFetching: isFetchingEvent } = useFetchEvent(eventId)
+
+ // Get content preview (first 500 chars, stripped of markdown) - ALWAYS call hooks before any returns
+ const contentPreview = useMemo(() => {
+ if (!fetchedEvent?.content) return ''
+ const stripped = stripMarkdown(fetchedEvent.content)
+ return stripped.length > 500 ? stripped.substring(0, 500) + '...' : stripped
+ }, [fetchedEvent])
+
+ // Early return after ALL hooks are called
if (!autoLoadMedia) {
return null
}
- if (!title) {
- return null
+ // Check if we have any opengraph data (title, description, or image)
+ const hasOpengraphData = title || description || image
+
+ // If no opengraph metadata available, show enhanced fallback link card
+ if (!hasOpengraphData) {
+ // Enhanced card for event URLs (always show if nostr identifier detected, even while loading)
+ if (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') {
+ const eventMetadata = fetchedEvent ? getLongFormArticleMetadataFromEvent(fetchedEvent) : null
+ const eventTypeName = fetchedEvent ? getEventTypeName(fetchedEvent.kind) : null
+ const eventTitle = eventMetadata?.title || eventTypeName
+ const eventSummary = eventMetadata?.summary || description
+ const eventImage = eventMetadata?.image
+
+ return (
+
{
+ e.stopPropagation()
+ window.open(url, '_blank')
+ }}
+ >
+ {eventImage && fetchedEvent && (
+
+ )}
+
+
+ {fetchedEvent ? (
+ <>
+
+
+ •
+ {eventTypeName}
+ >
+ ) : (
+
+ {isFetchingEvent ? 'Loading event...' : 'Event'}
+
+ )}
+
+
+ {fetchedEvent && (
+ <>
+ {eventTitle && (
+
{eventTitle}
+ )}
+ {eventSummary && (
+
{eventSummary}
+ )}
+ {contentPreview && (
+
+ {contentPreview}
+
+ )}
+ >
+ )}
+
{hostname}
+
+
+ )
+ }
+
+ // Enhanced card for profile URLs (loading state)
+ if (nostrType === 'npub' || nostrType === 'nprofile') {
+ return (
+
{
+ e.stopPropagation()
+ window.open(url, '_blank')
+ }}
+ >
+ {fetchedProfile ? (
+
+ ) : (
+
+ )}
+
+
+ {fetchedProfile ? (
+
+ ) : (
+
+ {isFetchingProfile ? 'Loading profile...' : 'Profile'}
+
+ )}
+
+
+
{hostname}
+
{url}
+
+
+ )
+ }
+
+ // Basic fallback for non-nostr URLs
+ return (
+
{
+ e.stopPropagation()
+ window.open(url, '_blank')
+ }}
+ >
+
+
+ )
}
if (isSmallScreen && image) {
@@ -38,7 +261,8 @@ export default function WebPreview({ url, className }: { url: string; className?
{hostname}
-
{title}
+ {title &&
{title}
}
+ {!title && description &&
{description}
}
)
@@ -61,8 +285,12 @@ export default function WebPreview({ url, className }: { url: string; className?
)}