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": {