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

<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('![') && !before.includes('<img') && !after.startsWith('](') && !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>