You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
721 lines
25 KiB
721 lines
25 KiB
import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager' |
|
import Image from '@/components/Image' |
|
import MediaPlayer from '@/components/MediaPlayer' |
|
import Wikilink from '@/components/UniversalContent/Wikilink' |
|
import WebPreview from '@/components/WebPreview' |
|
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
|
import { toNoteList } from '@/lib/link' |
|
import { useMediaExtraction } from '@/hooks' |
|
import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url' |
|
import { getImetaInfosFromEvent } from '@/lib/event' |
|
import { Event, kinds } from 'nostr-tools' |
|
import { ExtendedKind, WS_URL_REGEX } from '@/constants' |
|
import React, { useMemo, useState, useCallback } from 'react' |
|
import { createPortal } from 'react-dom' |
|
import Lightbox from 'yet-another-react-lightbox' |
|
import Zoom from 'yet-another-react-lightbox/plugins/zoom' |
|
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' |
|
import { preprocessMarkdownMediaLinks } from './preprocessMarkup' |
|
|
|
/** |
|
* Truncate link display text to 200 characters, adding ellipsis if truncated |
|
*/ |
|
function truncateLinkText(text: string, maxLength: number = 200): string { |
|
if (text.length <= maxLength) { |
|
return text |
|
} |
|
return text.substring(0, maxLength) + '...' |
|
} |
|
|
|
/** |
|
* Parse markdown content and render with post-processing for nostr: links and hashtags |
|
* Post-processes: |
|
* - nostr: links -> EmbeddedNote or EmbeddedMention |
|
* - #hashtags -> green hyperlinks to /notes?t=hashtag |
|
* - wss:// and ws:// URLs -> hyperlinks to /relays/{url} |
|
* Returns both rendered nodes and a set of hashtags found in content (for deduplication) |
|
*/ |
|
function parseMarkdownContent( |
|
content: string, |
|
options: { |
|
eventPubkey: string |
|
imageIndexMap: Map<string, number> |
|
openLightbox: (index: number) => void |
|
navigateToHashtag: (href: string) => void |
|
navigateToRelay: (url: string) => void |
|
} |
|
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string> } { |
|
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay } = options |
|
const parts: React.ReactNode[] = [] |
|
const hashtagsInContent = new Set<string>() |
|
let lastIndex = 0 |
|
|
|
// Find all patterns: markdown images, markdown links, relay URLs, nostr addresses, hashtags, wikilinks |
|
const patterns: Array<{ index: number; end: number; type: string; data: any }> = [] |
|
|
|
// Markdown images:  or  |
|
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g |
|
const imageMatches = Array.from(content.matchAll(markdownImageRegex)) |
|
imageMatches.forEach(match => { |
|
if (match.index !== undefined) { |
|
patterns.push({ |
|
index: match.index, |
|
end: match.index + match[0].length, |
|
type: 'markdown-image', |
|
data: { alt: match[1], url: match[2] } |
|
}) |
|
} |
|
}) |
|
|
|
// Markdown links: [text](url) - but not images |
|
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g |
|
const linkMatches = Array.from(content.matchAll(markdownLinkRegex)) |
|
linkMatches.forEach(match => { |
|
if (match.index !== undefined) { |
|
// Skip if this is already an image |
|
const isImage = content.substring(Math.max(0, match.index - 1), match.index) === '!' |
|
if (!isImage) { |
|
patterns.push({ |
|
index: match.index, |
|
end: match.index + match[0].length, |
|
type: 'markdown-link', |
|
data: { text: match[1], url: match[2] } |
|
}) |
|
} |
|
} |
|
}) |
|
|
|
// Relay URLs (wss:// or ws://) - not in markdown links |
|
const relayUrlMatches = Array.from(content.matchAll(WS_URL_REGEX)) |
|
relayUrlMatches.forEach(match => { |
|
if (match.index !== undefined) { |
|
const url = match[0] |
|
// Only add if not already covered by a markdown link/image |
|
const isInMarkdown = patterns.some(p => |
|
(p.type === 'markdown-link' || p.type === 'markdown-image') && |
|
match.index! >= p.index && |
|
match.index! < p.end |
|
) |
|
// Only process valid websocket URLs |
|
if (!isInMarkdown && isWebsocketUrl(url)) { |
|
patterns.push({ |
|
index: match.index, |
|
end: match.index + match[0].length, |
|
type: 'relay-url', |
|
data: { url } |
|
}) |
|
} |
|
} |
|
}) |
|
|
|
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links or relay URLs |
|
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g |
|
const nostrMatches = Array.from(content.matchAll(nostrRegex)) |
|
nostrMatches.forEach(match => { |
|
if (match.index !== undefined) { |
|
// Only add if not already covered by a markdown link/image or relay URL |
|
const isInOther = patterns.some(p => |
|
(p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'relay-url') && |
|
match.index! >= p.index && |
|
match.index! < p.end |
|
) |
|
if (!isInOther) { |
|
patterns.push({ |
|
index: match.index, |
|
end: match.index + match[0].length, |
|
type: 'nostr', |
|
data: match[1] |
|
}) |
|
} |
|
} |
|
}) |
|
|
|
// Hashtags (#tag) - but not inside markdown links, relay URLs, or nostr addresses |
|
const hashtagRegex = /#([a-zA-Z0-9_]+)/g |
|
const hashtagMatches = Array.from(content.matchAll(hashtagRegex)) |
|
hashtagMatches.forEach(match => { |
|
if (match.index !== undefined) { |
|
// Only add if not already covered by another pattern |
|
const isInOther = patterns.some(p => |
|
match.index! >= p.index && |
|
match.index! < p.end |
|
) |
|
if (!isInOther) { |
|
patterns.push({ |
|
index: match.index, |
|
end: match.index + match[0].length, |
|
type: 'hashtag', |
|
data: match[1] |
|
}) |
|
} |
|
} |
|
}) |
|
|
|
// Wikilinks ([[link]] or [[link|display]]) |
|
const wikilinkRegex = /\[\[([^\]]+)\]\]/g |
|
const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex)) |
|
wikilinkMatches.forEach(match => { |
|
if (match.index !== undefined) { |
|
// Only add if not already covered by another pattern |
|
const isInOther = patterns.some(p => |
|
match.index! >= p.index && |
|
match.index! < p.end |
|
) |
|
if (!isInOther) { |
|
patterns.push({ |
|
index: match.index, |
|
end: match.index + match[0].length, |
|
type: 'wikilink', |
|
data: match[1] |
|
}) |
|
} |
|
} |
|
}) |
|
|
|
// Sort patterns by index |
|
patterns.sort((a, b) => a.index - b.index) |
|
|
|
// Remove overlapping patterns (keep the first one) |
|
const filteredPatterns: typeof patterns = [] |
|
let lastEnd = 0 |
|
patterns.forEach(pattern => { |
|
if (pattern.index >= lastEnd) { |
|
filteredPatterns.push(pattern) |
|
lastEnd = pattern.end |
|
} |
|
}) |
|
|
|
// Build React nodes from patterns |
|
filteredPatterns.forEach((pattern, i) => { |
|
// Add text before pattern |
|
if (pattern.index > lastIndex) { |
|
const text = content.slice(lastIndex, pattern.index) |
|
if (text) { |
|
parts.push(<span key={`text-${i}`}>{text}</span>) |
|
} |
|
} |
|
|
|
// Render pattern |
|
if (pattern.type === 'markdown-image') { |
|
const { url } = pattern.data |
|
const cleaned = cleanUrl(url) |
|
const imageIndex = imageIndexMap.get(cleaned) |
|
if (isImage(cleaned)) { |
|
parts.push( |
|
<div key={`img-${i}`} className="my-2 block"> |
|
<Image |
|
image={{ url, pubkey: eventPubkey }} |
|
className="max-w-[400px] rounded-lg cursor-zoom-in" |
|
classNames={{ |
|
wrapper: 'rounded-lg block', |
|
errorPlaceholder: 'aspect-square h-[30vh]' |
|
}} |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
if (imageIndex !== undefined) { |
|
openLightbox(imageIndex) |
|
} |
|
}} |
|
/> |
|
</div> |
|
) |
|
} else if (isVideo(cleaned) || isAudio(cleaned)) { |
|
parts.push( |
|
<div key={`media-${i}`} className="my-2"> |
|
<MediaPlayer |
|
src={cleaned} |
|
className="max-w-[400px]" |
|
mustLoad={false} |
|
/> |
|
</div> |
|
) |
|
} |
|
} else if (pattern.type === 'markdown-link') { |
|
const { text, url } = pattern.data |
|
const displayText = truncateLinkText(text) |
|
// Check if it's a relay URL - if so, link to relay page instead |
|
if (isWebsocketUrl(url)) { |
|
const relayPath = `/relays/${encodeURIComponent(url)}` |
|
parts.push( |
|
<a |
|
key={`relay-${i}`} |
|
href={relayPath} |
|
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
e.preventDefault() |
|
navigateToRelay(relayPath) |
|
}} |
|
title={text.length > 200 ? text : undefined} |
|
> |
|
{displayText} |
|
</a> |
|
) |
|
} else { |
|
// Render as green link (will show WebPreview at bottom for HTTP/HTTPS) |
|
parts.push( |
|
<a |
|
key={`link-${i}`} |
|
href={url} |
|
target="_blank" |
|
rel="noreferrer noopener" |
|
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" |
|
onClick={(e) => e.stopPropagation()} |
|
title={text.length > 200 ? text : undefined} |
|
> |
|
{displayText} |
|
</a> |
|
) |
|
} |
|
} else if (pattern.type === 'relay-url') { |
|
const { url } = pattern.data |
|
const relayPath = `/relays/${encodeURIComponent(url)}` |
|
const displayText = truncateLinkText(url) |
|
parts.push( |
|
<a |
|
key={`relay-${i}`} |
|
href={relayPath} |
|
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
e.preventDefault() |
|
navigateToRelay(relayPath) |
|
}} |
|
title={url.length > 200 ? url : undefined} |
|
> |
|
{displayText} |
|
</a> |
|
) |
|
} else if (pattern.type === 'nostr') { |
|
const bech32Id = pattern.data |
|
// Check if it's a profile type (mentions/handles should be inline) |
|
if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { |
|
parts.push( |
|
<span key={`nostr-${i}`} className="inline-block"> |
|
<EmbeddedMention userId={bech32Id} /> |
|
</span> |
|
) |
|
} else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { |
|
// Embedded events should be block-level and fill width |
|
parts.push( |
|
<div key={`nostr-${i}`} className="w-full my-2"> |
|
<EmbeddedNote noteId={bech32Id} /> |
|
</div> |
|
) |
|
} else { |
|
parts.push(<span key={`nostr-${i}`}>nostr:{bech32Id}</span>) |
|
} |
|
} else if (pattern.type === 'hashtag') { |
|
const tag = pattern.data |
|
const tagLower = tag.toLowerCase() |
|
hashtagsInContent.add(tagLower) // Track hashtags rendered inline |
|
parts.push( |
|
<a |
|
key={`hashtag-${i}`} |
|
href={`/notes?t=${tagLower}`} |
|
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer" |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
e.preventDefault() |
|
navigateToHashtag(`/notes?t=${tagLower}`) |
|
}} |
|
> |
|
#{tag} |
|
</a> |
|
) |
|
} else if (pattern.type === 'wikilink') { |
|
const linkContent = pattern.data |
|
let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim() |
|
let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim() |
|
|
|
if (linkContent.startsWith('book:')) { |
|
target = linkContent.replace('book:', '').trim() |
|
} |
|
|
|
const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') |
|
|
|
parts.push( |
|
<Wikilink key={`wikilink-${i}`} dTag={dtag} displayText={displayText} /> |
|
) |
|
} |
|
|
|
lastIndex = pattern.end |
|
}) |
|
|
|
// Add remaining text |
|
if (lastIndex < content.length) { |
|
const text = content.slice(lastIndex) |
|
if (text) { |
|
parts.push(<span key="text-end">{text}</span>) |
|
} |
|
} |
|
|
|
// If no patterns, just return the content as text |
|
if (parts.length === 0) { |
|
return { nodes: [<span key="text-only">{content}</span>], hashtagsInContent } |
|
} |
|
|
|
return { nodes: parts, hashtagsInContent } |
|
} |
|
|
|
export default function MarkdownArticle({ |
|
event, |
|
className, |
|
hideMetadata = false |
|
}: { |
|
event: Event |
|
className?: string |
|
hideMetadata?: boolean |
|
}) { |
|
const { push } = useSecondaryPage() |
|
const { navigateToHashtag } = useSmartHashtagNavigation() |
|
const { navigateToRelay } = useSmartRelayNavigation() |
|
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
|
|
|
// Extract all media from event |
|
const extractedMedia = useMediaExtraction(event, event.content) |
|
|
|
// Extract media from tags only (for display at top) |
|
const tagMedia = useMemo(() => { |
|
const seenUrls = new Set<string>() |
|
const media: Array<{ url: string; type: 'image' | 'video' | 'audio' }> = [] |
|
|
|
// Extract from imeta tags |
|
const imetaInfos = getImetaInfosFromEvent(event) |
|
imetaInfos.forEach((info) => { |
|
const cleaned = cleanUrl(info.url) |
|
if (!cleaned || seenUrls.has(cleaned)) return |
|
if (!isImage(cleaned) && !isMedia(cleaned)) return |
|
|
|
seenUrls.add(cleaned) |
|
if (info.m?.startsWith('image/') || isImage(cleaned)) { |
|
media.push({ url: info.url, type: 'image' }) |
|
} else if (info.m?.startsWith('video/') || isVideo(cleaned)) { |
|
media.push({ url: info.url, type: 'video' }) |
|
} else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { |
|
media.push({ url: info.url, type: 'audio' }) |
|
} |
|
}) |
|
|
|
// Extract from r tags |
|
event.tags.filter(tag => tag[0] === 'r' && tag[1]).forEach(tag => { |
|
const url = tag[1] |
|
const cleaned = cleanUrl(url) |
|
if (!cleaned || seenUrls.has(cleaned)) return |
|
if (!isImage(cleaned) && !isMedia(cleaned)) return |
|
|
|
seenUrls.add(cleaned) |
|
if (isImage(cleaned)) { |
|
media.push({ url, type: 'image' }) |
|
} else if (isVideo(cleaned)) { |
|
media.push({ url, type: 'video' }) |
|
} else if (isAudio(cleaned)) { |
|
media.push({ url, type: 'audio' }) |
|
} |
|
}) |
|
|
|
// Extract from image tag |
|
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) |
|
if (imageTag?.[1]) { |
|
const cleaned = cleanUrl(imageTag[1]) |
|
if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) { |
|
seenUrls.add(cleaned) |
|
media.push({ url: imageTag[1], type: 'image' }) |
|
} |
|
} |
|
|
|
return media |
|
}, [event.id, JSON.stringify(event.tags)]) |
|
|
|
// Extract non-media links from tags |
|
const tagLinks = useMemo(() => { |
|
const links: string[] = [] |
|
const seenUrls = new Set<string>() |
|
|
|
event.tags |
|
.filter(tag => tag[0] === 'r' && tag[1]) |
|
.forEach(tag => { |
|
const url = tag[1] |
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return |
|
if (isImage(url) || isMedia(url)) return |
|
|
|
const cleaned = cleanUrl(url) |
|
if (cleaned && !seenUrls.has(cleaned)) { |
|
links.push(cleaned) |
|
seenUrls.add(cleaned) |
|
} |
|
}) |
|
|
|
return links |
|
}, [event.id, JSON.stringify(event.tags)]) |
|
|
|
// Get all images for gallery (deduplicated) |
|
const allImages = useMemo(() => { |
|
const seenUrls = new Set<string>() |
|
const images: Array<{ url: string; alt?: string }> = [] |
|
|
|
// Add images from extractedMedia |
|
extractedMedia.images.forEach(img => { |
|
const cleaned = cleanUrl(img.url) |
|
if (cleaned && !seenUrls.has(cleaned)) { |
|
seenUrls.add(cleaned) |
|
images.push({ url: img.url, alt: img.alt }) |
|
} |
|
}) |
|
|
|
// Add metadata image if it exists |
|
if (metadata.image) { |
|
const cleaned = cleanUrl(metadata.image) |
|
if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) { |
|
seenUrls.add(cleaned) |
|
images.push({ url: metadata.image }) |
|
} |
|
} |
|
|
|
return images |
|
}, [extractedMedia.images, metadata.image]) |
|
|
|
// Create image index map for lightbox |
|
const imageIndexMap = useMemo(() => { |
|
const map = new Map<string, number>() |
|
allImages.forEach((img, index) => { |
|
const cleaned = cleanUrl(img.url) |
|
if (cleaned) map.set(cleaned, index) |
|
}) |
|
return map |
|
}, [allImages]) |
|
|
|
// Parse content to find media URLs that are already rendered |
|
const mediaUrlsInContent = useMemo(() => { |
|
const urls = new Set<string>() |
|
const urlRegex = /https?:\/\/[^\s<>"']+/g |
|
let match |
|
while ((match = urlRegex.exec(event.content)) !== null) { |
|
const url = match[0] |
|
const cleaned = cleanUrl(url) |
|
if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned))) { |
|
urls.add(cleaned) |
|
} |
|
} |
|
return urls |
|
}, [event.content]) |
|
|
|
// Extract non-media links from content |
|
const contentLinks = useMemo(() => { |
|
const links: string[] = [] |
|
const seenUrls = new Set<string>() |
|
const urlRegex = /https?:\/\/[^\s<>"']+/g |
|
let match |
|
while ((match = urlRegex.exec(event.content)) !== null) { |
|
const url = match[0] |
|
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { |
|
const cleaned = cleanUrl(url) |
|
if (cleaned && !seenUrls.has(cleaned)) { |
|
links.push(cleaned) |
|
seenUrls.add(cleaned) |
|
} |
|
} |
|
} |
|
return links |
|
}, [event.content]) |
|
|
|
// Image gallery state |
|
const [lightboxIndex, setLightboxIndex] = useState(-1) |
|
|
|
const openLightbox = useCallback((index: number) => { |
|
setLightboxIndex(index) |
|
}, []) |
|
|
|
// Filter tag media to only show what's not in content |
|
const leftoverTagMedia = useMemo(() => { |
|
const metadataImageUrl = metadata.image ? cleanUrl(metadata.image) : null |
|
return tagMedia.filter(media => { |
|
const cleaned = cleanUrl(media.url) |
|
if (!cleaned) return false |
|
// Skip if already in content |
|
if (mediaUrlsInContent.has(cleaned)) return false |
|
// Skip if this is the metadata image (shown separately) |
|
if (metadataImageUrl && cleaned === metadataImageUrl && !hideMetadata) return false |
|
return true |
|
}) |
|
}, [tagMedia, mediaUrlsInContent, metadata.image, hideMetadata]) |
|
|
|
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards) |
|
const leftoverTagLinks = useMemo(() => { |
|
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean)) |
|
return tagLinks.filter(link => { |
|
const cleaned = cleanUrl(link) |
|
return cleaned && !contentLinksSet.has(cleaned) |
|
}) |
|
}, [tagLinks, contentLinks]) |
|
|
|
// Preprocess content to convert URLs to markdown syntax |
|
const preprocessedContent = useMemo(() => { |
|
return preprocessMarkdownMediaLinks(event.content) |
|
}, [event.content]) |
|
|
|
// Parse markdown content with post-processing for nostr: links and hashtags |
|
const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { |
|
return parseMarkdownContent(preprocessedContent, { |
|
eventPubkey: event.pubkey, |
|
imageIndexMap, |
|
openLightbox, |
|
navigateToHashtag, |
|
navigateToRelay |
|
}) |
|
}, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay]) |
|
|
|
// Filter metadata tags to only show what's not already in content |
|
const leftoverMetadataTags = useMemo(() => { |
|
return metadata.tags.filter(tag => !hashtagsInContent.has(tag.toLowerCase())) |
|
}, [metadata.tags, hashtagsInContent]) |
|
|
|
return ( |
|
<> |
|
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}> |
|
{/* Metadata */} |
|
{!hideMetadata && metadata.title && <h1 className="break-words">{metadata.title}</h1>} |
|
{!hideMetadata && metadata.summary && ( |
|
<blockquote> |
|
<p className="break-words">{metadata.summary}</p> |
|
</blockquote> |
|
)} |
|
{hideMetadata && metadata.title && event.kind !== ExtendedKind.DISCUSSION && ( |
|
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2> |
|
)} |
|
|
|
{/* Metadata image */} |
|
{!hideMetadata && metadata.image && (() => { |
|
const cleanedMetadataImage = cleanUrl(metadata.image) |
|
// Don't show if already in content |
|
if (cleanedMetadataImage && mediaUrlsInContent.has(cleanedMetadataImage)) { |
|
return null |
|
} |
|
|
|
const metadataImageIndex = imageIndexMap.get(cleanedMetadataImage) |
|
|
|
return ( |
|
<Image |
|
image={{ url: metadata.image, pubkey: event.pubkey }} |
|
className="max-w-[400px] w-full h-auto my-0 cursor-zoom-in" |
|
classNames={{ |
|
wrapper: 'rounded-lg', |
|
errorPlaceholder: 'aspect-square h-[30vh]' |
|
}} |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
if (metadataImageIndex !== undefined) { |
|
openLightbox(metadataImageIndex) |
|
} |
|
}} |
|
/> |
|
) |
|
})()} |
|
|
|
{/* Media from tags (only if not in content) */} |
|
{leftoverTagMedia.length > 0 && ( |
|
<div className="space-y-4 mb-6"> |
|
{leftoverTagMedia.map((media) => { |
|
const cleaned = cleanUrl(media.url) |
|
const mediaIndex = imageIndexMap.get(cleaned) |
|
|
|
if (media.type === 'image') { |
|
return ( |
|
<div key={`tag-media-${cleaned}`} className="my-2"> |
|
<Image |
|
image={{ url: media.url, pubkey: event.pubkey }} |
|
className="max-w-[400px] rounded-lg cursor-zoom-in" |
|
classNames={{ |
|
wrapper: 'rounded-lg', |
|
errorPlaceholder: 'aspect-square h-[30vh]' |
|
}} |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
if (mediaIndex !== undefined) { |
|
openLightbox(mediaIndex) |
|
} |
|
}} |
|
/> |
|
</div> |
|
) |
|
} else if (media.type === 'video' || media.type === 'audio') { |
|
return ( |
|
<div key={`tag-media-${cleaned}`} className="my-2"> |
|
<MediaPlayer |
|
src={media.url} |
|
className="max-w-[400px]" |
|
mustLoad={true} |
|
/> |
|
</div> |
|
) |
|
} |
|
return null |
|
})} |
|
</div> |
|
)} |
|
|
|
{/* Parsed content */} |
|
<div className="break-words whitespace-pre-wrap"> |
|
{parsedContent} |
|
</div> |
|
|
|
{/* Hashtags from metadata (only if not already in content) */} |
|
{leftoverMetadataTags.length > 0 && ( |
|
<div className="flex gap-2 flex-wrap pb-2 mt-4"> |
|
{leftoverMetadataTags.map((tag) => ( |
|
<div |
|
key={tag} |
|
title={tag} |
|
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground" |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) |
|
}} |
|
> |
|
#<span className="truncate">{tag}</span> |
|
</div> |
|
))} |
|
</div> |
|
)} |
|
|
|
{/* WebPreview cards for links from tags (only if not already in content) */} |
|
{/* Note: Links in content are already rendered as green hyperlinks above, so we don't show WebPreview for them */} |
|
{leftoverTagLinks.length > 0 && ( |
|
<div className="space-y-3 mt-6"> |
|
{leftoverTagLinks.map((url, index) => ( |
|
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" /> |
|
))} |
|
</div> |
|
)} |
|
</div> |
|
|
|
{/* Image gallery lightbox */} |
|
{allImages.length > 0 && lightboxIndex >= 0 && createPortal( |
|
<div onClick={(e) => e.stopPropagation()}> |
|
<Lightbox |
|
index={lightboxIndex} |
|
slides={allImages.map(({ url, alt }) => ({ |
|
src: url, |
|
alt: alt || url |
|
}))} |
|
plugins={[Zoom]} |
|
open={lightboxIndex >= 0} |
|
close={() => setLightboxIndex(-1)} |
|
controller={{ |
|
closeOnBackdropClick: true, |
|
closeOnPullUp: true, |
|
closeOnPullDown: true |
|
}} |
|
styles={{ |
|
toolbar: { paddingTop: '2.25rem' } |
|
}} |
|
carousel={{ |
|
finite: false |
|
}} |
|
/> |
|
</div>, |
|
document.body |
|
)} |
|
</> |
|
) |
|
}
|
|
|