diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 6e46d91..4bef69c 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -1,304 +1,268 @@ -
- {@html rendered} +
+ {@html renderedHtml}
diff --git a/src/lib/components/content/mount-component-action.ts b/src/lib/components/content/mount-component-action.ts index a385678..9433817 100644 --- a/src/lib/components/content/mount-component-action.ts +++ b/src/lib/components/content/mount-component-action.ts @@ -14,26 +14,49 @@ export function mountComponent( if (component && typeof component === 'function') { // Using Svelte 4 component API (enabled via compatibility mode in svelte.config.js) try { + // Clear the node first to ensure clean mounting + node.innerHTML = ''; + // Create a new instance + // In Svelte 5 with compatibility mode, components are instantiated with the Svelte 4 API instance = new (component as any)({ target: node, - props + props, + // Ensure the component is hydrated and rendered + hydrate: false, + intro: false }); + + // Verify the component was mounted + if (!instance) { + console.warn('[mountComponent] Component instance not created', { component, props }); + } } catch (e) { - console.error('Failed to mount component:', e); + console.error('[mountComponent] Failed to mount component:', e, { component, props, node }); } + } else { + console.warn('[mountComponent] Invalid component provided', { component, props }); } return { update(newProps: Record) { if (instance && typeof instance.$set === 'function') { - instance.$set(newProps); + try { + instance.$set(newProps); + } catch (e) { + console.error('[mountComponent] Failed to update component:', e); + } } }, destroy() { if (instance && typeof instance.$destroy === 'function') { - instance.$destroy(); + try { + instance.$destroy(); + } catch (e) { + console.error('[mountComponent] Failed to destroy component:', e); + } } + instance = null; } }; } diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte index 8e2ac60..83b1dce 100644 --- a/src/lib/components/layout/ProfileBadge.svelte +++ b/src/lib/components/layout/ProfileBadge.svelte @@ -15,30 +15,13 @@ let activityMessage = $state(null); let imageError = $state(false); - // Debounce requests to allow batching from parent components - let loadTimeout: ReturnType | null = null; - $effect(() => { if (pubkey) { imageError = false; // Reset image error when pubkey changes - - // Clear any pending timeout - if (loadTimeout) { - clearTimeout(loadTimeout); - } - - // Debounce requests by 200ms to allow parent components to batch fetch - loadTimeout = setTimeout(() => { - loadProfile(); - loadStatus(); - updateActivityStatus(); - }, 200); - - return () => { - if (loadTimeout) { - clearTimeout(loadTimeout); - } - }; + // Load immediately - no debounce + loadProfile(); + loadStatus(); + updateActivityStatus(); } }); diff --git a/src/lib/services/nostr/nip21-parser.ts b/src/lib/services/nostr/nip21-parser.ts index 58e4eb4..c6bf8f0 100644 --- a/src/lib/services/nostr/nip21-parser.ts +++ b/src/lib/services/nostr/nip21-parser.ts @@ -60,27 +60,108 @@ export function parseNIP21(uri: string): ParsedNIP21 | null { /** * Find all NIP-21 URIs in text + * Also finds plain bech32 mentions (npub1..., note1..., etc.) without nostr: prefix */ export function findNIP21Links(text: string): Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> { const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = []; + const seenPositions = new Set(); // Track positions to avoid duplicates + const seenEntities = new Map(); // Track entities to prefer nostr: versions - // Match nostr: URIs (case-insensitive) + // First, match nostr: URIs (case-insensitive) - these take priority // Also match hex event IDs (64 hex characters) as nostr:hexID - const regex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi; + const nostrUriRegex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi; let match; - while ((match = regex.exec(text)) !== null) { + while ((match = nostrUriRegex.exec(text)) !== null) { const uri = match[0]; const parsed = parseNIP21(uri); + if (parsed) { + const key = `${match.index}-${match.index + uri.length}`; + if (!seenPositions.has(key)) { + seenPositions.add(key); + // Extract the entity identifier (without nostr: prefix) + const entityId = uri.slice(6); // Remove 'nostr:' prefix + seenEntities.set(entityId.toLowerCase(), { start: match.index, end: match.index + uri.length }); + links.push({ + uri, + start: match.index, + end: match.index + uri.length, + parsed + }); + } + } + } + + // Also match plain bech32 mentions (npub1..., note1..., nevent1..., naddr1..., nprofile1...) + // and hex event IDs (64 hex characters) without nostr: prefix + // Use word boundaries to avoid matching partial strings + // BUT skip if we already found a nostr: version of the same entity + const bech32Regex = /\b((npub|note|nevent|naddr|nprofile)1[a-z0-9]{58,})\b/gi; + while ((match = bech32Regex.exec(text)) !== null) { + const bech32String = match[1]; + const key = `${match.index}-${match.index + bech32String.length}`; + + // Skip if this position overlaps with a nostr: URI we already found + if (seenPositions.has(key)) continue; + + // Skip if we already found a nostr: version of this entity + const existing = seenEntities.get(bech32String.toLowerCase()); + if (existing) { + // Check if positions overlap + if (!(match.index >= existing.end || match.index + bech32String.length <= existing.start)) { + continue; // Overlaps with nostr: version, skip + } + } + + seenPositions.add(key); + // Create a nostr: URI for parsing + const uri = `nostr:${bech32String}`; + const parsed = parseNIP21(uri); if (parsed) { links.push({ - uri, + uri: bech32String, // Store without nostr: prefix for display start: match.index, - end: match.index + uri.length, + end: match.index + bech32String.length, parsed }); } } + // Match hex event IDs (64 hex characters) without nostr: prefix + // BUT skip if we already found a nostr: version + const hexIdRegex = /\b([0-9a-f]{64})\b/gi; + while ((match = hexIdRegex.exec(text)) !== null) { + const hexId = match[1]; + const key = `${match.index}-${match.index + hexId.length}`; + + // Skip if this position overlaps with a nostr: URI we already found + if (seenPositions.has(key)) continue; + + // Skip if we already found a nostr: version of this hex ID + const existing = seenEntities.get(hexId.toLowerCase()); + if (existing) { + // Check if positions overlap + if (!(match.index >= existing.end || match.index + hexId.length <= existing.start)) { + continue; // Overlaps with nostr: version, skip + } + } + + seenPositions.add(key); + // Create a nostr: URI for parsing + const uri = `nostr:${hexId}`; + const parsed = parseNIP21(uri); + if (parsed) { + links.push({ + uri: hexId, // Store without nostr: prefix + start: match.index, + end: match.index + hexId.length, + parsed + }); + } + } + + // Sort by start position + links.sort((a, b) => a.start - b.start); + return links; } diff --git a/src/lib/services/security/sanitizer.ts b/src/lib/services/security/sanitizer.ts index 406ba7c..762f98a 100644 --- a/src/lib/services/security/sanitizer.ts +++ b/src/lib/services/security/sanitizer.ts @@ -3,12 +3,18 @@ */ import DOMPurify from 'dompurify'; +import { browser } from '$app/environment'; /** * Sanitize HTML content */ export function sanitizeHtml(dirty: string): string { - return DOMPurify.sanitize(dirty, { + // Only sanitize in browser - during SSR, return as-is (will be sanitized on client) + if (!browser) { + return dirty; + } + + const config = { ALLOWED_TAGS: [ 'p', 'br', @@ -35,10 +41,15 @@ export function sanitizeHtml(dirty: string): string { 'div', 'span' ], - ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'data-pubkey', 'data-event-id', 'data-placeholder'], + 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'], ALLOW_DATA_ATTR: true, - KEEP_CONTENT: true - }); + KEEP_CONTENT: true, + // Ensure images are preserved + FORBID_TAGS: [], + FORBID_ATTR: [] + }; + + return DOMPurify.sanitize(dirty, config); } /**