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.
 
 
 
 
 

458 lines
13 KiB

<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 ![alt](url)
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>