From 1fdc01c0cd8836f1e451f4d71b2dbfa181f83b83 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 19 Jul 2026 21:05:00 +0200 Subject: [PATCH] feat: linkify resolves ': @handle' social mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform-prefixed handles (x/twitter/instagram/facebook/tiktok/youtube/twitch/threads/ telegram/soundcloud) now link to the platform's profile URL — only the @handle is linked, the ':' label stays as text. A required colon/arrow separator + the known-platform list keep false positives out; a bare @handle with no platform stays plain. --- frontend/src/lib/linkify.tsx | 88 ++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/frontend/src/lib/linkify.tsx b/frontend/src/lib/linkify.tsx index f6071229..3aa3be25 100644 --- a/frontend/src/lib/linkify.tsx +++ b/frontend/src/lib/linkify.tsx @@ -1,43 +1,73 @@ import { Fragment, type ReactNode } from "react"; -// Recognises three link shapes in plain text: -// 1. explicit http(s):// URLs -// 2. www.* bare hosts -// 3. domain.tld/path bare URLs — the required path AND a letters-only TLD keep false positives -// (version numbers like "3.2/x", file names like "index.html") from matching. -// The last matched char excludes trailing sentence punctuation, so "see foo.com/bar." links -// "foo.com/bar" and leaves the period. Bare @handles are intentionally NOT linked — the platform -// is ambiguous. -const URL_RE = - /((?:https?:\/\/|www\.)[^\s<]+[^\s<.,!?;:)\]}"']|[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}\/[^\s<]*[^\s<.,!?;:)\]}"'])/gi; +// Known platform → profile base URL, for ": @handle" mentions in descriptions. +const PLATFORMS: Record = { + x: "https://x.com/", + twitter: "https://x.com/", + instagram: "https://instagram.com/", + insta: "https://instagram.com/", + ig: "https://instagram.com/", + facebook: "https://facebook.com/", + fb: "https://facebook.com/", + tiktok: "https://www.tiktok.com/@", + youtube: "https://www.youtube.com/@", + yt: "https://www.youtube.com/@", + twitch: "https://www.twitch.tv/", + threads: "https://www.threads.net/@", + telegram: "https://t.me/", + soundcloud: "https://soundcloud.com/", +}; + +// Two link shapes, combined into one scanner: +// URL — explicit http(s):// URLs, www.* hosts, or domain.tld/path bare URLs (required path + +// letters-only TLD keep version numbers / file names out). Non-http gets an https:// href. +// MENTION — ": @handle" where is one of the known keys above. The colon/arrow +// separator is REQUIRED, so a stray "x" in prose won't match. Only the @handle is linked +// (to the platform's profile URL); the ": " label stays as text. Bare @handles +// with no platform, and #hashtags, are left plain. +const URL_SOURCE = + '(?:https?://|www\\.)[^\\s<]+[^\\s<.,!?;:)\\]}"\']|[a-z0-9-]+(?:\\.[a-z0-9-]+)*\\.[a-z]{2,}/[^\\s<]*[^\\s<.,!?;:)\\]}"\']'; +const MENTION_SOURCE = + "\\b(" + + Object.keys(PLATFORMS).join("|") + + ")\\b[ \\t]*[::►▶▸]+[ \\t]*@([a-z0-9_](?:[a-z0-9_.]{0,28}[a-z0-9_])?)"; +const TOKEN_RE = new RegExp("(" + URL_SOURCE + ")|" + MENTION_SOURCE, "gi"); /** - * Turn bare URLs (http(s), www, or domain.tld/path) inside a plain-text string into links that open - * in a new tab (safe rel). Non-http matches get an https:// prefix on the href, but keep their - * original text. Everything else is returned as text, so it composes with `whitespace-pre-wrap`. + * Turn links inside a plain-text string into clickable s that open in a new tab (safe rel): + * bare URLs (http(s)/www/domain.tld/path) and ": @handle" social mentions. Everything + * else is returned as text, so it composes with `whitespace-pre-wrap` (line breaks preserved). */ export function linkify(text: string): ReactNode[] { const out: ReactNode[] = []; let last = 0; let key = 0; - URL_RE.lastIndex = 0; + TOKEN_RE.lastIndex = 0; + const link = (href: string, label: string) => ( + + {label} + + ); let m: RegExpExecArray | null; - while ((m = URL_RE.exec(text)) !== null) { + while ((m = TOKEN_RE.exec(text)) !== null) { if (m.index > last) out.push({text.slice(last, m.index)}); - const raw = m[0]; - const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; - out.push( - - {raw} - , - ); - last = m.index + raw.length; + if (m[1]) { + // Plain URL. + const raw = m[1]; + out.push(link(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`, raw)); + } else { + // ": @handle" — keep the ": " prefix as text, link only the handle. + const handle = m[3]; + out.push({m[0].slice(0, m[0].length - handle.length - 1)}); + out.push(link(PLATFORMS[m[2].toLowerCase()] + handle, "@" + handle)); + } + last = m.index + m[0].length; } if (last < text.length) out.push({text.slice(last)}); return out;