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.
530 lines
18 KiB
530 lines
18 KiB
<script lang="ts"> |
|
import { marked } from 'marked'; |
|
import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; |
|
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'; |
|
import { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
interface Props { |
|
content: string; |
|
event?: NostrEvent; // Optional event for emoji resolution |
|
} |
|
|
|
let { content, event }: Props = $props(); |
|
let containerRef = $state<HTMLElement | null>(null); |
|
let emojiUrls = $state<Map<string, string>>(new Map()); |
|
|
|
// Extract pubkey from npub or nprofile |
|
function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null { |
|
if (!parsed) return null; |
|
|
|
try { |
|
// parsed.data is the bech32 string (e.g., "npub1..." or "nprofile1...") |
|
// We need to decode it to get the actual pubkey |
|
const decoded = nip19.decode(parsed.data); |
|
|
|
if (parsed.type === 'npub') { |
|
// npub decodes directly to pubkey (hex string) |
|
if (decoded.type === 'npub') { |
|
return String(decoded.data); |
|
} |
|
} else if (parsed.type === 'nprofile') { |
|
// nprofile decodes to object with pubkey property |
|
if (decoded.type === 'nprofile' && decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
return String(decoded.data.pubkey); |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error decoding NIP-21 URI:', error, parsed); |
|
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 |
|
function escapeHtml(text: string): string { |
|
return text |
|
.replace(/&/g, '&') |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>') |
|
.replace(/"/g, '"') |
|
.replace(/'/g, '''); |
|
} |
|
|
|
// Convert plain media URLs (images, videos, audio) to HTML tags |
|
function convertMediaUrls(text: string): string { |
|
// Match media URLs (http/https URLs ending in media extensions) |
|
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i; |
|
const videoExtensions = /\.(mp4|webm|ogg|mov|avi|mkv)(\?[^\s<>"']*)?$/i; |
|
const audioExtensions = /\.(mp3|wav|ogg|flac|aac|m4a)(\?[^\s<>"']*)?$/i; |
|
const urlPattern = /https?:\/\/[^\s<>"']+/g; |
|
|
|
let result = text; |
|
const matches: Array<{ url: string; index: number; endIndex: number; type: 'image' | 'video' | 'audio' }> = []; |
|
|
|
// Find all URLs |
|
let match; |
|
while ((match = urlPattern.exec(text)) !== null) { |
|
const url = match[0]; |
|
const index = match.index; |
|
const endIndex = index + url.length; |
|
|
|
// Check if this URL is already in markdown or HTML |
|
const before = text.substring(Math.max(0, index - 10), index); |
|
const after = text.substring(endIndex, Math.min(text.length, endIndex + 10)); |
|
|
|
// Skip if it's already in markdown or HTML tags |
|
if (before.includes(' || after.startsWith('</img>') || after.startsWith('</video>') || after.startsWith('</audio>')) { |
|
continue; |
|
} |
|
|
|
// Determine media type |
|
if (imageExtensions.test(url)) { |
|
matches.push({ url, index, endIndex, type: 'image' }); |
|
} else if (videoExtensions.test(url)) { |
|
matches.push({ url, index, endIndex, type: 'video' }); |
|
} else if (audioExtensions.test(url)) { |
|
matches.push({ url, index, endIndex, type: 'audio' }); |
|
} |
|
} |
|
|
|
// Replace from end to start to preserve indices |
|
for (let i = matches.length - 1; i >= 0; i--) { |
|
const { url, index, endIndex, type } = matches[i]; |
|
const escapedUrl = escapeHtml(url); |
|
|
|
if (type === 'image') { |
|
result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" loading="lazy" />` + result.substring(endIndex); |
|
} else if (type === 'video') { |
|
result = result.substring(0, index) + `<video src="${escapedUrl}" controls preload="none" style="max-width: 100%; max-height: 500px;"></video>` + result.substring(endIndex); |
|
} else if (type === 'audio') { |
|
result = result.substring(0, index) + `<audio src="${escapedUrl}" controls preload="none" style="width: 100%;"></audio>` + result.substring(endIndex); |
|
} |
|
} |
|
|
|
return result; |
|
} |
|
|
|
// Resolve custom emojis in content |
|
async function resolveContentEmojis(text: string): Promise<void> { |
|
if (!event) return; // Need event to resolve emojis |
|
|
|
// Find all :shortcode: patterns |
|
const emojiPattern = /:([a-zA-Z0-9_+-]+):/g; |
|
const matches: Array<{ shortcode: string; fullMatch: string }> = []; |
|
let match; |
|
while ((match = emojiPattern.exec(text)) !== null) { |
|
const shortcode = match[1]; |
|
const fullMatch = match[0]; |
|
if (!matches.find(m => m.fullMatch === fullMatch)) { |
|
matches.push({ shortcode, fullMatch }); |
|
} |
|
} |
|
|
|
if (matches.length === 0) return; |
|
|
|
// Collect all pubkeys to check: event author and p tags |
|
const pubkeysToCheck = new Set<string>(); |
|
pubkeysToCheck.add(event.pubkey); |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'p' && tag[1]) { |
|
pubkeysToCheck.add(tag[1]); |
|
} |
|
} |
|
|
|
// Resolve emojis - first try specific pubkeys, then search broadly |
|
const resolvedUrls = new Map<string, string>(); |
|
for (const { shortcode, fullMatch } of matches) { |
|
// First try specific pubkeys (event author, p tags) |
|
let url = await resolveEmojiShortcode(shortcode, Array.from(pubkeysToCheck), false); |
|
|
|
// If not found, search broadly across all emoji packs |
|
if (!url) { |
|
url = await resolveEmojiShortcode(shortcode, [], true); |
|
} |
|
|
|
if (url) { |
|
resolvedUrls.set(fullMatch, url); |
|
} |
|
} |
|
|
|
emojiUrls = resolvedUrls; |
|
} |
|
|
|
// Replace emoji shortcodes with images in text |
|
function replaceEmojis(text: string): string { |
|
let processed = text; |
|
|
|
// Replace from end to start to preserve indices |
|
const sortedEntries = Array.from(emojiUrls.entries()).sort((a, b) => { |
|
const indexA = processed.lastIndexOf(a[0]); |
|
const indexB = processed.lastIndexOf(b[0]); |
|
return indexB - indexA; // Sort by last index descending |
|
}); |
|
|
|
for (const [shortcode, url] of sortedEntries) { |
|
const escapedUrl = escapeHtml(url); |
|
const escapedShortcode = escapeHtml(shortcode); |
|
// Replace with img tag, preserving the shortcode as alt text |
|
const imgTag = `<img src="${escapedUrl}" alt="${escapedShortcode}" class="emoji-inline" style="display: inline-block; width: 1.2em; height: 1.2em; vertical-align: middle;" />`; |
|
processed = processed.replaceAll(shortcode, imgTag); |
|
} |
|
|
|
return processed; |
|
} |
|
|
|
// Process content: replace nostr URIs with HTML span elements and convert media URLs |
|
function processContent(text: string): string { |
|
// First, replace emoji shortcodes with images if resolved |
|
let processed = replaceEmojis(text); |
|
|
|
// Then, convert plain media URLs (images, videos, audio) to HTML tags |
|
processed = convertMediaUrls(processed); |
|
|
|
// Find all NIP-21 links (nostr:npub, nostr:nprofile, nostr:nevent, etc.) |
|
const links = findNIP21Links(processed); |
|
|
|
// 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 = `<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 |
|
// Process from end to start to preserve indices |
|
for (let i = profileLinks.length - 1; i >= 0; i--) { |
|
const link = profileLinks[i]; |
|
const pubkey = getPubkeyFromNIP21(link.parsed); |
|
if (pubkey) { |
|
// Escape pubkey to prevent XSS (though pubkeys should be safe hex strings) |
|
const escapedPubkey = escapeHtml(pubkey); |
|
// Create a span element that will survive markdown rendering |
|
// Markdown won't process HTML tags, so this will remain as-is |
|
const span = `<span data-nostr-profile data-pubkey="${escapedPubkey}"></span>`; |
|
processed = |
|
processed.slice(0, link.start) + |
|
span + |
|
processed.slice(link.end); |
|
} |
|
} |
|
|
|
return processed; |
|
} |
|
|
|
// Configure marked once - ensure images are rendered and HTML is preserved |
|
marked.setOptions({ |
|
breaks: true, // Convert line breaks to <br> |
|
gfm: true, // GitHub Flavored Markdown |
|
silent: false // Don't suppress errors |
|
// HTML tags (like <img>) pass through by default in marked |
|
}); |
|
|
|
// Resolve emojis when content or event changes |
|
$effect(() => { |
|
if (content && event) { |
|
// Run async resolution without blocking |
|
resolveContentEmojis(content).catch(err => { |
|
console.warn('Error resolving content emojis:', err); |
|
}); |
|
} else { |
|
emojiUrls = new Map(); |
|
} |
|
}); |
|
|
|
// Render markdown to HTML |
|
function renderMarkdown(text: string): string { |
|
if (!content) return ''; |
|
|
|
const processed = processContent(content); |
|
|
|
// Render markdown - this should convert  to <img src="url" alt="alt"> |
|
let html: string; |
|
try { |
|
html = marked.parse(processed) as string; |
|
} catch (error) { |
|
console.error('Marked parsing error:', error); |
|
return processed; // Fallback to raw text if parsing fails |
|
} |
|
|
|
// Sanitize HTML (but preserve our data attributes and image src) |
|
const sanitized = sanitizeMarkdown(html); |
|
|
|
return sanitized; |
|
} |
|
|
|
const renderedHtml = $derived(renderMarkdown(content)); |
|
|
|
// Mount ProfileBadge components after rendering |
|
function mountProfileBadges() { |
|
if (!containerRef) return; |
|
|
|
// Find all profile placeholders and mount ProfileBadge components |
|
const placeholders = containerRef.querySelectorAll('[data-nostr-profile]:not([data-mounted])'); |
|
|
|
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 |
|
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) + '...'; |
|
} catch { |
|
placeholder.textContent = pubkey.slice(0, 12) + '...'; |
|
} |
|
} |
|
} 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 { |
|
// 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(() => { |
|
if (!containerRef || !renderedHtml) return; |
|
|
|
// Use requestAnimationFrame + setTimeout to ensure DOM is ready |
|
const frameId = requestAnimationFrame(() => { |
|
const timeoutId = setTimeout(() => { |
|
mountProfileBadges(); |
|
mountEmbeddedEvents(); |
|
}, 150); |
|
|
|
return () => clearTimeout(timeoutId); |
|
}); |
|
|
|
return () => cancelAnimationFrame(frameId); |
|
}); |
|
|
|
// Also use MutationObserver to catch any placeholders added later |
|
$effect(() => { |
|
if (!containerRef) return; |
|
|
|
const observer = new MutationObserver(() => { |
|
mountProfileBadges(); |
|
mountEmbeddedEvents(); |
|
}); |
|
|
|
observer.observe(containerRef, { |
|
childList: true, |
|
subtree: true |
|
}); |
|
|
|
return () => observer.disconnect(); |
|
}); |
|
</script> |
|
|
|
<div |
|
bind:this={containerRef} |
|
class="markdown-content prose prose-sm dark:prose-invert max-w-none" |
|
> |
|
{@html renderedHtml} |
|
</div> |
|
|
|
<style> |
|
:global(.markdown-content) { |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
} |
|
|
|
:global(.markdown-content img) { |
|
max-width: 100%; |
|
height: auto; |
|
border-radius: 0.25rem; |
|
margin: 0.5rem 0; |
|
display: block; |
|
visibility: visible !important; |
|
opacity: 1 !important; |
|
/* Content images should be prominent - no grayscale filters */ |
|
filter: none !important; |
|
} |
|
|
|
:global(.markdown-content img.emoji-inline) { |
|
display: inline-block; |
|
margin: 0; |
|
vertical-align: middle; |
|
/* Inline emojis should have grayscale filter like other emojis */ |
|
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); |
|
} |
|
|
|
:global(.dark .markdown-content img.emoji-inline) { |
|
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); |
|
} |
|
|
|
:global(.markdown-content video) { |
|
max-width: 100%; |
|
height: auto; |
|
border-radius: 0.25rem; |
|
margin: 0.5rem 0; |
|
/* Content videos should be prominent - no grayscale filters */ |
|
filter: none !important; |
|
} |
|
|
|
:global(.markdown-content audio) { |
|
width: 100%; |
|
margin: 0.5rem 0; |
|
/* Content audio should be prominent - no grayscale filters */ |
|
filter: none !important; |
|
} |
|
|
|
:global(.markdown-content a) { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: underline; |
|
} |
|
|
|
:global(.markdown-content a:hover) { |
|
text-decoration: none; |
|
} |
|
|
|
:global(.markdown-content code) { |
|
background: var(--fog-border, #e5e7eb); |
|
padding: 0.125rem 0.25rem; |
|
border-radius: 0.25rem; |
|
font-size: 0.875em; |
|
} |
|
|
|
:global(.dark .markdown-content code) { |
|
background: var(--fog-dark-border, #374151); |
|
} |
|
|
|
:global(.markdown-content pre) { |
|
background: var(--fog-border, #e5e7eb); |
|
padding: 1rem; |
|
border-radius: 0.5rem; |
|
overflow-x: auto; |
|
margin: 0.5rem 0; |
|
} |
|
|
|
:global(.dark .markdown-content pre) { |
|
background: var(--fog-dark-border, #374151); |
|
} |
|
|
|
:global(.markdown-content pre code) { |
|
background: transparent; |
|
padding: 0; |
|
} |
|
|
|
:global(.markdown-content blockquote) { |
|
border-left: 4px solid var(--fog-border, #e5e7eb); |
|
padding-left: 1rem; |
|
margin: 0.5rem 0; |
|
color: var(--fog-text-light, #6b7280); |
|
} |
|
|
|
:global(.dark .markdown-content blockquote) { |
|
border-color: var(--fog-dark-border, #374151); |
|
color: var(--fog-dark-text-light, #9ca3af); |
|
} |
|
|
|
/* Profile badges in markdown content should align with text baseline */ |
|
:global(.markdown-content [data-nostr-profile]), |
|
:global(.markdown-content .profile-badge) { |
|
vertical-align: middle; |
|
display: inline-flex; |
|
align-items: center; |
|
} |
|
|
|
/* Embedded events should be block-level */ |
|
:global(.markdown-content [data-nostr-event]) { |
|
display: block; |
|
margin: 1rem 0; |
|
} |
|
</style>
|
|
|