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.
546 lines
18 KiB
546 lines
18 KiB
<script lang="ts"> |
|
import { getMimeTags } from "$lib/utils/mime"; |
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
|
import { toNpub } from "$lib/utils/nostrUtils"; |
|
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; |
|
import { activeInboxRelays } from "$lib/ndk"; |
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
|
import { getMatchingTags } from "$lib/utils/nostrUtils"; |
|
import { AProfilePreview } from "$lib/a"; |
|
import { goto } from "$app/navigation"; |
|
import { onMount } from "svelte"; |
|
import { getUserMetadata } from "$lib/utils/nostrUtils"; |
|
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; |
|
import { navigateToEvent } from "$lib/utils/nostrEventService"; |
|
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte"; |
|
import Notifications from "$lib/components/Notifications.svelte"; |
|
import { |
|
repostContent, |
|
quotedContent, |
|
} from "$lib/snippets/EmbeddedSnippets.svelte"; |
|
import { repostKinds } from "$lib/consts"; |
|
import { getNdkContext } from "$lib/ndk"; |
|
import type { UserProfile } from "$lib/models/user_profile"; |
|
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; |
|
import ATechBlock from "$lib/a/reader/ATechBlock.svelte"; |
|
import { Accordion, AccordionItem, Heading } from "flowbite-svelte"; |
|
import RelayActions from "$components/RelayActions.svelte"; |
|
|
|
const { |
|
event, |
|
profile = null, |
|
communityStatusMap = {}, |
|
} = $props<{ |
|
event: NDKEvent; |
|
profile?: UserProfile | null; |
|
communityStatusMap?: Record<string, boolean>; |
|
}>(); |
|
|
|
const ndk = getNdkContext(); |
|
|
|
let authorDisplayName = $state<string | undefined>(undefined); |
|
let showFullContent = $state(false); |
|
let shouldTruncate = $derived(event.content.length > 250 && !showFullContent); |
|
let isRepost = $derived( |
|
repostKinds.includes(event.kind) || |
|
(event.kind === 1 && event.getMatchingTags("q").length > 0), |
|
); |
|
|
|
function getEventTitle(event: NDKEvent): string { |
|
// First try to get title from title tag |
|
const titleTag = getMatchingTags(event, "title")[0]?.[1]; |
|
if (titleTag) { |
|
return titleTag; |
|
} |
|
|
|
// For kind 30023 events, extract title from markdown content if no title tag |
|
if (event.kind === 30023 && event.content) { |
|
const match = event.content.match(/^#\s+(.+)$/m); |
|
if (match) { |
|
return match[1].trim(); |
|
} |
|
} |
|
|
|
// For kind 30040, 30041, and 30818 events, extract title from AsciiDoc content if no title tag |
|
if ( |
|
(event.kind === 30040 || event.kind === 30041 || event.kind === 30818) && |
|
event.content |
|
) { |
|
// First try to find a document header (= ) |
|
const docMatch = event.content.match(/^=\s+(.+)$/m); |
|
if (docMatch) { |
|
return docMatch[1].trim(); |
|
} |
|
|
|
// If no document header, try to find the first section header (== ) |
|
const sectionMatch = event.content.match(/^==\s+(.+)$/m); |
|
if (sectionMatch) { |
|
return sectionMatch[1].trim(); |
|
} |
|
} |
|
|
|
return "Untitled"; |
|
} |
|
|
|
function getEventSummary(event: NDKEvent): string { |
|
return getMatchingTags(event, "summary")[0]?.[1] || ""; |
|
} |
|
|
|
function getEventTypeDisplay(event: NDKEvent): string { |
|
const [_, MTag] = getMimeTags(event.kind || 0); |
|
return MTag[1].split("/")[1] || `Event Kind ${event.kind}`; |
|
} |
|
|
|
function getTagButtonInfo(tag: string[]): { |
|
text: string; |
|
gotoValue?: string; |
|
} { |
|
if (tag[0] === "a" && tag.length > 1) { |
|
const parts = tag[1].split(":"); |
|
if (parts.length >= 3) { |
|
const [kind, pubkey, d] = parts; |
|
// Validate that pubkey is a valid hex string |
|
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) { |
|
try { |
|
const mockEvent = { |
|
kind: +kind, |
|
pubkey, |
|
tags: [["d", d]], |
|
content: "", |
|
id: "", |
|
sig: "", |
|
} as any; |
|
const naddr = naddrEncode(mockEvent, $activeInboxRelays); |
|
return { |
|
text: `a:${tag[1]}`, |
|
gotoValue: naddr, |
|
}; |
|
} catch (error) { |
|
console.warn("Failed to encode naddr for a tag:", tag[1], error); |
|
return { text: `a:${tag[1]}` }; |
|
} |
|
} else { |
|
console.warn("Invalid pubkey in a tag:", pubkey); |
|
return { text: `a:${tag[1]}` }; |
|
} |
|
} else { |
|
console.warn("Invalid a tag format:", tag[1]); |
|
return { text: `a:${tag[1]}` }; |
|
} |
|
} else if (tag[0] === "e" && tag.length > 1) { |
|
// Validate that event ID is a valid hex string |
|
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { |
|
try { |
|
const mockEvent = { |
|
id: tag[1], |
|
kind: 1, |
|
content: "", |
|
tags: [], |
|
pubkey: "", |
|
sig: "", |
|
} as any; |
|
const nevent = neventEncode(mockEvent, $activeInboxRelays); |
|
return { |
|
text: `e:${tag[1]}`, |
|
gotoValue: nevent, |
|
}; |
|
} catch (error) { |
|
console.warn("Failed to encode nevent for e tag:", tag[1], error); |
|
return { text: `e:${tag[1]}` }; |
|
} |
|
} else { |
|
console.warn("Invalid event ID in e tag:", tag[1]); |
|
return { text: `e:${tag[1]}` }; |
|
} |
|
} else if (tag[0] === "p" && tag.length > 1) { |
|
const npub = toNpub(tag[1]); |
|
return { |
|
text: `p:${npub || tag[1]}`, |
|
gotoValue: npub ? npub : undefined, |
|
}; |
|
} else if (tag[0] === "note" && tag.length > 1) { |
|
// 'note' tags are the same as 'e' tags but with different prefix |
|
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { |
|
try { |
|
const mockEvent = { |
|
id: tag[1], |
|
kind: 1, |
|
content: "", |
|
tags: [], |
|
pubkey: "", |
|
sig: "", |
|
} as any; |
|
const nevent = neventEncode(mockEvent, $activeInboxRelays); |
|
return { |
|
text: `note:${tag[1]}`, |
|
gotoValue: nevent, |
|
}; |
|
} catch (error) { |
|
console.warn("Failed to encode nevent for note tag:", tag[1], error); |
|
return { text: `note:${tag[1]}` }; |
|
} |
|
} else { |
|
console.warn("Invalid event ID in note tag:", tag[1]); |
|
return { text: `note:${tag[1]}` }; |
|
} |
|
} else if (tag[0] === "d" && tag.length > 1) { |
|
// 'd' tags are used for identifiers in addressable events |
|
return { |
|
text: `d:${tag[1]}`, |
|
gotoValue: `d:${tag[1]}`, |
|
}; |
|
} else if (tag[0] === "t" && tag.length > 1) { |
|
// 't' tags are hashtags - navigate to t-tag search |
|
return { |
|
text: `t:${tag[1]}`, |
|
gotoValue: `t:${tag[1]}`, |
|
}; |
|
} else if (tag[0] === "q" && tag.length > 1) { |
|
// 'q' tags are quoted events - navigate to the quoted event |
|
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { |
|
try { |
|
const mockEvent = { |
|
id: tag[1], |
|
kind: 1, |
|
content: "", |
|
tags: [], |
|
pubkey: "", |
|
sig: "", |
|
} as any; |
|
const nevent = neventEncode(mockEvent, $activeInboxRelays); |
|
return { |
|
text: `q:${tag[1]}`, |
|
gotoValue: nevent, |
|
}; |
|
} catch (error) { |
|
console.warn("Failed to encode nevent for q tag:", tag[1], error); |
|
return { text: `q:${tag[1]}` }; |
|
} |
|
} else { |
|
console.warn("Invalid event ID in q tag:", tag[1]); |
|
return { text: `q:${tag[1]}` }; |
|
} |
|
} |
|
return { text: `${tag[0]}:${tag[1]}` }; |
|
} |
|
|
|
// Navigation for tag buttons (moved out of template) |
|
function handleTagGoto(value: string) { |
|
if (!value) return; |
|
if ( |
|
value.startsWith("naddr") || |
|
value.startsWith("nevent") || |
|
value.startsWith("npub") || |
|
value.startsWith("nprofile") || |
|
value.startsWith("note") |
|
) { |
|
goto(`/events?id=${value}`); |
|
} else if (value.startsWith("/")) { |
|
goto(value); |
|
} else if (value.startsWith("d:")) { |
|
const dTag = value.substring(2); |
|
goto(`/events?d=${encodeURIComponent(dTag)}`); |
|
} else if (value.startsWith("t:")) { |
|
const tTag = value.substring(2); |
|
goto(`/events?t=${encodeURIComponent(tTag)}`); |
|
} else if (/^[0-9a-fA-F]{64}$/.test(value)) { |
|
navigateToEvent(value); |
|
} else { |
|
goto(`/events?id=${value}`); |
|
} |
|
} |
|
|
|
$effect(() => { |
|
if (!event?.pubkey) { |
|
authorDisplayName = undefined; |
|
return; |
|
} |
|
|
|
getUserMetadata(toNpub(event.pubkey) as string, undefined).then( |
|
(profile) => { |
|
authorDisplayName = |
|
profile.displayName || |
|
(profile as any).display_name || |
|
profile.name || |
|
event.pubkey; |
|
}, |
|
); |
|
}); |
|
|
|
// --- Identifier helpers --- |
|
function getIdentifiers( |
|
event: NDKEvent, |
|
_profile: any, |
|
): { label: string; value: string; link?: string }[] { |
|
const ids: { label: string; value: string; link?: string }[] = []; |
|
if (event.kind === 0) { |
|
// npub |
|
const npub = toNpub(event.pubkey); |
|
if (npub) |
|
ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` }); |
|
// nprofile |
|
ids.push({ |
|
label: "nprofile", |
|
value: nprofileEncode(event.pubkey, $activeInboxRelays), |
|
link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`, |
|
}); |
|
// nevent |
|
ids.push({ |
|
label: "nevent", |
|
value: neventEncode(event, $activeInboxRelays), |
|
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`, |
|
}); |
|
// hex pubkey |
|
ids.push({ label: "pubkey", value: event.pubkey }); |
|
} else { |
|
// nevent |
|
ids.push({ |
|
label: "nevent", |
|
value: neventEncode(event, $activeInboxRelays), |
|
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`, |
|
}); |
|
// naddr (if addressable) |
|
try { |
|
const naddr = naddrEncode(event, $activeInboxRelays); |
|
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` }); |
|
} catch {} |
|
// hex id - make it a clickable link to search for the event ID |
|
ids.push({ |
|
label: "id", |
|
value: event.id, |
|
link: `/events?id=${event.id}`, |
|
}); |
|
} |
|
return ids; |
|
} |
|
|
|
function navigateToIdentifier(link: string) { |
|
goto(link); |
|
} |
|
|
|
onMount(() => { |
|
function handleInternalLinkClick(event: MouseEvent) { |
|
const target = event.target as HTMLElement; |
|
if (target.tagName === "A") { |
|
const href = (target as HTMLAnchorElement).getAttribute("href"); |
|
if (href && href.startsWith("/")) { |
|
event.preventDefault(); |
|
goto(href); |
|
} |
|
} |
|
} |
|
document.addEventListener("click", handleInternalLinkClick); |
|
return () => document.removeEventListener("click", handleInternalLinkClick); |
|
}); |
|
</script> |
|
|
|
<div class="flex flex-col space-y-4 min-w-0"> |
|
{#if event.kind !== 0} |
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 break-words"> |
|
{@render basicMarkup(getEventTitle(event), ndk)} |
|
</h2> |
|
|
|
<div class="flex items-center space-x-2 min-w-0"> |
|
{#if toNpub(event.pubkey)} |
|
<span class="text-gray-600 dark:text-gray-400 min-w-0" |
|
>Author: {@render userBadge( |
|
toNpub(event.pubkey) || "", |
|
profile?.display_name || undefined, |
|
ndk, |
|
)}</span |
|
> |
|
{:else} |
|
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words" |
|
>Author: {profile?.display_name || event.pubkey}</span |
|
> |
|
{/if} |
|
</div> |
|
|
|
<div class="flex items-center space-x-2 min-w-0"> |
|
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span> |
|
<span class="font-mono flex-shrink-0">{event.kind}</span> |
|
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0" |
|
>({getEventTypeDisplay(event)})</span |
|
> |
|
</div> |
|
|
|
<div class="flex flex-col space-y-1 min-w-0"> |
|
<span class="text-gray-700 dark:text-gray-300">Summary:</span> |
|
<div |
|
class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0" |
|
> |
|
{@render basicMarkup(getEventSummary(event), ndk)} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Containing Publications --> |
|
<ContainingIndexes {event} /> |
|
|
|
<!-- Content --> |
|
{#if event.kind !== 0} |
|
{@const kind = event.kind} |
|
{@const content = event.content.trim()} |
|
<div |
|
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden" |
|
> |
|
<div class="flex flex-col space-y-1 min-w-0"> |
|
<span class="text-gray-700 dark:text-gray-300 font-semibold" |
|
>Content:</span |
|
> |
|
<div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}> |
|
{#if isRepost} |
|
<!-- Repost content handling --> |
|
{#if repostKinds.includes(event.kind)} |
|
<!-- Kind 6 and 16 reposts - stringified JSON content --> |
|
<div |
|
class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2" |
|
> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
{event.kind === 6 |
|
? "Reposted content:" |
|
: "Generic reposted content:"} |
|
</div> |
|
{@render repostContent(event.content)} |
|
</div> |
|
{:else if event.kind === 1 && event.getMatchingTags("q").length > 0} |
|
<!-- Quote repost - kind 1 with q tag --> |
|
<div |
|
class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2" |
|
> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
Quote repost: |
|
</div> |
|
{@render quotedContent(event, [], ndk)} |
|
{#if content} |
|
<div |
|
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700" |
|
> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
Added comment: |
|
</div> |
|
{#if repostKinds.includes(kind)} |
|
{@html content} |
|
{:else} |
|
{@render basicMarkup(content, ndk)} |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
{:else} |
|
<!-- Regular content --> |
|
<div class={shouldTruncate ? "max-h-32 overflow-hidden" : ""}> |
|
{#if repostKinds.includes(kind)} |
|
{@html content} |
|
{:else} |
|
{@render basicMarkup(content, ndk)} |
|
{/if} |
|
</div> |
|
{#if shouldTruncate} |
|
<button |
|
class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200" |
|
onclick={() => (showFullContent = true)}>Show more</button |
|
> |
|
{/if} |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- If event is profile --> |
|
{#if event.kind === 0} |
|
<AProfilePreview {event} {profile} {communityStatusMap} /> |
|
{/if} |
|
|
|
<ATechBlock> |
|
{#snippet content()} |
|
<Heading tag="h3" class="h-leather my-6">Technical details</Heading> |
|
|
|
<Accordion flush class="w-full"> |
|
<AccordionItem open={false}> |
|
{#snippet header()}Identifiers{/snippet} |
|
{#if event} |
|
<div class="flex flex-col gap-2"> |
|
{#each getIdentifiers(event, profile) as identifier} |
|
<div |
|
class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-start gap-2 min-w-0" |
|
> |
|
<span class="min-w-24 text-gray-600 dark:text-gray-400" |
|
>{identifier.label}:</span |
|
> |
|
<div class="min-w-0"> |
|
{#if identifier.link} |
|
<button |
|
class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer bg-transparent border-none p-0 text-left" |
|
onclick={() => |
|
navigateToIdentifier(identifier.link ?? "")} |
|
> |
|
{identifier.value} |
|
</button> |
|
{:else} |
|
<span |
|
class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all" |
|
>{identifier.value}</span |
|
> |
|
{/if} |
|
</div> |
|
<div class="justify-self-end"> |
|
<CopyToClipboard |
|
displayText="" |
|
copyText={identifier.value} |
|
/> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</AccordionItem> |
|
<!-- Event Tags Section --> |
|
{#if event.tags && event.tags.length} |
|
<AccordionItem open={false}> |
|
{#snippet header()} |
|
Tags |
|
{/snippet} |
|
<div class="flex flex-wrap gap-2 break-words min-w-0"> |
|
{#each event.tags as tag} |
|
{@const tagInfo = getTagButtonInfo(tag)} |
|
{#if tagInfo.text && tagInfo.gotoValue} |
|
<button |
|
onclick={() => handleTagGoto(tagInfo.gotoValue || "")} |
|
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100 break-all max-w-full" |
|
> |
|
{tagInfo.text} |
|
</button> |
|
{/if} |
|
{/each} |
|
</div> |
|
</AccordionItem> |
|
{/if} |
|
|
|
<AccordionItem open={false} contentClass="relative"> |
|
{#snippet header()}Event JSON{/snippet} |
|
<div class="absolute top-5 right-0 z-10"> |
|
<CopyToClipboard |
|
displayText="" |
|
copyText={JSON.stringify(event.rawEvent(), null, 2)} |
|
/> |
|
</div> |
|
{#if event} |
|
<pre class="p-4 wrap-break-word bg-highlight dark:bg-primary-900"> |
|
<code class="text-wrap" |
|
>{JSON.stringify(event.rawEvent(), null, 2)}</code |
|
> |
|
</pre> |
|
{/if} |
|
</AccordionItem> |
|
|
|
<AccordionItem open={true}> |
|
{#snippet header()}Relay Info{/snippet} |
|
<RelayActions {event} /> |
|
</AccordionItem> |
|
</Accordion> |
|
{/snippet} |
|
</ATechBlock> |
|
</div>
|
|
|