PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
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 +536 -134 3.2.13 → 3.2.24 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)
@@ -396,9 +457,27 @@
396 457 if (parts.length == 2) return parts.pop().split(";").shift();
397 458 }
398 459
399 460 function generateSessionId() {
400 - return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
461 + // Session IDs function as the de-facto bearer token for an anonymous
462 + // chat, so generate them with a CSPRNG when available. Math.random is a
463 + // legacy fallback for ancient/sandboxed environments that lack
464 + // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
465 + // code pattern-matches on it). (plan-0c17b5)
466 + var rand;
467 + try {
468 + if (window.crypto && window.crypto.getRandomValues) {
469 + var buf = new Uint8Array(16); // 128 bits
470 + window.crypto.getRandomValues(buf);
471 + rand = Array.prototype.map.call(buf, function (b) {
472 + return ('0' + b.toString(16)).slice(-2);
473 + }).join('');
474 + }
475 + } catch (e) {}
476 + if (!rand) {
477 + rand = Math.random().toString(36).substr(2, 9); // legacy fallback
478 + }
479 + return 'mxchat_chat_' + rand;
401 480 }
402 481
403 482 // Legacy function - now delegates to instance manager
404 483 function getChatSession(botId) {
@@ -442,23 +521,28 @@
442 521 // ====================================
443 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
444 523 // ====================================
445 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 +
446 530 function getPageContext() {
447 531 // Check if contextual awareness is enabled
448 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
449 533 return null;
450 534 }
451 -
535 +
452 536 // Get page URL
453 537 const pageUrl = window.location.href;
454 -
538 +
455 539 // Get page title
456 540 const pageTitle = document.title || '';
457 -
541 +
458 542 // Get main content from the page
459 543 let pageContent = '';
460 -
544 +
461 545 // Try to get content from common content areas
462 546 const contentSelectors = [
463 547 'main',
464 548 '[role="main"]',
@@ -470,9 +554,9 @@
470 554 'article',
471 555 '#content',
472 556 '#main'
473 557 ];
474 -
558 +
475 559 let contentElement = null;
476 560 for (const selector of contentSelectors) {
477 561 contentElement = document.querySelector(selector);
478 562 if (contentElement) {
@@ -478,18 +562,31 @@
478 562 if (contentElement) {
479 563 break;
480 564 }
481 565 }
482 -
566 +
483 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
484 568 if (!contentElement) {
485 569 contentElement = document.body;
486 570 }
487 -
571 +
488 572 if (contentElement) {
489 - // Clone the element to avoid modifying the original
490 - const clone = contentElement.cloneNode(true);
491 -
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 +
492 589 // Remove unwanted elements
493 590 const unwantedSelectors = [
494 591 'header',
495 592 'footer',
@@ -507,16 +604,25 @@
507 604 '#floating-chatbot',
508 605 '#floating-chatbot-button',
509 606 '.mxchat',
510 607 '[class*="chat"]',
511 - '[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'
512 618 ];
513 -
619 +
514 620 unwantedSelectors.forEach(selector => {
515 621 const elements = clone.querySelectorAll(selector);
516 622 elements.forEach(el => el.remove());
517 623 });
518 -
624 +
519 625 // Extract MxChat context data attributes before getting text content
520 626 const contextData = [];
521 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
522 628 const contextValue = el.dataset.mxchatContext;
@@ -523,34 +629,34 @@
523 629 if (contextValue && contextValue.trim()) {
524 630 contextData.push(contextValue);
525 631 }
526 632 });
527 -
633 +
528 634 // Get text content and clean it up
529 635 pageContent = clone.textContent || clone.innerText || '';
530 -
636 +
531 637 // Add context data to page content if any were found
532 638 if (contextData.length > 0) {
533 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
534 640 }
535 -
641 +
536 642 // Clean up whitespace and limit length
537 643 pageContent = pageContent
538 644 .replace(/\s+/g, ' ')
539 645 .trim()
540 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;
541 656 }
542 -
543 - // Only return context if we have meaningful content
544 - if (!pageContent || pageContent.length < 50) {
545 - return null;
546 - }
547 -
548 - return {
549 - url: pageUrl,
550 - title: pageTitle,
551 - content: pageContent
552 - };
657 +
658 + return null;
553 659 }
554 660
555 661 // Track originating page when chat starts
556 662 function trackOriginatingPage() {
@@ -601,8 +707,26 @@
601 707 sendButton.style.pointerEvents = 'none';
602 708 }
603 709 }
604 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 +
605 729 function enableChatInput(botId) {
606 730 botId = botId || 'default';
607 731 var chatInput = getElementDOM(botId, 'chat-input');
608 732 var sendButton = getElementDOM(botId, 'send-button');
@@ -608,9 +732,11 @@
608 732 var sendButton = getElementDOM(botId, 'send-button');
609 733 if (chatInput) {
610 734 chatInput.disabled = false;
611 735 chatInput.style.opacity = '1';
612 - chatInput.focus();
736 + if (mxchatShouldAutofocusAfterReply()) {
737 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 + }
613 739 }
614 740 if (sendButton) {
615 741 sendButton.disabled = false;
616 742 sendButton.style.opacity = '1';
@@ -721,9 +847,12 @@
721 847 if (typeof customMxChatFilter === 'function') {
722 848 message = customMxChatFilter(message, "prompt");
723 849 }
724 850
725 - 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() !== '') {
726 855 // Don't disable input in live agent mode - let users chat freely
727 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
728 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
729 858 if (!isAgentMode) {
@@ -740,14 +869,16 @@
740 869 }
741 870 appendThinkingMessage(botId);
742 871 scrollToBottom(botId);
743 872
744 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
873 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
745 874
746 875 // Check if streaming is enabled AND supported for this model
747 876 if (shouldUseStreaming(currentModel)) {
748 877 callMxChatStream(message, function(response) {
749 - 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');
750 881 }, botId);
751 882 } else {
752 883 callMxChat(message, function(response) {
753 884 replaceLastMessage("bot", response, '', [], botId);
@@ -765,8 +896,14 @@
765 896 if (typeof customMxChatFilter === 'function') {
766 897 message = customMxChatFilter(message, "prompt");
767 898 }
768 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 +
769 906 // Don't disable input in live agent mode - let users chat freely
770 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
771 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
772 909 if (!isAgentMode) {
@@ -780,14 +917,15 @@
780 917 }
781 918 appendThinkingMessage(botId);
782 919 scrollToBottom(botId);
783 920
784 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
921 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
785 922
786 923 // Check if streaming is enabled AND supported for this model
787 924 if (shouldUseStreaming(currentModel)) {
788 925 callMxChatStream(message, function(response) {
789 - 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');
790 928 }, botId);
791 929 } else {
792 930 callMxChat(message, function(response) {
793 931 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -957,12 +1095,13 @@
957 1095 // Re-send the original message with the new session (user message is already displayed)
958 1096 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
959 1097 if (originalMessage) {
960 1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
961 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1099 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
962 1100 if (shouldUseStreaming(currentModel)) {
963 1101 callMxChatStream(originalMessage, function(response) {
964 - 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');
965 1104 }, botId);
966 1105 } else {
967 1106 callMxChat(originalMessage, function(response) {
968 1107 replaceLastMessage("bot", response, '', [], botId);
@@ -1110,9 +1249,9 @@
1110 1249
1111 1250 // Store the message in case we need to retry after session reset
1112 1251 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1113 1252
1114 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1253 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
1115 1254 if (!isStreamingSupported(currentModel)) {
1116 1255 callMxChat(message, callback, botId);
1117 1256 return;
1118 1257 }
@@ -1165,9 +1304,28 @@
1165 1304
1166 1305 let accumulatedContent = '';
1167 1306 let testingDataReceived = false;
1168 1307 let streamingStarted = false;
1308 + // Server-pushed html to append as its OWN bot bubble once the stream
1309 + // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1310 + // deferred to the end of the stream so the embed always lands BELOW the
1311 + // streamed text.
1312 + let pendingAppendHtml = '';
1169 1313
1314 + // Render the stashed appendix as its own bot bubble below the streamed
1315 + // text — mirrors how it is saved in the transcript, so history replays
1316 + // identically. Called from BOTH ends of a stream: the [DONE] line and
1317 + // the stream simply closing (the OpenAI Responses path and Gemini end
1318 + // without a [DONE] line). The stash is cleared, so a stream that sends
1319 + // [DONE] and then closes renders it once. Any future appendix-type
1320 + // event belongs in here, not in one of the two branches.
1321 + function flushAppendHtml() {
1322 + if (pendingAppendHtml) {
1323 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1324 + pendingAppendHtml = '';
1325 + }
1326 + }
1327 +
1170 1328 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1171 1329 // The Stop control (send button swapped in place) aborts both the read
1172 1330 // loop and the underlying request.
1173 1331 var streamControl = { controller: new AbortController(), aborted: false };
@@ -1248,8 +1406,10 @@
1248 1406
1249 1407 // Re-enable chat input when stream ends with content
1250 1408 enableChatInput(botId);
1251 1409
1410 + flushAppendHtml();
1411 +
1252 1412 // Scroll the user's last message to the top now that the
1253 1413 // bot's full reply has rendered (gives max reading room).
1254 1414 var $chatBoxDone = getElement(botId, 'chat-box');
1255 1415 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
@@ -1280,8 +1440,10 @@
1280 1440
1281 1441 // Re-enable chat input after streaming completes
1282 1442 enableChatInput(botId);
1283 1443
1444 + flushAppendHtml();
1445 +
1284 1446 // Scroll the user's last message to the top now
1285 1447 // that the bot's full reply has rendered.
1286 1448 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1287 1449 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1315,8 +1477,21 @@
1315 1477 streamingStarted = true;
1316 1478 accumulatedContent += json.content;
1317 1479 updateStreamingMessage(accumulatedContent, botId);
1318 1480 }
1481 + // Stash appendix html (e.g. video embed) for [DONE]
1482 + else if (json.append_html) {
1483 + pendingAppendHtml = json.append_html;
1484 + }
1485 + // Server-side final pass changed the assembled text
1486 + // (ffef6f: dead-link stripping) — swap the rendered
1487 + // bubble for the validated version. Arrives at most
1488 + // once, just before [DONE].
1489 + else if (json.replace_content) {
1490 + streamingStarted = true;
1491 + accumulatedContent = json.replace_content;
1492 + updateStreamingMessage(accumulatedContent, botId);
1493 + }
1319 1494 // Handle complete response in stream (fallback response)
1320 1495 else if (json.text || json.message || json.html) {
1321 1496 handleNonStreamResponse(json, callback, botId);
1322 1497 return;
@@ -1418,9 +1593,9 @@
1418 1593 // Re-send the original message with the new session (user message is already displayed)
1419 1594 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1420 1595 if (originalMessage) {
1421 1596 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1422 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1597 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1423 1598 if (shouldUseStreaming(currentModel)) {
1424 1599 callMxChatStream(originalMessage, callback, botId);
1425 1600 } else {
1426 1601 callMxChat(originalMessage, callback, botId);
@@ -1553,8 +1728,15 @@
1553 1728 var $chatBox = getElement(botId, 'chat-box');
1554 1729 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1555 1730
1556 1731 if (tempMessage.length) {
1732 + // aria-busy=true for the whole stream: the bubble is rewritten on
1733 + // every chunk, and without busy a polite live region announces those
1734 + // rewrites continuously. Flipped false once the reply is final, so
1735 + // assistive tech announces the completed message ONCE (plan 67f126).
1736 + if (tempMessage.attr('aria-busy') !== 'true') {
1737 + tempMessage.attr('aria-busy', 'true');
1738 + }
1557 1739 // Update existing message
1558 1740 tempMessage.html(formattedContent);
1559 1741 } else {
1560 1742 // Create new temporary message if it doesn't exist
@@ -1590,25 +1772,21 @@
1590 1772 if (this.__mxchatStopJustShown) return;
1591 1773 mxchatStopStreaming(botId);
1592 1774 return;
1593 1775 }
1594 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1595 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1596 - disableChatInput(botId);
1597 - }
1776 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1598 1777 sendMessage(botId);
1599 1778 });
1600 1779
1601 -// Override enter key handler (using event delegation)
1780 +// Enter sends, Shift+Enter inserts a newline (using event delegation).
1781 +// sendMessage() owns the disable: it greys the box only when a real message is
1782 +// about to go out. Disabling here first left the widget locked for good after
1783 +// Enter on an empty box - the send returned early and no reply path ever
1784 +// re-enabled it (plan b67fd5).
1602 1785 $(document).on('keypress', '.chat-input', function(e) {
1603 1786 if (e.which == 13 && !e.shiftKey) {
1604 1787 e.preventDefault();
1605 - var botId = getBotIdFromElement(this);
1606 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1607 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1608 - disableChatInput(botId);
1609 - }
1610 - sendMessage(botId);
1788 + sendMessage(getBotIdFromElement(this));
1611 1789 }
1612 1790 });
1613 1791
1614 1792 // Chat input character counter + soft limit feedback (plan 7091a2).
@@ -2048,9 +2226,11 @@
2048 2226
2049 2227 messageDiv.html(fullMessage);
2050 2228
2051 2229 if (isTemporary) {
2052 - messageDiv.addClass('temporary-message');
2230 + // In-flight bubble: hold aria-busy so the live region stays quiet
2231 + // until the content is finalized (plan 67f126).
2232 + messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
2053 2233 }
2054 2234
2055 2235 // Append to the correct chatbot instance's chat-box
2056 2236 var $chatBox = getElement(botId, 'chat-box');
@@ -2081,8 +2261,56 @@
2081 2261 // Error rendering message - silently continue
2082 2262 }
2083 2263 }
2084 2264
2265 +// Link clicks in bot and agent messages: the click is recorded, the browser
2266 +// follows the anchor itself. Navigation must not wait for the tracking
2267 +// request: a window.open() made from that request's callback is outside the
2268 +// visitor's tap, and Safari's pop-up blocker drops it without a message.
2269 +
2270 +// Give a tracked anchor the target it should open in, and noopener on a new tab.
2271 +function prepareTrackedLink($link) {
2272 + if (linkTarget === '_blank' && $link.attr('target') !== '_blank') {
2273 + $link.attr('target', '_blank');
2274 + }
2275 + if ($link.attr('target') === '_blank') {
2276 + var rel = $link.attr('rel') || '';
2277 + if (!/(^|\s)noopener(\s|$)/.test(rel)) {
2278 + $link.attr('rel', $.trim(rel + ' noopener'));
2279 + }
2280 + }
2281 +}
2282 +
2283 +// Record one click, fire-and-forget. sendBeacon survives the page unloading
2284 +// when the link opens in the same tab.
2285 +function trackLinkClick(href, messageContext, botId) {
2286 + var fields = {
2287 + action: 'mxchat_track_url_click',
2288 + session_id: getChatSession(botId),
2289 + url: href,
2290 + message_context: messageContext,
2291 + nonce: mxchatChat.nonce
2292 + };
2293 + try {
2294 + if (navigator.sendBeacon && typeof FormData !== 'undefined') {
2295 + var body = new FormData();
2296 + $.each(fields, function(name, value) {
2297 + body.append(name, value == null ? '' : value);
2298 + });
2299 + if (navigator.sendBeacon(mxchatChat.ajax_url, body)) {
2300 + return;
2301 + }
2302 + }
2303 + } catch (error) {
2304 + // Fall through to the plain request
2305 + }
2306 + $.ajax({
2307 + url: mxchatChat.ajax_url,
2308 + type: 'POST',
2309 + data: fields
2310 + });
2311 +}
2312 +
2085 2313 // Helper function to attach link tracking with proper event handling
2086 2314 function attachLinkTracking(messageDiv, messageText, botId) {
2087 2315 botId = botId || 'default';
2088 2316 // Use a slight delay to ensure DOM is ready
@@ -2100,11 +2328,13 @@
2100 2328 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
2101 2329 // Remove any existing click handlers first
2102 2330 $link.off('click.tracking');
2103 2331
2332 + // The anchor carries its own target: the browser follows it
2333 + prepareTrackedLink($link);
2334 +
2104 2335 // Add new click handler with namespace
2105 2336 $link.on('click.tracking', function(e) {
2106 - e.preventDefault();
2107 2337 e.stopPropagation();
2108 2338
2109 2339 const messageContext = typeof messageText === 'string'
2110 2340 ? messageText.substring(0, 200)
@@ -2109,30 +2339,10 @@
2109 2339 const messageContext = typeof messageText === 'string'
2110 2340 ? messageText.substring(0, 200)
2111 2341 : '';
2112 2342
2113 - // Track the click
2114 - $.ajax({
2115 - url: mxchatChat.ajax_url,
2116 - type: 'POST',
2117 - data: {
2118 - action: 'mxchat_track_url_click',
2119 - session_id: getChatSession(botId),
2120 - url: originalHref,
2121 - message_context: messageContext,
2122 - nonce: mxchatChat.nonce
2123 - },
2124 - complete: function() {
2125 - // Always redirect, even if tracking fails
2126 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
2127 - window.open(originalHref, '_blank');
2128 - } else {
2129 - window.location.href = originalHref;
2130 - }
2131 - }
2132 - });
2133 -
2134 - return false; // Extra insurance to prevent default
2343 + prepareTrackedLink($link);
2344 + trackLinkClick(originalHref, messageContext, botId);
2135 2345 });
2136 2346 }
2137 2347 });
2138 2348 }, 100); // Small delay to ensure DOM is ready
@@ -2172,13 +2382,16 @@
2172 2382
2173 2383 if (images.length > 0) {
2174 2384 fullMessage += '<div class="image-gallery" dir="auto">';
2175 2385 images.forEach(img => {
2386 + const safeTitle = sanitizeUserInput(img.title);
2387 + const safeUrl = escapeAttr(img.image_url);
2388 + const safeThumbnail = escapeAttr(img.thumbnail_url);
2176 2389 fullMessage += `
2177 2390 <div style="margin-bottom: 10px;">
2178 - <strong>${img.title}</strong><br>
2179 - <a href="${img.image_url}" target="_blank">
2180 - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" />
2391 + <strong>${safeTitle}</strong><br>
2392 + <a href="${safeUrl}" target="_blank">
2393 + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2181 2394 </a>
2182 2395 </div>`;
2183 2396 });
2184 2397 fullMessage += '</div>';
@@ -2185,13 +2398,16 @@
2185 2398 }
2186 2399
2187 2400 if (lastMessageDiv.length) {
2188 2401 // Replace content immediately to prevent visual gap between thinking dots and response
2402 + // aria-busy released AFTER the final content is set, so the live region
2403 + // announces the finished message once (plan 67f126).
2189 2404 lastMessageDiv
2190 2405 .html(fullMessage)
2191 2406 .removeClass('bot-message user-message temporary-message')
2192 2407 .addClass(messageClass)
2193 - .attr('dir', 'auto');
2408 + .attr('dir', 'auto')
2409 + .attr('aria-busy', 'false');
2194 2410
2195 2411 // Only apply inline colors if AI theme is not active (let CSS handle it)
2196 2412 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2197 2413 if (!skipColors) {
@@ -2251,10 +2467,15 @@
2251 2467 var botMessageFontColor = mxchatChat.bot_message_font_color;
2252 2468 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2253 2469
2254 2470 // Build thinking dots HTML - skip inline colors if AI theme is active
2471 + // The dots are decorative; the sr-only span is what the live region
2472 + // announces for the waiting state (plan 67f126). Server-localized
2473 + // string — safe to inject (esc_html__ output, no user content).
2255 2474 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2256 - var thinkingHtml = '<div class="thinking-dots-container">' +
2475 + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2476 + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2477 + '<div class="thinking-dots-container" aria-hidden="true">' +
2257 2478 '<div class="thinking-dots">' +
2258 2479 '<span class="dot"' + dotStyle + '></span>' +
2259 2480 '<span class="dot"' + dotStyle + '></span>' +
2260 2481 '<span class="dot"' + dotStyle + '></span>' +
@@ -2289,27 +2510,42 @@
2289 2510 // Check for % followed by exactly 2 hex digits
2290 2511 return /%[0-9a-fA-F]{2}/.test(url);
2291 2512 }
2292 2513
2293 - // Helper function to safely encode URLs only if needed
2514 + // Helper function to safely encode URLs only if needed, then make the
2515 + // result attribute-safe. "Don't double-encode an already-encoded URL"
2516 + // used to mean "return it untouched" — so a literal quote riding next
2517 + // to a %20 reached href="…" intact and closed the attribute
2518 + // (plan 2db182). Every href this function feeds now goes through
2519 + // escapeAttr(), whether or not the URL was already percent-encoded.
2294 2520 function safeEncodeUrl(url) {
2295 - // If URL already contains encoded characters, return as-is
2296 - if (isUrlEncoded(url)) {
2297 - return url;
2298 - }
2299 - // Otherwise, encode it
2300 - return encodeURI(url);
2521 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2522 + return escapeAttr(encoded);
2301 2523 }
2302 2524
2303 - // Process markdown headers FIRST
2304 - let processedText = formatMarkdownHeaders(inputText);
2305 -
2525 + // Take backslash escapes and code (fenced + inline) out of the text FIRST
2526 + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2527 + // backtick could not protect an underscore and \_ was not an escape at
2528 + // all; the header rule also ran over fenced code. Each escape becomes a
2529 + // placeholder carrying its literal character, each code span a
2530 + // placeholder carrying its rendered HTML; both are restored below, after
2531 + // the styling rules have run, so nothing downstream changes.
2532 + const literals = protectLiterals(inputText);
2533 + let processedText = literals.text;
2534 +
2535 + // Process markdown headers
2536 + processedText = formatMarkdownHeaders(processedText);
2537 +
2306 2538 // Process text styling (bold, italic, strikethrough)
2307 2539 processedText = formatTextStyling(processedText);
2308 -
2309 - // Process code blocks BEFORE processing links
2540 +
2541 + // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2542 + // rendered inside protectLiterals) — still BEFORE processing links
2310 2543 processedText = formatCodeBlocks(processedText);
2311 2544
2545 + // Put the rendered code and the escaped characters back
2546 + processedText = literals.restore(processedText);
2547 +
2312 2548 // Process markdown tables BEFORE converting newlines to paragraphs
2313 2549 processedText = formatMarkdownTables(processedText);
2314 2550
2315 2551 // NOW convert to paragraphs
@@ -2321,10 +2557,13 @@
2321 2557 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2322 2558 // Clean the URL of any trailing punctuation
2323 2559 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2324 2560 const safeUrl = safeEncodeUrl(cleanUrl);
2561 + // The bracket body is the only link text that can carry angle
2562 + // brackets (the standalone matcher excludes them) — keep it text.
2563 + const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
2325 2564 // Return as a proper link without the brackets
2326 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2565 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2327 2566 });
2328 2567
2329 2568 // Process markdown links: [text](url) and [](url)
2330 2569 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2428,8 +2667,30 @@
2428 2667
2429 2668 return processedText;
2430 2669 }
2431 2670
2671 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2672 + // A single alternation keeps the two honest with each other: an escaped
2673 + // backtick is consumed as an escape before it can open a code span, and a
2674 + // code span is consumed whole so the escapes inside it stay verbatim.
2675 + // Placeholders are private-use characters — no underscore, asterisk,
2676 + // backtick, hash, pipe or bracket for a later rule to match.
2677 + function protectLiterals(text) {
2678 + const store = [];
2679 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2680 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2681 + return '\uE000' + (store.length - 1) + '\uE001';
2682 + });
2683 + return {
2684 + text: out,
2685 + restore: function(t) {
2686 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2687 + return store[Number(i)];
2688 + });
2689 + }
2690 + };
2691 + }
2692 +
2432 2693 function formatMarkdownHeaders(text) {
2433 2694 // Handle h1 to h6 headers
2434 2695 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2435 2696 const level = hashes.length;
@@ -2471,11 +2732,24 @@
2471 2732 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2472 2733 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2473 2734 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2474 2735
2475 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2476 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2477 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2736 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2737 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2738 + // text or a non-word character, the closer must precede the end or a
2739 + // non-word character, and the content may not start or end with a
2740 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2741 + // while "use _this_ word" still italicises. A token whose content is
2742 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2743 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2744 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2745 + // adjacent to another underscore or a word character.
2746 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2747 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2748 + return match;
2749 + }
2750 + return before + '<em>' + content + '</em>';
2751 + });
2478 2752
2479 2753 // Handle strikethrough (~~text~~)
2480 2754 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2481 2755
@@ -2609,8 +2883,24 @@
2609 2883 div.textContent = text;
2610 2884 return div.innerHTML;
2611 2885 }
2612 2886
2887 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2888 + // Percent-encodes the characters that can end or extend an attribute.
2889 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2890 + // never changes where a legitimate link resolves — encodeURI() already
2891 + // produces most of these, this just guarantees them for URLs that skip
2892 + // encodeURI() because they were percent-encoded on arrival.
2893 + function escapeAttr(url) {
2894 + return String(url)
2895 + .replace(/"/g, '%22')
2896 + .replace(/'/g, '%27')
2897 + .replace(/</g, '%3C')
2898 + .replace(/>/g, '%3E')
2899 + .replace(/`/g, '%60')
2900 + .replace(/ /g, '%20');
2901 + }
2902 +
2613 2903 function escapeHtml(unsafe) {
2614 2904 // Skip escaping if it's already escaped or contains HTML code block markup
2615 2905 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2616 2906 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -2954,9 +3244,30 @@
2954 3244 }
2955 3245
2956 3246 // Only process if there are actual messages
2957 3247 if (response.data.conversation.length > 0) {
2958 - // IMPORTANT: Clear existing messages before loading history
3248 + // Restored history must be SILENT to screen readers
3249 + // (plan 67f126): these are DOM additions inside the
3250 + // live region and would otherwise announce as if
3251 + // they just arrived. Lift aria-live for the batch
3252 + // repopulate, restore it after the browser has
3253 + // processed the mutations.
3254 + var mxLiveRegionEl = $chatBox.get(0);
3255 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3256 + if (mxLiveRegionEl) {
3257 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3258 + }
3259 +
3260 + // IMPORTANT: Clear existing messages before loading history.
3261 + // Detach the greeting first and put it back below —
3262 + // it is server-rendered and never stored in the
3263 + // transcript, so the old unconditional .empty()
3264 + // deleted it for the rest of the page life (plan
3265 + // a1a79b). Detach rather than rebuild: intro_message
3266 + // is not localized to JS, and this node already
3267 + // carries the per-bot inline colors and any
3268 + // {visitor_name} substitution already applied to it.
3269 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2959 3270 $chatBox.empty();
2960 3271
2961 3272 $.each(response.data.conversation, function(index, message) {
2962 3273 // Skip agent messages if persistence is off
@@ -2993,15 +3304,28 @@
2993 3304 var content = message.content;
2994 3305 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2995 3306 content = decodeHTMLEntities(content);
2996 3307
3308 + // A visitor's own rows get the same treatment as the
3309 + // live turn (appendMessage escapes user text before
3310 + // linkify). Without this a replayed user message came
3311 + // back as live markup after a refresh (plan 2db182).
3312 + // Bot/agent rows are trusted HTML and stay as they were.
3313 + if (message.role === 'user') {
3314 + content = sanitizeUserInput(content);
3315 + }
3316 +
2997 3317 // Skip linkify for messages containing structured HTML
2998 3318 // (forms, product cards, galleries, etc.) to avoid
2999 3319 // markdown formatting corrupting HTML attributes
3000 - // (e.g. underscores in name="field_name" becoming <em> tags)
3001 - if (content.includes("mxchat-product-card") ||
3002 - content.includes("mxchat-image-gallery") ||
3003 - content.includes("mxchat-featured-products") ||
3320 + // (e.g. underscores in name="field_name" becoming <em> tags).
3321 + // One family check instead of a per-card literal list: any
3322 + // element carrying an mxchat- prefixed class is MxChat-generated
3323 + // structured markup and replays raw. The old list drifted every
3324 + // time an add-on minted a new card class — the filtered-search
3325 + // card ("mxchat-filtered-product-card") missed it and replayed
3326 + // through linkify as visible markup.
3327 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3004 3328 content.includes("<form") ||
3005 3329 content.includes("<input") ||
3006 3330 content.includes("<select") ||
3007 3331 content.includes("<textarea")) {
@@ -3019,12 +3343,29 @@
3019 3343 instance.processedMessageIds.add(message.id);
3020 3344 }
3021 3345 });
3022 3346
3023 - // Only append messages and scroll if we have content
3347 + // Only append messages and scroll if we have content.
3348 + // Greeting goes back FIRST, above the restored
3349 + // history: "Hello — [earlier conversation]" is the
3350 + // natural reading and matches the order a fresh
3351 + // visitor sees (plan a1a79b).
3352 + if ($mxIntro && $mxIntro.length) {
3353 + $chatBox.append($mxIntro);
3354 + }
3024 3355 $chatBox.append($fragment);
3025 3356 scrollToBottom(botId, true);
3026 3357
3358 + // Re-attach live semantics AFTER the rehydration
3359 + // mutations have been processed with the region off
3360 + // (plan 67f126). Restoring later announces nothing
3361 + // retroactively; new turns announce normally.
3362 + if (mxLiveRegionEl) {
3363 + setTimeout(function() {
3364 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3365 + }, 200);
3366 + }
3367 +
3027 3368 // Collapse quick questions if we have conversation history
3028 3369 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3029 3370 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3030 3371 collapseQuickQuestions(botId);
@@ -3314,8 +3655,30 @@
3314 3655 e.stopPropagation();
3315 3656 var botId = getBotIdFromElement(this);
3316 3657 collapseQuickQuestions(botId);
3317 3658 });
3659 +
3660 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3661 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3662 +// Delegated so it also works for embeds restored from chat history.
3663 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3664 + e.preventDefault();
3665 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3666 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3667 + if (!videoId) {
3668 + return;
3669 + }
3670 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3671 + var $iframe = $('<iframe>', {
3672 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3673 + title: title,
3674 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3675 + allowfullscreen: true,
3676 + frameborder: 0
3677 + }).addClass('mxchat-youtube-iframe');
3678 + $wrap.addClass('mxchat-youtube-playing');
3679 + $(this).replaceWith($iframe);
3680 +});
3318 3681
3319 3682 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3320 3683 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3321 3684 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
@@ -3334,8 +3697,9 @@
3334 3697 $(this).addClass('hidden').attr('aria-expanded', 'true');
3335 3698 $badge.hide(); // Hide notification when opening chat
3336 3699 disableScroll();
3337 3700 $preChat.fadeOut(250);
3701 + mxchatFireOpened(botId);
3338 3702
3339 3703 // First open per page load: re-fetch behavior settings in case
3340 3704 // this page's inline values came from a stale full-page cache
3341 3705 // (plan-32db95). Idempotent — later opens are a no-op.
@@ -3604,9 +3968,12 @@
3604 3968 $('.chat-toolbar').show();
3605 3969 } else {
3606 3970 $('.chat-toolbar').hide();
3607 3971 }
3608 -
3972 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3973 + MxChatInstances.toolbarReady = true;
3974 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3975 +
3609 3976 // Apply toolbar icon colors
3610 3977 const toolbarElements = [
3611 3978 '#mxchat-chatbot .toolbar-btn svg',
3612 3979 '#mxchat-chatbot .active-pdf-name',
@@ -3731,10 +4098,13 @@
3731 4098 function replaceVisitorNamePlaceholder(botId, visitorName) {
3732 4099 var chatBox = getElementDOM(botId, 'chat-box');
3733 4100 if (!chatBox) return;
3734 4101
3735 - // Find the first bot message (intro message)
3736 - var introMessage = chatBox.querySelector('.bot-message');
4102 + // Find the greeting by its marker, not by position (plan a1a79b) —
4103 + // after a persistence restore the first .bot-message is a restored
4104 + // reply, and {visitor_name} was being substituted into that instead.
4105 + // Positional fallback for HTML cached before this release only.
4106 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3737 4107 if (!introMessage) return;
3738 4108
3739 4109 var messageContent = introMessage.querySelector('div[dir="auto"]');
3740 4110 if (!messageContent) return;
@@ -3878,14 +4248,25 @@
3878 4248 return response.json();
3879 4249 })
3880 4250 .then((data) => {
3881 4251 if (data.success) {
3882 - if (data.data.logged_in || data.data.email) {
4252 + // A success response means the server has already decided the
4253 + // required info is on file. With email optional (c0cfaf) that
4254 + // info can be a name and no address, so keying on data.email
4255 + // alone re-prompted a name-only visitor on every page load.
4256 + if (data.data.logged_in || data.data.email || data.data.name) {
3883 4257 showChatContainerForBot(botId);
3884 4258 } else {
3885 4259 showEmailFormForBot(botId);
3886 4260 }
3887 4261 } else {
4262 + // Pre-fill for a signed-in visitor being shown the form
4263 + // (c0cfaf). It arrives on this nocache'd response rather than
4264 + // in the page HTML, which is cacheable. Never overwrite what
4265 + // the visitor has already typed.
4266 + if (data.data) {
4267 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4268 + }
3888 4269 showEmailFormForBot(botId);
3889 4270 }
3890 4271 })
3891 4272 .catch((error) => {
@@ -3892,8 +4273,19 @@
3892 4273 showEmailFormForBot(botId);
3893 4274 });
3894 4275 }
3895 4276
4277 + function prefillEmailForm(botId, name, email) {
4278 + var nameInput = getElementDOM(botId, 'user-name');
4279 + var emailInput = getElementDOM(botId, 'user-email');
4280 + if (nameInput && name && !nameInput.value) {
4281 + nameInput.value = name;
4282 + }
4283 + if (emailInput && email && !emailInput.value) {
4284 + emailInput.value = email;
4285 + }
4286 + }
4287 +
3896 4288 // Event delegation for email form submission
3897 4289 $(document).on('submit', '.email-collection-form', function(e) {
3898 4290 e.preventDefault();
3899 4291 e.stopPropagation();
@@ -3906,23 +4298,34 @@
3906 4298 }
3907 4299
3908 4300 var emailInput = getElementDOM(botId, 'user-email');
3909 4301 var nameInput = getElementDOM(botId, 'user-name');
4302 + var consentInput = getElementDOM(botId, 'user-consent');
3910 4303 var userEmail = emailInput ? emailInput.value.trim() : '';
3911 4304 var userName = nameInput ? nameInput.value.trim() : '';
3912 4305 var sessionId = MxChatInstances.ensureSession(botId);
3913 4306
3914 - // Validate email
3915 - if (!userEmail) {
4307 + // Validate email. The address is optional when the owner turned
4308 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4309 + // still has to be a valid address. The server enforces both rules
4310 + // independently.
4311 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4312 +
4313 + if (emailRequired && !userEmail) {
3916 4314 showEmailError(botId, 'Please enter your email address.');
3917 4315 return false;
3918 4316 }
3919 4317
3920 - if (!isValidEmailAddress(userEmail)) {
4318 + if (userEmail && !isValidEmailAddress(userEmail)) {
3921 4319 showEmailError(botId, 'Please enter a valid email address.');
3922 4320 return false;
3923 4321 }
3924 4322
4323 + if (!emailRequired && !userEmail && !userName) {
4324 + showEmailError(botId, 'Please enter your name or email address.');
4325 + return false;
4326 + }
4327 +
3925 4328 // Validate name if field exists and has content
3926 4329 if (nameInput && userName && !isValidNameInput(userName)) {
3927 4330 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3928 4331 return false;
@@ -3927,8 +4330,15 @@
3927 4330 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3928 4331 return false;
3929 4332 }
3930 4333
4334 + // Consent checkbox (b062c4): backstop behind the native required
4335 + // attribute; the server enforces this independently either way.
4336 + if (consentInput && consentInput.required && !consentInput.checked) {
4337 + showEmailError(botId, 'Please tick the consent box to continue.');
4338 + return false;
4339 + }
4340 +
3931 4341 clearEmailError(botId);
3932 4342 setEmailSubmissionState(botId, true);
3933 4343
3934 4344 // Prepare form data
@@ -3942,8 +4352,14 @@
3942 4352 if (userName) {
3943 4353 formData.append('name', userName);
3944 4354 }
3945 4355
4356 + // Ticked/unticked both travel when the checkbox is rendered, so an
4357 + // optional-consent "no" is recorded as a decision, not an absence.
4358 + if (consentInput) {
4359 + formData.append('consent', consentInput.checked ? '1' : '0');
4360 + }
4361 +
3946 4362 fetch(mxchatChat.ajax_url, {
3947 4363 method: 'POST',
3948 4364 headers: {
3949 4365 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4065,8 +4481,9 @@
4065 4481 $chatbot.removeClass('hidden').addClass('visible');
4066 4482 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4067 4483 handlePreChatDismissal(botId);
4068 4484 disableScroll(); // Disable scroll when chatbot opens
4485 + mxchatFireOpened(botId);
4069 4486
4070 4487 // Load chat history for returning visitors (persistence)
4071 4488 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4072 4489 if (chatPersistenceEnabled) {
@@ -4173,14 +4590,8 @@
4173 4590 if (messageDiv.length > 0) {
4174 4591 const originalHref = $link.attr('href');
4175 4592
4176 4593 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
4177 - e.preventDefault();
4178 - e.stopPropagation();
4179 -
4180 - // Mark as tracked
4181 - $link.attr('data-tracked', 'true');
4182 -
4183 4594 // Get bot ID from the chat box context
4184 4595 var botId = getBotIdFromElement(this);
4185 4596
4186 4597 // Get message context from the message div
@@ -4185,28 +4596,12 @@
4185 4596
4186 4597 // Get message context from the message div
4187 4598 const messageText = messageDiv.text().substring(0, 200);
4188 4599
4189 - $.ajax({
4190 - url: mxchatChat.ajax_url,
4191 - type: 'POST',
4192 - data: {
4193 - action: 'mxchat_track_url_click',
4194 - session_id: getChatSession(botId),
4195 - url: originalHref,
4196 - message_context: messageText,
4197 - nonce: mxchatChat.nonce
4198 - },
4199 - complete: function() {
4200 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
4201 - window.open(originalHref, '_blank');
4202 - } else {
4203 - window.location.href = originalHref;
4204 - }
4205 - }
4206 - });
4207 -
4208 - return false;
4600 + // Same path as attachLinkTracking(): set the target, record the
4601 + // click, and let the browser follow the anchor.
4602 + prepareTrackedLink($link);
4603 + trackLinkClick(originalHref, messageText, botId);
4209 4604 }
4210 4605 }
4211 4606 });
4212 4607
@@ -4232,8 +4627,15 @@
4232 4627 // Initialize chat visibility for all instances
4233 4628 $('.mxchat-chatbot-wrapper').each(function() {
4234 4629 var botId = $(this).data('bot-id') || 'default';
4235 4630 initializeChatVisibility(botId);
4631 + // An embedded widget is on screen from the first paint; a floating one
4632 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4633 + if (isEmbeddedBot(botId)) {
4634 + mxchatFireOpened(botId);
4635 + } else {
4636 + mxchatWatchForOpen(botId);
4637 + }
4236 4638 });
4237 4639
4238 4640 // Make functions globally available for add-ons
4239 4641 window.hasQuickQuestions = hasQuickQuestions;