Link nostr entities and URLs in chat messages

Image URLs included
This commit is contained in:
dtonon 2026-06-01 20:45:02 +01:00
parent 19b71a87c0
commit fc51e33ceb
2 changed files with 214 additions and 54 deletions

View file

@ -1,6 +1,6 @@
<script lang="ts">
import * as nip19 from "@nostr/tools/nip19";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { tokenizeChat } from "$lib/linkify";
type Props = {
content: string;
@ -9,53 +9,7 @@
let { content, profiles = {} }: Props = $props();
type Token =
| { type: "text"; value: string }
| { type: "mention"; pubkey: string; entity: string; fallback: string };
const MENTION_RE = /nostr:(npub1[a-z0-9]+|nprofile1[a-z0-9]+)/gi;
function shortEntity(entity: string): string {
const m = entity.match(/^(npub|nprofile)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)}`;
}
const tokens = $derived.by<Token[]>(() => {
const out: Token[] = [];
let last = 0;
for (const m of content.matchAll(MENTION_RE)) {
const start = m.index ?? 0;
const entity = m[1].toLowerCase();
let pubkey: string | null = null;
try {
const decoded = nip19.decode(entity);
if (decoded.type === "npub") pubkey = decoded.data;
else if (decoded.type === "nprofile") pubkey = decoded.data.pubkey;
} catch {
// Invalid bech32, fall through to text
}
if (start > last)
out.push({ type: "text", value: content.slice(last, start) });
if (pubkey) {
out.push({
type: "mention",
pubkey,
entity,
fallback: shortEntity(entity),
});
} else {
out.push({ type: "text", value: m[0] });
}
last = start + m[0].length;
}
if (last < content.length)
out.push({ type: "text", value: content.slice(last) });
return out;
});
const tokens = $derived(tokenizeChat(content));
let resolvedUsers = $state<Record<string, NostrUser>>({});
@ -77,5 +31,16 @@
target="_blank"
rel="noopener noreferrer"
class="text-brand hover:underline">@{u?.shortName ?? t.fallback}</a
>{:else if t.type === "entity"}<a
href={t.href}
target="_blank"
rel="noopener noreferrer"
class="text-brand hover:underline">{t.label}</a
>{:else if t.type === "link"}<a
href={t.href}
title={t.href}
target="_blank"
rel="noopener noreferrer"
class="text-brand hover:underline">{t.label}</a
>{:else}{t.value}{/if}{/each}</span
>

195
src/lib/linkify.ts Normal file
View file

@ -0,0 +1,195 @@
import * as nip19 from "@nostr/tools/nip19";
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
const TLDS = [
"com",
"org",
"net",
"edu",
"gov",
"mil",
"int",
"info",
"biz",
"name",
"pro",
"io",
"co",
"app",
"dev",
"ai",
"sh",
"me",
"ly",
"tv",
"fm",
"lol",
"club",
"online",
"site",
"store",
"blog",
"tech",
"xyz",
"art",
"design",
"news",
"media",
"page",
"link",
"fun",
"gg",
"gl",
"st",
"to",
"run",
"life",
"world",
"space",
"cloud",
"email",
"social",
"chat",
"wtf",
"cafe",
"zone",
"studio",
"us",
"uk",
"de",
"fr",
"it",
"es",
"nl",
"ru",
"jp",
"cn",
"ca",
"au",
"br",
"in",
"mx",
"se",
"no",
"fi",
"dk",
"ch",
"at",
"be",
"pl",
"pt",
"gr",
"cz",
"ie",
"nz",
"kr",
"sg",
"hk",
"tw",
"za",
"cc",
"ws",
"eu",
"tr",
"ua",
"il",
"ar",
"cl",
"pe",
].join("|");
// Matches nostr entities, http(s) URLs, and bare domains
const URL_SOURCE = `nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+|https?:\\/\\/[^\\s<>"']+|(?<![\\w@.\\/])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:${TLDS})\\b(?:[\\/?#][^\\s<>"']*)?`;
// Trailing punctuation that is usually not part of the URL
const TRAILING_PUNCT_RE = /[).,;:!?'"]+$/;
export type ChatToken =
| { type: "text"; value: string }
| { type: "link"; href: string; label: string }
| { type: "mention"; pubkey: string; entity: string; fallback: string }
| { type: "entity"; entity: string; href: string; label: string };
// Shorten a nostr entity to xxxxxxxx...xxxx
export function shortNostrEntity(entity: string): string {
if (entity.length <= 12) return entity;
return `${entity.slice(0, 8)}...${entity.slice(-4)}`;
}
// Shorten a URL to domain.tld/...last-10-chars-of-the-final-part
export function shortUrl(href: string): string {
const rest = href.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
const slash = rest.indexOf("/");
if (slash === -1) return rest;
const host = rest.slice(0, slash);
const path = rest.slice(slash + 1);
if (path === "") return host;
const finalPart = path.slice(path.lastIndexOf("/") + 1);
const tail = finalPart.length > 10 ? finalPart.slice(-10) : finalPart;
return `${host}/...${tail}`;
}
function decodeEntity(entity: string): ChatToken | null {
try {
const decoded = nip19.decode(entity);
if (decoded.type === "npub")
return {
type: "mention",
pubkey: decoded.data,
entity,
fallback: shortNostrEntity(entity),
};
if (decoded.type === "nprofile")
return {
type: "mention",
pubkey: decoded.data.pubkey,
entity,
fallback: shortNostrEntity(entity),
};
if (decoded.type === "note" || decoded.type === "nevent" || decoded.type === "naddr")
return {
type: "entity",
entity,
href: `https://njump.me/${entity}`,
label: shortNostrEntity(entity),
};
} catch {
// Invalid bech32, fall through to text
}
return null;
}
// Tokenize a plain-text chat message into text, links, mentions and entities.
// Image URLs are treated as plain links (never embedded).
export function tokenizeChat(content: string): ChatToken[] {
const out: ChatToken[] = [];
let last = 0;
const re = new RegExp(URL_SOURCE, "gi");
for (const m of content.matchAll(re)) {
const start = m.index ?? 0;
let raw = m[0];
const isNostr = /^nostr:/i.test(raw);
let trailing = "";
if (!isNostr) {
const trail = raw.match(TRAILING_PUNCT_RE);
if (trail) {
trailing = trail[0];
raw = raw.slice(0, -trailing.length);
}
}
if (start > last)
out.push({ type: "text", value: content.slice(last, start) });
if (isNostr) {
const entity = raw.slice(6).toLowerCase();
const token = decodeEntity(entity);
out.push(token ?? { type: "text", value: m[0] });
} else {
const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
out.push({ type: "link", href, label: shortUrl(href) });
if (trailing) out.push({ type: "text", value: trailing });
}
last = start + m[0].length;
}
if (last < content.length)
out.push({ type: "text", value: content.slice(last) });
return out;
}