@ -13,6 +13,7 @@ import { useMemo } from 'react'
import Image from '../Image'
import Image from '../Image'
import Username from '../Username'
import Username from '../Username'
import { cleanUrl } from '@/lib/url'
import { cleanUrl } from '@/lib/url'
import { tagNameEquals } from '@/lib/tag'
// Helper function to get event type name
// Helper function to get event type name
function getEventTypeName ( kind : number ) : string {
function getEventTypeName ( kind : number ) : string {
@ -105,17 +106,45 @@ export default function WebPreview({ url, className }: { url: string; className?
return naddrMatch ? . [ 1 ] || neventMatch ? . [ 1 ] || noteMatch ? . [ 1 ] || npubMatch ? . [ 1 ] || nprofileMatch ? . [ 1 ] || null
return naddrMatch ? . [ 1 ] || neventMatch ? . [ 1 ] || noteMatch ? . [ 1 ] || npubMatch ? . [ 1 ] || nprofileMatch ? . [ 1 ] || null
} , [ cleanedUrl ] )
} , [ cleanedUrl ] )
// Determine nostr type
// Determine nostr type and extract details
const nostrType = useMemo ( ( ) = > {
const nostrDetails = useMemo ( ( ) = > {
if ( ! nostrIdentifier ) return null
if ( ! nostrIdentifier ) return null
try {
try {
const decoded = nip19 . decode ( nostrIdentifier )
const decoded = nip19 . decode ( nostrIdentifier )
return decoded . type
const details : {
type : string
hexId? : string
dTag? : string
kind? : number
pubkey? : string
identifier? : string
} = { type : decoded . type }
if ( decoded . type === 'note' ) {
details . hexId = decoded . data
} else if ( decoded . type === 'nevent' ) {
details . hexId = decoded . data . id
details . kind = decoded . data . kind
details . pubkey = decoded . data . author
} else if ( decoded . type === 'naddr' ) {
details . kind = decoded . data . kind
details . pubkey = decoded . data . pubkey
details . identifier = decoded . data . identifier
details . dTag = decoded . data . identifier
} else if ( decoded . type === 'npub' ) {
details . pubkey = decoded . data
} else if ( decoded . type === 'nprofile' ) {
details . pubkey = decoded . data . pubkey
}
return details
} catch {
} catch {
return null
return null
}
}
} , [ nostrIdentifier ] )
} , [ nostrIdentifier ] )
const nostrType = nostrDetails ? . type || null
// Fetch profile for npub/nprofile
// Fetch profile for npub/nprofile
const profileId = nostrType === 'npub' || nostrType === 'nprofile' ? ( nostrIdentifier || undefined ) : undefined
const profileId = nostrType === 'npub' || nostrType === 'nprofile' ? ( nostrIdentifier || undefined ) : undefined
const { profile : fetchedProfile , isFetching : isFetchingProfile } = useFetchProfile ( profileId )
const { profile : fetchedProfile , isFetching : isFetchingProfile } = useFetchProfile ( profileId )
@ -124,6 +153,14 @@ export default function WebPreview({ url, className }: { url: string; className?
const eventId = ( nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note' ) ? ( nostrIdentifier || undefined ) : undefined
const eventId = ( nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note' ) ? ( nostrIdentifier || undefined ) : undefined
const { event : fetchedEvent , isFetching : isFetchingEvent } = useFetchEvent ( eventId )
const { event : fetchedEvent , isFetching : isFetchingEvent } = useFetchEvent ( eventId )
// Extract d-tag from fetched event if available
const eventDTag = useMemo ( ( ) = > {
if ( fetchedEvent ) {
return fetchedEvent . tags . find ( tagNameEquals ( 'd' ) ) ? . [ 1 ]
}
return nostrDetails ? . dTag
} , [ fetchedEvent , nostrDetails ] )
// Get content preview (first 500 chars, stripped of markdown) - ALWAYS call hooks before any returns
// Get content preview (first 500 chars, stripped of markdown) - ALWAYS call hooks before any returns
const contentPreview = useMemo ( ( ) = > {
const contentPreview = useMemo ( ( ) = > {
if ( ! fetchedEvent ? . content ) return ''
if ( ! fetchedEvent ? . content ) return ''
@ -136,10 +173,12 @@ export default function WebPreview({ url, className }: { url: string; className?
return null
return null
}
}
// Always try to fetch OG data for standalone hyperlinks (except internal jumble links)
// Check if we have any opengraph data (title, description, or image)
// Check if we have any opengraph data (title, description, or image)
const hasOpengraphData = ! isInternalJumbleLink && ( title || description || image )
const hasOpengraphData = ! isInternalJumbleLink && ( title || description || image )
// If no opengraph metadata available, show enhanced fallback link card
// If no opengraph metadata available, show enhanced fallback link card
// Note: We always attempt to fetch OG data via useFetchWebMetadata hook above
if ( ! hasOpengraphData ) {
if ( ! hasOpengraphData ) {
// Enhanced card for event URLs (always show if nostr identifier detected, even while loading)
// Enhanced card for event URLs (always show if nostr identifier detected, even while loading)
if ( nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note' ) {
if ( nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note' ) {
@ -160,9 +199,26 @@ export default function WebPreview({ url, className }: { url: string; className?
. join ( ' ' )
. join ( ' ' )
}
}
// Build identifier details
const identifierParts : string [ ] = [ ]
if ( nostrDetails ? . hexId ) {
identifierParts . push ( ` Hex: ${ nostrDetails . hexId . substring ( 0 , 16 ) } ... ` )
}
if ( eventDTag ) {
identifierParts . push ( ` d-tag: ${ eventDTag } ` )
} else if ( nostrDetails ? . dTag ) {
identifierParts . push ( ` d-tag: ${ nostrDetails . dTag } ` )
}
if ( nostrDetails ? . kind ) {
identifierParts . push ( ` kind: ${ nostrDetails . kind } ` )
}
if ( nostrType ) {
identifierParts . push ( ` Type: ${ nostrType } ` )
}
return (
return (
< div
< div
className = { cn ( 'p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3' , className ) }
className = { cn ( 'p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 ' , className ) }
onClick = { ( e ) = > {
onClick = { ( e ) = > {
e . stopPropagation ( )
e . stopPropagation ( )
window . open ( cleanedUrl , '_blank' )
window . open ( cleanedUrl , '_blank' )
@ -171,7 +227,7 @@ export default function WebPreview({ url, className }: { url: string; className?
{ eventImage && fetchedEvent && (
{ eventImage && fetchedEvent && (
< Image
< Image
image = { { url : eventImage , pubkey : fetchedEvent.pubkey } }
image = { { url : eventImage , pubkey : fetchedEvent.pubkey } }
className = "w-20 h-20 rounded-lg flex-shrink-0 object-cover"
className = "w-20 h-20 rounded-lg flex-shrink-0 object-cover border border-green-200 dark:border-green-800 "
hideIfError
hideIfError
/ >
/ >
) }
) }
@ -188,12 +244,12 @@ export default function WebPreview({ url, className }: { url: string; className?
{ isFetchingEvent ? 'Loading event...' : 'Event' }
{ isFetchingEvent ? 'Loading event...' : 'Event' }
< / span >
< / span >
) }
) }
< ExternalLink className = "w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" / >
< ExternalLink className = "w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0 ml-auto" / >
< / div >
< / div >
{ fetchedEvent && (
{ fetchedEvent && (
< >
< >
{ eventTitle && (
{ eventTitle && (
< div className = "font-semibold text-sm line-clamp-2 mb-1" > { eventTitle } < / div >
< div className = "font-semibold text-sm line-clamp-2 mb-1 text-green-900 dark:text-green-100 " > { eventTitle } < / div >
) }
) }
{ isBookstrEvent && bookMetadata && (
{ isBookstrEvent && bookMetadata && (
< div className = "text-xs text-muted-foreground space-x-2 mb-1" >
< div className = "text-xs text-muted-foreground space-x-2 mb-1" >
@ -214,6 +270,13 @@ export default function WebPreview({ url, className }: { url: string; className?
) }
) }
< / >
< / >
) }
) }
{ identifierParts . length > 0 && (
< div className = "text-xs text-muted-foreground space-x-2 mt-2 pt-2 border-t border-green-200 dark:border-green-800" >
{ identifierParts . map ( ( part , idx ) = > (
< span key = { idx } className = "font-mono" > { part } < / span >
) ) }
< / div >
) }
< div className = "text-xs text-muted-foreground truncate mt-1" > { hostname } < / div >
< div className = "text-xs text-muted-foreground truncate mt-1" > { hostname } < / div >
< / div >
< / div >
< / div >
< / div >
@ -222,25 +285,62 @@ export default function WebPreview({ url, className }: { url: string; className?
// Enhanced card for profile URLs (loading state)
// Enhanced card for profile URLs (loading state)
if ( nostrType === 'npub' || nostrType === 'nprofile' ) {
if ( nostrType === 'npub' || nostrType === 'nprofile' ) {
// Build identifier details for profile
const profileIdentifierParts : string [ ] = [ ]
if ( nostrDetails ? . pubkey ) {
profileIdentifierParts . push ( ` Pubkey: ${ nostrDetails . pubkey . substring ( 0 , 16 ) } ... ` )
}
if ( fetchedProfile ? . nip05 ) {
profileIdentifierParts . push ( ` NIP-05: ${ fetchedProfile . nip05 } ` )
}
if ( nostrType ) {
profileIdentifierParts . push ( ` Type: ${ nostrType } ` )
}
return (
return (
< div
< div
className = { cn ( 'p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3' , className ) }
className = { cn ( 'p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 ' , className ) }
onClick = { ( e ) = > {
onClick = { ( e ) = > {
e . stopPropagation ( )
e . stopPropagation ( )
window . open ( cleanedUrl , '_blank' )
window . open ( cleanedUrl , '_blank' )
} }
} }
>
>
{ fetchedProfile ? . avatar && (
< Image
image = { { url : fetchedProfile.avatar , pubkey : fetchedProfile.pubkey } }
className = "w-16 h-16 rounded-lg flex-shrink-0 object-cover border border-green-200 dark:border-green-800"
hideIfError
/ >
) }
< div className = "flex-1 min-w-0" >
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2" >
< div className = "flex items-center gap-2 mb-1 " >
{ fetchedProfile ? (
{ fetchedProfile ? (
< Username userId = { fetchedProfile . pubkey } / >
< >
< Username userId = { fetchedProfile . pubkey } / >
{ fetchedProfile . nip05 && (
< >
< span className = "text-xs text-muted-foreground" > • < / span >
< span className = "text-xs text-green-600 dark:text-green-400" > { fetchedProfile . nip05 } < / span >
< / >
) }
< / >
) : (
) : (
< span className = "text-sm text-muted-foreground" >
< span className = "text-sm text-muted-foreground" >
{ isFetchingProfile ? 'Loading profile...' : 'Profile' }
{ isFetchingProfile ? 'Loading profile...' : 'Profile' }
< / span >
< / span >
) }
) }
< ExternalLink className = "w-3 h-3 text-muted-foreground flex-shrink-0" / >
< ExternalLink className = "w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0 ml-auto " / >
< / div >
< / div >
{ fetchedProfile ? . about && (
< div className = "text-xs text-muted-foreground line-clamp-2 mb-1 mt-1" > { fetchedProfile . about } < / div >
) }
{ profileIdentifierParts . length > 0 && (
< div className = "text-xs text-muted-foreground space-x-2 mt-2 pt-2 border-t border-green-200 dark:border-green-800" >
{ profileIdentifierParts . map ( ( part , idx ) = > (
< span key = { idx } className = "font-mono" > { part } < / span >
) ) }
< / div >
) }
< div className = "text-xs text-muted-foreground truncate mt-1" > { hostname } < / div >
< div className = "text-xs text-muted-foreground truncate mt-1" > { hostname } < / div >
< div className = "text-xs text-muted-foreground truncate" > { url } < / div >
< div className = "text-xs text-muted-foreground truncate" > { url } < / div >
< / div >
< / div >
@ -248,21 +348,21 @@ export default function WebPreview({ url, className }: { url: string; className?
)
)
}
}
// Basic fallback for non-nostr URLs
// Basic fallback for non-nostr URLs - show site information
return (
return (
< div
< div
className = { cn ( 'p-2 clickable flex w-full border rounded-lg overflow-hidden' , className ) }
className = { cn ( 'p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 ' , className ) }
onClick = { ( e ) = > {
onClick = { ( e ) = > {
e . stopPropagation ( )
e . stopPropagation ( )
window . open ( cleanedUrl , '_blank' )
window . open ( cleanedUrl , '_blank' )
} }
} }
>
>
< div className = "flex-1 w-0 flex items-center gap-2" >
< div className = "flex-1 min-w-0" >
< ExternalLink className = "w-4 h-4 text-muted-foreground flex-shrink-0" / >
< div className = "flex items-center gap-2 mb-1" >
< div className = "flex-1 min-w-0" >
< div className = "text-sm font-semibold text-green-900 dark:text-green-100 truncate" > { hostname } < / div >
< div className = "text-xs text-muted-foreground truncate" > { hostname } < / div >
< ExternalLink className = "w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0" / >
< div className = "text-sm font-medium truncate" > { url } < / div >
< / div >
< / div >
< div className = "text-xs text-muted-foreground break-all line-clamp-2" > { cleanedUrl } < / div >
< / div >
< / div >
< / div >
< / div >
)
)