diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index 5ffe79b..99de11f 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -11,7 +11,8 @@ import { withJoin } from "$lib/join.svelte"; import Reactions from "$lib/components/Reactions.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 }; @@ -48,16 +49,27 @@ ].join("|"); const URL_RE = new RegExp( - `https?:\\/\\/[^\\s<>"']+|(?"']*)?`, + `nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+|https?:\\/\\/[^\\s<>"']+|(?"']*)?`, "gi", ); const IMG_EXT_RE = /\.(?:jpg|jpeg|png|gif|webp|avif|svg|bmp)(?:\?.*)?$/i; // Trailing punctuation that is usually not part of the URL 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: "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: "image"; value: string } @@ -67,7 +79,15 @@ const blocks: Block[] = []; let inlines: Inline[] = []; 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 }); inlines = []; }; @@ -75,22 +95,64 @@ for (const m of text.matchAll(URL_RE)) { const start = m.index ?? 0; let url = m[0]; + const isNostr = /^nostr:/i.test(url); let trailing = ""; - const trail = url.match(TRAILING_PUNCT_RE); - if (trail) { - trailing = trail[0]; - url = url.slice(0, -trailing.length); + if (!isNostr) { + const trail = url.match(TRAILING_PUNCT_RE); + if (trail) { + trailing = trail[0]; + url = url.slice(0, -trailing.length); + } } if (start > last) inlines.push({ type: "text", value: text.slice(last, start) }); - const href = /^https?:\/\//i.test(url) ? url : `https://${url}`; - if (IMG_EXT_RE.test(url)) { - flush(); - blocks.push({ type: "image", value: href }); - if (trailing) inlines.push({ type: "text", value: trailing }); + 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 { - inlines.push({ type: "link", href, label: url }); - if (trailing) inlines.push({ type: "text", value: trailing }); + const href = /^https?:\/\//i.test(url) ? url : `https://${url}`; + if (IMG_EXT_RE.test(url)) { + flush(); + blocks.push({ type: "image", value: href }); + if (trailing) inlines.push({ type: "text", value: trailing }); + } else { + inlines.push({ type: "link", href, label: url }); + if (trailing) inlines.push({ type: "text", value: trailing }); + } } last = start + m[0].length; } @@ -133,6 +195,27 @@ const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []); const postEls = $derived([opEl, ...replyEls]); + let resolvedUsers = $state>({}); + + $effect(() => { + if (!detail) return; + const seen = new Set(); + 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 $effect(() => { if (page.params.id) loadThread(page.params.id); @@ -214,6 +297,21 @@ rel="noopener noreferrer" class="text-brand hover:underline break-all" >{inline.label} + {:else if inline.type === "mention"} + {@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]} + @{u?.shortName ?? inline.fallback} + {:else if inline.type === "entity"} + {inline.label} {:else}{inline.value}{/if} {/each}