From 94ec4a3f3f6c5170bb13bb1a3c2f840c9e9cbc45 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Mon, 5 Jan 2026 09:55:24 -0300 Subject: [PATCH 1/2] feat: add clickable links with WhatsApp-style colors in chat bubbles - Implement URL detection and linkification in message content - Apply WhatsApp teal (#00897B) and cyan (#4FC3F7) colors for links - Handle both search highlighting and URL links simultaneously - URLs are clickable with target='_blank' and proper styling - Add quotedMessage field to ChatMessage interface (prepared for future use) --- src/lib/components/MessageBubble.svelte | 113 ++++++++++++++++++++---- src/lib/parser/chat-parser.ts | 5 ++ 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/lib/components/MessageBubble.svelte b/src/lib/components/MessageBubble.svelte index 1432fa6..581096e 100644 --- a/src/lib/components/MessageBubble.svelte +++ b/src/lib/components/MessageBubble.svelte @@ -174,27 +174,87 @@ $effect(() => { // Highlight search terms in text (safe from XSS) function highlightText(text: string, query: string): string { - if (!query.trim()) return escapeHtml(text); + if (!query.trim()) return linkifyText(text); - // Search on original text, then escape parts between matches - const regex = new RegExp(escapeRegex(query), 'gi'); - let lastIndex = 0; - let result = ''; + // First, detect URLs and mark their positions + const urlRegex = /(https?:\/\/[^\s]+)/g; + const urls: { start: number; end: number; url: string }[] = []; let match; - while ((match = regex.exec(text)) !== null) { - // Prevent infinite loop if regex matches empty string + while ((match = urlRegex.exec(text)) !== null) { + urls.push({ + start: match.index, + end: match.index + match[0].length, + url: match[0], + }); + } + + // Search for query matches + const searchRegex = new RegExp(escapeRegex(query), 'gi'); + const searchMatches: { start: number; end: number; text: string }[] = []; + while ((match = searchRegex.exec(text)) !== null) { if (match[0].length === 0) { - regex.lastIndex++; + searchRegex.lastIndex++; continue; } - // Add text before the match, escaped - result += escapeHtml(text.slice(lastIndex, match.index)); - // Add the matched text, escaped and wrapped in - result += `${escapeHtml(match[0])}`; - lastIndex = match.index + match[0].length; + searchMatches.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0], + }); } - // Add the rest of the text, escaped - result += escapeHtml(text.slice(lastIndex)); + + // Build result by processing text in order + let result = ''; + let lastIndex = 0; + + // Combine and sort all markers + const markers: Array< + | { type: 'url'; pos: number; data: { start: number; end: number; url: string } } + | { type: 'search'; pos: number; data: { start: number; end: number; text: string } } + > = []; + + urls.forEach((url) => { + markers.push({ type: 'url', pos: url.start, data: url }); + }); + + searchMatches.forEach((search) => { + markers.push({ type: 'search', pos: search.start, data: search }); + }); + + markers.sort((a, b) => a.pos - b.pos); + + markers.forEach((marker) => { + if (marker.type === 'url') { + // Add text before URL + if (marker.data.start > lastIndex) { + result += escapeHtml(text.slice(lastIndex, marker.data.start)); + } + // Add URL as link + const url = marker.data.url; + result += `${escapeHtml(url)}`; + lastIndex = marker.data.end; + } else if (marker.type === 'search') { + // Skip if inside a URL + const insideUrl = urls.some( + (url) => marker.data.start >= url.start && marker.data.end <= url.end, + ); + if (insideUrl) return; + + // Add text before match + if (marker.data.start > lastIndex) { + result += escapeHtml(text.slice(lastIndex, marker.data.start)); + } + // Add highlighted match + result += `${escapeHtml(marker.data.text)}`; + lastIndex = marker.data.end; + } + }); + + // Add remaining text + if (lastIndex < text.length) { + result += escapeHtml(text.slice(lastIndex)); + } + return result; } @@ -211,6 +271,27 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +// Convert URLs in text to clickable links +function linkifyText(text: string): string { + const urlRegex = /(https?:\/\/[^\s]+)/g; + let lastIndex = 0; + let result = ''; + let match; + + while ((match = urlRegex.exec(text)) !== null) { + // Add text before the URL, escaped + result += escapeHtml(text.slice(lastIndex, match.index)); + // Add the URL as a clickable link, escaped + const url = match[0]; + result += `${escapeHtml(url)}`; + lastIndex = match.index + url.length; + } + + // Add the rest of the text, escaped + result += escapeHtml(text.slice(lastIndex)); + return result; +} + async function loadMedia() { if (!message.mediaFile || mediaLoading || mediaUrl) return; @@ -497,7 +578,7 @@ async function transcribeVoiceMessage() { {#if searchQuery && isSearchMatch} {@html highlightText(message.content, searchQuery)} {:else} - {message.content} + {@html linkifyText(message.content)} {/if}

{/if} diff --git a/src/lib/parser/chat-parser.ts b/src/lib/parser/chat-parser.ts index bb5d5ba..97683e6 100644 --- a/src/lib/parser/chat-parser.ts +++ b/src/lib/parser/chat-parser.ts @@ -21,6 +21,11 @@ export interface ChatMessage { | 'contact' | 'location'; rawLine: string; + // Quoted/replied message info + quotedMessage?: { + sender: string; + content: string; + }; } export interface ParsedChat { From ef1ad44af43119576da90f589e76910c59abe8d9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 5 Jan 2026 12:56:11 +0000 Subject: [PATCH 2/2] chore(release): 1.28.0 [skip ci] # [1.28.0](https://github.com/rodrigogs/whats-reader/compare/v1.27.0...v1.28.0) (2026-01-05) ### Features * add clickable links with WhatsApp-style colors in chat bubbles ([94ec4a3](https://github.com/rodrigogs/whats-reader/commit/94ec4a3f3f6c5170bb13bb1a3c2f840c9e9cbc45)), closes [#00897](https://github.com/rodrigogs/whats-reader/issues/00897) [#4FC3F7](https://github.com/rodrigogs/whats-reader/issues/4FC3F7) --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1753fd5..7baf3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.28.0](https://github.com/rodrigogs/whats-reader/compare/v1.27.0...v1.28.0) (2026-01-05) + + +### Features + +* add clickable links with WhatsApp-style colors in chat bubbles ([94ec4a3](https://github.com/rodrigogs/whats-reader/commit/94ec4a3f3f6c5170bb13bb1a3c2f840c9e9cbc45)), closes [#00897](https://github.com/rodrigogs/whats-reader/issues/00897) [#4FC3F7](https://github.com/rodrigogs/whats-reader/issues/4FC3F7) + # [1.27.0](https://github.com/rodrigogs/whats-reader/compare/v1.26.1...v1.27.0) (2026-01-05) diff --git a/package-lock.json b/package-lock.json index 34f19ed..20f75e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "whats-reader", - "version": "1.27.0", + "version": "1.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "whats-reader", - "version": "1.27.0", + "version": "1.28.0", "license": "AGPL-3.0", "dependencies": { "@floating-ui/dom": "^1.7.4", diff --git a/package.json b/package.json index 93fd285..6a36218 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "whats-reader", "productName": "WhatsApp Backup Reader", - "version": "1.27.0", + "version": "1.28.0", "description": "A desktop app to read and visualize WhatsApp chat exports", "license": "AGPL-3.0", "author": {