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.
 
 
 
 
 

945 lines
34 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 { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
import { renderAsciiDoc } from '../../services/content/asciidoctor-renderer.js';
import HighlightOverlay from './HighlightOverlay.svelte';
import { getHighlightsForEvent, findHighlightMatches, type Highlight } from '../../services/nostr/highlight-service.js';
import { mountComponent } from './mount-component-action.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());
let highlights = $state<Highlight[]>([]);
let highlightMatches = $state<Array<{ start: number; end: number; highlight: Highlight }>>([]);
let highlightsLoaded = $state(false);
// Validate if a string is a valid bech32 or hex string
function isValidNostrId(str: string): boolean {
if (!str || typeof str !== 'string') return false;
// Check for HTML tags or other invalid characters
if (/<[^>]+>/.test(str)) return false;
// Check if it's hex (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(str)) return true;
// Check if it's bech32 (npub1..., note1..., nevent1..., naddr1..., nprofile1...)
if (/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(str)) return true;
return false;
}
// Extract pubkey from npub or nprofile
function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null {
if (!parsed) return null;
// Validate before decoding
if (!isValidNostrId(parsed.data)) {
console.warn('Invalid NIP-21 data format:', parsed.data);
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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('![') || before.includes('<img') || before.includes('<video') || before.includes('<audio') ||
after.startsWith('](') || 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, but skip code blocks
function replaceEmojis(text: string): string {
let processed = text;
// Find all code blocks (Markdown and AsciiDoc syntax)
const codeBlockRanges: Array<{ start: number; end: number }> = [];
// First, match AsciiDoc source blocks (---- ... ----)
// AsciiDoc source blocks can have attributes like [source, json] before the dashes
// Match: 4+ dashes on a line, content, then 4+ dashes on a line
// We'll match the dashes and then find the closing dashes
const asciidocSourceBlockPattern = /^----+$/gm;
const lines = text.split(/\r?\n/);
let inAsciidocBlock = false;
let blockStart = -1;
let lineIndex = 0;
let charIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineStart = charIndex;
const lineEnd = charIndex + line.length;
// Check if this line is 4+ dashes
if (/^----+$/.test(line.trim())) {
if (!inAsciidocBlock) {
// Starting a new block
inAsciidocBlock = true;
blockStart = lineStart;
} else {
// Ending a block
codeBlockRanges.push({ start: blockStart, end: lineEnd });
inAsciidocBlock = false;
blockStart = -1;
}
}
charIndex = lineEnd + 1; // +1 for newline
}
// If we're still in a block at the end, close it
if (inAsciidocBlock && blockStart >= 0) {
codeBlockRanges.push({ start: blockStart, end: text.length });
}
// Match Markdown/AsciiDoc fenced code blocks (```...```)
// Match triple backticks with optional language identifier
let match;
const fencedCodeBlockPattern = /```[a-zA-Z]*\n?[\s\S]*?```/g;
while ((match = fencedCodeBlockPattern.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
// Only add if not already inside an AsciiDoc source block
const isInsideAsciidoc = codeBlockRanges.some(range => start >= range.start && end <= range.end);
if (!isInsideAsciidoc) {
codeBlockRanges.push({ start, end });
}
}
// Then match inline code (`code`) - but exclude those already inside other blocks
// Match single backtick, but not if it's part of triple backticks
const inlineCodePattern = /`[^`\n]+`/g;
while ((match = inlineCodePattern.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
// Check if this inline code is already inside another code block
const isInsideBlock = codeBlockRanges.some(range => start >= range.start && end <= range.end);
if (!isInsideBlock) {
codeBlockRanges.push({ start, end });
}
}
// Sort ranges by start position
codeBlockRanges.sort((a, b) => a.start - b.start);
// Helper function to check if a position is inside a code block
function isInCodeBlock(index: number): boolean {
return codeBlockRanges.some(range => index >= range.start && index < range.end);
}
// 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.6em; height: 1.6em; vertical-align: middle; object-fit: contain;" />`;
// Find all occurrences and only replace those outside code blocks
let searchIndex = 0;
while (true) {
const index = processed.indexOf(shortcode, searchIndex);
if (index === -1) break;
if (!isInCodeBlock(index)) {
// Replace this occurrence
processed = processed.substring(0, index) + imgTag + processed.substring(index + shortcode.length);
searchIndex = index + imgTag.length;
} else {
// Skip this occurrence (it's in a code block)
searchIndex = index + shortcode.length;
}
}
}
return processed;
}
// Convert hashtags (#hashtag) to links
function convertHashtags(text: string): string {
// Match hashtags: # followed by alphanumeric characters, underscores, and hyphens
// Don't match if it's already in a link or markdown
const hashtagPattern = /(^|\s)(#[a-zA-Z0-9_-]+)/g;
return text.replace(hashtagPattern, (match, prefix, hashtag) => {
// Skip if it's already in a link or markdown
const before = text.substring(Math.max(0, text.indexOf(match) - 20), text.indexOf(match));
if (before.includes('<a') || before.includes('](') || before.includes('href=')) {
return match;
}
const tagName = hashtag.substring(1); // Remove #
const escapedTag = escapeHtml(tagName);
const escapedHashtag = escapeHtml(hashtag);
return `${prefix}<a href="/topics/${escapedTag}" class="hashtag-link">${escapedHashtag}</a>`;
});
}
// Convert greentext (>text with no space) to styled spans
function convertGreentext(text: string): string {
// Split by lines and process each line
const lines = text.split('\n');
const processedLines = lines.map(line => {
// Check if line starts with > followed immediately by non-whitespace (greentext)
// Must match: >text (no space after >)
// Must NOT match: > text (space after >, normal blockquote)
// Also handle HTML-escaped > (&gt;)
const greentextPattern = /^(&gt;|>)([^\s>].*)$/;
const match = line.match(greentextPattern);
if (match) {
// This is greentext - wrap in span with greentext class
// Use > character (not &gt;) since we're inserting HTML
const greentextContent = escapeHtml(match[2]);
return `<span class="greentext">&gt;${greentextContent}</span>`;
}
return line;
});
return processedLines.join('\n');
}
// Process content: replace nostr URIs with HTML span elements and convert media URLs
function processContent(text: string): string {
// First, convert greentext (must be before markdown processing)
let processed = convertGreentext(text);
// Then, replace emoji shortcodes with images if resolved
processed = replaceEmojis(processed);
// Convert hashtags to links
processed = convertHashtags(processed);
// 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();
}
});
// Check if event should use Asciidoctor (kinds 30818 and 30041)
const useAsciidoctor = $derived(event?.kind === 30818 || event?.kind === 30041);
const isKind30040 = $derived(event?.kind === 30040);
// Load highlights for event
async function loadHighlights(abortSignal: AbortSignal) {
if (!event || !content) return;
// Skip highlights for kind 30040 index events (they have no content)
// Highlights should be loaded for the indexed sections instead
if (isKind30040 && !content.trim()) {
if (!abortSignal.aborted) {
highlightsLoaded = true;
}
return;
}
// For kind 30040 sections, wait a bit for full publication load
if (isKind30040) {
await new Promise(resolve => setTimeout(resolve, 2000));
// Check if operation was aborted after delay
if (abortSignal.aborted) return;
}
try {
// Load highlights for this specific event (which could be a section event)
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1];
const eventHighlights = await getHighlightsForEvent(
event.id,
event.kind,
event.pubkey,
dTag
);
// Check if operation was aborted before updating state
if (abortSignal.aborted) return;
highlights = eventHighlights;
highlightMatches = findHighlightMatches(content, eventHighlights);
highlightsLoaded = true;
} catch (error) {
// Only update state if not aborted
if (abortSignal.aborted) return;
console.error('Error loading highlights:', error);
highlightsLoaded = true;
}
}
// Load highlights when content or event changes
$effect(() => {
if (content && event) {
// Create abort controller to track effect lifecycle
const abortController = new AbortController();
// Load highlights with abort signal
loadHighlights(abortController.signal);
// Cleanup: abort the async operation if effect re-runs or component unmounts
return () => {
abortController.abort();
};
}
});
// Post-process HTML to convert blockquotes that are actually greentext
function postProcessGreentext(html: string): string {
// Find blockquotes that match greentext pattern (>text with no space)
// These are blockquotes that markdown created from greentext lines
// Pattern: <blockquote><p>&gt;text</p></blockquote> where there's no space after &gt;
const greentextBlockquotePattern = /<blockquote[^>]*>\s*<p[^>]*>&gt;([^\s<].*?)<\/p>\s*<\/blockquote>/g;
return html.replace(greentextBlockquotePattern, (match, content) => {
// Convert to greentext span
const escapedContent = escapeHtml(content);
return `<span class="greentext">&gt;${escapedContent}</span>`;
});
}
// Render markdown or AsciiDoc to HTML
function renderMarkdown(text: string): string {
if (!content) return '';
const processed = processContent(content);
let html: string;
if (useAsciidoctor) {
// Use Asciidoctor for kinds 30818 and 30041
try {
html = renderAsciiDoc(processed);
} catch (error) {
console.error('Asciidoctor parsing error:', error);
return processed; // Fallback to raw text if parsing fails
}
} else {
// Use marked for all other kinds
try {
html = marked.parse(processed) as string;
} catch (error) {
console.error('Marked parsing error:', error);
return processed; // Fallback to raw text if parsing fails
}
}
// Post-process to fix any greentext that markdown converted to blockquotes
html = postProcessGreentext(html);
// Remove emoji images that are inside code blocks (they should be plain text)
// This handles cases where emojis were replaced before markdown/AsciiDoc parsing
// Handle <code> tags (inline code)
html = html.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (match, codeContent) => {
// Remove any emoji img tags inside code blocks and restore the original shortcode
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1');
});
// Handle <pre> tags (code blocks and AsciiDoc source blocks)
html = html.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (match, preContent) => {
// Remove any emoji img tags inside pre blocks and restore the original shortcode
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1');
});
// Handle AsciiDoc source blocks (they use <div class="listingblock"> with <pre> inside)
html = html.replace(/<div[^>]*class="listingblock"[^>]*>([\s\S]*?)<\/div>/gi, (match, divContent) => {
// Remove emoji images from AsciiDoc listing blocks
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1');
});
// Also handle any other code-related elements that might contain emojis
html = html.replace(/<[^>]*class="[^"]*code[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi, (match, content) => {
// Remove emoji images from any element with "code" in its class
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1');
});
// Fix malformed image tags - ensure all img src attributes are absolute URLs
// This prevents the browser from trying to fetch markdown syntax or malformed tags as relative URLs
html = html.replace(/<img\s+([^>]*?)>/gi, (match, attributes) => {
// Extract src attribute
const srcMatch = attributes.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
const src = srcMatch[1];
// If src doesn't start with http:// or https://, it might be malformed
// Check if it looks like it should be a URL but isn't properly formatted
if (!src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('/')) {
// This might be a malformed URL - try to extract a valid URL from it
const urlMatch = src.match(/(https?:\/\/[^\s<>"']+)/i);
if (urlMatch) {
// Replace with the valid URL
return match.replace(srcMatch[0], `src="${urlMatch[1]}"`);
}
// If no valid URL found, remove the img tag to prevent 404s
return '';
}
}
// If no src attribute or src is empty, remove the img tag
if (!srcMatch || !srcMatch[1]) {
return '';
}
return match;
});
// Fix markdown image syntax that wasn't properly converted (e.g., ![image](url) as text)
// This should be rare, but if marked didn't convert it, we need to handle it
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
// Only convert if it's a valid URL
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url);
const escapedAlt = escapeHtml(alt);
return `<img src="${escapedUrl}" alt="${escapedAlt}" loading="lazy" />`;
}
// If not a valid URL, remove the markdown syntax to prevent 404s
return alt || '';
});
// Remove any escaped HTML that looks like img tags (e.g., &lt;img src="..."&gt;)
// These might be causing the browser to try to fetch them as URLs
html = html.replace(/&lt;img\s+[^&]*src=["']([^"']+)["'][^&]*&gt;/gi, (match, url) => {
// If it's a valid image URL, convert to a proper img tag
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url);
return `<img src="${escapedUrl}" alt="" loading="lazy" />`;
}
// Otherwise, remove to prevent 404s
return '';
});
// Remove any plain text patterns that look like markdown image syntax or HTML img tags
// These patterns in text nodes can cause the browser to try to fetch them as relative URLs
// We match text between HTML tags (not inside tags) that contains these patterns
html = html.replace(/>([^<]*?)(!\[[^\]]*\]\(https?:\/\/[^)]+\)|img\s+src=["']https?:\/\/[^"']+["'])([^<]*?)</gi, (match, before, pattern, after) => {
// Extract URL from the pattern
const urlMatch = pattern.match(/(https?:\/\/[^\s<>"')]+)/i);
if (urlMatch) {
const url = urlMatch[1];
// If it's an image URL, convert to proper img tag
if (/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i.test(url)) {
const escapedUrl = escapeHtml(url);
return `>${before}<img src="${escapedUrl}" alt="" loading="lazy" />${after}<`;
}
}
// Otherwise, remove the problematic pattern to prevent 404s
return `>${before}${after}<`;
});
// Filter out invalid relative links (like /a, /b, etc.) that cause 404s
// These are likely malformed markdown links
html = html.replace(/<a\s+href="\/([a-z]|\.)"[^>]*>.*?<\/a>/gi, (match, char) => {
// Only filter single-character or dot links that aren't valid routes
const validSingleCharRoutes = ['r', 'f', 'w', 't', 'c']; // /rss, /feed, /write, /topics, /cache
if (validSingleCharRoutes.includes(char.toLowerCase())) {
return match; // Keep valid routes
}
// Remove the link but keep the text content
const textMatch = match.match(/>(.*?)<\/a>/);
return textMatch ? textMatch[1] : '';
});
// 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>
<HighlightOverlay highlights={highlightMatches} content={content} event={event!}>
<div
bind:this={containerRef}
class="markdown-content prose prose-sm dark:prose-invert max-w-none"
>
{@html renderedHtml}
</div>
</HighlightOverlay>
<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;
width: 1.6em;
height: 1.6em;
object-fit: contain;
/* Emojis should be in full color, no grayscale filter */
}
: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 a.hashtag-link) {
color: var(--fog-accent, #64748b);
text-decoration: none;
font-weight: 500;
}
:global(.markdown-content a.hashtag-link:hover) {
text-decoration: underline;
}
:global(.dark .markdown-content a.hashtag-link) {
color: var(--fog-dark-accent, #94a3b8);
}
: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);
}
/* Greentext styling - 4chan style */
:global(.markdown-content .greentext) {
color: #789922;
display: block;
margin: 0.25rem 0;
font-family: inherit;
}
:global(.dark .markdown-content .greentext) {
color: #8fbc8f;
}
/* Ensure greentext lines appear on their own line even if markdown processes them */
:global(.markdown-content p .greentext),
:global(.markdown-content .greentext) {
display: block;
margin: 0.25rem 0;
}
/* 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;
}
/* Ensure normal Unicode emojis are displayed correctly */
:global(.markdown-content) {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
/* Ensure emojis are not filtered or hidden */
}
:global(.markdown-content *) {
/* Normal emojis (Unicode characters) should not have filters applied */
/* Only emoji images (custom emojis) should have the grayscale filter */
}
</style>