Render Nostr entities, user and events
This commit is contained in:
parent
ef98b62a72
commit
c73b0f0c78
1 changed files with 113 additions and 15 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue