Browse Source

render embedded events

master
Silberengel 1 month ago
parent
commit
26121a6e5f
  1. 75
      src/lib/components/content/EmbeddedEvent.svelte
  2. 87
      src/lib/components/content/MarkdownRenderer.svelte
  3. 15
      src/lib/modules/feed/FeedPage.svelte
  4. 16
      src/lib/modules/threads/ThreadList.svelte
  5. 2
      src/lib/services/security/sanitizer.ts

75
src/lib/components/content/EmbeddedEvent.svelte

@ -5,7 +5,7 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
import { goto } from '$app/navigation'; import ProfileBadge from '../layout/ProfileBadge.svelte';
interface Props { interface Props {
eventId: string; // Can be hex, note, nevent, naddr eventId: string; // Can be hex, note, nevent, naddr
@ -40,12 +40,38 @@
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
hexId = String(decoded.data.id); hexId = String(decoded.data.id);
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { } else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') {
// For naddr, we need to fetch by kind+pubkey+d // For naddr, fetch by kind+pubkey+d tag
// This is more complex, for now just try to get the identifier const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] };
console.warn('naddr fetching not fully implemented'); 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; error = true;
loading = false;
return; return;
} }
}
} catch (e) { } catch (e) {
console.error('Failed to decode event ID:', e); console.error('Failed to decode event ID:', e);
error = true; error = true;
@ -103,14 +129,19 @@
return preview.length < event.content.length ? preview + '...' : preview; return preview.length < event.content.length ? preview + '...' : preview;
} }
function getThreadUrl(): string {
if (!event) return '#';
return `/thread/${event.id}`;
}
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
e.preventDefault(); 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);
} }
</script> </script>
@ -130,12 +161,16 @@
</div> </div>
{/if} {/if}
<div class="embedded-event-content"> <div class="embedded-event-content">
<div class="embedded-event-header">
<h4 class="embedded-event-title">{getTitle()}</h4> <h4 class="embedded-event-title">{getTitle()}</h4>
{#if event}
<ProfileBadge pubkey={event.pubkey} />
{/if}
</div>
{#if getSubject()} {#if getSubject()}
<p class="embedded-event-subject">{getSubject()}</p> <p class="embedded-event-subject">{getSubject()}</p>
{/if} {/if}
<p class="embedded-event-preview">{getPreview()}</p> <p class="embedded-event-preview">{getPreview()}</p>
<a href={getThreadUrl()} class="embedded-event-link" onclick={(e) => e.stopPropagation()}>View thread →</a>
</div> </div>
</div> </div>
{/if} {/if}
@ -184,6 +219,14 @@
gap: 0.5rem; gap: 0.5rem;
} }
.embedded-event-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.embedded-event-title { .embedded-event-title {
font-weight: 600; font-weight: 600;
font-size: 1.125rem; font-size: 1.125rem;
@ -216,16 +259,6 @@
color: var(--fog-dark-text, #cbd5e1); 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.loading,
.embedded-event.error { .embedded-event.error {

87
src/lib/components/content/MarkdownRenderer.svelte

@ -4,6 +4,7 @@
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js'; import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import ProfileBadge from '../layout/ProfileBadge.svelte'; import ProfileBadge from '../layout/ProfileBadge.svelte';
import EmbeddedEvent from './EmbeddedEvent.svelte';
import { mountComponent } from './mount-component-action.js'; import { mountComponent } from './mount-component-action.js';
interface Props { interface Props {
@ -41,6 +42,19 @@
return null; 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<typeof parseNIP21>): 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 // Escape HTML to prevent XSS
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
return text return text
@ -111,14 +125,35 @@
// First, convert plain media URLs (images, videos, audio) to HTML tags // First, convert plain media URLs (images, videos, audio) to HTML tags
let processed = convertMediaUrls(text); 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); const links = findNIP21Links(processed);
// Only process npub and nprofile for profile badges // Separate into profile links and event links
const profileLinks = links.filter(link => const profileLinks = links.filter(link =>
link.parsed.type === 'npub' || link.parsed.type === 'nprofile' 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 = `<div data-nostr-event data-event-id="${escapedEventId}"></div>`;
processed =
processed.slice(0, link.start) +
div +
processed.slice(link.end);
}
}
// Replace profile links with HTML span elements that have data attributes // Replace profile links with HTML span elements that have data attributes
// Process from end to start to preserve indices // Process from end to start to preserve indices
for (let i = profileLinks.length - 1; i >= 0; i--) { for (let i = profileLinks.length - 1; i >= 0; i--) {
@ -178,8 +213,7 @@
// Find all profile placeholders and mount ProfileBadge components // Find all profile placeholders and mount ProfileBadge components
const placeholders = containerRef.querySelectorAll('[data-nostr-profile]:not([data-mounted])'); const placeholders = containerRef.querySelectorAll('[data-nostr-profile]:not([data-mounted])');
if (placeholders.length === 0) return; if (placeholders.length > 0) {
console.debug(`Mounting ${placeholders.length} ProfileBadge components`); console.debug(`Mounting ${placeholders.length} ProfileBadge components`);
placeholders.forEach((placeholder) => { placeholders.forEach((placeholder) => {
@ -219,6 +253,43 @@
} }
}); });
} }
}
// 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 {
// 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) + '...';
}
}
});
}
}
$effect(() => { $effect(() => {
if (!containerRef || !renderedHtml) return; if (!containerRef || !renderedHtml) return;
@ -227,6 +298,7 @@
const frameId = requestAnimationFrame(() => { const frameId = requestAnimationFrame(() => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
mountProfileBadges(); mountProfileBadges();
mountEmbeddedEvents();
}, 150); }, 150);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
@ -241,6 +313,7 @@
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
mountProfileBadges(); mountProfileBadges();
mountEmbeddedEvents();
}); });
observer.observe(containerRef, { observer.observe(containerRef, {
@ -349,4 +422,10 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
/* Embedded events should be block-level */
:global(.markdown-content [data-nostr-event]) {
display: block;
margin: 1rem 0;
}
</style> </style>

15
src/lib/modules/feed/FeedPage.svelte

@ -38,6 +38,21 @@
await loadFeed(); 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 // Cleanup on unmount
$effect(() => { $effect(() => {
return () => { return () => {

16
src/lib/modules/threads/ThreadList.svelte

@ -4,6 +4,7 @@
import FeedPost from '../feed/FeedPost.svelte'; import FeedPost from '../feed/FeedPost.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte'; import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
// Data maps - all data loaded upfront // Data maps - all data loaded upfront
let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread
@ -421,6 +422,21 @@
drawerOpen = false; drawerOpen = false;
selectedEvent = null; 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);
};
});
</script> </script>
<div class="thread-list"> <div class="thread-list">

2
src/lib/services/security/sanitizer.ts

@ -41,7 +41,7 @@ export function sanitizeHtml(dirty: string): string {
'div', 'div',
'span' '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, ALLOW_DATA_ATTR: true,
KEEP_CONTENT: true, KEEP_CONTENT: true,
// Ensure images are preserved // Ensure images are preserved

Loading…
Cancel
Save