Browse Source
Nostr-Signature: b92b203686c0629409fef055e7f3189cf9f26be5cca0253ab00cf7e8498e1115 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 06a13aac9d2f794e52b0416044db6ebf9dd248d254d2166d7e7f3fefd2b7d37d1a85072c3e92316898c31068e25cf37bc5afd2fcd8ae2050d0a30b1bc1973678main
12 changed files with 692 additions and 147 deletions
@ -0,0 +1,203 @@
@@ -0,0 +1,203 @@
|
||||
<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> |
||||
Loading…
Reference in new issue