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}