diff --git a/src/lib/components/content/EmbeddedEvent.svelte b/src/lib/components/content/EmbeddedEvent.svelte index 013f50c..98a9e63 100644 --- a/src/lib/components/content/EmbeddedEvent.svelte +++ b/src/lib/components/content/EmbeddedEvent.svelte @@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '../../types/nostr.js'; import { stripMarkdown } from '../../services/text-utils.js'; - import { goto } from '$app/navigation'; + import ProfileBadge from '../layout/ProfileBadge.svelte'; interface Props { eventId: string; // Can be hex, note, nevent, naddr @@ -40,11 +40,37 @@ } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { hexId = String(decoded.data.id); } else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { - // For naddr, we need to fetch by kind+pubkey+d - // This is more complex, for now just try to get the identifier - console.warn('naddr fetching not fully implemented'); - error = true; - return; + // For naddr, fetch by kind+pubkey+d tag + const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] }; + if (naddrData.kind && naddrData.pubkey) { + const dTag = naddrData.identifier || ''; + const threadRelays = relayManager.getThreadReadRelays(); + const feedRelays = relayManager.getFeedReadRelays(); + const allRelays = [...new Set([...threadRelays, ...feedRelays, ...(naddrData.relays || [])])]; + + // Fetch parameterized replaceable event + const filter: any = { + kinds: [naddrData.kind], + authors: [naddrData.pubkey], + '#d': [dTag], + limit: 1 + }; + + const events = await nostrClient.fetchEvents([filter], allRelays, { useCache: true, cacheResults: true }); + if (events.length > 0) { + event = events[0]; + loading = false; + return; + } else { + error = true; + loading = false; + return; + } + } else { + error = true; + loading = false; + return; + } } } catch (e) { console.error('Failed to decode event ID:', e); @@ -103,14 +129,19 @@ return preview.length < event.content.length ? preview + '...' : preview; } - function getThreadUrl(): string { - if (!event) return '#'; - return `/thread/${event.id}`; - } - function handleClick(e: MouseEvent) { e.preventDefault(); - goto(getThreadUrl()); + if (!event) return; + + // Dispatch custom event on window to open in side-panel + // Parent components (like FeedPage, ThreadList) listen for this event on window + const openEvent = new CustomEvent('openEventInDrawer', { + detail: { event }, + bubbles: true, + cancelable: true + }); + + window.dispatchEvent(openEvent); } @@ -130,12 +161,16 @@ {/if}
-

{getTitle()}

+
+

{getTitle()}

+ {#if event} + + {/if} +
{#if getSubject()}

{getSubject()}

{/if}

{getPreview()}

- e.stopPropagation()}>View thread →
{/if} @@ -184,6 +219,14 @@ gap: 0.5rem; } + .embedded-event-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + } + .embedded-event-title { font-weight: 600; font-size: 1.125rem; @@ -216,16 +259,6 @@ color: var(--fog-dark-text, #cbd5e1); } - .embedded-event-link { - font-size: 0.875rem; - color: var(--fog-accent, #64748b); - text-decoration: none; - margin-top: 0.25rem; - } - - .embedded-event-link:hover { - text-decoration: underline; - } .embedded-event.loading, .embedded-event.error { diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 8b82fda..60c5aed 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -4,6 +4,7 @@ import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js'; import { nip19 } from 'nostr-tools'; import ProfileBadge from '../layout/ProfileBadge.svelte'; + import EmbeddedEvent from './EmbeddedEvent.svelte'; import { mountComponent } from './mount-component-action.js'; interface Props { @@ -41,6 +42,19 @@ return null; } + // Extract event identifier from note, nevent, or naddr + // Returns the bech32 string (note1..., nevent1..., naddr1...) which EmbeddedEvent can decode + function getEventIdFromNIP21(parsed: ReturnType): string | null { + if (!parsed) return null; + + if (parsed.type === 'note' || parsed.type === 'nevent' || parsed.type === 'naddr') { + // Return the bech32 string - EmbeddedEvent will decode it + return parsed.data; + } + + return null; + } + // Escape HTML to prevent XSS function escapeHtml(text: string): string { return text @@ -111,14 +125,35 @@ // First, convert plain media URLs (images, videos, audio) to HTML tags let processed = convertMediaUrls(text); - // Find all NIP-21 links (nostr:npub, nostr:nprofile, etc.) + // Find all NIP-21 links (nostr:npub, nostr:nprofile, nostr:nevent, etc.) const links = findNIP21Links(processed); - // Only process npub and nprofile for profile badges + // Separate into profile links and event links const profileLinks = links.filter(link => link.parsed.type === 'npub' || link.parsed.type === 'nprofile' ); + const eventLinks = links.filter(link => + link.parsed.type === 'note' || link.parsed.type === 'nevent' || link.parsed.type === 'naddr' + ); + + // Replace event links with HTML div elements (for block-level display) + // Process from end to start to preserve indices + for (let i = eventLinks.length - 1; i >= 0; i--) { + const link = eventLinks[i]; + const eventId = getEventIdFromNIP21(link.parsed); + if (eventId) { + // Escape event ID to prevent XSS + const escapedEventId = escapeHtml(eventId); + // Create a div element for embedded event cards (block-level) + const div = `
`; + processed = + processed.slice(0, link.start) + + div + + processed.slice(link.end); + } + } + // Replace profile links with HTML span elements that have data attributes // Process from end to start to preserve indices for (let i = profileLinks.length - 1; i >= 0; i--) { @@ -178,24 +213,33 @@ // Find all profile placeholders and mount ProfileBadge components const placeholders = containerRef.querySelectorAll('[data-nostr-profile]:not([data-mounted])'); - if (placeholders.length === 0) return; - - console.debug(`Mounting ${placeholders.length} ProfileBadge components`); - - placeholders.forEach((placeholder) => { - const pubkey = placeholder.getAttribute('data-pubkey'); - if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) { - placeholder.setAttribute('data-mounted', 'true'); - - try { + if (placeholders.length > 0) { + console.debug(`Mounting ${placeholders.length} ProfileBadge components`); + + placeholders.forEach((placeholder) => { + const pubkey = placeholder.getAttribute('data-pubkey'); + if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) { + placeholder.setAttribute('data-mounted', 'true'); + + try { // Clear and mount component placeholder.innerHTML = ''; // Use inline mode for profile badges in markdown content const instance = mountComponent(placeholder as HTMLElement, ProfileBadge as any, { pubkey, inline: true }); - - if (!instance) { - console.warn('ProfileBadge mount returned null', { pubkey }); - // Fallback + + if (!instance) { + console.warn('ProfileBadge mount returned null', { pubkey }); + // Fallback + try { + const npub = nip19.npubEncode(pubkey); + placeholder.textContent = npub.slice(0, 12) + '...'; + } catch { + placeholder.textContent = pubkey.slice(0, 12) + '...'; + } + } + } catch (error) { + console.error('Error mounting ProfileBadge:', error, { pubkey }); + // Show fallback try { const npub = nip19.npubEncode(pubkey); placeholder.textContent = npub.slice(0, 12) + '...'; @@ -203,21 +247,48 @@ placeholder.textContent = pubkey.slice(0, 12) + '...'; } } - } catch (error) { - console.error('Error mounting ProfileBadge:', error, { pubkey }); - // Show fallback + } else if (pubkey) { + console.warn('Invalid pubkey format:', pubkey); + placeholder.textContent = pubkey.slice(0, 12) + '...'; + } + }); + } + } + + // Mount EmbeddedEvent components after rendering + function mountEmbeddedEvents() { + if (!containerRef) return; + + // Find all event placeholders and mount EmbeddedEvent components + const placeholders = containerRef.querySelectorAll('[data-nostr-event]:not([data-mounted])'); + + if (placeholders.length > 0) { + console.debug(`Mounting ${placeholders.length} EmbeddedEvent components`); + + placeholders.forEach((placeholder) => { + const eventId = placeholder.getAttribute('data-event-id'); + if (eventId) { + placeholder.setAttribute('data-mounted', 'true'); + try { - const npub = nip19.npubEncode(pubkey); - placeholder.textContent = npub.slice(0, 12) + '...'; - } catch { - placeholder.textContent = pubkey.slice(0, 12) + '...'; + // Clear and mount component + placeholder.innerHTML = ''; + // Mount EmbeddedEvent component - it will decode and fetch the event + const instance = mountComponent(placeholder as HTMLElement, EmbeddedEvent as any, { eventId }); + + if (!instance) { + console.warn('EmbeddedEvent mount returned null', { eventId }); + // Fallback: show the event ID + placeholder.textContent = eventId.slice(0, 20) + '...'; + } + } catch (error) { + console.error('Error mounting EmbeddedEvent:', error, { eventId }); + // Show fallback + placeholder.textContent = eventId.slice(0, 20) + '...'; } } - } else if (pubkey) { - console.warn('Invalid pubkey format:', pubkey); - placeholder.textContent = pubkey.slice(0, 12) + '...'; - } - }); + }); + } } $effect(() => { @@ -227,6 +298,7 @@ const frameId = requestAnimationFrame(() => { const timeoutId = setTimeout(() => { mountProfileBadges(); + mountEmbeddedEvents(); }, 150); return () => clearTimeout(timeoutId); @@ -241,6 +313,7 @@ const observer = new MutationObserver(() => { mountProfileBadges(); + mountEmbeddedEvents(); }); observer.observe(containerRef, { @@ -349,4 +422,10 @@ display: inline-flex; align-items: center; } + + /* Embedded events should be block-level */ + :global(.markdown-content [data-nostr-event]) { + display: block; + margin: 1rem 0; + } diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index bc2ce4c..0781c41 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -38,6 +38,21 @@ await loadFeed(); }); + // Listen for custom event from EmbeddedEvent components + $effect(() => { + const handleOpenEvent = (e: CustomEvent) => { + if (e.detail?.event) { + openDrawer(e.detail.event); + } + }; + + window.addEventListener('openEventInDrawer', handleOpenEvent as EventListener); + + return () => { + window.removeEventListener('openEventInDrawer', handleOpenEvent as EventListener); + }; + }); + // Cleanup on unmount $effect(() => { return () => { diff --git a/src/lib/modules/threads/ThreadList.svelte b/src/lib/modules/threads/ThreadList.svelte index f1d0377..a887807 100644 --- a/src/lib/modules/threads/ThreadList.svelte +++ b/src/lib/modules/threads/ThreadList.svelte @@ -4,6 +4,7 @@ import FeedPost from '../feed/FeedPost.svelte'; import ThreadDrawer from '../feed/ThreadDrawer.svelte'; import type { NostrEvent } from '../../types/nostr.js'; + import { onMount } from 'svelte'; // Data maps - all data loaded upfront let threadsMap = $state>(new Map()); // threadId -> thread @@ -421,6 +422,21 @@ drawerOpen = false; selectedEvent = null; } + + onMount(() => { + // Listen for custom event from EmbeddedEvent components + const handleOpenEvent = (e: CustomEvent) => { + if (e.detail?.event) { + openThreadDrawer(e.detail.event); + } + }; + + window.addEventListener('openEventInDrawer', handleOpenEvent as EventListener); + + return () => { + window.removeEventListener('openEventInDrawer', handleOpenEvent as EventListener); + }; + });
diff --git a/src/lib/services/security/sanitizer.ts b/src/lib/services/security/sanitizer.ts index 762f98a..ce31181 100644 --- a/src/lib/services/security/sanitizer.ts +++ b/src/lib/services/security/sanitizer.ts @@ -41,7 +41,7 @@ export function sanitizeHtml(dirty: string): string { 'div', 'span' ], - ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'width', 'height', 'data-pubkey', 'data-event-id', 'data-placeholder', 'data-nostr-profile', 'data-mounted'], + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'width', 'height', 'data-pubkey', 'data-event-id', 'data-placeholder', 'data-nostr-profile', 'data-nostr-event', 'data-mounted'], ALLOW_DATA_ATTR: true, KEEP_CONTENT: true, // Ensure images are preserved