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.
381 lines
11 KiB
381 lines
11 KiB
<script lang="ts"> |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { onMount } from 'svelte'; |
|
import { nip19 } from 'nostr-tools'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { stripMarkdown } from '../../services/text-utils.js'; |
|
import ProfileBadge from '../layout/ProfileBadge.svelte'; |
|
import { KIND } from '../../types/kind-lookup.js'; |
|
|
|
interface Props { |
|
eventId: string; // Can be hex, note, nevent, naddr |
|
} |
|
|
|
let { eventId }: Props = $props(); |
|
|
|
let event = $state<NostrEvent | null>(null); |
|
let loading = $state(true); |
|
let error = $state(false); |
|
let loadingEvent = $state(false); |
|
let lastEventId = $state<string | null>(null); |
|
|
|
onMount(async () => { |
|
if (eventId && eventId !== lastEventId) { |
|
await loadEvent(); |
|
} |
|
}); |
|
|
|
$effect(() => { |
|
if (eventId && eventId !== lastEventId && !loadingEvent) { |
|
lastEventId = eventId; |
|
loadEvent(); |
|
} |
|
}); |
|
|
|
// Validate if a string is a valid bech32 or hex string |
|
function isValidNostrId(str: string): boolean { |
|
if (!str || typeof str !== 'string') return false; |
|
// Check for HTML tags or other invalid characters |
|
if (/<[^>]+>/.test(str)) return false; |
|
// Check if it's hex (64 hex characters) |
|
if (/^[0-9a-f]{64}$/i.test(str)) return true; |
|
// Check if it's bech32 (npub1..., note1..., nevent1..., naddr1..., nprofile1...) |
|
if (/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(str)) return true; |
|
return false; |
|
} |
|
|
|
async function loadEvent() { |
|
// Prevent concurrent loads for the same event |
|
if (loadingEvent) { |
|
return; |
|
} |
|
loadingEvent = true; |
|
loading = true; |
|
error = false; |
|
try { |
|
// Validate eventId before processing |
|
if (!eventId || !isValidNostrId(eventId)) { |
|
console.warn('Invalid event ID format:', eventId); |
|
error = true; |
|
loading = false; |
|
loadingEvent = false; |
|
return; |
|
} |
|
|
|
// Decode event ID |
|
let hexId: string | null = null; |
|
|
|
// Check if it's already hex |
|
if (/^[0-9a-f]{64}$/i.test(eventId)) { |
|
hexId = eventId.toLowerCase(); |
|
} else { |
|
// Try to decode bech32 |
|
try { |
|
const decoded = nip19.decode(eventId); |
|
if (decoded.type === 'note') { |
|
hexId = String(decoded.data); |
|
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
|
hexId = String(decoded.data.id); |
|
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { |
|
// For naddr, fetch by kind+pubkey+d tag |
|
const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] }; |
|
if (naddrData.kind && naddrData.pubkey) { |
|
const dTag = naddrData.identifier || ''; |
|
const threadRelays = relayManager.getThreadReadRelays(); |
|
const feedRelays = relayManager.getFeedReadRelays(); |
|
const allRelays = [...new Set([...threadRelays, ...feedRelays, ...(naddrData.relays || [])])]; |
|
|
|
// Fetch parameterized replaceable event |
|
const filter: any = { |
|
kinds: [naddrData.kind], |
|
authors: [naddrData.pubkey], |
|
'#d': [dTag], |
|
limit: 1 |
|
}; |
|
|
|
const events = await nostrClient.fetchEvents([filter], allRelays, { useCache: true, cacheResults: true }); |
|
if (events.length > 0) { |
|
event = events[0]; |
|
loading = false; |
|
return; |
|
} else { |
|
error = true; |
|
loading = false; |
|
return; |
|
} |
|
} else { |
|
error = true; |
|
loading = false; |
|
return; |
|
} |
|
} |
|
} catch (e) { |
|
console.error('Failed to decode event ID:', e); |
|
error = true; |
|
loading = false; |
|
loadingEvent = false; |
|
return; |
|
} |
|
} |
|
|
|
if (!hexId) { |
|
error = true; |
|
loading = false; |
|
loadingEvent = false; |
|
return; |
|
} |
|
|
|
// Validate hexId is exactly 64 characters (proper event ID length) |
|
if (hexId.length !== 64 || !/^[0-9a-f]{64}$/i.test(hexId)) { |
|
console.warn('Invalid hex event ID length or format:', hexId); |
|
error = true; |
|
loading = false; |
|
loadingEvent = false; |
|
return; |
|
} |
|
|
|
const relays = relayManager.getThreadReadRelays(); |
|
const feedRelays = relayManager.getFeedReadRelays(); |
|
const allRelays = [...new Set([...relays, ...feedRelays])]; |
|
|
|
const loadedEvent = await nostrClient.getEventById(hexId, allRelays); |
|
event = loadedEvent; |
|
} catch (err) { |
|
console.error('Error loading embedded event:', err); |
|
error = true; |
|
} finally { |
|
loading = false; |
|
loadingEvent = false; |
|
} |
|
} |
|
|
|
function getTitle(): string { |
|
if (!event) return ''; |
|
if (event.kind === KIND.DISCUSSION_THREAD) { |
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
|
return titleTag?.[1] || ''; |
|
} |
|
// For other event kinds, don't use content as title - leave it blank |
|
return ''; |
|
} |
|
|
|
function getSubject(): string | null { |
|
if (!event) return null; |
|
const subjectTag = event.tags.find(t => t[0] === 'subject'); |
|
return subjectTag?.[1] || null; |
|
} |
|
|
|
function getImageUrl(): string | null { |
|
if (!event) return null; |
|
const imageTag = event.tags.find(t => t[0] === 'image'); |
|
return imageTag?.[1] || null; |
|
} |
|
|
|
// Extract image URLs from event content |
|
function getImageUrlsFromContent(): string[] { |
|
if (!event) return []; |
|
const imageUrls: string[] = []; |
|
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i; |
|
const urlPattern = /https?:\/\/[^\s<>"']+/g; |
|
|
|
let match; |
|
while ((match = urlPattern.exec(event.content)) !== null) { |
|
const url = match[0]; |
|
if (imageExtensions.test(url)) { |
|
// Check if it's not already in markdown or HTML |
|
const before = event.content.substring(Math.max(0, match.index - 10), match.index); |
|
const after = event.content.substring(match.index + url.length, Math.min(event.content.length, match.index + url.length + 10)); |
|
if (!before.includes(' && !after.startsWith('</img>')) { |
|
imageUrls.push(url); |
|
} |
|
} |
|
} |
|
|
|
return imageUrls; |
|
} |
|
|
|
function getPreview(): string { |
|
if (!event) return ''; |
|
// Remove image URLs from preview text |
|
let preview = event.content; |
|
const imageUrls = getImageUrlsFromContent(); |
|
for (const url of imageUrls) { |
|
preview = preview.replace(url, '').trim(); |
|
} |
|
preview = stripMarkdown(preview).slice(0, 150); |
|
return preview.length < event.content.length ? preview + '...' : preview; |
|
} |
|
|
|
function handleClick(e: MouseEvent) { |
|
e.preventDefault(); |
|
if (!event) return; |
|
|
|
// Dispatch custom event on window to open in side-panel |
|
// Parent components (like FeedPage, DiscussionList) listen for this event on window |
|
const openEvent = new CustomEvent('openEventInDrawer', { |
|
detail: { event }, |
|
bubbles: true, |
|
cancelable: true |
|
}); |
|
|
|
window.dispatchEvent(openEvent); |
|
} |
|
</script> |
|
|
|
{#if loading} |
|
<div class="embedded-event loading"> |
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading event...</span> |
|
</div> |
|
{:else if error} |
|
<div class="embedded-event error"> |
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">Failed to load event</span> |
|
</div> |
|
{:else if event} |
|
<div class="embedded-event" onclick={handleClick} role="button" tabindex="0" onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(e as any); } }}> |
|
{#if getImageUrl()} |
|
<div class="embedded-event-image"> |
|
<img src={getImageUrl()} alt={getTitle()} loading="lazy" /> |
|
</div> |
|
{:else} |
|
{@const contentImages = getImageUrlsFromContent()} |
|
{#if contentImages.length > 0} |
|
<div class="embedded-event-images"> |
|
{#each contentImages.slice(0, 3) as imageUrl} |
|
<div class="embedded-event-image"> |
|
<img src={imageUrl} alt={getTitle()} loading="lazy" /> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
{/if} |
|
<div class="embedded-event-content"> |
|
<div class="embedded-event-header"> |
|
{#if getTitle()} |
|
<h4 class="embedded-event-title">{getTitle()}</h4> |
|
{/if} |
|
{#if event} |
|
<ProfileBadge pubkey={event.pubkey} /> |
|
{/if} |
|
</div> |
|
{#if getSubject()} |
|
<p class="embedded-event-subject">{getSubject()}</p> |
|
{/if} |
|
<p class="embedded-event-preview">{getPreview()}</p> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<style> |
|
.embedded-event { |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
padding: 1rem; |
|
margin: 0.5rem 0; |
|
cursor: pointer; |
|
transition: background-color 0.2s; |
|
background: var(--fog-post, #ffffff); |
|
} |
|
|
|
:global(.dark) .embedded-event { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.embedded-event:hover { |
|
background: var(--fog-highlight, #f1f5f9); |
|
} |
|
|
|
:global(.dark) .embedded-event:hover { |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.embedded-event-image { |
|
width: 100%; |
|
max-height: 200px; |
|
overflow: hidden; |
|
border-radius: 0.25rem; |
|
margin-bottom: 0.75rem; |
|
} |
|
|
|
.embedded-event-image img { |
|
width: 100%; |
|
height: auto; |
|
object-fit: cover; |
|
} |
|
|
|
.embedded-event-images { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
margin-bottom: 0.75rem; |
|
} |
|
|
|
.embedded-event-images .embedded-event-image { |
|
margin-bottom: 0; |
|
} |
|
|
|
.embedded-event-content { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
} |
|
|
|
.embedded-event-header { |
|
display: flex; |
|
align-items: center; |
|
justify-content: space-between; |
|
gap: 1rem; |
|
flex-wrap: wrap; |
|
line-height: 1.5; |
|
} |
|
|
|
.embedded-event-header :global(.profile-badge) { |
|
display: inline-flex; |
|
align-items: center; |
|
vertical-align: middle; |
|
line-height: 1.5; |
|
} |
|
|
|
.embedded-event-title { |
|
font-weight: 600; |
|
font-size: 1.125rem; |
|
margin: 0; |
|
line-height: 1.5; |
|
color: var(--fog-text, #1f2937); |
|
display: inline-block; |
|
vertical-align: middle; |
|
} |
|
|
|
:global(.dark) .embedded-event-title { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.embedded-event-subject { |
|
font-size: 0.875rem; |
|
color: var(--fog-text-light, #64748b); |
|
margin: 0; |
|
} |
|
|
|
:global(.dark) .embedded-event-subject { |
|
color: var(--fog-dark-text-light, #9ca3af); |
|
} |
|
|
|
.embedded-event-preview { |
|
font-size: 0.875rem; |
|
color: var(--fog-text, #475569); |
|
margin: 0; |
|
line-height: 1.5; |
|
} |
|
|
|
:global(.dark) .embedded-event-preview { |
|
color: var(--fog-dark-text, #cbd5e1); |
|
} |
|
|
|
|
|
.embedded-event.loading, |
|
.embedded-event.error { |
|
padding: 1rem; |
|
text-align: center; |
|
} |
|
</style>
|
|
|