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.
433 lines
19 KiB
433 lines
19 KiB
<script lang="ts"> |
|
import * as marked from 'marked'; |
|
import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; |
|
import { findNIP21Links } from '../../services/nostr/nip21-parser.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
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; |
|
} |
|
|
|
let { content = '' }: Props = $props(); |
|
|
|
let rendered = $state(''); |
|
let containerElement: HTMLDivElement | null = $state(null); |
|
|
|
// Track profile badges and embedded events to render |
|
let profileBadges = $state<Map<string, string>>(new Map()); // placeholder -> pubkey |
|
let embeddedEvents = $state<Map<string, string>>(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)) { |
|
// Don't clear if already mounted |
|
if (el.children.length === 0) { |
|
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)) { |
|
// Don't clear if already mounted |
|
if (el.children.length === 0) { |
|
mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId }); |
|
} |
|
} |
|
}); |
|
}); |
|
}); |
|
|
|
// Process rendered HTML to add lazy loading and prevent autoplay |
|
function processMediaElements(html: string): string { |
|
// Add loading="lazy" to all images |
|
html = html.replace(/<img([^>]*)>/gi, (match, attrs) => { |
|
// Don't add if already has loading attribute |
|
if (/loading\s*=/i.test(attrs)) { |
|
return match; |
|
} |
|
return `<img${attrs} loading="lazy">`; |
|
}); |
|
|
|
// Ensure videos don't autoplay and use preload="none" |
|
html = html.replace(/<video([^>]*)>/gi, (match, attrs) => { |
|
let newAttrs = attrs; |
|
// Remove autoplay if present |
|
newAttrs = newAttrs.replace(/\s*autoplay\s*/gi, ' '); |
|
// Set preload to none if not already set |
|
if (!/preload\s*=/i.test(newAttrs)) { |
|
newAttrs += ' preload="none"'; |
|
} else { |
|
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"'); |
|
} |
|
// Ensure autoplay is explicitly false |
|
if (!/autoplay\s*=/i.test(newAttrs)) { |
|
newAttrs += ' autoplay="false"'; |
|
} |
|
return `<video${newAttrs}>`; |
|
}); |
|
|
|
// Ensure audio doesn't autoplay and use preload="none" |
|
html = html.replace(/<audio([^>]*)>/gi, (match, attrs) => { |
|
let newAttrs = attrs; |
|
// Remove autoplay if present |
|
newAttrs = newAttrs.replace(/\s*autoplay\s*/gi, ' '); |
|
// Set preload to none if not already set |
|
if (!/preload\s*=/i.test(newAttrs)) { |
|
newAttrs += ' preload="none"'; |
|
} else { |
|
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"'); |
|
} |
|
// Ensure autoplay is explicitly false |
|
if (!/autoplay\s*=/i.test(newAttrs)) { |
|
newAttrs += ' autoplay="false"'; |
|
} |
|
return `<audio${newAttrs}>`; |
|
}); |
|
|
|
return html; |
|
} |
|
|
|
$effect(() => { |
|
if (content) { |
|
// Process NIP-21 links before markdown parsing |
|
let processed = content; |
|
const links = findNIP21Links(content); |
|
|
|
// Replace links with placeholders, then restore after markdown parsing |
|
const placeholders: Map<string, { uri: string; parsed: any }> = new Map(); |
|
let offset = 0; |
|
|
|
// Process in reverse order to maintain indices |
|
const sortedLinks = [...links].sort((a, b) => b.start - a.start); |
|
|
|
for (const link of sortedLinks) { |
|
// Use a special marker that will be replaced after markdown parsing |
|
// Use a format that markdown won't process: a code-like structure |
|
const placeholder = `\`NIP21PLACEHOLDER${offset}\``; |
|
const before = processed.slice(0, link.start); |
|
const after = processed.slice(link.end); |
|
processed = before + placeholder + after; |
|
placeholders.set(placeholder, { uri: link.uri, parsed: link.parsed }); |
|
offset++; |
|
} |
|
|
|
const parseResult = marked.parse(processed); |
|
if (parseResult instanceof Promise) { |
|
parseResult.then((html) => { |
|
let finalHtml = sanitizeMarkdown(html); |
|
|
|
// Process media elements for lazy loading |
|
finalHtml = processMediaElements(finalHtml); |
|
|
|
// Replace placeholders with actual NIP-21 links/components |
|
for (const [placeholder, { uri, parsed }] of placeholders.entries()) { |
|
let replacement = ''; |
|
|
|
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 = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`; |
|
} 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) { |
|
// 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 = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`; |
|
} else { |
|
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; |
|
} |
|
} 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 = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`; |
|
} 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 = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`; |
|
} 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 = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></span>`; |
|
} else { |
|
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; |
|
} |
|
} |
|
} catch { |
|
// Fallback to generic link if decoding fails |
|
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 = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`; |
|
} else { |
|
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`; |
|
} |
|
} catch { |
|
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`; |
|
} |
|
} else { |
|
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`; |
|
} |
|
} |
|
|
|
// Replace placeholder - it will be in a <code> tag after markdown parsing |
|
// The placeholder is like `NIP21PLACEHOLDER0`, which becomes <code>NIP21PLACEHOLDER0</code> |
|
const placeholderText = placeholder.replace(/`/g, ''); // Remove backticks |
|
const codePlaceholder = `<code>${placeholderText}</code>`; |
|
// Escape special regex characters |
|
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement); |
|
// Also try without code tag (in case markdown didn't process it) |
|
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement); |
|
} |
|
|
|
// Clean up any remaining placeholders (fallback) - look for code tags with our placeholder |
|
finalHtml = finalHtml.replace(/<code>NIP21PLACEHOLDER\d+<\/code>/g, ''); |
|
finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, ''); |
|
|
|
rendered = finalHtml; |
|
}); |
|
} else { |
|
let finalHtml = sanitizeMarkdown(parseResult); |
|
|
|
// Process media elements for lazy loading |
|
finalHtml = processMediaElements(finalHtml); |
|
|
|
// Replace placeholders with actual NIP-21 links/components |
|
for (const [placeholder, { uri, parsed }] of placeholders.entries()) { |
|
let replacement = ''; |
|
|
|
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 = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`; |
|
} 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 = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`; |
|
} else { |
|
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; |
|
} |
|
} 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 = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`; |
|
} 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 = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`; |
|
} 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 = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></span>`; |
|
} else { |
|
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; |
|
} |
|
} |
|
} catch { |
|
// Fallback to generic link if decoding fails |
|
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 = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`; |
|
} else { |
|
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; |
|
} |
|
} catch { |
|
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; |
|
} |
|
} else { |
|
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; |
|
} |
|
} |
|
|
|
// Replace placeholder - it will be in a <code> tag after markdown parsing |
|
const placeholderText = placeholder.replace(/`/g, ''); // Remove backticks |
|
const codePlaceholder = `<code>${placeholderText}</code>`; |
|
// Escape special regex characters |
|
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement); |
|
// Also try without code tag (in case markdown didn't process it) |
|
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement); |
|
} |
|
|
|
// Clean up any remaining placeholders (fallback) |
|
finalHtml = finalHtml.replace(/\u200B\u200B\u200BNIP21_LINK_\d+\u200B\u200B\u200B/g, ''); |
|
|
|
rendered = finalHtml; |
|
} |
|
} else { |
|
rendered = ''; |
|
} |
|
}); |
|
</script> |
|
|
|
<div class="markdown-content anon-content" bind:this={containerElement}> |
|
{@html rendered} |
|
</div> |
|
|
|
<style> |
|
.markdown-content { |
|
line-height: var(--line-height); |
|
} |
|
|
|
.markdown-content :global(p) { |
|
margin: 0.5em 0; |
|
} |
|
|
|
.markdown-content :global(a) { |
|
color: #64748b; |
|
text-decoration: underline; |
|
} |
|
|
|
:global(.dark) .markdown-content :global(a) { |
|
color: #94a3b8; |
|
} |
|
|
|
.markdown-content :global(a:hover) { |
|
color: #475569; |
|
} |
|
|
|
:global(.dark) .markdown-content :global(a:hover) { |
|
color: #cbd5e1; |
|
} |
|
|
|
.markdown-content :global(.nostr-link) { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: underline; |
|
cursor: pointer; |
|
} |
|
|
|
:global(.dark) .markdown-content :global(.nostr-link) { |
|
color: var(--fog-dark-accent, #64748b); |
|
} |
|
|
|
.markdown-content :global(.nostr-link:hover) { |
|
color: var(--fog-text, #475569); |
|
} |
|
|
|
:global(.dark) .markdown-content :global(.nostr-link:hover) { |
|
color: var(--fog-dark-text, #cbd5e1); |
|
} |
|
|
|
.markdown-content :global(code) { |
|
background: #e2e8f0; |
|
padding: 0.2em 0.4em; |
|
border-radius: 3px; |
|
font-family: monospace; |
|
color: #475569; |
|
} |
|
|
|
:global(.dark) .markdown-content :global(code) { |
|
background: #475569; |
|
color: #cbd5e1; |
|
} |
|
|
|
.markdown-content :global(pre) { |
|
background: #e2e8f0; |
|
padding: 1em; |
|
border-radius: 5px; |
|
overflow-x: auto; |
|
border: 1px solid #cbd5e1; |
|
} |
|
|
|
:global(.dark) .markdown-content :global(pre) { |
|
background: #475569; |
|
border-color: #64748b; |
|
} |
|
|
|
.markdown-content :global(img) { |
|
max-width: 100%; |
|
height: auto; |
|
display: block; |
|
margin: 0.5em 0; |
|
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); |
|
} |
|
|
|
:global(.dark) .markdown-content :global(img) { |
|
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); |
|
} |
|
|
|
.markdown-content :global(.nostr-profile-badge-placeholder), |
|
.markdown-content :global(.nostr-embedded-event-placeholder) { |
|
display: inline-block; |
|
vertical-align: middle; |
|
} |
|
|
|
/* Style emojis in content */ |
|
.markdown-content :global(span[role="img"]), |
|
.markdown-content :global(.emoji) { |
|
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); |
|
display: inline-block; |
|
} |
|
|
|
:global(.dark) .markdown-content :global(span[role="img"]), |
|
:global(.dark) .markdown-content :global(.emoji) { |
|
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); |
|
} |
|
</style>
|
|
|