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