clone of repo on github
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

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