Render Nostr entities, user and events

This commit is contained in:
dtonon 2026-04-13 10:52:29 +01:00
parent ef98b62a72
commit c73b0f0c78

View file

@ -11,7 +11,8 @@
import { withJoin } from "$lib/join.svelte"; import { withJoin } from "$lib/join.svelte";
import Reactions from "$lib/components/Reactions.svelte"; import Reactions from "$lib/components/Reactions.svelte";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte"; import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata"; import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import * as nip19 from "@nostr/tools/nip19";
type Author = { pubkey: string; name: string; picture?: string }; type Author = { pubkey: string; name: string; picture?: string };
@ -48,16 +49,27 @@
].join("|"); ].join("|");
const URL_RE = new RegExp( const URL_RE = new RegExp(
`https?:\\/\\/[^\\s<>"']+|(?<![\\w@.\\/])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:${TLDS})\\b(?:[\\/?#][^\\s<>"']*)?`, `nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+|https?:\\/\\/[^\\s<>"']+|(?<![\\w@.\\/])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:${TLDS})\\b(?:[\\/?#][^\\s<>"']*)?`,
"gi", "gi",
); );
const IMG_EXT_RE = /\.(?:jpg|jpeg|png|gif|webp|avif|svg|bmp)(?:\?.*)?$/i; const IMG_EXT_RE = /\.(?:jpg|jpeg|png|gif|webp|avif|svg|bmp)(?:\?.*)?$/i;
// Trailing punctuation that is usually not part of the URL // Trailing punctuation that is usually not part of the URL
const TRAILING_PUNCT_RE = /[).,;:!?'"]+$/; const TRAILING_PUNCT_RE = /[).,;:!?'"]+$/;
function shortEntity(entity: string): string {
const m = entity.match(/^(npub|nprofile|note|nevent|naddr)1/);
if (!m) return entity;
const prefix = m[0];
const rest = entity.slice(prefix.length);
if (rest.length <= 12) return entity;
return `${prefix}${rest.slice(0, 6)}…${rest.slice(-4)}`;
}
type Inline = type Inline =
| { type: "text"; value: string } | { type: "text"; value: string }
| { type: "link"; href: string; label: string }; | { type: "link"; href: string; label: string }
| { type: "mention"; pubkey: string; entity: string; fallback: string }
| { type: "entity"; entity: string; label: string };
type Block = type Block =
| { type: "image"; value: string } | { type: "image"; value: string }
@ -67,7 +79,15 @@
const blocks: Block[] = []; const blocks: Block[] = [];
let inlines: Inline[] = []; let inlines: Inline[] = [];
const flush = () => { const flush = () => {
if (inlines.some((i) => i.type === "link" || i.value.trim())) if (
inlines.some(
(i) =>
i.type === "link" ||
i.type === "mention" ||
i.type === "entity" ||
i.value.trim(),
)
)
blocks.push({ type: "para", inlines }); blocks.push({ type: "para", inlines });
inlines = []; inlines = [];
}; };
@ -75,14 +95,55 @@
for (const m of text.matchAll(URL_RE)) { for (const m of text.matchAll(URL_RE)) {
const start = m.index ?? 0; const start = m.index ?? 0;
let url = m[0]; let url = m[0];
const isNostr = /^nostr:/i.test(url);
let trailing = ""; let trailing = "";
if (!isNostr) {
const trail = url.match(TRAILING_PUNCT_RE); const trail = url.match(TRAILING_PUNCT_RE);
if (trail) { if (trail) {
trailing = trail[0]; trailing = trail[0];
url = url.slice(0, -trailing.length); url = url.slice(0, -trailing.length);
} }
}
if (start > last) if (start > last)
inlines.push({ type: "text", value: text.slice(last, start) }); inlines.push({ type: "text", value: text.slice(last, start) });
if (isNostr) {
const entity = url.slice(6).toLowerCase();
let handled = false;
try {
const decoded = nip19.decode(entity);
if (decoded.type === "npub") {
inlines.push({
type: "mention",
pubkey: decoded.data,
entity,
fallback: shortEntity(entity),
});
handled = true;
} else if (decoded.type === "nprofile") {
inlines.push({
type: "mention",
pubkey: decoded.data.pubkey,
entity,
fallback: shortEntity(entity),
});
handled = true;
} else if (
decoded.type === "note" ||
decoded.type === "nevent" ||
decoded.type === "naddr"
) {
inlines.push({
type: "entity",
entity,
label: shortEntity(entity),
});
handled = true;
}
} catch {
// Fall through to text
}
if (!handled) inlines.push({ type: "text", value: m[0] });
} else {
const href = /^https?:\/\//i.test(url) ? url : `https://${url}`; const href = /^https?:\/\//i.test(url) ? url : `https://${url}`;
if (IMG_EXT_RE.test(url)) { if (IMG_EXT_RE.test(url)) {
flush(); flush();
@ -92,6 +153,7 @@
inlines.push({ type: "link", href, label: url }); inlines.push({ type: "link", href, label: url });
if (trailing) inlines.push({ type: "text", value: trailing }); if (trailing) inlines.push({ type: "text", value: trailing });
} }
}
last = start + m[0].length; last = start + m[0].length;
} }
if (last < text.length) if (last < text.length)
@ -133,6 +195,27 @@
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []); const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
const postEls = $derived([opEl, ...replyEls]); const postEls = $derived([opEl, ...replyEls]);
let resolvedUsers = $state<Record<string, NostrUser>>({});
$effect(() => {
if (!detail) return;
const seen = new Set<string>();
for (const post of [detail.op, ...detail.replies]) {
for (const block of tokenize(post.content)) {
if (block.type !== "para") continue;
for (const inline of block.inlines) {
if (inline.type === "mention") seen.add(inline.pubkey);
}
}
}
for (const pubkey of seen) {
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
loadNostrUser(pubkey).then((u) => {
resolvedUsers = { ...resolvedUsers, [pubkey]: u };
});
}
});
// Reload when navigating between threads // Reload when navigating between threads
$effect(() => { $effect(() => {
if (page.params.id) loadThread(page.params.id); if (page.params.id) loadThread(page.params.id);
@ -214,6 +297,21 @@
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-brand hover:underline break-all" class="text-brand hover:underline break-all"
>{inline.label}</a> >{inline.label}</a>
{:else if inline.type === "mention"}
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
<a
href="https://njump.me/{inline.entity}"
target="_blank"
rel="noopener noreferrer"
class="text-brand hover:underline"
>@{u?.shortName ?? inline.fallback}</a>
{:else if inline.type === "entity"}
<a
href="https://njump.me/{inline.entity}"
target="_blank"
rel="noopener noreferrer"
class="text-brand hover:underline break-all"
>{inline.label}</a>
{:else}{inline.value}{/if} {:else}{inline.value}{/if}
{/each} {/each}
</p> </p>