Link nostr entities and URLs in chat messages
Image URLs included
This commit is contained in:
parent
19b71a87c0
commit
fc51e33ceb
2 changed files with 214 additions and 54 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
import { tokenizeChat } from "$lib/linkify";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -9,53 +9,7 @@
|
||||||
|
|
||||||
let { content, profiles = {} }: Props = $props();
|
let { content, profiles = {} }: Props = $props();
|
||||||
|
|
||||||
type Token =
|
const tokens = $derived(tokenizeChat(content));
|
||||||
| { 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
||||||
|
|
||||||
|
|
@ -77,5 +31,16 @@
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-brand hover:underline">@{u?.shortName ?? t.fallback}</a
|
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
|
>{:else}{t.value}{/if}{/each}</span
|
||||||
>
|
>
|
||||||
|
|
|
||||||
195
src/lib/linkify.ts
Normal file
195
src/lib/linkify.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue