Fix frontend JS: CSRF resync, status-comment flow, markdown/XSS, kanban

- base.js lt.api: resync window.CSRF_TOKEN from response bodies before
  throwing on errors and attach err.data/err.status, so a desynced client
  auto-recovers without a reload
- add lt.ticketStatus.submit: status changes that require a comment now
  prompt, post the comment, and retry update_ticket with it; wired into
  the ticket dropdown, dashboard quick-status, kanban drag-drop and the
  1-4 keyboard shortcuts (bulk ops unchanged) — matches the new server
  requires_comment enforcement
- base.js markdown.render: drop the unsafe marked/markdownit delegation;
  always use the built-in XSS-safe renderer
- ticket.js: XHR upload sends the X-CSRF-Token header and resyncs the
  token; use lt.escHtml instead of a re-inlined escape chain; @-mention
  trigger requires a word boundary (no firing inside emails); idempotent,
  anchor-safe highlightMentions
- base.js typeahead: discard out-of-order async results
- markdown.js: balanced table tbody/thead; ticket-ref linkification runs
  after code extraction so #ids inside code aren't linked
- dashboard.js kanban: don't swallow the click after a drag
- keyboard-shortcuts.js: J/K skip hidden/skeleton rows; drop duplicate ?

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:50:27 -04:00
co-authored by Claude Opus 4.8
parent d11cb989bf
commit 113b7f9d3f
5 changed files with 200 additions and 56 deletions
+102 -6
View File
@@ -468,7 +468,15 @@
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); } try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
let data; let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); // Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;
err.status = resp.status;
throw err;
}
return data; return data;
} }
@@ -2004,6 +2012,7 @@
let _focusedIdx = -1; let _focusedIdx = -1;
let _items = []; let _items = [];
let _debTimer = null; let _debTimer = null;
let _searchSeq = 0;
function _render(items, query) { function _render(items, query) {
_items = items.slice(0, maxResults); _items = items.slice(0, maxResults);
@@ -2028,16 +2037,21 @@
} }
async function _search(query) { async function _search(query) {
// Sequence guard: only the latest query is allowed to render, so a slow
// earlier async source() cannot overwrite a newer query's results.
const seq = ++_searchSeq;
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>'; dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
dropdown.classList.add('is-open'); dropdown.classList.add('is-open');
inputEl.setAttribute('aria-busy', 'true'); inputEl.setAttribute('aria-busy', 'true');
try { try {
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase())); const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
if (seq !== _searchSeq) return;
_render(results, query); _render(results, query);
} catch(e) { } catch(e) {
if (seq !== _searchSeq) return;
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>'; dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
} finally { } finally {
inputEl.setAttribute('aria-busy', 'false'); if (seq === _searchSeq) inputEl.setAttribute('aria-busy', 'false');
} }
} }
@@ -2704,7 +2718,15 @@
} }
let data; let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); // Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;
err.status = resp.status;
throw err;
}
return data; return data;
} }
api.get = url => _apiFetchAuth('GET', url); api.get = url => _apiFetchAuth('GET', url);
@@ -2713,6 +2735,79 @@
api.patch = (u, b) => _apiFetchAuth('PATCH', u, b); api.patch = (u, b) => _apiFetchAuth('PATCH', u, b);
api.delete = (u, b) => _apiFetchAuth('DELETE', u, b); api.delete = (u, b) => _apiFetchAuth('DELETE', u, b);
/* ================================================================
TICKET STATUS CHANGE (comment-aware)
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
Posts /api/update_ticket.php. If the server rejects with
requires_comment, opens a comment modal, persists the comment via
/api/add_comment.php, then retries the update once WITH the comment.
Rejects with err.cancelled === true if the user cancels the modal.
================================================================ */
function _statusCommentModal(newStatus) {
return new Promise(resolve => {
const modalId = 'ltStatusCommentModal' + Date.now();
const safeStatus = escHtml(newStatus);
document.body.insertAdjacentHTML('beforeend',
'<div class="lt-modal-overlay" id="' + modalId + '" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="' + modalId + '_title">' +
'<div class="lt-modal lt-modal-sm">' +
'<div class="lt-modal-header" style="color:var(--terminal-amber)">' +
'<span class="lt-modal-title" id="' + modalId + '_title">[ ! ] Change Status to ' + safeStatus + '</span>' +
'<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
'</div>' +
'<div class="lt-modal-body">' +
'<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">A comment is required when changing status to <strong>' + safeStatus + '</strong>. Enter your reason below.</p>' +
'<textarea id="' + modalId + '_comment" class="lt-input lt-w-full" rows="3" placeholder="Reason for status change…" style="resize:vertical;font-family:inherit;font-size:0.8rem" aria-label="Required comment for status change"></textarea>' +
'</div>' +
'<div class="lt-modal-footer">' +
'<button class="lt-btn lt-btn-primary" id="' + modalId + '_confirm">CONFIRM CHANGE</button>' +
'<button class="lt-btn lt-btn-ghost" id="' + modalId + '_cancel">CANCEL</button>' +
'</div>' +
'</div>' +
'</div>');
const modalEl = document.getElementById(modalId);
openModal(modalId);
let done = false;
const finish = (value) => {
if (done) return;
done = true;
closeModal(modalId);
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
resolve(value);
};
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
const ta = document.getElementById(modalId + '_comment');
const comment = ta ? ta.value.trim() : '';
if (!comment) { if (ta) ta.focus(); toast.warning('Please enter a reason for this status change.'); return; }
finish(comment);
});
setTimeout(() => { const ta = document.getElementById(modalId + '_comment'); if (ta) ta.focus(); }, 100);
});
}
const ticketStatus = {
submit(ticketId, newStatus, opts) {
opts = opts || {};
const id = String(ticketId);
const payload = { ticket_id: id, status: newStatus };
if (opts.comment) payload.comment = opts.comment;
return api.post('/api/update_ticket.php', payload).catch(err => {
if (!(err && err.data && err.data.requires_comment)) throw err;
return _statusCommentModal(newStatus).then(comment => {
if (!comment) {
const cancelErr = new Error('Status change cancelled');
cancelErr.cancelled = true;
throw cancelErr;
}
// Persist the comment, then retry the status change with it included.
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment })
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment }));
});
});
},
};
/* ================================================================ /* ================================================================
MODULE 54 — MARKDOWN RENDERER MODULE 54 — MARKDOWN RENDERER
lt.markdown.render(mdString) → HTML string (sanitized) lt.markdown.render(mdString) → HTML string (sanitized)
@@ -2722,9 +2817,9 @@
================================================================ */ ================================================================ */
const markdown = { const markdown = {
render(md) { render(md) {
// Delegate to window.marked if available // Always use the built-in XSS-safe micro-renderer. Do NOT delegate to
if (global.marked) return global.marked.parse(md); // window.marked / window.markdownit: their raw HTML output is not sanitized
if (global.markdownit) return global.markdownit().render(md); // here, so delegating would enable stored XSS if such a lib were ever loaded.
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr // Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
let html = escHtml(md) let html = escHtml(md)
// Fenced code blocks // Fenced code blocks
@@ -2943,6 +3038,7 @@
lightbox, lightbox,
auth, auth,
markdown, markdown,
ticketStatus,
pagination, pagination,
sidebarSubmenus: { init: initSidebarSubmenus }, sidebarSubmenus: { init: initSidebarSubmenus },
}; };
+18 -11
View File
@@ -1000,18 +1000,20 @@ function performQuickStatusChange(ticketId) {
if (!quickStatusEl) return; if (!quickStatusEl) return;
const newStatus = quickStatusEl.value; const newStatus = quickStatusEl.value;
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus }) // Close this modal first so the comment modal (if requires_comment) stacks cleanly.
.then(data => {
closeQuickStatusModal(); closeQuickStatusModal();
if (data.success) {
lt.ticketStatus.submit(ticketId, newStatus)
.then(data => {
if (data && data.success) {
lt.toast.success(`Status updated to ${newStatus}`, 3000); lt.toast.success(`Status updated to ${newStatus}`, 3000);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000); showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
} else { } else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000); lt.toast.error('Error: ' + ((data && data.error) || 'Unknown error'), 4000);
} }
}) })
.catch(error => { .catch(error => {
closeQuickStatusModal(); if (error && error.cancelled) return;
lt.toast.error('Error updating status', 4000); lt.toast.error('Error updating status', 4000);
}); });
} }
@@ -1168,8 +1170,9 @@ function populateKanbanCards() {
card.dataset.ticketId = ticketId; card.dataset.ticketId = ticketId;
card.dataset.status = status; card.dataset.status = status;
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
// Don't navigate if drag just ended (drag adds/removes is-dragging briefly) // Don't navigate if a drag just ended. The flag is cleared on a timer
if (card.dataset.dragged) { delete card.dataset.dragged; return; } // (see handleKanbanSort), so a genuine later click is not swallowed.
if (card.dataset.dragged) return;
window.location.href = '/ticket/' + encodeURIComponent(ticketId); window.location.href = '/ticket/' + encodeURIComponent(ticketId);
}); });
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); }; card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
@@ -1214,6 +1217,9 @@ function populateKanbanCards() {
movedCard.dataset.status = newStatus; movedCard.dataset.status = newStatus;
movedCard.dataset.dragged = '1'; movedCard.dataset.dragged = '1';
// Clear the drag flag shortly after the drop so it suppresses only the
// synthetic click fired on drop, not the user's next genuine click.
setTimeout(function () { delete movedCard.dataset.dragged; }, 400);
// Optimistically update column counts // Optimistically update column counts
const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`); const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`);
@@ -1230,8 +1236,9 @@ function populateKanbanCards() {
if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')'; if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')';
}; };
// POST status update via the shared wrapper (adds CSRF + JSON, throws on non-2xx) // Submit via the shared comment-aware helper. Dropping to Closed (or
lt.api.post('/api/update_ticket.php', { ticket_id: String(ticketId), status: newStatus }) // reopening) prompts for a required comment and retries; cancel reverts.
lt.ticketStatus.submit(String(ticketId), newStatus)
.then(function (data) { .then(function (data) {
if (data && data.success) { if (data && data.success) {
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500); lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
@@ -1241,8 +1248,8 @@ function populateKanbanCards() {
revert(); revert();
} }
}) })
.catch(function () { .catch(function (error) {
lt.toast.error('Status update failed — reverting'); if (!(error && error.cancelled)) lt.toast.error('Status update failed — reverting');
revert(); revert();
}); });
} }
+19 -5
View File
@@ -6,11 +6,27 @@
// Track currently selected row for J/K navigation // Track currently selected row for J/K navigation
let currentSelectedRowIndex = -1; let currentSelectedRowIndex = -1;
let lastNavRowCount = -1;
// Only navigate real, visible rows — skip skeleton placeholders and rows hidden
// by filters/column toggles (offsetParent is null when display:none).
function getNavigableRows() {
return Array.from(document.querySelectorAll('tbody tr')).filter(function(row) {
return !row.classList.contains('lt-skeleton-row') && row.offsetParent !== null;
});
}
function navigateTableRow(direction) { function navigateTableRow(direction) {
const rows = document.querySelectorAll('tbody tr'); const rows = getNavigableRows();
if (rows.length === 0) return; if (rows.length === 0) return;
// Reset the index when the row set changes (e.g. filter/reload) so navigation
// never lands on a stale/hidden index.
if (rows.length !== lastNavRowCount) {
currentSelectedRowIndex = -1;
lastNavRowCount = rows.length;
}
rows.forEach(row => row.classList.remove('keyboard-selected')); rows.forEach(row => row.classList.remove('keyboard-selected'));
if (direction === 'next') { if (direction === 'next') {
@@ -47,10 +63,8 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer // Note: the '?' help shortcut is registered by lt.keys.initDefaults(); do not
lt.keys.on('?', function() { // re-bind it here or the help modal opens twice.
if (window.lt) lt.modal.open('lt-keys-help');
});
// J: Next row // J: Next row
lt.keys.on('j', () => navigateTableRow('next')); lt.keys.on('j', () => navigateTableRow('next'));
+21 -11
View File
@@ -41,9 +41,6 @@ function parseMarkdown(markdown) {
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
// Ticket references (#123456789) - convert to clickable links
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Code blocks (```code```) - preserve content and don't process further // Code blocks (```code```) - preserve content and don't process further
const codeBlocks = []; const codeBlocks = [];
html = html.replace(/```([\s\S]*?)```/g, function(match, code) { html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
@@ -58,6 +55,11 @@ function parseMarkdown(markdown) {
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%'; return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
}); });
// Ticket references (#123456789) - convert to clickable links.
// Runs AFTER code extraction so a literal #123456789 inside inline/fenced code
// (now replaced by a placeholder) is not turned into a link.
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Tables (must be processed before other block elements) // Tables (must be processed before other block elements)
html = parseMarkdownTables(html); html = parseMarkdownTables(html);
@@ -287,25 +289,33 @@ function buildTable(rows) {
if (rows.length === 0) return ''; if (rows.length === 0) return '';
let html = '<table class="markdown-table">'; let html = '<table class="markdown-table">';
let inThead = false;
let inTbody = false;
rows.forEach((row, index) => { rows.forEach((row) => {
const cells = row.content.split('|').filter(cell => cell.trim() !== ''); const cells = row.content.split('|').filter(cell => cell.trim() !== '');
const tag = row.type === 'header' ? 'th' : 'td'; const isHeader = row.type === 'header';
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : ''); const tag = isHeader ? 'th' : 'td';
if (wrapper === 'thead') html += '<thead>'; if (isHeader && !inThead) { html += '<thead>'; inThead = true; }
if (wrapper === 'tbody') html += '<tbody>'; if (!isHeader && !inTbody) {
if (inThead) { html += '</thead>'; inThead = false; }
html += '<tbody>';
inTbody = true;
}
html += '<tr>'; html += '<tr>';
cells.forEach(cell => { cells.forEach(cell => {
html += `<${tag}>${cell.trim()}</${tag}>`; html += `<${tag}>${cell.trim()}</${tag}>`;
}); });
html += '</tr>'; html += '</tr>';
if (row.type === 'header') html += '</thead>';
}); });
html += '</tbody></table>'; // Close whichever section is still open so tags are balanced for header-only,
// body-only, and header+body tables alike.
if (inThead) html += '</thead>';
if (inTbody) html += '</tbody>';
html += '</table>';
return html; return html;
} }
+39 -22
View File
@@ -291,14 +291,8 @@ function addComment() {
// For markdown, use parseMarkdown (sanitizes HTML) // For markdown, use parseMarkdown (sanitizes HTML)
displayText = parseMarkdown(commentText); displayText = parseMarkdown(commentText);
} else { } else {
// For non-markdown, convert line breaks to <br> and escape HTML // For non-markdown, escape HTML then convert line breaks to <br>
displayText = commentText displayText = lt.escHtml(commentText).replace(/\n/g, '<br>');
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br>');
} }
// Add new comment to the list // Add new comment to the list
@@ -538,11 +532,12 @@ function updateTicketStatus() {
return; return;
} }
cleanup(true); cleanup(true);
// Post comment first, then change status // Post comment first (persists it), then change status with the same
// comment included so the server's requires_comment check passes.
const ticketId = getTicketIdFromUrl(); const ticketId = getTicketIdFromUrl();
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment }) lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
.then(() => performStatusChange(statusSelect, selectedOption, newStatus)) .then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment))
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus)); .catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment));
}); });
// Focus textarea on open // Focus textarea on open
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100); setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
@@ -552,8 +547,11 @@ function updateTicketStatus() {
performStatusChange(statusSelect, selectedOption, newStatus); performStatusChange(statusSelect, selectedOption, newStatus);
} }
// Extract status change logic into reusable function // Extract status change logic into reusable function.
function performStatusChange(statusSelect, selectedOption, newStatus) { // `comment` (optional) is included in the update_ticket payload so requires_comment
// transitions pass server validation. lt.ticketStatus.submit handles the
// comment-aware retry if a comment is required but was not pre-collected.
function performStatusChange(statusSelect, selectedOption, newStatus, comment) {
const ticketId = getTicketIdFromUrl(); const ticketId = getTicketIdFromUrl();
if (!ticketId) { if (!ticketId) {
@@ -561,10 +559,10 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
return; return;
} }
// Update status via API // Update status via the shared comment-aware helper
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus }) lt.ticketStatus.submit(ticketId, newStatus, { comment: comment })
.then(data => { .then(data => {
if (data.success) { if (data && data.success) {
// Update the dropdown to show new status as current (preserve TDS v1.2 classes) // Update the dropdown to show new status as current (preserve TDS v1.2 classes)
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-'); const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass; statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
@@ -582,12 +580,14 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
window.location.reload(); window.location.reload();
}, 500); }, 500);
} else { } else {
lt.toast.error('Error updating status: ' + (data.error || 'Unknown error')); lt.toast.error('Error updating status: ' + ((data && data.error) || 'Unknown error'));
// Reset to current status // Reset to current status
statusSelect.selectedIndex = 0; statusSelect.selectedIndex = 0;
} }
}) })
.catch(error => { .catch(error => {
// User cancelled the required-comment modal — silently revert the dropdown
if (error && error.cancelled) { statusSelect.selectedIndex = 0; return; }
lt.toast.error('Error updating status: ' + error.message); lt.toast.error('Error updating status: ' + error.message);
// Reset to current status // Reset to current status
statusSelect.selectedIndex = 0; statusSelect.selectedIndex = 0;
@@ -938,6 +938,8 @@ function handleFileUpload(files) {
if (xhr.status === 200 || xhr.status === 201) { if (xhr.status === 200 || xhr.status === 201) {
try { try {
const response = JSON.parse(xhr.responseText); const response = JSON.parse(xhr.responseText);
// Keep the CSRF token in sync if the server rotated it
if (response.csrf_token) window.CSRF_TOKEN = response.csrf_token;
if (response.success) { if (response.success) {
if (uploadedCount === totalFiles) { if (uploadedCount === totalFiles) {
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000); lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
@@ -968,6 +970,9 @@ function handleFileUpload(files) {
}); });
xhr.open('POST', '/api/upload_attachment.php'); xhr.open('POST', '/api/upload_attachment.php');
// Send CSRF via header to match the rest of the app (endpoint accepts both
// the X-CSRF-Token header and the csrf_token form field).
if (window.CSRF_TOKEN) xhr.setRequestHeader('X-CSRF-Token', window.CSRF_TOKEN);
xhr.send(formData); xhr.send(formData);
}); });
} }
@@ -1142,12 +1147,17 @@ function handleMentionInput(e) {
const text = textarea.value; const text = textarea.value;
const cursorPos = textarea.selectionStart; const cursorPos = textarea.selectionStart;
// Find @ symbol before cursor // Find @ symbol before cursor. Only trigger when the @ is at a word boundary
// (start of input or preceded by whitespace) so it does not fire inside email
// addresses like foo@bar.
let atPos = -1; let atPos = -1;
for (let i = cursorPos - 1; i >= 0; i--) { for (let i = cursorPos - 1; i >= 0; i--) {
const char = text[i]; const char = text[i];
if (char === '@') { if (char === '@') {
const prev = i > 0 ? text[i - 1] : '';
if (i === 0 || /\s/.test(prev)) {
atPos = i; atPos = i;
}
break; break;
} }
if (char === ' ' || char === '\n') { if (char === ' ' || char === '\n') {
@@ -1277,20 +1287,27 @@ function selectMention(username) {
} }
/** /**
* Highlight mentions in comment text * Highlight mentions in comment text.
* Skips content inside existing anchor tags so URLs/emails that contain '@'
* (e.g. auto-linked links or mailto:) are not corrupted or nested.
*/ */
function highlightMentions(text) { function highlightMentions(text) {
return text.replace(/@([a-zA-Z0-9_-]+)/g, '<span class="mention">$1</span>'); return text.replace(/<a\b[^>]*>[\s\S]*?<\/a>|@[a-zA-Z0-9_-]+/gi, function (m) {
if (m.charAt(0) === '<') return m; // leave anchor tags untouched
return '<span class="mention">' + m.slice(1) + '</span>';
});
} }
// Initialize mention autocomplete when DOM is ready // Initialize mention autocomplete when DOM is ready
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initMentionAutocomplete(); initMentionAutocomplete();
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements) // Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements).
// Idempotency guard: only process each element once so re-runs don't nest spans.
document.querySelectorAll('.comment-text').forEach(el => { document.querySelectorAll('.comment-text').forEach(el => {
if (!el.hasAttribute('data-markdown')) { if (!el.hasAttribute('data-markdown') && !el.dataset.mentionsProcessed) {
el.innerHTML = highlightMentions(el.innerHTML); el.innerHTML = highlightMentions(el.innerHTML);
el.dataset.mentionsProcessed = '1';
} }
}); });