import * as nip19 from "@nostr/tools/nip19"; import { RELAY_URL } from "./config"; // 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<>"']+|(?"']*)?`; // 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; // Underlying event id for note/nevent — used to resolve a forum thread. id?: 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") return { type: "entity", entity, href: `https://njump.me/${entity}`, label: shortNostrEntity(entity), id: decoded.type === "note" ? decoded.data : decoded.data.id, }; if (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; } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // Rewrite same-origin forum thread URLs into nostr entities before publishing, // so the reference is portable to other Nostr clients. A `#post-` fragment // targets the reply (kind 1111); otherwise the thread itself (kind 11). The // author is omitted; the parent is recovered from the reply's own tags on read. export function convertForumUrls(content: string): string { if (typeof window === "undefined") return content; const re = new RegExp( `${escapeRegExp(window.location.origin)}/thread/([0-9a-f]{64})\\/?(?:#post-([0-9a-f]{64}))?`, "g", ); return content.replace(re, (whole, threadId, replyId) => { try { const nevent = replyId ? nip19.neventEncode({ id: replyId, kind: 1111, relays: [RELAY_URL] }) : nip19.neventEncode({ id: threadId, kind: 11, relays: [RELAY_URL] }); return `nostr:${nevent}`; } catch { return whole; } }); }