PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | js/chat-script.js +406 -80 3.2.16 → 3.2.23 View file →
@@ -167,8 +167,16 @@
167 167 // Instance registry - tracks all chatbot instances on the page
168 168 const MxChatInstances = {
169 169 instances: {},
170 170
171 + // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The
172 + // events below fire once; a script that loads late checks these instead.
173 + toolbarReady: false,
174 + opened: {},
175 + hasOpened: function(botId) {
176 + return !!this.opened[botId || 'default'];
177 + },
178 +
171 179 // Initialize an instance for a bot
172 180 init: function(botId) {
173 181 if (!this.instances[botId]) {
174 182 // When persistence is OFF, track when this session started
@@ -284,9 +292,20 @@
284 292 var newSessionId = generateSessionId();
285 293 this.setChatSession(botId, newSessionId);
286 294 var $chatBox = getElement(botId, 'chat-box');
287 295 if ($chatBox.length) {
288 - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
296 + // Keep the greeting, drop everything else. Identify the greeting
297 + // by its marker class, NOT by position (plan a1a79b): after a
298 + // chat-persistence restore the first .bot-message is a real
299 + // reply, so ":not(:first)" left a stale answer sitting at the
300 + // top of an otherwise empty box. The positional fallback only
301 + // runs when the marker is absent — a page served from HTML cache
302 + // that predates this release — and behaves exactly as before.
303 + if ($chatBox.find('.mxchat-intro-message').length) {
304 + $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
305 + } else {
306 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
307 + }
289 308 }
290 309 if (this.instances[botId]) {
291 310 this.instances[botId].chatHistoryLoaded = false;
292 311 this.instances[botId].processedMessageIds = new Set();
@@ -306,8 +325,50 @@
306 325 }
307 326 };
308 327
309 328 // ====================================
329 + // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6)
330 + // ====================================
331 + //
332 + // Document-level CustomEvents an add-on listens for instead of polling the
333 + // DOM or firing requests on page load:
334 + // mxchat:toolbar-ready detail { enabled } once, right after the
335 + // toolbar show/hide pass; items rendered through the
336 + // mxchat_chat_toolbar_items filter can bind now.
337 + // mxchat:opened detail { botId, embedded } once per bot per page
338 + // load, the first time its window is shown — the
339 + // launcher, the pre-chat bubble, an embedded widget
340 + // (at load), or any script that reveals it.
341 + // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId).
342 + function mxchatDispatch(name, detail) {
343 + try {
344 + document.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
345 + } catch (err) { /* CustomEvent unsupported — nothing to notify */ }
346 + }
347 +
348 + function mxchatFireOpened(botId) {
349 + botId = botId || 'default';
350 + if (MxChatInstances.opened[botId]) return;
351 + MxChatInstances.opened[botId] = true;
352 + mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) });
353 + }
354 +
355 + // Catch openers this file does not own (the Trigger add-on, theme buttons,
356 + // custom scripts): a floating window gaining .visible counts as opened.
357 + function mxchatWatchForOpen(botId) {
358 + var el = getElementDOM(botId, 'floating-chatbot');
359 + if (!el || typeof MutationObserver === 'undefined') return;
360 + if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; }
361 + var obs = new MutationObserver(function() {
362 + if (el.classList.contains('visible')) {
363 + mxchatFireOpened(botId);
364 + obs.disconnect();
365 + }
366 + });
367 + obs.observe(el, { attributes: true, attributeFilter: ['class'] });
368 + }
369 +
370 + // ====================================
310 371 // ELEMENT SELECTOR HELPERS
311 372 // ====================================
312 373
313 374 // Check if a specific bot has an AI theme assigned (skip inline colors)
@@ -460,23 +521,28 @@
460 521 // ====================================
461 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
462 523 // ====================================
463 524
525 +// Plan f3e515: the page context is computed once per page, not once per message.
526 +// Keyed on URL + title + the content element's text length — cheap to check,
527 +// and enough to notice a navigation or a re-rendered content area.
528 +var pageContextCache = { key: null, value: null };
529 +
464 530 function getPageContext() {
465 531 // Check if contextual awareness is enabled
466 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
467 533 return null;
468 534 }
469 -
535 +
470 536 // Get page URL
471 537 const pageUrl = window.location.href;
472 -
538 +
473 539 // Get page title
474 540 const pageTitle = document.title || '';
475 -
541 +
476 542 // Get main content from the page
477 543 let pageContent = '';
478 -
544 +
479 545 // Try to get content from common content areas
480 546 const contentSelectors = [
481 547 'main',
482 548 '[role="main"]',
@@ -488,9 +554,9 @@
488 554 'article',
489 555 '#content',
490 556 '#main'
491 557 ];
492 -
558 +
493 559 let contentElement = null;
494 560 for (const selector of contentSelectors) {
495 561 contentElement = document.querySelector(selector);
496 562 if (contentElement) {
@@ -496,18 +562,31 @@
496 562 if (contentElement) {
497 563 break;
498 564 }
499 565 }
500 -
566 +
501 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
502 568 if (!contentElement) {
503 569 contentElement = document.body;
504 570 }
505 -
571 +
506 572 if (contentElement) {
507 - // Clone the element to avoid modifying the original
508 - const clone = contentElement.cloneNode(true);
509 -
573 + const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length;
574 + if (pageContextCache.key === cacheKey) {
575 + return pageContextCache.value;
576 + }
577 +
578 + // Copy the element into a document that has no browsing context
579 + // (plan f3e515). A live cloneNode(true) constructed real <video> /
580 + // <audio> / <img> elements: media with a src runs resource selection
581 + // on creation whether or not it is attached, and an autoplay attribute
582 + // made the detached copy PLAY — audibly, once the visible player had
583 + // been unmuted through the property. Every message spawned another
584 + // one. Elements in an inert document never fetch or play, while
585 + // querySelectorAll / textContent / dataset all still work on them.
586 + const inertDocument = document.implementation.createHTMLDocument('');
587 + const clone = inertDocument.importNode(contentElement, true);
588 +
510 589 // Remove unwanted elements
511 590 const unwantedSelectors = [
512 591 'header',
513 592 'footer',
@@ -525,16 +604,25 @@
525 604 '#floating-chatbot',
526 605 '#floating-chatbot-button',
527 606 '.mxchat',
528 607 '[class*="chat"]',
529 - '[id*="chat"]'
608 + '[id*="chat"]',
609 + // Media contributes no text and (outside the inert document) fetches.
610 + 'video',
611 + 'audio',
612 + 'iframe',
613 + 'object',
614 + 'embed',
615 + 'picture',
616 + 'img',
617 + 'source'
530 618 ];
531 -
619 +
532 620 unwantedSelectors.forEach(selector => {
533 621 const elements = clone.querySelectorAll(selector);
534 622 elements.forEach(el => el.remove());
535 623 });
536 -
624 +
537 625 // Extract MxChat context data attributes before getting text content
538 626 const contextData = [];
539 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
540 628 const contextValue = el.dataset.mxchatContext;
@@ -541,34 +629,34 @@
541 629 if (contextValue && contextValue.trim()) {
542 630 contextData.push(contextValue);
543 631 }
544 632 });
545 -
633 +
546 634 // Get text content and clean it up
547 635 pageContent = clone.textContent || clone.innerText || '';
548 -
636 +
549 637 // Add context data to page content if any were found
550 638 if (contextData.length > 0) {
551 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
552 640 }
553 -
641 +
554 642 // Clean up whitespace and limit length
555 643 pageContent = pageContent
556 644 .replace(/\s+/g, ' ')
557 645 .trim()
558 646 .substring(0, 3000); // Limit to 3000 characters to avoid token limits
647 +
648 + // Only return context if we have meaningful content
649 + const result = (!pageContent || pageContent.length < 50) ? null : {
650 + url: pageUrl,
651 + title: pageTitle,
652 + content: pageContent
653 + };
654 + pageContextCache = { key: cacheKey, value: result };
655 + return result;
559 656 }
560 -
561 - // Only return context if we have meaningful content
562 - if (!pageContent || pageContent.length < 50) {
563 - return null;
564 - }
565 -
566 - return {
567 - url: pageUrl,
568 - title: pageTitle,
569 - content: pageContent
570 - };
657 +
658 + return null;
571 659 }
572 660
573 661 // Track originating page when chat starts
574 662 function trackOriginatingPage() {
@@ -619,8 +707,26 @@
619 707 sendButton.style.pointerEvents = 'none';
620 708 }
621 709 }
622 710
711 +// Whether the input may grab focus after a completed reply (plan 03799f).
712 +// On coarse-pointer devices focusing a text input summons the on-screen
713 +// keyboard over the answer the visitor is trying to read, so 'auto' (the
714 +// default) focuses only on fine-pointer devices. The site-wide
715 +// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
716 +// NOT used on widget open (:~3424) — that focus is a deliberate act and is
717 +// what makes the widget keyboard-accessible.
718 +function mxchatShouldAutofocusAfterReply() {
719 + var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
720 + if (pref === 'on') return true;
721 + if (pref === 'off') return false;
722 + try {
723 + return !window.matchMedia('(pointer: coarse)').matches;
724 + } catch (err) {
725 + return true;
726 + }
727 +}
728 +
623 729 function enableChatInput(botId) {
624 730 botId = botId || 'default';
625 731 var chatInput = getElementDOM(botId, 'chat-input');
626 732 var sendButton = getElementDOM(botId, 'send-button');
@@ -626,9 +732,11 @@
626 732 var sendButton = getElementDOM(botId, 'send-button');
627 733 if (chatInput) {
628 734 chatInput.disabled = false;
629 735 chatInput.style.opacity = '1';
630 - chatInput.focus();
736 + if (mxchatShouldAutofocusAfterReply()) {
737 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 + }
631 739 }
632 740 if (sendButton) {
633 741 sendButton.disabled = false;
634 742 sendButton.style.opacity = '1';
@@ -739,9 +847,12 @@
739 847 if (typeof customMxChatFilter === 'function') {
740 848 message = customMxChatFilter(message, "prompt");
741 849 }
742 850
743 - if (message) {
851 + // Whitespace-only is empty: nothing is sent and nothing is disabled. When
852 + // there IS a message the original string (spaces and all) is what goes out,
853 + // so the transcript is unchanged (plan b67fd5).
854 + if (message && String(message).trim() !== '') {
744 855 // Don't disable input in live agent mode - let users chat freely
745 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
746 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
747 858 if (!isAgentMode) {
@@ -763,9 +874,11 @@
763 874
764 875 // Check if streaming is enabled AND supported for this model
765 876 if (shouldUseStreaming(currentModel)) {
766 877 callMxChatStream(message, function(response) {
767 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
878 + // Content is final: releasing aria-busy lets the live region
879 + // announce the completed reply once (plan 67f126).
880 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
768 881 }, botId);
769 882 } else {
770 883 callMxChat(message, function(response) {
771 884 replaceLastMessage("bot", response, '', [], botId);
@@ -783,8 +896,14 @@
783 896 if (typeof customMxChatFilter === 'function') {
784 897 message = customMxChatFilter(message, "prompt");
785 898 }
786 899
900 + // Nothing to send means nothing to disable (plan b67fd5): a caller handing
901 + // over an empty string must not lock the box the way Enter-on-empty did.
902 + if (!message || String(message).trim() === '') {
903 + return;
904 + }
905 +
787 906 // Don't disable input in live agent mode - let users chat freely
788 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
789 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
790 909 if (!isAgentMode) {
@@ -803,9 +922,10 @@
803 922
804 923 // Check if streaming is enabled AND supported for this model
805 924 if (shouldUseStreaming(currentModel)) {
806 925 callMxChatStream(message, function(response) {
807 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
926 + // Final content — release aria-busy so the reply announces once (67f126).
927 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
808 928 }, botId);
809 929 } else {
810 930 callMxChat(message, function(response) {
811 931 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -978,9 +1098,10 @@
978 1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
979 1099 var currentModel = mxchatChat.model || 'gpt-5.6-sol';
980 1100 if (shouldUseStreaming(currentModel)) {
981 1101 callMxChatStream(originalMessage, function(response) {
982 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1102 + // Final content — release aria-busy so the reply announces once (67f126).
1103 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
983 1104 }, botId);
984 1105 } else {
985 1106 callMxChat(originalMessage, function(response) {
986 1107 replaceLastMessage("bot", response, '', [], botId);
@@ -1350,8 +1471,17 @@
1350 1471 // Stash appendix html (e.g. video embed) for [DONE]
1351 1472 else if (json.append_html) {
1352 1473 pendingAppendHtml = json.append_html;
1353 1474 }
1475 + // Server-side final pass changed the assembled text
1476 + // (ffef6f: dead-link stripping) — swap the rendered
1477 + // bubble for the validated version. Arrives at most
1478 + // once, just before [DONE].
1479 + else if (json.replace_content) {
1480 + streamingStarted = true;
1481 + accumulatedContent = json.replace_content;
1482 + updateStreamingMessage(accumulatedContent, botId);
1483 + }
1354 1484 // Handle complete response in stream (fallback response)
1355 1485 else if (json.text || json.message || json.html) {
1356 1486 handleNonStreamResponse(json, callback, botId);
1357 1487 return;
@@ -1588,8 +1718,15 @@
1588 1718 var $chatBox = getElement(botId, 'chat-box');
1589 1719 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1590 1720
1591 1721 if (tempMessage.length) {
1722 + // aria-busy=true for the whole stream: the bubble is rewritten on
1723 + // every chunk, and without busy a polite live region announces those
1724 + // rewrites continuously. Flipped false once the reply is final, so
1725 + // assistive tech announces the completed message ONCE (plan 67f126).
1726 + if (tempMessage.attr('aria-busy') !== 'true') {
1727 + tempMessage.attr('aria-busy', 'true');
1728 + }
1592 1729 // Update existing message
1593 1730 tempMessage.html(formattedContent);
1594 1731 } else {
1595 1732 // Create new temporary message if it doesn't exist
@@ -1625,25 +1762,21 @@
1625 1762 if (this.__mxchatStopJustShown) return;
1626 1763 mxchatStopStreaming(botId);
1627 1764 return;
1628 1765 }
1629 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1630 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1631 - disableChatInput(botId);
1632 - }
1766 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1633 1767 sendMessage(botId);
1634 1768 });
1635 1769
1636 -// Override enter key handler (using event delegation)
1770 +// Enter sends, Shift+Enter inserts a newline (using event delegation).
1771 +// sendMessage() owns the disable: it greys the box only when a real message is
1772 +// about to go out. Disabling here first left the widget locked for good after
1773 +// Enter on an empty box - the send returned early and no reply path ever
1774 +// re-enabled it (plan b67fd5).
1637 1775 $(document).on('keypress', '.chat-input', function(e) {
1638 1776 if (e.which == 13 && !e.shiftKey) {
1639 1777 e.preventDefault();
1640 - var botId = getBotIdFromElement(this);
1641 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1642 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1643 - disableChatInput(botId);
1644 - }
1645 - sendMessage(botId);
1778 + sendMessage(getBotIdFromElement(this));
1646 1779 }
1647 1780 });
1648 1781
1649 1782 // Chat input character counter + soft limit feedback (plan 7091a2).
@@ -2083,9 +2216,11 @@
2083 2216
2084 2217 messageDiv.html(fullMessage);
2085 2218
2086 2219 if (isTemporary) {
2087 - messageDiv.addClass('temporary-message');
2220 + // In-flight bubble: hold aria-busy so the live region stays quiet
2221 + // until the content is finalized (plan 67f126).
2222 + messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
2088 2223 }
2089 2224
2090 2225 // Append to the correct chatbot instance's chat-box
2091 2226 var $chatBox = getElement(botId, 'chat-box');
@@ -2207,13 +2342,16 @@
2207 2342
2208 2343 if (images.length > 0) {
2209 2344 fullMessage += '<div class="image-gallery" dir="auto">';
2210 2345 images.forEach(img => {
2346 + const safeTitle = sanitizeUserInput(img.title);
2347 + const safeUrl = escapeAttr(img.image_url);
2348 + const safeThumbnail = escapeAttr(img.thumbnail_url);
2211 2349 fullMessage += `
2212 2350 <div style="margin-bottom: 10px;">
2213 - <strong>${img.title}</strong><br>
2214 - <a href="${img.image_url}" target="_blank">
2215 - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" />
2351 + <strong>${safeTitle}</strong><br>
2352 + <a href="${safeUrl}" target="_blank">
2353 + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2216 2354 </a>
2217 2355 </div>`;
2218 2356 });
2219 2357 fullMessage += '</div>';
@@ -2220,13 +2358,16 @@
2220 2358 }
2221 2359
2222 2360 if (lastMessageDiv.length) {
2223 2361 // Replace content immediately to prevent visual gap between thinking dots and response
2362 + // aria-busy released AFTER the final content is set, so the live region
2363 + // announces the finished message once (plan 67f126).
2224 2364 lastMessageDiv
2225 2365 .html(fullMessage)
2226 2366 .removeClass('bot-message user-message temporary-message')
2227 2367 .addClass(messageClass)
2228 - .attr('dir', 'auto');
2368 + .attr('dir', 'auto')
2369 + .attr('aria-busy', 'false');
2229 2370
2230 2371 // Only apply inline colors if AI theme is not active (let CSS handle it)
2231 2372 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2232 2373 if (!skipColors) {
@@ -2286,10 +2427,15 @@
2286 2427 var botMessageFontColor = mxchatChat.bot_message_font_color;
2287 2428 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2288 2429
2289 2430 // Build thinking dots HTML - skip inline colors if AI theme is active
2431 + // The dots are decorative; the sr-only span is what the live region
2432 + // announces for the waiting state (plan 67f126). Server-localized
2433 + // string — safe to inject (esc_html__ output, no user content).
2290 2434 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2291 - var thinkingHtml = '<div class="thinking-dots-container">' +
2435 + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2436 + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2437 + '<div class="thinking-dots-container" aria-hidden="true">' +
2292 2438 '<div class="thinking-dots">' +
2293 2439 '<span class="dot"' + dotStyle + '></span>' +
2294 2440 '<span class="dot"' + dotStyle + '></span>' +
2295 2441 '<span class="dot"' + dotStyle + '></span>' +
@@ -2324,27 +2470,42 @@
2324 2470 // Check for % followed by exactly 2 hex digits
2325 2471 return /%[0-9a-fA-F]{2}/.test(url);
2326 2472 }
2327 2473
2328 - // Helper function to safely encode URLs only if needed
2474 + // Helper function to safely encode URLs only if needed, then make the
2475 + // result attribute-safe. "Don't double-encode an already-encoded URL"
2476 + // used to mean "return it untouched" — so a literal quote riding next
2477 + // to a %20 reached href="…" intact and closed the attribute
2478 + // (plan 2db182). Every href this function feeds now goes through
2479 + // escapeAttr(), whether or not the URL was already percent-encoded.
2329 2480 function safeEncodeUrl(url) {
2330 - // If URL already contains encoded characters, return as-is
2331 - if (isUrlEncoded(url)) {
2332 - return url;
2333 - }
2334 - // Otherwise, encode it
2335 - return encodeURI(url);
2481 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2482 + return escapeAttr(encoded);
2336 2483 }
2337 2484
2338 - // Process markdown headers FIRST
2339 - let processedText = formatMarkdownHeaders(inputText);
2340 -
2485 + // Take backslash escapes and code (fenced + inline) out of the text FIRST
2486 + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2487 + // backtick could not protect an underscore and \_ was not an escape at
2488 + // all; the header rule also ran over fenced code. Each escape becomes a
2489 + // placeholder carrying its literal character, each code span a
2490 + // placeholder carrying its rendered HTML; both are restored below, after
2491 + // the styling rules have run, so nothing downstream changes.
2492 + const literals = protectLiterals(inputText);
2493 + let processedText = literals.text;
2494 +
2495 + // Process markdown headers
2496 + processedText = formatMarkdownHeaders(processedText);
2497 +
2341 2498 // Process text styling (bold, italic, strikethrough)
2342 2499 processedText = formatTextStyling(processedText);
2343 -
2344 - // Process code blocks BEFORE processing links
2500 +
2501 + // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2502 + // rendered inside protectLiterals) — still BEFORE processing links
2345 2503 processedText = formatCodeBlocks(processedText);
2346 2504
2505 + // Put the rendered code and the escaped characters back
2506 + processedText = literals.restore(processedText);
2507 +
2347 2508 // Process markdown tables BEFORE converting newlines to paragraphs
2348 2509 processedText = formatMarkdownTables(processedText);
2349 2510
2350 2511 // NOW convert to paragraphs
@@ -2356,10 +2517,13 @@
2356 2517 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2357 2518 // Clean the URL of any trailing punctuation
2358 2519 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2359 2520 const safeUrl = safeEncodeUrl(cleanUrl);
2521 + // The bracket body is the only link text that can carry angle
2522 + // brackets (the standalone matcher excludes them) — keep it text.
2523 + const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
2360 2524 // Return as a proper link without the brackets
2361 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2525 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2362 2526 });
2363 2527
2364 2528 // Process markdown links: [text](url) and [](url)
2365 2529 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2463,8 +2627,30 @@
2463 2627
2464 2628 return processedText;
2465 2629 }
2466 2630
2631 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2632 + // A single alternation keeps the two honest with each other: an escaped
2633 + // backtick is consumed as an escape before it can open a code span, and a
2634 + // code span is consumed whole so the escapes inside it stay verbatim.
2635 + // Placeholders are private-use characters — no underscore, asterisk,
2636 + // backtick, hash, pipe or bracket for a later rule to match.
2637 + function protectLiterals(text) {
2638 + const store = [];
2639 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2640 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2641 + return '\uE000' + (store.length - 1) + '\uE001';
2642 + });
2643 + return {
2644 + text: out,
2645 + restore: function(t) {
2646 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2647 + return store[Number(i)];
2648 + });
2649 + }
2650 + };
2651 + }
2652 +
2467 2653 function formatMarkdownHeaders(text) {
2468 2654 // Handle h1 to h6 headers
2469 2655 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2470 2656 const level = hashes.length;
@@ -2506,11 +2692,24 @@
2506 2692 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2507 2693 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2508 2694 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2509 2695
2510 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2511 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2512 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2696 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2697 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2698 + // text or a non-word character, the closer must precede the end or a
2699 + // non-word character, and the content may not start or end with a
2700 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2701 + // while "use _this_ word" still italicises. A token whose content is
2702 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2703 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2704 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2705 + // adjacent to another underscore or a word character.
2706 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2707 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2708 + return match;
2709 + }
2710 + return before + '<em>' + content + '</em>';
2711 + });
2513 2712
2514 2713 // Handle strikethrough (~~text~~)
2515 2714 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2516 2715
@@ -2644,8 +2843,24 @@
2644 2843 div.textContent = text;
2645 2844 return div.innerHTML;
2646 2845 }
2647 2846
2847 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2848 + // Percent-encodes the characters that can end or extend an attribute.
2849 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2850 + // never changes where a legitimate link resolves — encodeURI() already
2851 + // produces most of these, this just guarantees them for URLs that skip
2852 + // encodeURI() because they were percent-encoded on arrival.
2853 + function escapeAttr(url) {
2854 + return String(url)
2855 + .replace(/"/g, '%22')
2856 + .replace(/'/g, '%27')
2857 + .replace(/</g, '%3C')
2858 + .replace(/>/g, '%3E')
2859 + .replace(/`/g, '%60')
2860 + .replace(/ /g, '%20');
2861 + }
2862 +
2648 2863 function escapeHtml(unsafe) {
2649 2864 // Skip escaping if it's already escaped or contains HTML code block markup
2650 2865 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2651 2866 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -2989,9 +3204,30 @@
2989 3204 }
2990 3205
2991 3206 // Only process if there are actual messages
2992 3207 if (response.data.conversation.length > 0) {
2993 - // IMPORTANT: Clear existing messages before loading history
3208 + // Restored history must be SILENT to screen readers
3209 + // (plan 67f126): these are DOM additions inside the
3210 + // live region and would otherwise announce as if
3211 + // they just arrived. Lift aria-live for the batch
3212 + // repopulate, restore it after the browser has
3213 + // processed the mutations.
3214 + var mxLiveRegionEl = $chatBox.get(0);
3215 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3216 + if (mxLiveRegionEl) {
3217 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3218 + }
3219 +
3220 + // IMPORTANT: Clear existing messages before loading history.
3221 + // Detach the greeting first and put it back below —
3222 + // it is server-rendered and never stored in the
3223 + // transcript, so the old unconditional .empty()
3224 + // deleted it for the rest of the page life (plan
3225 + // a1a79b). Detach rather than rebuild: intro_message
3226 + // is not localized to JS, and this node already
3227 + // carries the per-bot inline colors and any
3228 + // {visitor_name} substitution already applied to it.
3229 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2994 3230 $chatBox.empty();
2995 3231
2996 3232 $.each(response.data.conversation, function(index, message) {
2997 3233 // Skip agent messages if persistence is off
@@ -3028,16 +3264,28 @@
3028 3264 var content = message.content;
3029 3265 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3030 3266 content = decodeHTMLEntities(content);
3031 3267
3268 + // A visitor's own rows get the same treatment as the
3269 + // live turn (appendMessage escapes user text before
3270 + // linkify). Without this a replayed user message came
3271 + // back as live markup after a refresh (plan 2db182).
3272 + // Bot/agent rows are trusted HTML and stay as they were.
3273 + if (message.role === 'user') {
3274 + content = sanitizeUserInput(content);
3275 + }
3276 +
3032 3277 // Skip linkify for messages containing structured HTML
3033 3278 // (forms, product cards, galleries, etc.) to avoid
3034 3279 // markdown formatting corrupting HTML attributes
3035 - // (e.g. underscores in name="field_name" becoming <em> tags)
3036 - if (content.includes("mxchat-product-card") ||
3037 - content.includes("mxchat-image-gallery") ||
3038 - content.includes("mxchat-featured-products") ||
3039 - content.includes("mxchat-youtube-embed") ||
3280 + // (e.g. underscores in name="field_name" becoming <em> tags).
3281 + // One family check instead of a per-card literal list: any
3282 + // element carrying an mxchat- prefixed class is MxChat-generated
3283 + // structured markup and replays raw. The old list drifted every
3284 + // time an add-on minted a new card class — the filtered-search
3285 + // card ("mxchat-filtered-product-card") missed it and replayed
3286 + // through linkify as visible markup.
3287 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3040 3288 content.includes("<form") ||
3041 3289 content.includes("<input") ||
3042 3290 content.includes("<select") ||
3043 3291 content.includes("<textarea")) {
@@ -3055,12 +3303,29 @@
3055 3303 instance.processedMessageIds.add(message.id);
3056 3304 }
3057 3305 });
3058 3306
3059 - // Only append messages and scroll if we have content
3307 + // Only append messages and scroll if we have content.
3308 + // Greeting goes back FIRST, above the restored
3309 + // history: "Hello — [earlier conversation]" is the
3310 + // natural reading and matches the order a fresh
3311 + // visitor sees (plan a1a79b).
3312 + if ($mxIntro && $mxIntro.length) {
3313 + $chatBox.append($mxIntro);
3314 + }
3060 3315 $chatBox.append($fragment);
3061 3316 scrollToBottom(botId, true);
3062 3317
3318 + // Re-attach live semantics AFTER the rehydration
3319 + // mutations have been processed with the region off
3320 + // (plan 67f126). Restoring later announces nothing
3321 + // retroactively; new turns announce normally.
3322 + if (mxLiveRegionEl) {
3323 + setTimeout(function() {
3324 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3325 + }, 200);
3326 + }
3327 +
3063 3328 // Collapse quick questions if we have conversation history
3064 3329 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3065 3330 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3066 3331 collapseQuickQuestions(botId);
@@ -3392,8 +3657,9 @@
3392 3657 $(this).addClass('hidden').attr('aria-expanded', 'true');
3393 3658 $badge.hide(); // Hide notification when opening chat
3394 3659 disableScroll();
3395 3660 $preChat.fadeOut(250);
3661 + mxchatFireOpened(botId);
3396 3662
3397 3663 // First open per page load: re-fetch behavior settings in case
3398 3664 // this page's inline values came from a stale full-page cache
3399 3665 // (plan-32db95). Idempotent — later opens are a no-op.
@@ -3662,9 +3928,12 @@
3662 3928 $('.chat-toolbar').show();
3663 3929 } else {
3664 3930 $('.chat-toolbar').hide();
3665 3931 }
3666 -
3932 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3933 + MxChatInstances.toolbarReady = true;
3934 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3935 +
3667 3936 // Apply toolbar icon colors
3668 3937 const toolbarElements = [
3669 3938 '#mxchat-chatbot .toolbar-btn svg',
3670 3939 '#mxchat-chatbot .active-pdf-name',
@@ -3789,10 +4058,13 @@
3789 4058 function replaceVisitorNamePlaceholder(botId, visitorName) {
3790 4059 var chatBox = getElementDOM(botId, 'chat-box');
3791 4060 if (!chatBox) return;
3792 4061
3793 - // Find the first bot message (intro message)
3794 - var introMessage = chatBox.querySelector('.bot-message');
4062 + // Find the greeting by its marker, not by position (plan a1a79b) —
4063 + // after a persistence restore the first .bot-message is a restored
4064 + // reply, and {visitor_name} was being substituted into that instead.
4065 + // Positional fallback for HTML cached before this release only.
4066 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3795 4067 if (!introMessage) return;
3796 4068
3797 4069 var messageContent = introMessage.querySelector('div[dir="auto"]');
3798 4070 if (!messageContent) return;
@@ -3936,14 +4208,25 @@
3936 4208 return response.json();
3937 4209 })
3938 4210 .then((data) => {
3939 4211 if (data.success) {
3940 - if (data.data.logged_in || data.data.email) {
4212 + // A success response means the server has already decided the
4213 + // required info is on file. With email optional (c0cfaf) that
4214 + // info can be a name and no address, so keying on data.email
4215 + // alone re-prompted a name-only visitor on every page load.
4216 + if (data.data.logged_in || data.data.email || data.data.name) {
3941 4217 showChatContainerForBot(botId);
3942 4218 } else {
3943 4219 showEmailFormForBot(botId);
3944 4220 }
3945 4221 } else {
4222 + // Pre-fill for a signed-in visitor being shown the form
4223 + // (c0cfaf). It arrives on this nocache'd response rather than
4224 + // in the page HTML, which is cacheable. Never overwrite what
4225 + // the visitor has already typed.
4226 + if (data.data) {
4227 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4228 + }
3946 4229 showEmailFormForBot(botId);
3947 4230 }
3948 4231 })
3949 4232 .catch((error) => {
@@ -3950,8 +4233,19 @@
3950 4233 showEmailFormForBot(botId);
3951 4234 });
3952 4235 }
3953 4236
4237 + function prefillEmailForm(botId, name, email) {
4238 + var nameInput = getElementDOM(botId, 'user-name');
4239 + var emailInput = getElementDOM(botId, 'user-email');
4240 + if (nameInput && name && !nameInput.value) {
4241 + nameInput.value = name;
4242 + }
4243 + if (emailInput && email && !emailInput.value) {
4244 + emailInput.value = email;
4245 + }
4246 + }
4247 +
3954 4248 // Event delegation for email form submission
3955 4249 $(document).on('submit', '.email-collection-form', function(e) {
3956 4250 e.preventDefault();
3957 4251 e.stopPropagation();
@@ -3964,23 +4258,34 @@
3964 4258 }
3965 4259
3966 4260 var emailInput = getElementDOM(botId, 'user-email');
3967 4261 var nameInput = getElementDOM(botId, 'user-name');
4262 + var consentInput = getElementDOM(botId, 'user-consent');
3968 4263 var userEmail = emailInput ? emailInput.value.trim() : '';
3969 4264 var userName = nameInput ? nameInput.value.trim() : '';
3970 4265 var sessionId = MxChatInstances.ensureSession(botId);
3971 4266
3972 - // Validate email
3973 - if (!userEmail) {
4267 + // Validate email. The address is optional when the owner turned
4268 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4269 + // still has to be a valid address. The server enforces both rules
4270 + // independently.
4271 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4272 +
4273 + if (emailRequired && !userEmail) {
3974 4274 showEmailError(botId, 'Please enter your email address.');
3975 4275 return false;
3976 4276 }
3977 4277
3978 - if (!isValidEmailAddress(userEmail)) {
4278 + if (userEmail && !isValidEmailAddress(userEmail)) {
3979 4279 showEmailError(botId, 'Please enter a valid email address.');
3980 4280 return false;
3981 4281 }
3982 4282
4283 + if (!emailRequired && !userEmail && !userName) {
4284 + showEmailError(botId, 'Please enter your name or email address.');
4285 + return false;
4286 + }
4287 +
3983 4288 // Validate name if field exists and has content
3984 4289 if (nameInput && userName && !isValidNameInput(userName)) {
3985 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3986 4291 return false;
@@ -3985,8 +4290,15 @@
3985 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3986 4291 return false;
3987 4292 }
3988 4293
4294 + // Consent checkbox (b062c4): backstop behind the native required
4295 + // attribute; the server enforces this independently either way.
4296 + if (consentInput && consentInput.required && !consentInput.checked) {
4297 + showEmailError(botId, 'Please tick the consent box to continue.');
4298 + return false;
4299 + }
4300 +
3989 4301 clearEmailError(botId);
3990 4302 setEmailSubmissionState(botId, true);
3991 4303
3992 4304 // Prepare form data
@@ -4000,8 +4312,14 @@
4000 4312 if (userName) {
4001 4313 formData.append('name', userName);
4002 4314 }
4003 4315
4316 + // Ticked/unticked both travel when the checkbox is rendered, so an
4317 + // optional-consent "no" is recorded as a decision, not an absence.
4318 + if (consentInput) {
4319 + formData.append('consent', consentInput.checked ? '1' : '0');
4320 + }
4321 +
4004 4322 fetch(mxchatChat.ajax_url, {
4005 4323 method: 'POST',
4006 4324 headers: {
4007 4325 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4123,8 +4441,9 @@
4123 4441 $chatbot.removeClass('hidden').addClass('visible');
4124 4442 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4125 4443 handlePreChatDismissal(botId);
4126 4444 disableScroll(); // Disable scroll when chatbot opens
4445 + mxchatFireOpened(botId);
4127 4446
4128 4447 // Load chat history for returning visitors (persistence)
4129 4448 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4130 4449 if (chatPersistenceEnabled) {
@@ -4290,8 +4609,15 @@
4290 4609 // Initialize chat visibility for all instances
4291 4610 $('.mxchat-chatbot-wrapper').each(function() {
4292 4611 var botId = $(this).data('bot-id') || 'default';
4293 4612 initializeChatVisibility(botId);
4613 + // An embedded widget is on screen from the first paint; a floating one
4614 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4615 + if (isEmbeddedBot(botId)) {
4616 + mxchatFireOpened(botId);
4617 + } else {
4618 + mxchatWatchForOpen(botId);
4619 + }
4294 4620 });
4295 4621
4296 4622 // Make functions globally available for add-ons
4297 4623 window.hasQuickQuestions = hasQuickQuestions;