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

<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>