diff --git a/internal/web/static/index.html b/internal/web/static/index.html
index a3fe6a7..13acd42 100644
--- a/internal/web/static/index.html
+++ b/internal/web/static/index.html
@@ -3204,7 +3204,7 @@
html += '
' + esc(ln) + '
';
hasContent = true;
} else if (ln.trim()) {
- html += '' + linkify(esc(ln)) + '
';
+ html += '' + linkify(ln) + '
';
hasContent = true;
}
}
@@ -3255,7 +3255,7 @@
var timeStr = ts.toLocaleTimeString(dateLocale, { hour: '2-digit', minute: '2-digit' });
var text = msg.Text || msg.text || '';
currentMsgTexts.push(text);
- var mediaHtml = '', textHtml = linkify(esc(text)).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '
');
+ var mediaHtml = '', textHtml = linkify(text).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '
');
// Check for [REPLY]:ID or [REPLY] format (backward compat: also [REPLY:ID])
var replyMatch = text.match(/^\[REPLY\](?::(\d+))?/) || text.match(/^\[REPLY:(\d+)\]/);
if (replyMatch) {
@@ -3269,7 +3269,7 @@
textHtml = renderPollCard(rpPollBody);
mediaHtml += '[POLL]
';
} else {
- textHtml = linkify(esc(replyBody)).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '
');
+ textHtml = linkify(replyBody).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '
');
}
if (replyId > 0 && msgByID[replyId]) {
var rpText = (msgByID[replyId].Text || msgByID[replyId].text || '').replace(/^\[(?:IMAGE|VIDEO|FILE|AUDIO|STICKER|GIF|POLL|CONTACT|LOCATION|REPLY)[^\]]*\](?::\d+)?\n?/, '');
@@ -3287,7 +3287,7 @@
for (var m = 0; m < mediaTypes.length; m++) {
if (text.indexOf(mediaTypes[m]) === 0) {
mediaHtml = '' + mediaTypes[m] + '
';
- textHtml = linkify(esc(text.substring(mediaTypes[m].length).replace(/^\n/, ''))).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '
'); break
+ textHtml = linkify(text.substring(mediaTypes[m].length).replace(/^\n/, '')).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '
'); break
}
}
}
@@ -3608,20 +3608,31 @@
// ===== UTILITIES =====
function esc(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML }
function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, ''') }
- function linkify(s) {
- return s.replace(/(https?:\/\/[^\s<>&"']+)/g, function(url) {
- var trail = '';
- // Strip trailing punctuation that's not part of the URL
- while (url.length > 1) {
- var ch = url[url.length - 1];
- if (ch === ')' && url.split('(').length <= url.split(')').length - 1) {
- trail = ch + trail; url = url.slice(0, -1);
- } else if (/[.,;:!?>\u200C\u200F]/.test(ch)) {
- trail = ch + trail; url = url.slice(0, -1);
- } else { break }
+ function linkify(raw) {
+ // Accepts raw (unescaped) text. Handles [label](url) markdown links and
+ // plain URLs. Escapes HTML in non-URL segments so & in URLs is preserved.
+ var result = '', last = 0, m;
+ var re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>"']+)/g;
+ while ((m = re.exec(raw)) !== null) {
+ result += esc(raw.slice(last, m.index));
+ if (m[2]) {
+ result += '' + esc(m[1]) + '';
+ } else {
+ var url = m[3], trail = '';
+ while (url.length > 1) {
+ var ch = url[url.length - 1];
+ if (ch === ')' && url.split('(').length <= url.split(')').length - 1) {
+ trail = ch + trail; url = url.slice(0, -1);
+ } else if (/[.,;:!?>\u200C\u200F]/.test(ch)) {
+ trail = ch + trail; url = url.slice(0, -1);
+ } else { break; }
+ }
+ result += '' + esc(url) + '' + esc(trail);
}
- return '' + url + '' + trail;
- });
+ last = m.index + m[0].length;
+ }
+ result += esc(raw.slice(last));
+ return result;
}
function scrollToMsg(id) {
var els = document.querySelectorAll('.msg');