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}