|
|
<script lang="ts"> |
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
import { onMount } from 'svelte'; |
|
|
|
|
|
interface Props { |
|
|
event: NostrEvent; |
|
|
} |
|
|
|
|
|
let { event }: Props = $props(); |
|
|
|
|
|
// Track which media items should be loaded |
|
|
let loadedMedia = $state<Set<string>>(new Set()); |
|
|
let mediaRefs = $state<Map<string, HTMLElement>>(new Map()); |
|
|
|
|
|
interface MediaItem { |
|
|
url: string; |
|
|
type: 'image' | 'video' | 'audio' | 'file'; |
|
|
mimeType?: string; |
|
|
width?: number; |
|
|
height?: number; |
|
|
size?: number; |
|
|
source: 'image-tag' | 'imeta' | 'file-tag' | 'content'; |
|
|
} |
|
|
|
|
|
function normalizeUrl(url: string): string { |
|
|
try { |
|
|
const parsed = new URL(url); |
|
|
// Remove query params and fragments for comparison |
|
|
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, ''); |
|
|
} catch { |
|
|
return url; |
|
|
} |
|
|
} |
|
|
|
|
|
// Check if a URL appears in the content (as plain URL or in markdown) |
|
|
function isUrlInContent(url: string): boolean { |
|
|
const normalized = normalizeUrl(url); |
|
|
const content = event.content.toLowerCase(); |
|
|
|
|
|
// Check if URL appears as plain text (with or without protocol) |
|
|
const urlWithoutProtocol = normalized.replace(/^https?:\/\//i, ''); |
|
|
if (content.includes(normalized.toLowerCase()) || content.includes(urlWithoutProtocol.toLowerCase())) { |
|
|
return true; |
|
|
} |
|
|
|
|
|
// Check if URL appears in markdown image syntax  |
|
|
const markdownImageRegex = /!\[.*?\]\((.*?)\)/gi; |
|
|
let match; |
|
|
while ((match = markdownImageRegex.exec(event.content)) !== null) { |
|
|
const markdownUrl = normalizeUrl(match[1]); |
|
|
if (markdownUrl === normalized) { |
|
|
return true; |
|
|
} |
|
|
} |
|
|
|
|
|
// Check if URL appears in HTML img/video/audio tags |
|
|
const htmlTagRegex = /<(img|video|audio)[^>]+src=["']([^"']+)["']/gi; |
|
|
while ((match = htmlTagRegex.exec(event.content)) !== null) { |
|
|
const htmlUrl = normalizeUrl(match[2]); |
|
|
if (htmlUrl === normalized) { |
|
|
return true; |
|
|
} |
|
|
} |
|
|
|
|
|
return false; |
|
|
} |
|
|
|
|
|
function extractMedia(): MediaItem[] { |
|
|
const media: MediaItem[] = []; |
|
|
const seen = new Set<string>(); |
|
|
|
|
|
// 1. Image tag (NIP-23) - cover image |
|
|
const imageTag = event.tags.find((t) => t[0] === 'image'); |
|
|
if (imageTag && imageTag[1]) { |
|
|
const normalized = normalizeUrl(imageTag[1]); |
|
|
if (!seen.has(normalized)) { |
|
|
media.push({ |
|
|
url: imageTag[1], |
|
|
type: 'image', |
|
|
source: 'image-tag' |
|
|
}); |
|
|
seen.add(normalized); |
|
|
} |
|
|
} |
|
|
|
|
|
// 2. imeta tags (NIP-92) - only display if NOT already in content |
|
|
for (const tag of event.tags) { |
|
|
if (tag[0] === 'imeta') { |
|
|
let url: string | undefined; |
|
|
let mimeType: string | undefined; |
|
|
let width: number | undefined; |
|
|
let height: number | undefined; |
|
|
|
|
|
for (let i = 1; i < tag.length; i++) { |
|
|
const item = tag[i]; |
|
|
if (item.startsWith('url ')) { |
|
|
url = item.substring(4).trim(); |
|
|
} else if (item.startsWith('m ')) { |
|
|
mimeType = item.substring(2).trim(); |
|
|
} else if (item.startsWith('x ')) { |
|
|
width = parseInt(item.substring(2).trim(), 10); |
|
|
} else if (item.startsWith('y ')) { |
|
|
height = parseInt(item.substring(2).trim(), 10); |
|
|
} |
|
|
} |
|
|
|
|
|
if (url) { |
|
|
const normalized = normalizeUrl(url); |
|
|
// Skip if already displayed in content (imeta is just metadata reference) |
|
|
if (isUrlInContent(url)) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (!seen.has(normalized)) { |
|
|
let type: 'image' | 'video' | 'audio' = 'image'; |
|
|
if (mimeType) { |
|
|
if (mimeType.startsWith('video/')) type = 'video'; |
|
|
else if (mimeType.startsWith('audio/')) type = 'audio'; |
|
|
} |
|
|
|
|
|
media.push({ |
|
|
url, |
|
|
type, |
|
|
mimeType, |
|
|
width, |
|
|
height, |
|
|
source: 'imeta' |
|
|
}); |
|
|
seen.add(normalized); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// 3. file tags (NIP-94) |
|
|
for (const tag of event.tags) { |
|
|
if (tag[0] === 'file' && tag[1]) { |
|
|
const normalized = normalizeUrl(tag[1]); |
|
|
if (!seen.has(normalized)) { |
|
|
let mimeType: string | undefined; |
|
|
let size: number | undefined; |
|
|
|
|
|
for (let i = 2; i < tag.length; i++) { |
|
|
const item = tag[i]; |
|
|
if (item && !item.startsWith('size ')) { |
|
|
mimeType = item; |
|
|
} else if (item.startsWith('size ')) { |
|
|
size = parseInt(item.substring(5).trim(), 10); |
|
|
} |
|
|
} |
|
|
|
|
|
media.push({ |
|
|
url: tag[1], |
|
|
type: 'file', |
|
|
mimeType, |
|
|
size, |
|
|
source: 'file-tag' |
|
|
}); |
|
|
seen.add(normalized); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// 4. Extract from markdown content (images in markdown syntax) |
|
|
const imageRegex = /!\[.*?\]\((.*?)\)/g; |
|
|
let match; |
|
|
while ((match = imageRegex.exec(event.content)) !== null) { |
|
|
const url = match[1]; |
|
|
const normalized = normalizeUrl(url); |
|
|
if (!seen.has(normalized)) { |
|
|
media.push({ |
|
|
url, |
|
|
type: 'image', |
|
|
source: 'content' |
|
|
}); |
|
|
seen.add(normalized); |
|
|
} |
|
|
} |
|
|
|
|
|
// 5. Don't extract plain image URLs from content - let markdown render them inline |
|
|
// This ensures images appear where the URL is in the content, not at the top |
|
|
// Only extract images from tags (image, imeta, file) which are handled above |
|
|
|
|
|
return media; |
|
|
} |
|
|
|
|
|
const mediaItems = $derived(extractMedia()); |
|
|
const coverImage = $derived(mediaItems.find((m) => m.source === 'image-tag')); |
|
|
const otherMedia = $derived(mediaItems.filter((m) => m.source !== 'image-tag')); |
|
|
|
|
|
// Intersection Observer for lazy loading |
|
|
let observer: IntersectionObserver | null = $state(null); |
|
|
|
|
|
onMount(() => { |
|
|
observer = new IntersectionObserver( |
|
|
(entries) => { |
|
|
entries.forEach((entry) => { |
|
|
if (entry.isIntersecting) { |
|
|
const url = entry.target.getAttribute('data-media-url'); |
|
|
if (url) { |
|
|
loadedMedia.add(url); |
|
|
// Force reactivity update |
|
|
loadedMedia = new Set(loadedMedia); |
|
|
observer?.unobserve(entry.target); |
|
|
} |
|
|
} |
|
|
}); |
|
|
}, |
|
|
{ |
|
|
rootMargin: '100px' // Start loading 100px before element is visible |
|
|
} |
|
|
); |
|
|
|
|
|
// Observe all existing placeholders |
|
|
$effect(() => { |
|
|
if (observer && containerRef) { |
|
|
const placeholders = containerRef.querySelectorAll('[data-media-url]'); |
|
|
placeholders.forEach((placeholder) => { |
|
|
if (observer) { |
|
|
observer.observe(placeholder); |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
return () => { |
|
|
observer?.disconnect(); |
|
|
observer = null; |
|
|
}; |
|
|
}); |
|
|
|
|
|
let containerRef = $state<HTMLElement | null>(null); |
|
|
|
|
|
// Action to set media ref and observe it |
|
|
function mediaRefAction(node: HTMLElement, url: string) { |
|
|
mediaRefs.set(url, node); |
|
|
// Observe the element when it's added |
|
|
if (observer) { |
|
|
observer.observe(node); |
|
|
// Also check if it's already visible (in case IntersectionObserver hasn't fired yet) |
|
|
const rect = node.getBoundingClientRect(); |
|
|
const isVisible = rect.top < window.innerHeight + 100 && rect.bottom > -100; |
|
|
if (isVisible) { |
|
|
loadedMedia.add(url); |
|
|
loadedMedia = new Set(loadedMedia); |
|
|
observer.unobserve(node); |
|
|
} |
|
|
} |
|
|
return { |
|
|
destroy() { |
|
|
if (observer) { |
|
|
observer.unobserve(node); |
|
|
} |
|
|
mediaRefs.delete(url); |
|
|
} |
|
|
}; |
|
|
} |
|
|
|
|
|
function shouldLoad(url: string): boolean { |
|
|
// Always load cover images immediately |
|
|
if (coverImage && coverImage.url === url) { |
|
|
return true; |
|
|
} |
|
|
return loadedMedia.has(url); |
|
|
} |
|
|
</script> |
|
|
|
|
|
<div bind:this={containerRef}> |
|
|
{#if coverImage} |
|
|
<div class="cover-image mb-4"> |
|
|
{#if shouldLoad(coverImage.url)} |
|
|
<img |
|
|
src={coverImage.url} |
|
|
alt="" |
|
|
class="w-full max-h-96 object-cover rounded" |
|
|
loading="lazy" |
|
|
/> |
|
|
{:else} |
|
|
<div |
|
|
class="media-placeholder w-full max-h-96 bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center" |
|
|
use:mediaRefAction={coverImage.url} |
|
|
data-media-url={coverImage.url} |
|
|
style="min-height: 200px;" |
|
|
> |
|
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading image...</span> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
{#if otherMedia.length > 0} |
|
|
<div class="media-gallery mb-4"> |
|
|
{#each otherMedia as item} |
|
|
{#if item.type === 'image'} |
|
|
<div class="media-item"> |
|
|
{#if shouldLoad(item.url)} |
|
|
<img |
|
|
src={item.url} |
|
|
alt="" |
|
|
class="max-w-full rounded" |
|
|
loading="lazy" |
|
|
/> |
|
|
{:else} |
|
|
<div |
|
|
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center" |
|
|
use:mediaRefAction={item.url} |
|
|
data-media-url={item.url} |
|
|
style="min-height: 150px; min-width: 150px;" |
|
|
> |
|
|
<span class="text-fog-text-light dark:text-fog-dark-text-light text-sm">Loading...</span> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
{:else if item.type === 'video'} |
|
|
<div class="media-item"> |
|
|
{#if shouldLoad(item.url)} |
|
|
<video |
|
|
src={item.url} |
|
|
controls |
|
|
preload="none" |
|
|
class="max-w-full rounded" |
|
|
style="max-height: 500px;" |
|
|
autoplay={false} |
|
|
muted={false} |
|
|
> |
|
|
<track kind="captions" /> |
|
|
Your browser does not support the video tag. |
|
|
</video> |
|
|
{:else} |
|
|
<div |
|
|
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center" |
|
|
use:mediaRefAction={item.url} |
|
|
data-media-url={item.url} |
|
|
style="min-height: 200px; min-width: 200px;" |
|
|
> |
|
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">▶️ Video</span> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
{:else if item.type === 'audio'} |
|
|
<div class="media-item"> |
|
|
{#if shouldLoad(item.url)} |
|
|
<audio |
|
|
src={item.url} |
|
|
controls |
|
|
preload="none" |
|
|
class="w-full" |
|
|
autoplay={false} |
|
|
> |
|
|
Your browser does not support the audio tag. |
|
|
</audio> |
|
|
{:else} |
|
|
<div |
|
|
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center" |
|
|
use:mediaRefAction={item.url} |
|
|
data-media-url={item.url} |
|
|
style="min-height: 60px; width: 100%;" |
|
|
> |
|
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">🎵 Audio</span> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
{:else if item.type === 'file'} |
|
|
<div class="media-item file-item"> |
|
|
<a |
|
|
href={item.url} |
|
|
target="_blank" |
|
|
rel="noopener noreferrer" |
|
|
class="file-link" |
|
|
> |
|
|
📎 {item.mimeType || 'File'} {item.size ? `(${(item.size / 1024).toFixed(1)} KB)` : ''} |
|
|
</a> |
|
|
</div> |
|
|
{/if} |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<style> |
|
|
.cover-image { |
|
|
margin-bottom: 1rem; |
|
|
} |
|
|
|
|
|
.cover-image img { |
|
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
|
max-width: 600px; |
|
|
width: 100%; |
|
|
height: auto; |
|
|
} |
|
|
|
|
|
:global(.dark) .cover-image img { |
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
} |
|
|
|
|
|
.media-gallery { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
|
|
gap: 1rem; |
|
|
margin-top: 1rem; |
|
|
} |
|
|
|
|
|
@media (max-width: 640px) { |
|
|
.media-gallery { |
|
|
grid-template-columns: 1fr; |
|
|
} |
|
|
} |
|
|
|
|
|
.media-item { |
|
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
|
border-radius: 0.25rem; |
|
|
overflow: hidden; |
|
|
background: var(--fog-post, #ffffff); |
|
|
padding: 0.5rem; |
|
|
} |
|
|
|
|
|
:global(.dark) .media-item { |
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
background: var(--fog-dark-post, #1f2937); |
|
|
} |
|
|
|
|
|
.media-item img, |
|
|
.media-item video { |
|
|
max-width: 600px; |
|
|
width: 100%; |
|
|
height: auto; |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.media-item audio { |
|
|
max-width: 600px; |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
.file-item { |
|
|
padding: 1rem; |
|
|
} |
|
|
|
|
|
.file-link { |
|
|
color: var(--fog-accent, #64748b); |
|
|
text-decoration: none; |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.file-link:hover { |
|
|
text-decoration: underline; |
|
|
} |
|
|
|
|
|
.media-placeholder { |
|
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
|
} |
|
|
|
|
|
:global(.dark) .media-placeholder { |
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
} |
|
|
</style>
|
|
|
|