|
|
|
@ -9,11 +9,15 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider' |
|
|
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
|
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
|
|
import { ExternalLink } from 'lucide-react' |
|
|
|
import { ExternalLink } from 'lucide-react' |
|
|
|
import { nip19, kinds } from 'nostr-tools' |
|
|
|
import { nip19, kinds } from 'nostr-tools' |
|
|
|
import { useMemo } from 'react' |
|
|
|
import { useMemo, useEffect, useState } 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' |
|
|
|
import { tagNameEquals } from '@/lib/tag' |
|
|
|
|
|
|
|
import client from '@/services/client.service' |
|
|
|
|
|
|
|
import { Event } from 'nostr-tools' |
|
|
|
|
|
|
|
import { BIG_RELAY_URLS } from '@/constants' |
|
|
|
|
|
|
|
import { getImetaInfosFromEvent } from '@/lib/event' |
|
|
|
|
|
|
|
|
|
|
|
// Helper function to get event type name
|
|
|
|
// Helper function to get event type name
|
|
|
|
function getEventTypeName(kind: number): string { |
|
|
|
function getEventTypeName(kind: number): string { |
|
|
|
@ -95,16 +99,197 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
|
|
|
|
|
|
|
|
const isInternalJumbleLink = useMemo(() => hostname === 'jumble.imwald.eu', [hostname]) |
|
|
|
const isInternalJumbleLink = useMemo(() => hostname === 'jumble.imwald.eu', [hostname]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Extract replaceable event info (d-tag and pubkey) from URL patterns
|
|
|
|
|
|
|
|
// This is separate from nostrIdentifier to allow fetching without kind
|
|
|
|
|
|
|
|
const replaceableEventInfo = useMemo(() => { |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
// Pattern 1: d-tag*npub format
|
|
|
|
|
|
|
|
const dtagNpubMatch = cleanedUrl.match(/([^\/\?\#\&\*]+)\*(npub1[a-z0-9]{58})/i) |
|
|
|
|
|
|
|
if (dtagNpubMatch) { |
|
|
|
|
|
|
|
const dTag = dtagNpubMatch[1].split('/').pop() || dtagNpubMatch[1] |
|
|
|
|
|
|
|
const npub = dtagNpubMatch[2] |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const decoded = nip19.decode(npub) |
|
|
|
|
|
|
|
if (decoded.type === 'npub') { |
|
|
|
|
|
|
|
return { dTag, pubkey: decoded.data } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} catch {} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Pattern 2: d-tag*hexpubkey format
|
|
|
|
|
|
|
|
const dtagHexMatch = cleanedUrl.match(/([^\/\?\#\&\*]+)\*([a-f0-9]{64})/i) |
|
|
|
|
|
|
|
if (dtagHexMatch) { |
|
|
|
|
|
|
|
const dTag = dtagHexMatch[1].split('/').pop() || dtagHexMatch[1] |
|
|
|
|
|
|
|
const hexPubkey = dtagHexMatch[2] |
|
|
|
|
|
|
|
return { dTag, pubkey: hexPubkey } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Pattern 3: d-tag/npub format
|
|
|
|
|
|
|
|
const dtagSlashNpubMatch = cleanedUrl.match(/([^\/\?\#\&]+)\/(npub1[a-z0-9]{58})/i) |
|
|
|
|
|
|
|
if (dtagSlashNpubMatch) { |
|
|
|
|
|
|
|
const dTag = dtagSlashNpubMatch[1].split('/').pop() || dtagSlashNpubMatch[1] |
|
|
|
|
|
|
|
const npub = dtagSlashNpubMatch[2] |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const decoded = nip19.decode(npub) |
|
|
|
|
|
|
|
if (decoded.type === 'npub') { |
|
|
|
|
|
|
|
return { dTag, pubkey: decoded.data } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} catch {} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Pattern 4: d-tag and npub in path (e.g., https://wikifreedia.xyz/nostr-event-register/npub1...)
|
|
|
|
|
|
|
|
// Only check if we haven't already matched a more specific pattern
|
|
|
|
|
|
|
|
if (!dtagNpubMatch && !dtagHexMatch && !dtagSlashNpubMatch) { |
|
|
|
|
|
|
|
const pathNpubMatch = cleanedUrl.match(/(npub1[a-z0-9]{58})/i) |
|
|
|
|
|
|
|
if (pathNpubMatch) { |
|
|
|
|
|
|
|
const npub = pathNpubMatch[1] |
|
|
|
|
|
|
|
const npubIndex = cleanedUrl.indexOf(npub) |
|
|
|
|
|
|
|
const pathBeforeNpub = cleanedUrl.substring(0, npubIndex) |
|
|
|
|
|
|
|
const pathSegments = pathBeforeNpub.split('/').filter(Boolean) |
|
|
|
|
|
|
|
if (pathSegments.length > 0) { |
|
|
|
|
|
|
|
const possibleDTag = pathSegments[pathSegments.length - 1] |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const decoded = nip19.decode(npub) |
|
|
|
|
|
|
|
if (decoded.type === 'npub') { |
|
|
|
|
|
|
|
return { dTag: possibleDTag, pubkey: decoded.data } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} catch {} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Pattern 5: d-tag only with /d/ prefix - try to find pubkey in URL
|
|
|
|
|
|
|
|
// Only check if we haven't already matched a pattern with both d-tag and pubkey
|
|
|
|
|
|
|
|
if (!dtagNpubMatch && !dtagHexMatch && !dtagSlashNpubMatch) { |
|
|
|
|
|
|
|
const dtagOnlyMatch = cleanedUrl.match(/\/d\/([^\/\?\#\&]+)/i) |
|
|
|
|
|
|
|
if (dtagOnlyMatch) { |
|
|
|
|
|
|
|
const dTag = dtagOnlyMatch[1] |
|
|
|
|
|
|
|
const urlParts = cleanedUrl.split('/d/') |
|
|
|
|
|
|
|
const pathBefore = urlParts[0].split('/').filter(Boolean) |
|
|
|
|
|
|
|
const pathAfter = urlParts[1] ? urlParts[1].split('/').filter(Boolean) : [] |
|
|
|
|
|
|
|
const allPathParts = [...pathBefore, ...pathAfter] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const part of allPathParts) { |
|
|
|
|
|
|
|
if (/^npub1[a-z0-9]{58}$/i.test(part)) { |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const decoded = nip19.decode(part) |
|
|
|
|
|
|
|
if (decoded.type === 'npub') { |
|
|
|
|
|
|
|
return { dTag, pubkey: decoded.data } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} catch {} |
|
|
|
|
|
|
|
} else if (/^[a-f0-9]{64}$/i.test(part)) { |
|
|
|
|
|
|
|
return { dTag, pubkey: part } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
// If no pubkey found, return d-tag only (we can't fetch without pubkey, will show OG card)
|
|
|
|
|
|
|
|
return { dTag, pubkey: null } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
|
|
// Failed to parse
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return null |
|
|
|
|
|
|
|
}, [cleanedUrl]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch replaceable event by d-tag and pubkey (without kind)
|
|
|
|
|
|
|
|
// If pubkey is null, fetch by d-tag only (across all authors)
|
|
|
|
|
|
|
|
// Only use the result if exactly one event is found (to avoid ambiguous d-tags)
|
|
|
|
|
|
|
|
const [fetchedReplaceableEvent, setFetchedReplaceableEvent] = useState<Event | null>(null) |
|
|
|
|
|
|
|
const [isFetchingReplaceableEvent, setIsFetchingReplaceableEvent] = useState(false) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
|
|
if (!replaceableEventInfo || !replaceableEventInfo.dTag) { |
|
|
|
|
|
|
|
setFetchedReplaceableEvent(null) |
|
|
|
|
|
|
|
setIsFetchingReplaceableEvent(false) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setIsFetchingReplaceableEvent(true) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch replaceable events by d-tag and pubkey across all replaceable kinds
|
|
|
|
|
|
|
|
// Common replaceable event kinds
|
|
|
|
|
|
|
|
const replaceableKinds = [30023, 30818, 30041, 30817, 30040, 30024] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const fetchReplaceableEvent = async () => { |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const filters = replaceableKinds.map(kind => { |
|
|
|
|
|
|
|
const filter: any = { |
|
|
|
|
|
|
|
kinds: [kind], |
|
|
|
|
|
|
|
'#d': [replaceableEventInfo.dTag], |
|
|
|
|
|
|
|
limit: 1 |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
// Only filter by author if we have a pubkey
|
|
|
|
|
|
|
|
if (replaceableEventInfo.pubkey) { |
|
|
|
|
|
|
|
filter.authors = [replaceableEventInfo.pubkey] |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return filter |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const events = await client.fetchEvents(BIG_RELAY_URLS, filters) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Find all events with matching d-tag
|
|
|
|
|
|
|
|
const matchingEvents = events.filter(event => { |
|
|
|
|
|
|
|
const eventDTag = event.tags.find(tagNameEquals('d'))?.[1] |
|
|
|
|
|
|
|
return eventDTag === replaceableEventInfo.dTag |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Only use the result if exactly one event is found
|
|
|
|
|
|
|
|
// If zero or multiple events, fall back to OG card (ambiguous d-tag)
|
|
|
|
|
|
|
|
if (matchingEvents.length === 1) { |
|
|
|
|
|
|
|
setFetchedReplaceableEvent(matchingEvents[0]) |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
setFetchedReplaceableEvent(null) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
|
|
// Failed to fetch
|
|
|
|
|
|
|
|
setFetchedReplaceableEvent(null) |
|
|
|
|
|
|
|
} finally { |
|
|
|
|
|
|
|
setIsFetchingReplaceableEvent(false) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fetchReplaceableEvent() |
|
|
|
|
|
|
|
}, [replaceableEventInfo]) |
|
|
|
|
|
|
|
|
|
|
|
// Extract nostr identifier from URL
|
|
|
|
// Extract nostr identifier from URL
|
|
|
|
|
|
|
|
// If we found a replaceable event and fetched it, create naddr from the fetched event
|
|
|
|
|
|
|
|
// Otherwise, check for direct nostr identifiers
|
|
|
|
const nostrIdentifier = useMemo(() => { |
|
|
|
const nostrIdentifier = useMemo(() => { |
|
|
|
|
|
|
|
// If we found a replaceable event and fetched it, create naddr from the fetched event
|
|
|
|
|
|
|
|
if (fetchedReplaceableEvent) { |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const eventDTag = fetchedReplaceableEvent.tags.find(tagNameEquals('d'))?.[1] || '' |
|
|
|
|
|
|
|
const naddr = nip19.naddrEncode({ |
|
|
|
|
|
|
|
kind: fetchedReplaceableEvent.kind, |
|
|
|
|
|
|
|
pubkey: fetchedReplaceableEvent.pubkey, |
|
|
|
|
|
|
|
identifier: eventDTag |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
return naddr |
|
|
|
|
|
|
|
} catch { |
|
|
|
|
|
|
|
// Failed to encode
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check for direct nostr identifiers in URL
|
|
|
|
|
|
|
|
// IMPORTANT: Check for npub in specific paths (like /p/npub1...) to avoid treating as event
|
|
|
|
|
|
|
|
const isNpubOnlyPath = /\/p\/(npub1[a-z0-9]{58})/i.test(cleanedUrl) ||
|
|
|
|
|
|
|
|
/\/profile\/(npub1[a-z0-9]{58})/i.test(cleanedUrl) || |
|
|
|
|
|
|
|
/\/user\/(npub1[a-z0-9]{58})/i.test(cleanedUrl) |
|
|
|
|
|
|
|
|
|
|
|
const naddrMatch = cleanedUrl.match(/(naddr1[a-z0-9]+)/i) |
|
|
|
const naddrMatch = cleanedUrl.match(/(naddr1[a-z0-9]+)/i) |
|
|
|
const neventMatch = cleanedUrl.match(/(nevent1[a-z0-9]+)/i) |
|
|
|
const neventMatch = cleanedUrl.match(/(nevent1[a-z0-9]+)/i) |
|
|
|
const noteMatch = cleanedUrl.match(/(note1[a-z0-9]{58})/i) |
|
|
|
const noteMatch = cleanedUrl.match(/(note1[a-z0-9]{58})/i) |
|
|
|
const npubMatch = cleanedUrl.match(/(npub1[a-z0-9]{58})/i) |
|
|
|
const npubMatch = isNpubOnlyPath ? null : cleanedUrl.match(/(npub1[a-z0-9]{58})/i) |
|
|
|
const nprofileMatch = cleanedUrl.match(/(nprofile1[a-z0-9]+)/i) |
|
|
|
const nprofileMatch = cleanedUrl.match(/(nprofile1[a-z0-9]+)/i) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// If npub-only path, extract npub for profile
|
|
|
|
|
|
|
|
if (isNpubOnlyPath) { |
|
|
|
|
|
|
|
const npubPathMatch = cleanedUrl.match(/(npub1[a-z0-9]{58})/i) |
|
|
|
|
|
|
|
return npubPathMatch?.[1] || null |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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, fetchedReplaceableEvent]) |
|
|
|
|
|
|
|
|
|
|
|
// Determine nostr type and extract details
|
|
|
|
// Determine nostr type and extract details
|
|
|
|
const nostrDetails = useMemo(() => { |
|
|
|
const nostrDetails = useMemo(() => { |
|
|
|
@ -150,16 +335,16 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
const { profile: fetchedProfile, isFetching: isFetchingProfile } = useFetchProfile(profileId) |
|
|
|
const { profile: fetchedProfile, isFetching: isFetchingProfile } = useFetchProfile(profileId) |
|
|
|
|
|
|
|
|
|
|
|
// Fetch event for naddr/nevent/note
|
|
|
|
// Fetch event for naddr/nevent/note
|
|
|
|
|
|
|
|
// If we already fetched a replaceable event, use that; otherwise fetch by identifier
|
|
|
|
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: fetchedEventById, isFetching: isFetchingEvent } = useFetchEvent(eventId) |
|
|
|
|
|
|
|
const fetchedEvent = fetchedReplaceableEvent || fetchedEventById |
|
|
|
|
|
|
|
const isFetchingEventFinal = isFetchingReplaceableEvent || isFetchingEvent |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch profile for event author (to show avatar in event cards)
|
|
|
|
|
|
|
|
const eventAuthorProfileId = fetchedEvent?.pubkey ? nip19.npubEncode(fetchedEvent.pubkey) : undefined |
|
|
|
|
|
|
|
const { profile: eventAuthorProfile } = useFetchProfile(eventAuthorProfileId) |
|
|
|
|
|
|
|
|
|
|
|
// 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(() => { |
|
|
|
@ -177,9 +362,11 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
// 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
|
|
|
|
// Show enhanced fallback link card if:
|
|
|
|
|
|
|
|
// 1. No OG data available, OR
|
|
|
|
|
|
|
|
// 2. A nostr identifier was detected (we want to show the detailed nostr card even with OG data)
|
|
|
|
// Note: We always attempt to fetch OG data via useFetchWebMetadata hook above
|
|
|
|
// Note: We always attempt to fetch OG data via useFetchWebMetadata hook above
|
|
|
|
if (!hasOpengraphData) { |
|
|
|
if (!hasOpengraphData || nostrIdentifier) { |
|
|
|
// 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') { |
|
|
|
const eventMetadata = fetchedEvent ? getLongFormArticleMetadataFromEvent(fetchedEvent) : null |
|
|
|
const eventMetadata = fetchedEvent ? getLongFormArticleMetadataFromEvent(fetchedEvent) : null |
|
|
|
@ -188,6 +375,18 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
const eventSummary = eventMetadata?.summary || description |
|
|
|
const eventSummary = eventMetadata?.summary || description |
|
|
|
const eventImage = eventMetadata?.image |
|
|
|
const eventImage = eventMetadata?.image |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Extract imeta info to check for thumbnails
|
|
|
|
|
|
|
|
const imetaInfos = fetchedEvent ? getImetaInfosFromEvent(fetchedEvent) : [] |
|
|
|
|
|
|
|
// Find thumbnail for the event image if available
|
|
|
|
|
|
|
|
let eventImageThumbnail: string | null = null |
|
|
|
|
|
|
|
if (eventImage && fetchedEvent) { |
|
|
|
|
|
|
|
const cleanedEventImage = cleanUrl(eventImage) |
|
|
|
|
|
|
|
// Find imeta info that matches the event image URL
|
|
|
|
|
|
|
|
const matchingImeta = imetaInfos.find(info => cleanUrl(info.url) === cleanedEventImage) |
|
|
|
|
|
|
|
// Return thumbnail if available, otherwise return original image
|
|
|
|
|
|
|
|
eventImageThumbnail = matchingImeta?.thumb || eventImage |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Extract bookstr metadata if applicable
|
|
|
|
// Extract bookstr metadata if applicable
|
|
|
|
const bookMetadata = fetchedEvent ? extractBookMetadata(fetchedEvent) : null |
|
|
|
const bookMetadata = fetchedEvent ? extractBookMetadata(fetchedEvent) : null |
|
|
|
const isBookstrEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.PUBLICATION || fetchedEvent.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata?.book |
|
|
|
const isBookstrEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.PUBLICATION || fetchedEvent.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata?.book |
|
|
|
@ -199,22 +398,8 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
.join(' ') |
|
|
|
.join(' ') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Build identifier details
|
|
|
|
// Truncate original URL to 150 characters
|
|
|
|
const identifierParts: string[] = [] |
|
|
|
const truncatedUrl = url.length > 150 ? url.substring(0, 150) + '...' : url |
|
|
|
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 |
|
|
|
@ -224,24 +409,34 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
window.open(cleanedUrl, '_blank') |
|
|
|
window.open(cleanedUrl, '_blank') |
|
|
|
}} |
|
|
|
}} |
|
|
|
> |
|
|
|
> |
|
|
|
{eventImage && fetchedEvent && ( |
|
|
|
{eventImageThumbnail && fetchedEvent && ( |
|
|
|
<Image |
|
|
|
<Image |
|
|
|
image={{ url: eventImage, pubkey: fetchedEvent.pubkey }} |
|
|
|
image={{ url: eventImageThumbnail, pubkey: fetchedEvent.pubkey }} |
|
|
|
className="w-20 h-20 rounded-lg flex-shrink-0 object-cover border border-green-200 dark:border-green-800" |
|
|
|
className="w-20 h-20 rounded-lg flex-shrink-0 object-cover border border-green-200 dark:border-green-800" |
|
|
|
hideIfError |
|
|
|
hideIfError |
|
|
|
/> |
|
|
|
/> |
|
|
|
)} |
|
|
|
)} |
|
|
|
<div className="flex-1 min-w-0"> |
|
|
|
<div className="flex-1 min-w-0"> |
|
|
|
<div className="flex items-center gap-2 mb-1"> |
|
|
|
<div className="flex items-center gap-1.5 mb-1"> |
|
|
|
{fetchedEvent ? ( |
|
|
|
{fetchedEvent ? ( |
|
|
|
<> |
|
|
|
<> |
|
|
|
<Username userId={fetchedEvent.pubkey} className="text-xs" /> |
|
|
|
<Username userId={fetchedEvent.pubkey} className="text-xs" /> |
|
|
|
|
|
|
|
{eventAuthorProfile?.avatar && ( |
|
|
|
|
|
|
|
<img |
|
|
|
|
|
|
|
src={eventAuthorProfile.avatar} |
|
|
|
|
|
|
|
alt="" |
|
|
|
|
|
|
|
className="w-5 h-5 rounded-full flex-shrink-0 object-cover" |
|
|
|
|
|
|
|
onError={(e) => { |
|
|
|
|
|
|
|
e.currentTarget.style.display = 'none' |
|
|
|
|
|
|
|
}} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
)} |
|
|
|
<span className="text-xs text-muted-foreground">•</span> |
|
|
|
<span className="text-xs text-muted-foreground">•</span> |
|
|
|
<span className="text-xs text-muted-foreground">{eventTypeName}</span> |
|
|
|
<span className="text-xs text-muted-foreground">{eventTypeName}</span> |
|
|
|
</> |
|
|
|
</> |
|
|
|
) : ( |
|
|
|
) : ( |
|
|
|
<span className="text-xs text-muted-foreground"> |
|
|
|
<span className="text-xs text-muted-foreground"> |
|
|
|
{isFetchingEvent ? 'Loading event...' : 'Event'} |
|
|
|
{isFetchingEventFinal ? 'Loading event...' : 'Event'} |
|
|
|
</span> |
|
|
|
</span> |
|
|
|
)} |
|
|
|
)} |
|
|
|
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0 ml-auto" /> |
|
|
|
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0 ml-auto" /> |
|
|
|
@ -270,14 +465,7 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
)} |
|
|
|
)} |
|
|
|
</> |
|
|
|
</> |
|
|
|
)} |
|
|
|
)} |
|
|
|
{identifierParts.length > 0 && ( |
|
|
|
<div className="text-xs text-muted-foreground truncate mt-2">{truncatedUrl}</div> |
|
|
|
<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> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
) |
|
|
|
) |
|
|
|
@ -285,17 +473,8 @@ 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
|
|
|
|
// Truncate original URL to 150 characters
|
|
|
|
const profileIdentifierParts: string[] = [] |
|
|
|
const truncatedUrl = url.length > 150 ? url.substring(0, 150) + '...' : url |
|
|
|
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 |
|
|
|
@ -334,15 +513,7 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
{fetchedProfile?.about && ( |
|
|
|
{fetchedProfile?.about && ( |
|
|
|
<div className="text-xs text-muted-foreground line-clamp-2 mb-1 mt-1">{fetchedProfile.about}</div> |
|
|
|
<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 truncate mt-1">{truncatedUrl}</div> |
|
|
|
<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">{url}</div> |
|
|
|
|
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
) |
|
|
|
) |
|
|
|
@ -377,7 +548,7 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
window.open(cleanedUrl, '_blank') |
|
|
|
window.open(cleanedUrl, '_blank') |
|
|
|
}} |
|
|
|
}} |
|
|
|
> |
|
|
|
> |
|
|
|
<Image image={{ url: image }} className="w-full max-w-[400px] h-44 rounded-none" hideIfError /> |
|
|
|
<Image image={{ url: image }} className="w-20 h-20 rounded-lg object-cover" hideIfError /> |
|
|
|
<div className="bg-muted p-2 w-full"> |
|
|
|
<div className="bg-muted p-2 w-full"> |
|
|
|
<div className="flex items-center gap-2"> |
|
|
|
<div className="flex items-center gap-2"> |
|
|
|
<div className="text-xs text-muted-foreground truncate">{hostname}</div> |
|
|
|
<div className="text-xs text-muted-foreground truncate">{hostname}</div> |
|
|
|
@ -402,7 +573,7 @@ export default function WebPreview({ url, className }: { url: string; className? |
|
|
|
{image && ( |
|
|
|
{image && ( |
|
|
|
<Image |
|
|
|
<Image |
|
|
|
image={{ url: image }} |
|
|
|
image={{ url: image }} |
|
|
|
className="aspect-[4/3] xl:aspect-video bg-foreground h-44 max-w-[400px] rounded-none flex-shrink-0" |
|
|
|
className="w-20 h-20 rounded-lg flex-shrink-0 object-cover" |
|
|
|
hideIfError |
|
|
|
hideIfError |
|
|
|
/> |
|
|
|
/> |
|
|
|
)} |
|
|
|
)} |
|
|
|
|