diff --git a/src/lib/components/content/EmbeddedEvent.svelte b/src/lib/components/content/EmbeddedEvent.svelte new file mode 100644 index 0000000..013f50c --- /dev/null +++ b/src/lib/components/content/EmbeddedEvent.svelte @@ -0,0 +1,235 @@ + + +{#if loading} +
+ Loading event... +
+{:else if error} +
+ Failed to load event +
+{:else if event} +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(e as any); } }}> + {#if getImageUrl()} +
+ {getTitle()} +
+ {/if} +
+

{getTitle()}

+ {#if getSubject()} +

{getSubject()}

+ {/if} +

{getPreview()}

+ e.stopPropagation()}>View thread → +
+
+{/if} + + diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 146fb12..24ef176 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -3,7 +3,10 @@ import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; import { findNIP21Links } from '../../services/nostr/nip21-parser.js'; import { nip19 } from 'nostr-tools'; - import { onMount } from 'svelte'; + import { onMount, tick } from 'svelte'; + import ProfileBadge from '../layout/ProfileBadge.svelte'; + import EmbeddedEvent from './EmbeddedEvent.svelte'; + import { mountComponent } from './mount-component-action.js'; interface Props { content?: string; @@ -13,6 +16,43 @@ let rendered = $state(''); let containerElement: HTMLDivElement | null = $state(null); + + // Track profile badges and embedded events to render + let profileBadges = $state>(new Map()); // placeholder -> pubkey + let embeddedEvents = $state>(new Map()); // placeholder -> eventId + + // Process placeholder divs after HTML is rendered and mount components + $effect(() => { + if (!containerElement || !rendered) return; + + tick().then(() => { + if (!containerElement) return; + + // Mount profile badge components + const badgeElements = containerElement.querySelectorAll('.nostr-profile-badge-placeholder'); + badgeElements.forEach((el) => { + const pubkey = el.getAttribute('data-pubkey'); + const placeholder = el.getAttribute('data-placeholder'); + if (pubkey && placeholder && profileBadges.has(placeholder)) { + // Clear the element and mount component + el.innerHTML = ''; + mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey }); + } + }); + + // Mount embedded event components + const eventElements = containerElement.querySelectorAll('.nostr-embedded-event-placeholder'); + eventElements.forEach((el) => { + const eventId = el.getAttribute('data-event-id'); + const placeholder = el.getAttribute('data-placeholder'); + if (eventId && placeholder && embeddedEvents.has(placeholder)) { + // Clear the element and mount component + el.innerHTML = ''; + mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId }); + } + }); + }); + }); // Process rendered HTML to add lazy loading and prevent autoplay function processMediaElements(html: string): string { @@ -95,30 +135,77 @@ // Process media elements for lazy loading finalHtml = processMediaElements(finalHtml); - // Replace placeholders with actual NIP-21 links + // Replace placeholders with actual NIP-21 links/components for (const [placeholder, { uri, parsed }] of placeholders.entries()) { let replacement = ''; try { - const decoded: any = nip19.decode(parsed.data); - if (decoded.type === 'npub') { - const pubkey = String(decoded.data); - replacement = `@${pubkey.slice(0, 8)}...`; - } else if (decoded.type === 'note') { - const eventId = String(decoded.data); - replacement = `${uri}`; - } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { - const eventId = String(decoded.data.id); - replacement = `${uri}`; + // Handle hexID type (no decoding needed) + if (parsed.type === 'hexID') { + const eventId = parsed.data; + const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; + embeddedEvents.set(eventPlaceholder, eventId); + replacement = `
`; } else { - replacement = `${uri}`; + const decoded: any = nip19.decode(parsed.data); + if (decoded.type === 'npub' || decoded.type === 'nprofile') { + const pubkey = decoded.type === 'npub' + ? String(decoded.data) + : (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data + ? String(decoded.data.pubkey) + : null); + if (pubkey) { + // Use custom element that will be replaced with ProfileBadge component + const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; + profileBadges.set(badgePlaceholder, pubkey); + replacement = `
`; + } else { + replacement = `${uri}`; + } + } else if (decoded.type === 'note') { + const eventId = String(decoded.data); + // Use custom element for embedded event + const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; + embeddedEvents.set(eventPlaceholder, eventId); + replacement = `
`; + } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { + const eventId = String(decoded.data.id); + const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; + embeddedEvents.set(eventPlaceholder, eventId); + replacement = `
`; + } else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { + // For naddr, we'd need to fetch by kind+pubkey+d, but for now use the bech32 string + const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`; + embeddedEvents.set(eventPlaceholder, parsed.data); // Store the bech32 string + replacement = `
`; + } else { + replacement = `${uri}`; + } } } catch { // Fallback to generic link if decoding fails - if (parsed.type === 'npub') { - replacement = `${uri}`; + const parsedType = parsed.type; + if (parsedType === 'npub' || parsedType === 'nprofile') { + // Try to extract pubkey from bech32 + try { + const decoded: any = nip19.decode(parsed.data); + const pubkey = decoded.type === 'npub' + ? String(decoded.data) + : (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data + ? String(decoded.data.pubkey) + : null); + if (pubkey) { + const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; + profileBadges.set(badgePlaceholder, pubkey); + replacement = `
`; + } else { + replacement = `${uri}`; + } + } catch { + replacement = `${uri}`; + } } else { - replacement = `${uri}`; + replacement = `${uri}`; } } @@ -138,28 +225,70 @@ // Process media elements for lazy loading finalHtml = processMediaElements(finalHtml); - // Replace placeholders with actual NIP-21 links + // Replace placeholders with actual NIP-21 links/components for (const [placeholder, { uri, parsed }] of placeholders.entries()) { let replacement = ''; - try { - const decoded: any = nip19.decode(parsed.data); - if (decoded.type === 'npub') { - const pubkey = String(decoded.data); - replacement = `@${pubkey.slice(0, 8)}...`; - } else if (decoded.type === 'note') { - const eventId = String(decoded.data); - replacement = `${uri}`; - } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { - const eventId = String(decoded.data.id); - replacement = `${uri}`; - } else { - replacement = `${uri}`; - } - } catch { + try { + // Handle hexID type (no decoding needed) + if (parsed.type === 'hexID') { + const eventId = parsed.data; + const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; + embeddedEvents.set(eventPlaceholder, eventId); + replacement = `
`; + } else { + const decoded: any = nip19.decode(parsed.data); + if (decoded.type === 'npub' || decoded.type === 'nprofile') { + const pubkey = decoded.type === 'npub' + ? String(decoded.data) + : (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data + ? String(decoded.data.pubkey) + : null); + if (pubkey) { + const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; + profileBadges.set(badgePlaceholder, pubkey); + replacement = `
`; + } else { + replacement = `${uri}`; + } + } else if (decoded.type === 'note') { + const eventId = String(decoded.data); + const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; + embeddedEvents.set(eventPlaceholder, eventId); + replacement = `
`; + } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { + const eventId = String(decoded.data.id); + const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; + embeddedEvents.set(eventPlaceholder, eventId); + replacement = `
`; + } else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { + const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`; + embeddedEvents.set(eventPlaceholder, parsed.data); + replacement = `
`; + } else { + replacement = `${uri}`; + } + } + } catch { // Fallback to generic link if decoding fails - if (parsed.type === 'npub') { - replacement = `${uri}`; + if (parsed.type === 'npub' || parsed.type === 'nprofile') { + try { + const decoded: any = nip19.decode(parsed.data); + const pubkey = decoded.type === 'npub' + ? String(decoded.data) + : (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data + ? String(decoded.data.pubkey) + : null); + if (pubkey) { + const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; + profileBadges.set(badgePlaceholder, pubkey); + replacement = `
`; + } else { + replacement = `${uri}`; + } + } catch { + replacement = `${uri}`; + } } else { replacement = `${uri}`; } diff --git a/src/lib/components/content/MediaAttachments.svelte b/src/lib/components/content/MediaAttachments.svelte index 268e322..0dae25e 100644 --- a/src/lib/components/content/MediaAttachments.svelte +++ b/src/lib/components/content/MediaAttachments.svelte @@ -139,34 +139,9 @@ } } - // 5. Extract plain image URLs from content (URLs ending in image extensions) - // Match URLs that end with common image extensions, handling various formats - // This regex matches URLs that: - // - Start with http:// or https:// - // - Contain valid URL characters - // - End with image file extensions - // - May have query parameters - const imageUrlRegex = /https?:\/\/[^\s<>"'\n\r]+\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?[^\s<>"'\n\r]*)?/gi; - let urlMatch; - const processedContent = event.content; - while ((urlMatch = imageUrlRegex.exec(processedContent)) !== null) { - let url = urlMatch[0]; - // Clean up URL - remove trailing punctuation that might have been captured - url = url.replace(/[.,;:!?]+$/, ''); - // Remove closing parentheses if URL was in parentheses - if (url.endsWith(')') && !url.includes('(')) { - url = url.slice(0, -1); - } - const normalized = normalizeUrl(url); - if (!seen.has(normalized) && url.length > 10) { // Basic validation - 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; } diff --git a/src/lib/components/content/mount-component-action.ts b/src/lib/components/content/mount-component-action.ts new file mode 100644 index 0000000..d913ac7 --- /dev/null +++ b/src/lib/components/content/mount-component-action.ts @@ -0,0 +1,39 @@ +/** + * Svelte action to mount a Svelte component into a DOM element + */ +import type { ComponentType, Snippet } from 'svelte'; + +export function mountComponent( + node: HTMLElement, + component: ComponentType, + props: Record +) { + let instance: any = null; + + // Mount the component + if (component && typeof component === 'function') { + // For Svelte 5, we need to use the component constructor differently + try { + // Create a new instance + instance = new (component as any)({ + target: node, + props + }); + } catch (e) { + console.error('Failed to mount component:', e); + } + } + + return { + update(newProps: Record) { + if (instance && typeof instance.$set === 'function') { + instance.$set(newProps); + } + }, + destroy() { + if (instance && typeof instance.$destroy === 'function') { + instance.$destroy(); + } + } + }; +} diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index c243f27..534178d 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -1,16 +1,14 @@