Browse Source

bugfix consolidation media display

imwald
Silberengel 5 months ago
parent
commit
8910aa0d47
  1. 301
      src/components/Content/index.tsx
  2. 4
      src/components/ImageCarousel/ImageCarousel.tsx
  3. 2
      src/components/ImageWithLightbox/index.tsx
  4. 100
      src/components/MediaRenderer/index.tsx
  5. 2
      src/components/Note/Article/index.tsx
  6. 2
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  7. 171
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  8. 19
      src/components/Note/PictureNote.tsx
  9. 32
      src/components/Note/VideoNote.tsx
  10. 145
      src/components/UniversalContent/EnhancedContent.tsx
  11. 2
      src/constants.ts
  12. 1
      src/hooks/index.tsx
  13. 19
      src/hooks/useMediaExtraction.tsx
  14. 149
      src/services/media-extraction.service.ts

301
src/components/Content/index.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { useTranslatedEvent } from '@/hooks'
import { useTranslatedEvent, useMediaExtraction } from '@/hooks'
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
@ -10,14 +10,11 @@ import { @@ -10,14 +10,11 @@ import {
parseContent
} from '@/lib/content-parser'
import logger from '@/lib/logger'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { cleanUrl, isImage, isMedia } from '@/lib/url'
import mediaUpload from '@/services/media-upload.service'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { tagNameEquals } from '@/lib/tag'
import { useMemo } from 'react'
import {
EmbeddedHashtag,
@ -45,8 +42,12 @@ export default function Content({ @@ -45,8 +42,12 @@ export default function Content({
mustLoadMedia?: boolean
}) {
const translatedEvent = useTranslatedEvent(event?.id)
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content
const _content = translatedEvent?.content ?? event?.content ?? content
// Use unified media extraction service
const extractedMedia = useMediaExtraction(event, _content)
const { nodes, lastNormalUrl, emojiInfos } = useMemo(() => {
if (!_content) return {}
const nodes = parseContent(_content, [
@ -59,111 +60,14 @@ export default function Content({ @@ -59,111 +60,14 @@ export default function Content({
EmbeddedEmojiParser
])
// Collect all images from multiple sources and deduplicate using cleaned URLs
const seenUrls = new Set<string>()
const allImages: TImetaInfo[] = []
// Helper to add image if not already seen (using cleaned URL for comparison)
const addImage = (url: string, pubkey?: string, mimeType?: string) => {
if (!url) return
const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return
// Only add if it's actually an image or media file
if (!isImage(cleaned) && !isMedia(cleaned)) return
seenUrls.add(cleaned)
allImages.push({
url: cleaned,
pubkey: pubkey || event?.pubkey,
m: mimeType || (isImage(cleaned) ? 'image/*' : 'media/*')
})
}
// 1. Extract from imeta tags
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach((info) => {
if (info.m?.startsWith('image/') || info.m?.startsWith('video/') || isImage(info.url) || isMedia(info.url)) {
addImage(info.url, info.pubkey, info.m)
}
})
}
// 2. Extract from r tags (reference/URL tags)
if (event) {
event.tags.filter(tagNameEquals('r')).forEach(([, url]) => {
if (url && (isImage(url) || isMedia(url))) {
addImage(url)
}
})
}
// 2b. Extract from image tag
if (event) {
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addImage(imageTag[1])
}
}
// 3. Extract from content nodes (already parsed URLs)
nodes.forEach((node) => {
if (node.type === 'image') {
addImage(node.data)
} else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => addImage(url))
} else if (node.type === 'url') {
// Check if URL is an image/media file
if (isImage(node.data) || isMedia(node.data)) {
addImage(node.data)
}
}
})
// 4. Extract directly from raw content (catch any URLs that weren't parsed)
// This ensures we don't miss any image URLs in the content
if (_content) {
const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = _content.matchAll(urlRegex)
for (const match of urlMatches) {
const url = match[0]
if (isImage(url) || isMedia(url)) {
addImage(url)
}
}
}
// 5. Try to match content URLs with imeta tags for better metadata
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
allImages.forEach((img, index) => {
// Try to find matching imeta info
const matchedImeta = imetaInfos.find(imeta => cleanUrl(imeta.url) === img.url)
if (matchedImeta && matchedImeta.m) {
allImages[index] = { ...img, m: matchedImeta.m }
} else {
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(img.url)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey)
if (parsedImeta) {
allImages[index] = parsedImeta
}
}
}
})
}
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
return { nodes, emojiInfos, lastNormalUrl }
}, [_content, event])
if (!nodes || nodes.length === 0) {
return null
@ -172,7 +76,7 @@ export default function Content({ @@ -172,7 +76,7 @@ export default function Content({
// Create maps for quick lookup of images/media by cleaned URL
const imageMap = new Map<string, TImetaInfo>()
const mediaMap = new Map<string, TImetaInfo>()
allImages.forEach((img) => {
extractedMedia.all.forEach((img: TImetaInfo) => {
if (img.m?.startsWith('image/')) {
imageMap.set(img.url, img)
} else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') {
@ -184,25 +88,85 @@ export default function Content({ @@ -184,25 +88,85 @@ export default function Content({
}
})
// First pass: find which images appear in content (will be rendered in a single carousel)
const mediaInContent = new Set<string>()
const imagesInContent: TImetaInfo[] = []
nodes.forEach((node) => {
if (node.type === 'image') {
const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo)
}
} else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => {
const cleaned = cleanUrl(url)
mediaInContent.add(cleaned)
const imageInfo = imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleaned)) {
imagesInContent.push(imageInfo)
}
})
} else if (node.type === 'media') {
mediaInContent.add(cleanUrl(node.data))
} else if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data)
if (isImage(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo)
}
} else if (isMedia(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
}
}
})
// Filter carousel: only show IMAGES that DON'T appear in content
const carouselImages = extractedMedia.images.filter((img: TImetaInfo) => {
return !mediaInContent.has(img.url)
})
logger.debug('[Content] Parsed content:', {
nodeCount: nodes.length,
allImages: allImages.length,
imageMapSize: imageMap.size,
allMedia: extractedMedia.all.length,
images: extractedMedia.images.length,
videos: extractedMedia.videos.length,
audio: extractedMedia.audio.length,
imageMapSize: imageMap.size,
mediaMapSize: mediaMap.size,
allImageUrls: allImages.map(img => img.url),
nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data }))
})
// Track which images/media have been rendered individually to prevent duplicates
const renderedUrls = new Set<string>()
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{/* Render all images/media once in a single carousel if we have any */}
{allImages.length > 0 && (
{/* Render images that appear in content in a single carousel at the top */}
{imagesInContent.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="content-images-gallery"
images={imagesInContent}
start={0}
end={imagesInContent.length}
mustLoad={mustLoadMedia}
/>
)}
{/* Render images/media that aren't in content in a single carousel */}
{carouselImages.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="all-images-gallery"
images={allImages}
images={carouselImages}
start={0}
end={allImages.length}
end={carouselImages.length}
mustLoad={mustLoadMedia}
/>
)}
@ -211,48 +175,18 @@ export default function Content({ @@ -211,48 +175,18 @@ export default function Content({
if (node.type === 'text') {
return node.data
}
// Render images individually in their content position
if (node.type === 'image') {
const cleanedUrl = cleanUrl(node.data)
const imageInfo = imageMap.get(cleanedUrl)
logger.debug('[Content] Rendering image node:', { cleanedUrl, hasImageInfo: !!imageInfo, imageMapKeys: Array.from(imageMap.keys()) })
// Always render, use imageInfo if available
return (
<ImageGallery
className="mt-2"
key={`img-${index}`}
images={imageInfo ? [imageInfo] : [{ url: cleanedUrl, pubkey: event?.pubkey }]}
start={0}
end={1}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
const imageInfos = urls
.map(url => {
const cleaned = cleanUrl(url)
return imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey }
})
.filter(Boolean) as TImetaInfo[]
if (imageInfos.length > 0) {
return (
<ImageGallery
className="mt-2"
key={`imgs-${index}`}
images={imageInfos}
start={0}
end={imageInfos.length}
mustLoad={mustLoadMedia}
/>
)
}
// Skip image nodes - they're rendered in the carousel at the top
if (node.type === 'image' || node.type === 'images') {
return null
}
// Render media individually in their content position
if (node.type === 'media') {
const cleanedUrl = cleanUrl(node.data)
// Skip if already rendered
if (renderedUrls.has(cleanedUrl)) {
return null
}
renderedUrls.add(cleanedUrl)
return (
<MediaPlayer
className="mt-2"
@ -264,59 +198,50 @@ export default function Content({ @@ -264,59 +198,50 @@ export default function Content({
}
if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data)
// Check if it's an image or media that should be rendered inline
// Also check if it's an image/media file even if not in our maps
// Check if it's an image, video, or audio that should be rendered inline
const isImageUrl = isImage(cleanedUrl)
const isMediaUrl = isMedia(cleanedUrl)
const isVideoUrl = isVideo(cleanedUrl)
const isAudioUrl = isAudio(cleanedUrl)
if (imageMap.has(cleanedUrl)) {
const imageInfo = imageMap.get(cleanedUrl)!
// Skip if already rendered (regardless of type)
if (renderedUrls.has(cleanedUrl)) {
return null
}
// Check video/audio first - never put them in ImageGallery
if (isVideoUrl || isAudioUrl || mediaMap.has(cleanedUrl)) {
renderedUrls.add(cleanedUrl)
return (
<ImageGallery
className="mt-2"
key={`url-img-${index}`}
images={[imageInfo]}
start={0}
end={1}
mustLoad={mustLoadMedia}
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
// Skip image URLs - they're rendered in the carousel at the top if they're in content
// Only render if they're NOT in content (from r tags, etc.)
if (isImageUrl) {
// It's an image URL but not in our map, render it anyway
logger.debug('[Content] Rendering image URL node:', { cleanedUrl, isImageUrl })
// If it's in content, skip it (already in carousel)
if (mediaInContent.has(cleanedUrl)) {
return null
}
// Otherwise it's an image from r tags not in content, render it
renderedUrls.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
return (
<ImageGallery
className="mt-2"
key={`url-img-${index}`}
images={[{ url: cleanedUrl, pubkey: event?.pubkey }]}
images={[imageInfo]}
start={0}
end={1}
mustLoad={mustLoadMedia}
/>
)
}
if (mediaMap.has(cleanedUrl)) {
return (
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
if (isMediaUrl) {
// It's a media URL but not in our map, render it anyway
return (
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
// Regular URL, not an image or media
return <EmbeddedNormalUrl url={node.data} key={index} />
}

4
src/components/ImageCarousel/ImageCarousel.tsx

@ -80,7 +80,7 @@ export default function ImageCarousel({ images, className = '' }: ImageCarouselP @@ -80,7 +80,7 @@ export default function ImageCarousel({ images, className = '' }: ImageCarouselP
{currentImage.m?.startsWith('video/') ? (
<video
src={currentImage.url}
className="w-full max-w-[800px] h-auto object-contain mx-auto"
className="w-full max-w-[400px] h-auto object-contain mx-auto"
controls
preload="metadata"
onClick={openFullscreen}
@ -89,7 +89,7 @@ export default function ImageCarousel({ images, className = '' }: ImageCarouselP @@ -89,7 +89,7 @@ export default function ImageCarousel({ images, className = '' }: ImageCarouselP
<div onClick={openFullscreen} className="cursor-pointer">
<ImageWithLightbox
image={currentImage}
className="w-full max-w-[800px] h-auto object-contain mx-auto"
className="w-full max-w-[400px] h-auto object-contain mx-auto"
/>
</div>
)}

2
src/components/ImageWithLightbox/index.tsx

@ -57,7 +57,7 @@ export default function ImageWithLightbox({ @@ -57,7 +57,7 @@ export default function ImageWithLightbox({
}
return (
<div>
<div className="max-w-[400px]">
<Image
key={0}
className={className}

100
src/components/MediaRenderer/index.tsx

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
import { useMemo } from 'react'
import { cleanUrl, isImage } from '@/lib/url'
import ImageGallery from '../ImageGallery'
import { ExtractedMedia } from '@/services/media-extraction.service'
import { cn } from '@/lib/utils'
interface MediaRendererProps {
extractedMedia: ExtractedMedia
content?: string
className?: string
mustLoadMedia?: boolean
/**
* If true, render images that appear in content in a single carousel at the top
* If false, render images individually where they appear in content
*/
groupImagesInCarousel?: boolean
}
/**
* Unified component for rendering media (images, videos, audio) from an event
* Handles deduplication, carousel grouping, and proper component selection
*/
export default function MediaRenderer({
extractedMedia,
content,
className,
mustLoadMedia = false,
groupImagesInCarousel = true
}: MediaRendererProps) {
// Find which images appear in content (for carousel grouping)
const imagesInContent = useMemo(() => {
if (!content || !groupImagesInCarousel) return []
const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = content.matchAll(urlRegex)
const imageUrls = new Set<string>()
for (const match of urlMatches) {
const url = match[0]
const cleaned = cleanUrl(url)
if (isImage(cleaned)) {
imageUrls.add(cleaned)
}
}
// Get image info for URLs that appear in content
return extractedMedia.images.filter(img => imageUrls.has(img.url))
}, [content, extractedMedia.images, groupImagesInCarousel])
// Images from tags only (not in content) go in separate carousel
const imagesFromTags = useMemo(() => {
if (!content || !groupImagesInCarousel) return extractedMedia.images
const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = content.matchAll(urlRegex)
const contentImageUrls = new Set<string>()
for (const match of urlMatches) {
const url = match[0]
const cleaned = cleanUrl(url)
if (isImage(cleaned)) {
contentImageUrls.add(cleaned)
}
}
return extractedMedia.images.filter(img => !contentImageUrls.has(img.url))
}, [content, extractedMedia.images, groupImagesInCarousel])
return (
<div className={cn(className)}>
{/* Render images from content in a single carousel at the top */}
{groupImagesInCarousel && imagesInContent.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="content-images-gallery"
images={imagesInContent}
start={0}
end={imagesInContent.length}
mustLoad={mustLoadMedia}
/>
)}
{/* Render images from tags only (not in content) in a separate carousel */}
{groupImagesInCarousel && imagesFromTags.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="tag-images-gallery"
images={imagesFromTags}
start={0}
end={imagesFromTags.length}
mustLoad={mustLoadMedia}
/>
)}
{/* Videos and audio should never be in carousel - they're rendered individually elsewhere */}
{/* This component just provides the extracted media data */}
</div>
)
}

2
src/components/Note/Article/index.tsx

@ -216,7 +216,7 @@ export default function Article({ @@ -216,7 +216,7 @@ export default function Article({
<div className="mb-8">
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[800px] h-auto object-contain rounded-lg shadow-lg mx-auto"
className="w-full max-w-[400px] h-auto object-contain rounded-lg shadow-lg mx-auto"
/>
</div>
)}

2
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -278,7 +278,7 @@ export default function AsciidocArticle({ @@ -278,7 +278,7 @@ export default function AsciidocArticle({
<div className="mb-8">
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[800px] h-auto object-contain rounded-lg shadow-lg mx-auto"
className="w-full max-w-[400px] h-auto object-contain rounded-lg shadow-lg mx-auto"
/>
</div>
)}

171
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -5,8 +5,8 @@ import MediaPlayer from '@/components/MediaPlayer' @@ -5,8 +5,8 @@ import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList, toProfile } from '@/lib/link'
import { extractAllImagesFromEvent } from '@/lib/image-extraction'
import { getImetaInfosFromEvent } from '@/lib/event'
import { useMediaExtraction } from '@/hooks'
import { cleanUrl } from '@/lib/url'
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import React, { useMemo, useEffect, useRef, useState } from 'react'
@ -32,11 +32,11 @@ export default function MarkdownArticle({ @@ -32,11 +32,11 @@ export default function MarkdownArticle({
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const [isImagesOpen, setIsImagesOpen] = useState(false)
// Extract all images from the event
const allImages = useMemo(() => extractAllImagesFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null)
// Use unified media extraction service
const extractedMedia = useMediaExtraction(event, event.content)
// Extract hashtags that are actually present in the content (as literal #hashtag)
// This ensures we only render green links for hashtags that are in the content, not from t-tags
const contentHashtags = useMemo(() => {
@ -49,91 +49,41 @@ export default function MarkdownArticle({ @@ -49,91 +49,41 @@ export default function MarkdownArticle({
return hashtags
}, [event.content])
// Extract, normalize, and deduplicate all media URLs (images, audio, video)
// from content, imeta tags, and image tags
const mediaUrls = useMemo(() => {
if (showImageGallery) return [] // Don't render inline for article content
const seenUrls = new Set<string>()
const mediaUrls: string[] = []
// Helper to normalize and add URL
const addUrl = (url: string) => {
if (!url) return
// Normalize URL by removing tracking parameters and cleaning it
let normalizedUrl = url
.replace(/[?&](utm_[^&]*)/g, '')
.replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '')
.replace(/[?&]w=\d+/g, '')
.replace(/[?&]h=\d+/g, '')
.replace(/[?&]q=\d+/g, '')
.replace(/[?&]f=\w+/g, '')
.replace(/[?&]auto=\w+/g, '')
.replace(/[?&]format=\w+/g, '')
.replace(/[?&]fit=\w+/g, '')
.replace(/[?&]crop=\w+/g, '')
.replace(/[?&]&+/g, '&')
.replace(/[?&]$/, '')
.replace(/\?$/, '')
try {
// Validate URL
const parsedUrl = new URL(normalizedUrl)
const extension = parsedUrl.pathname.split('.').pop()?.toLowerCase()
// Check if it's a media file
const isMedia =
// Audio extensions
(extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma'].includes(extension)) ||
// Video extensions
(extension && ['mp4', 'webm', 'ogg', 'avi', 'mov', 'mkv', 'm4v', '3gp'].includes(extension)) ||
// Image extensions
(extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension))
if (isMedia && !seenUrls.has(normalizedUrl)) {
mediaUrls.push(normalizedUrl)
seenUrls.add(normalizedUrl)
// Track which image URLs appear in the markdown content (for deduplication)
// Use cleaned URLs for comparison with extractedMedia
const imagesInContent = useMemo(() => {
const imageUrls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = event.content.matchAll(urlRegex)
for (const match of urlMatches) {
const url = match[0]
// Check if it's an image URL
const extension = url.split('.').pop()?.toLowerCase()
if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension)) {
const cleaned = cleanUrl(url)
if (cleaned) {
imageUrls.add(cleaned)
}
} catch {
// Invalid URL, skip
}
}
// 1. Extract from content - all URLs (need to match exactly what markdown will find)
const content = event.content || ''
// Match URLs that could be in markdown links or plain text
const urlMatches = content.match(/https?:\/\/[^\s<>"']+/g) || []
urlMatches.forEach(url => {
// Normalize the URL before adding
const normalized = url.replace(/[?&](utm_[^&]*)/g, '')
.replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '')
.replace(/[?&]w=\d+/g, '')
.replace(/[?&]h=\d+/g, '')
.replace(/[?&]q=\d+/g, '')
.replace(/[?&]f=\w+/g, '')
.replace(/[?&]auto=\w+/g, '')
.replace(/[?&]format=\w+/g, '')
.replace(/[?&]fit=\w+/g, '')
.replace(/[?&]crop=\w+/g, '')
.replace(/[?&]&+/g, '&')
.replace(/[?&]$/, '')
.replace(/\?$/, '')
addUrl(normalized)
})
// 2. Extract from imeta tags
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach(info => addUrl(info.url))
// 3. Extract from image tag
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addUrl(imageTag[1])
// Also check markdown image syntax: ![alt](url)
const markdownImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g
let imgMatch
while ((imgMatch = markdownImageRegex.exec(event.content)) !== null) {
if (imgMatch[1]) {
const cleaned = cleanUrl(imgMatch[1])
if (cleaned) {
imageUrls.add(cleaned)
}
}
}
return mediaUrls
}, [event.content, event.tags, event.pubkey, showImageGallery])
return imageUrls
}, [event.content])
// Images that should appear in the carousel (from tags only, not in content)
const carouselImages = useMemo(() => {
return extractedMedia.images.filter(img => !imagesInContent.has(img.url))
}, [extractedMedia.images, imagesInContent])
// Initialize highlight.js for syntax highlighting
useEffect(() => {
@ -236,7 +186,10 @@ export default function MarkdownArticle({ @@ -236,7 +186,10 @@ export default function MarkdownArticle({
.replace(/[?&]$/, '')
.replace(/\?$/, '')
if (mediaUrls.includes(normalizedHref)) {
// Check if this is a media URL that should be rendered inline
// Videos and audio are handled separately below
const extension = normalizedHref.split('.').pop()?.toLowerCase()
if (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mp4', 'webm', 'ogg', 'avi', 'mov', 'mkv', 'm4v', '3gp'].includes(extension)) {
return null
}
}
@ -337,21 +290,17 @@ export default function MarkdownArticle({ @@ -337,21 +290,17 @@ export default function MarkdownArticle({
img: ({ src }) => {
if (!src) return null
// If showing image gallery, don't render inline images - they'll be shown in the carousel
if (showImageGallery) {
return null
}
// For all other content, render images inline
// Always render images inline in their content position
// The carousel at the bottom only shows images from tags that aren't in content
return (
<ImageWithLightbox
image={{ url: src, pubkey: event.pubkey }}
className="max-w-full rounded-lg my-2"
className="max-w-[400px] rounded-lg my-2"
/>
)
}
}) as Components,
[showImageGallery, event.pubkey, mediaUrls, event.kind, contentHashtags]
[showImageGallery, event.pubkey, event.kind, contentHashtags]
)
return (
@ -502,35 +451,33 @@ export default function MarkdownArticle({ @@ -502,35 +451,33 @@ export default function MarkdownArticle({
</div>
{/* Inline Media - Show for non-article content (kinds 1, 11, 1111) */}
{!showImageGallery && mediaUrls.length > 0 && (
{!showImageGallery && extractedMedia.videos.length > 0 && (
<div className="space-y-4 mt-4">
{mediaUrls.map((url) => {
const extension = url.split('.').pop()?.toLowerCase()
// Images are already handled by the img component
if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension)) {
return null
}
// Render audio and video
return (
<MediaPlayer key={url} src={url} mustLoad={true} className="w-full" />
)
})}
{extractedMedia.videos.map((video) => (
<MediaPlayer key={video.url} src={video.url} mustLoad={true} className="w-full" />
))}
</div>
)}
{!showImageGallery && extractedMedia.audio.length > 0 && (
<div className="space-y-4 mt-4">
{extractedMedia.audio.map((audio) => (
<MediaPlayer key={audio.url} src={audio.url} mustLoad={true} className="w-full" />
))}
</div>
)}
{/* Image Carousel - Only show for article content (30023, 30041, 30817, 30818) */}
{showImageGallery && allImages.length > 0 && (
{/* Only show images that aren't already in the content (from tags only) */}
{showImageGallery && carouselImages.length > 0 && (
<Collapsible open={isImagesOpen} onOpenChange={setIsImagesOpen} className="mt-8">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span>Images in this article ({allImages.length})</span>
<span>Images in this article ({carouselImages.length})</span>
{isImagesOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
<ImageCarousel images={allImages} />
<ImageCarousel images={carouselImages} />
</CollapsibleContent>
</Collapsible>
)}

19
src/components/Note/PictureNote.tsx

@ -1,16 +1,29 @@ @@ -1,16 +1,29 @@
import { getImetaInfosFromEvent } from '@/lib/event'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { cleanUrl } from '@/lib/url'
import { useMediaExtraction } from '@/hooks/useMediaExtraction'
import Content from '../Content'
import ImageGallery from '../ImageGallery'
export default function PictureNote({ event, className }: { event: Event; className?: string }) {
const imageInfos = useMemo(() => getImetaInfosFromEvent(event), [event])
const { images } = useMediaExtraction(event, event.content)
// Extract cleaned URLs from content to avoid duplicate rendering
const contentUrls = useMemo(() => {
const content = event.content || ''
const urlMatches = content.match(/https?:\/\/[^\s]+/g) || []
return new Set(urlMatches.map(url => cleanUrl(url)))
}, [event.content])
// Images that don't appear in content (from tags only)
const imagesFromTags = useMemo(() => {
return images.filter(img => !contentUrls.has(img.url))
}, [images, contentUrls])
return (
<div className={className}>
<Content event={event} />
{imageInfos.length > 0 && <ImageGallery images={imageInfos} />}
{imagesFromTags.length > 0 && <ImageGallery images={imagesFromTags} className="mt-2" />}
</div>
)
}

32
src/components/Note/VideoNote.tsx

@ -1,37 +1,31 @@ @@ -1,37 +1,31 @@
import { getImetaInfosFromEvent } from '@/lib/event'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { cleanUrl } from '@/lib/url'
import { useMediaExtraction } from '@/hooks/useMediaExtraction'
import Content from '../Content'
import MediaPlayer from '../MediaPlayer'
export default function VideoNote({ event, className }: { event: Event; className?: string }) {
const videoInfos = useMemo(() => getImetaInfosFromEvent(event), [event])
const { videos } = useMediaExtraction(event, event.content)
// Extract URLs from content to avoid duplicate rendering
// Extract cleaned URLs from content to avoid duplicate rendering
const contentUrls = useMemo(() => {
const content = event.content || ''
const urlMatches = content.match(/https?:\/\/[^\s]+/g) || []
return urlMatches.map(url => {
try {
return new URL(url).href
} catch {
return url
}
})
return new Set(urlMatches.map(url => cleanUrl(url)))
}, [event.content])
// Videos that don't appear in content (from tags only)
const videosFromTags = useMemo(() => {
return videos.filter(video => !contentUrls.has(video.url))
}, [videos, contentUrls])
return (
<div className={className}>
<Content event={event} />
{videoInfos
.filter((video) => {
// Only render videos from imeta tags that are not already in the content
const videoUrl = new URL(video.url).href
return !contentUrls.includes(videoUrl)
})
.map((video) => (
<MediaPlayer src={video.url} key={video.url} className="mt-2" />
))}
{videosFromTags.map((video) => (
<MediaPlayer src={video.url} key={video.url} className="mt-2" mustLoad={false} />
))}
</div>
)
}

145
src/components/UniversalContent/EnhancedContent.tsx

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
* while maintaining compatibility with existing embedded content
*/
import { useTranslatedEvent } from '@/hooks'
import { useTranslatedEvent, useMediaExtraction } from '@/hooks'
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
@ -15,11 +15,9 @@ import { @@ -15,11 +15,9 @@ import {
parseContent
} from '@/lib/content-parser'
import logger from '@/lib/logger'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag, tagNameEquals } from '@/lib/tag'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
@ -52,6 +50,10 @@ export default function EnhancedContent({ @@ -52,6 +50,10 @@ export default function EnhancedContent({
useEnhancedParsing?: boolean
}) {
const translatedEvent = useTranslatedEvent(event?.id)
const _content = translatedEvent?.content ?? event?.content ?? content
// Use unified media extraction service
const extractedMedia = useMediaExtraction(event, _content)
// If enhanced parsing is enabled and we have an event, use the new parser
if (useEnhancedParsing && event) {
@ -69,8 +71,7 @@ export default function EnhancedContent({ @@ -69,8 +71,7 @@ export default function EnhancedContent({
}
// Fallback to original parsing logic
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content
const { nodes, lastNormalUrl, emojiInfos } = useMemo(() => {
if (!_content) return {}
const nodes = parseContent(_content, [
@ -83,126 +84,14 @@ export default function EnhancedContent({ @@ -83,126 +84,14 @@ export default function EnhancedContent({
EmbeddedEmojiParser
])
// Collect all images from multiple sources and deduplicate using cleaned URLs
const seenUrls = new Set<string>()
const allImages: TImetaInfo[] = []
// Helper to add image/media if not already seen (using cleaned URL for comparison)
const addImage = (url: string, pubkey?: string, mimeType?: string) => {
if (!url) return
const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return
// Only add if it's actually an image or media file
if (!isImage(cleaned) && !isMedia(cleaned)) return
seenUrls.add(cleaned)
// Determine mime type if not provided
let mime = mimeType
if (!mime) {
if (isImage(cleaned)) {
mime = 'image/*'
} else if (isAudio(cleaned)) {
mime = 'audio/*'
} else if (isVideo(cleaned)) {
mime = 'video/*'
} else {
mime = 'media/*'
}
}
allImages.push({
url: cleaned,
pubkey: pubkey || event?.pubkey,
m: mime
})
}
// 1. Extract from imeta tags
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach((info) => {
if (info.m?.startsWith('image/') || info.m?.startsWith('video/') || info.m?.startsWith('audio/') || isImage(info.url) || isMedia(info.url)) {
addImage(info.url, info.pubkey, info.m)
}
})
}
// 2. Extract from r tags (reference/URL tags)
if (event) {
event.tags.filter(tagNameEquals('r')).forEach(([, url]) => {
if (url && (isImage(url) || isMedia(url))) {
addImage(url)
}
})
}
// 2b. Extract from image tag
if (event) {
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addImage(imageTag[1])
}
}
// 3. Extract from content nodes (already parsed URLs)
nodes.forEach((node) => {
if (node.type === 'image') {
addImage(node.data)
} else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => addImage(url))
} else if (node.type === 'url') {
// Check if URL is an image/media file
if (isImage(node.data) || isMedia(node.data)) {
addImage(node.data)
}
}
})
// 4. Extract directly from raw content (catch any URLs that weren't parsed)
// This ensures we don't miss any image URLs in the content
if (_content) {
const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = _content.matchAll(urlRegex)
for (const match of urlMatches) {
const url = match[0]
if (isImage(url) || isMedia(url)) {
addImage(url)
}
}
}
// 5. Try to match content URLs with imeta tags for better metadata
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
allImages.forEach((img, index) => {
// Try to find matching imeta info
const matchedImeta = imetaInfos.find(imeta => cleanUrl(imeta.url) === img.url)
if (matchedImeta && matchedImeta.m) {
allImages[index] = { ...img, m: matchedImeta.m }
} else {
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(img.url)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey)
if (parsedImeta) {
allImages[index] = parsedImeta
}
}
}
})
}
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
return { nodes, emojiInfos, lastNormalUrl }
}, [_content, event])
if (!nodes || nodes.length === 0) {
return null
@ -211,7 +100,7 @@ export default function EnhancedContent({ @@ -211,7 +100,7 @@ export default function EnhancedContent({
// Create maps for quick lookup of images/media by cleaned URL
const imageMap = new Map<string, TImetaInfo>()
const mediaMap = new Map<string, TImetaInfo>()
allImages.forEach((img) => {
extractedMedia.all.forEach((img: TImetaInfo) => {
if (img.m?.startsWith('image/')) {
imageMap.set(img.url, img)
} else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') {
@ -225,10 +114,12 @@ export default function EnhancedContent({ @@ -225,10 +114,12 @@ export default function EnhancedContent({
logger.debug('[EnhancedContent] Parsed content:', {
nodeCount: nodes.length,
allImages: allImages.length,
allMedia: extractedMedia.all.length,
images: extractedMedia.images.length,
videos: extractedMedia.videos.length,
audio: extractedMedia.audio.length,
imageMapSize: imageMap.size,
mediaMapSize: mediaMap.size,
allImageUrls: allImages.map(img => img.url),
nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data }))
})
@ -276,13 +167,9 @@ export default function EnhancedContent({ @@ -276,13 +167,9 @@ export default function EnhancedContent({
// Filter carousel: only show IMAGES that DON'T appear in content
// (videos and audio should never be in carousel - they're rendered individually)
// (images in content will be rendered in a single carousel, not individually)
const carouselImages = allImages.filter(img => {
// Never include videos or audio in carousel
if (isVideo(img.url) || isAudio(img.url) || img.m?.startsWith('video/') || img.m?.startsWith('audio/')) {
return false
}
const carouselImages = extractedMedia.images.filter((img: TImetaInfo) => {
// Only include images that don't appear in content
return !mediaInContent.has(img.url) && isImage(img.url)
return !mediaInContent.has(img.url)
})
return (

2
src/constants.ts

@ -104,6 +104,8 @@ export const SEARCHABLE_RELAY_URLS = [ @@ -104,6 +104,8 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://relay.damus.io',
'wss://relay.lumina.rocks',
'wss://relay.snort.social',
'wss://nos.lol',
'wss://nostr.mom'
]
export const PROFILE_RELAY_URLS = [

1
src/hooks/index.tsx

@ -7,3 +7,4 @@ export * from './useFetchRelayInfos' @@ -7,3 +7,4 @@ export * from './useFetchRelayInfos'
export * from './useFetchRelayList'
export * from './useSearchProfiles'
export * from './useTranslatedEvent'
export * from './useMediaExtraction'

19
src/hooks/useMediaExtraction.tsx

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { extractAllMediaFromEvent, ExtractedMedia } from '@/services/media-extraction.service'
/**
* Hook to extract all media from an event
*/
export function useMediaExtraction(
event?: Event,
content?: string
): ExtractedMedia {
return useMemo(() => {
if (!event) {
return { images: [], videos: [], audio: [], all: [] }
}
return extractAllMediaFromEvent(event, content)
}, [event, content])
}

149
src/services/media-extraction.service.ts

@ -0,0 +1,149 @@ @@ -0,0 +1,149 @@
import { Event } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
import { TImetaInfo } from '@/types'
import mediaUpload from './media-upload.service'
import { getImetaInfoFromImetaTag } from '@/lib/tag'
export interface ExtractedMedia {
images: TImetaInfo[]
videos: TImetaInfo[]
audio: TImetaInfo[]
all: TImetaInfo[]
}
/**
* Unified service for extracting all media (images, videos, audio) from an event
* Sources: imeta tags, r tags, image tags, and content field
*/
export function extractAllMediaFromEvent(
event: Event,
content?: string
): ExtractedMedia {
const seenUrls = new Set<string>()
const allMedia: TImetaInfo[] = []
// Helper to add media if not already seen (using cleaned URL for comparison)
const addMedia = (url: string, pubkey?: string, mimeType?: string) => {
if (!url) return
const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return
// Only add if it's actually an image or media file
if (!isImage(cleaned) && !isMedia(cleaned)) return
seenUrls.add(cleaned)
// Determine mime type if not provided
let mime = mimeType
if (!mime) {
if (isImage(cleaned)) {
mime = 'image/*'
} else if (isAudio(cleaned)) {
mime = 'audio/*'
} else if (isVideo(cleaned)) {
mime = 'video/*'
} else {
mime = 'media/*'
}
}
allMedia.push({
url: cleaned,
pubkey: pubkey || event.pubkey,
m: mime
})
}
// 1. Extract from imeta tags
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach((info) => {
if (
info.m?.startsWith('image/') ||
info.m?.startsWith('video/') ||
info.m?.startsWith('audio/') ||
isImage(info.url) ||
isMedia(info.url)
) {
addMedia(info.url, info.pubkey, info.m)
}
})
// 2. Extract from r tags (reference/URL tags)
event.tags.filter(tagNameEquals('r')).forEach(([, url]) => {
if (url && (isImage(url) || isMedia(url))) {
addMedia(url)
}
})
// 3. Extract from image tag
const imageTag = event.tags.find((tag) => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addMedia(imageTag[1])
}
// 4. Extract from content (if provided)
if (content) {
// Extract directly from raw content (catch any URLs that weren't parsed)
const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = content.matchAll(urlRegex)
for (const match of urlMatches) {
const url = match[0]
if (isImage(url) || isMedia(url)) {
addMedia(url)
}
}
}
// 5. Try to match content URLs with imeta tags for better metadata
imetaInfos.forEach((imeta) => {
allMedia.forEach((media, index) => {
// Try to find matching imeta info
if (cleanUrl(imeta.url) === media.url && imeta.m) {
allMedia[index] = { ...media, m: imeta.m }
} else {
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(media.url)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey)
if (parsedImeta) {
allMedia[index] = parsedImeta
}
}
}
})
})
// Categorize media
const images: TImetaInfo[] = []
const videos: TImetaInfo[] = []
const audio: TImetaInfo[] = []
allMedia.forEach((media) => {
if (media.m?.startsWith('image/') || isImage(media.url)) {
images.push(media)
} else if (media.m?.startsWith('video/') || isVideo(media.url)) {
videos.push(media)
} else if (media.m?.startsWith('audio/') || isAudio(media.url)) {
audio.push(media)
} else {
// Fallback: try to determine by URL extension
if (isImage(media.url)) {
images.push(media)
} else if (isVideo(media.url)) {
videos.push(media)
} else if (isAudio(media.url)) {
audio.push(media)
}
}
})
return {
images,
videos,
audio,
all: allMedia
}
}
Loading…
Cancel
Save