From c14cd6cac5cf839037c795ebb5efa971601586d1 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:03:46 +0100 Subject: [PATCH] feat(matrix-web): add clickable links and link previews - Detect URLs in text messages using regex - Convert URLs to clickable links with proper styling - Add link preview card showing favicon and domain - Escape HTML properly to prevent XSS Co-Authored-By: Claude Opus 4.5 --- .../src/lib/components/chat/Message.svelte | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/matrix/apps/web/src/lib/components/chat/Message.svelte b/apps/matrix/apps/web/src/lib/components/chat/Message.svelte index eaf982b83..5eaafc3f2 100644 --- a/apps/matrix/apps/web/src/lib/components/chat/Message.svelte +++ b/apps/matrix/apps/web/src/lib/components/chat/Message.svelte @@ -54,6 +54,45 @@ await matrixStore.reactToMessage(message.id, emoji); } + // URL detection regex + const urlRegex = /(https?:\/\/[^\s<>"']+)/gi; + + // Escape HTML entities to prevent XSS + function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // Convert URLs to clickable links + function linkifyText(text: string, isOwn: boolean): string { + const escaped = escapeHtml(text); + const linkClass = isOwn + ? 'underline underline-offset-2 hover:opacity-80' + : 'text-primary underline underline-offset-2 hover:opacity-80'; + return escaped.replace(urlRegex, (url) => { + return `${url}`; + }); + } + + // Extract first URL for preview + let firstUrl = $derived(() => { + const match = message.body.match(urlRegex); + return match ? match[0] : null; + }); + + // Get domain from URL + function getDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } + } + // Audio player state let audioElement: HTMLAudioElement | null = $state(null); let isPlaying = $state(false); @@ -363,7 +402,30 @@ {message.body}

{:else} -

{message.body}

+ +

{@html linkifyText(message.body, message.isOwn)}

+ + + {#if firstUrl()} + + ((e.currentTarget as HTMLImageElement).style.display = 'none')} + /> + + {getDomain(firstUrl() || '')} + + + {/if} {/if} {#if message.edited}