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.
 
 
 
 
 

203 lines
6.0 KiB

<script lang="ts">
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import UserBadge from './UserBadge.svelte';
import {
loadNostrLinks,
processHtmlWithNostrLinks
} from '$lib/utils/nostr-links.js';
interface Props {
html: string;
relays?: string[];
}
let { html, relays = DEFAULT_NOSTR_RELAYS }: Props = $props();
// Create client once per relay set
let nostrClient = $state<NostrClient | null>(null);
let lastRelays = $state<string>('');
let nostrLinkEvents = $state<Map<string, NostrEvent>>(new Map());
let nostrLinkProfiles = $state<Map<string, string>>(new Map()); // link -> pubkey hex
let loading = $state(true);
let lastHtml = $state<string>('');
let version = $state(0); // Force reactivity when Maps update
let loadingPromise: Promise<void> | null = null; // Track current loading promise
// Initialize client when relays change
$effect(() => {
const relaysKey = JSON.stringify(relays);
if (!nostrClient || lastRelays !== relaysKey) {
nostrClient?.close();
nostrClient = new NostrClient(relays);
lastRelays = relaysKey;
}
});
// Process HTML into parts - reactive so it re-computes when events/profiles load
// Include version in dependency to force re-computation when Maps update
const htmlParts = $derived.by(() => {
if (!html) return [];
// Access version to track it as a dependency
version; // eslint-disable-line @typescript-eslint/no-unused-expressions
return processHtmlWithNostrLinks(html, nostrLinkEvents, nostrLinkProfiles);
});
// Load events and profiles from nostr links
async function loadEventsAndProfiles() {
if (!html || !nostrClient) {
loading = false;
return;
}
const currentHtml = html;
// Skip if html hasn't changed and we already have data
if (currentHtml === lastHtml && nostrLinkEvents.size > 0) {
loading = false;
return;
}
// Skip if already loading the same HTML
if (currentHtml === lastHtml && loadingPromise) {
return;
}
// If HTML changed, cancel previous load
if (currentHtml !== lastHtml) {
loadingPromise = null;
}
loading = true;
console.log('[NostrHtmlRenderer] Processing HTML, length:', currentHtml.length);
// Clear existing maps and create new ones
const newEventCache = new Map<string, NostrEvent>();
const newProfileCache = new Map<string, string>();
// Create and assign loading promise immediately
loadingPromise = (async () => {
try {
await loadNostrLinks(currentHtml, nostrClient!, newEventCache, newProfileCache);
console.log('[NostrHtmlRenderer] After loadNostrLinks - events:', newEventCache.size, 'profiles:', newProfileCache.size);
// Only update if this is still the current HTML (prevent race conditions)
if (currentHtml === html) {
nostrLinkEvents = newEventCache;
nostrLinkProfiles = newProfileCache;
version++; // Increment to force derived to re-compute
// Set lastHtml only after successful load to prevent re-triggering
lastHtml = currentHtml;
}
} catch (err) {
console.error('[NostrHtmlRenderer] Error loading nostr links:', err);
} finally {
// Only clear loading state if HTML hasn't changed (prevent race conditions)
if (currentHtml === html) {
loading = false;
loadingPromise = null;
}
}
})();
await loadingPromise;
}
function formatTime(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString();
}
// Load when html changes - use a more specific dependency
$effect(() => {
// Track html value to detect changes
const currentHtml = html;
const hasClient = !!nostrClient;
if (currentHtml && hasClient) {
// Only load if HTML actually changed (not just if we don't have data)
// This prevents re-loading when component re-renders
if (currentHtml !== lastHtml) {
loadEventsAndProfiles();
}
} else if (!currentHtml) {
loading = false;
nostrLinkEvents = new Map();
nostrLinkProfiles = new Map();
lastHtml = '';
loadingPromise = null;
}
});
</script>
<div class="nostr-html-renderer">
{#if loading}
<div class="loading">Loading nostr links...</div>
{:else}
{#each htmlParts as part}
{#if part.type === 'html'}
{@html part.content}
{:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} />
{:else if part.type === 'event' && part.event}
<div class="nostr-link-event">
<div class="nostr-link-event-header">
<UserBadge pubkey={part.event.pubkey} />
<span class="nostr-link-event-time">
{formatTime(part.event.created_at)}
</span>
</div>
<div class="nostr-link-event-content">
{part.event.content || '(No content)'}
</div>
</div>
{:else}
<span class="nostr-link-placeholder">{part.content}</span>
{/if}
{/each}
{/if}
</div>
<style>
.nostr-html-renderer {
width: 100%;
}
.loading {
padding: 0.5rem;
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.nostr-link-placeholder {
color: var(--text-secondary, #666);
font-style: italic;
}
.nostr-link-event {
margin: 0.5rem 0;
padding: 0.75rem;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
}
.nostr-link-event-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.nostr-link-event-time {
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.nostr-link-event-content {
color: var(--text-primary, #1a1a1a);
white-space: pre-wrap;
word-break: break-word;
}
</style>