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 +481 -85 3.2.12 → 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)
@@ -343,8 +404,20 @@
343 404 var id = $floating.attr('id') || '';
344 405 var match = id.match(/floating-chatbot-(.+)/);
345 406 if (match) return match[1];
346 407 }
408 + // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
409 + // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
410 + // .close-pre-chat-message button, which carries only a class and no id — miss both
411 + // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
412 + // and read the suffix. (closest() includes the element itself, so a click directly on
413 + // #pre-chat-message-{bot_id} resolves here too.)
414 + var $preChat = $(element).closest('[id^="pre-chat-message-"]');
415 + if ($preChat.length) {
416 + var preId = $preChat.attr('id') || '';
417 + var preMatch = preId.match(/^pre-chat-message-(.+)$/);
418 + if (preMatch) return preMatch[1];
419 + }
347 420 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
348 421 var elementId = $(element).attr('id') || '';
349 422 if (elementId) {
350 423 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -384,9 +457,27 @@
384 457 if (parts.length == 2) return parts.pop().split(";").shift();
385 458 }
386 459
387 460 function generateSessionId() {
388 - 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;
389 480 }
390 481
391 482 // Legacy function - now delegates to instance manager
392 483 function getChatSession(botId) {
@@ -430,23 +521,28 @@
430 521 // ====================================
431 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
432 523 // ====================================
433 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 +
434 530 function getPageContext() {
435 531 // Check if contextual awareness is enabled
436 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
437 533 return null;
438 534 }
439 -
535 +
440 536 // Get page URL
441 537 const pageUrl = window.location.href;
442 -
538 +
443 539 // Get page title
444 540 const pageTitle = document.title || '';
445 -
541 +
446 542 // Get main content from the page
447 543 let pageContent = '';
448 -
544 +
449 545 // Try to get content from common content areas
450 546 const contentSelectors = [
451 547 'main',
452 548 '[role="main"]',
@@ -458,9 +554,9 @@
458 554 'article',
459 555 '#content',
460 556 '#main'
461 557 ];
462 -
558 +
463 559 let contentElement = null;
464 560 for (const selector of contentSelectors) {
465 561 contentElement = document.querySelector(selector);
466 562 if (contentElement) {
@@ -466,18 +562,31 @@
466 562 if (contentElement) {
467 563 break;
468 564 }
469 565 }
470 -
566 +
471 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
472 568 if (!contentElement) {
473 569 contentElement = document.body;
474 570 }
475 -
571 +
476 572 if (contentElement) {
477 - // Clone the element to avoid modifying the original
478 - const clone = contentElement.cloneNode(true);
479 -
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 +
480 589 // Remove unwanted elements
481 590 const unwantedSelectors = [
482 591 'header',
483 592 'footer',
@@ -495,16 +604,25 @@
495 604 '#floating-chatbot',
496 605 '#floating-chatbot-button',
497 606 '.mxchat',
498 607 '[class*="chat"]',
499 - '[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'
500 618 ];
501 -
619 +
502 620 unwantedSelectors.forEach(selector => {
503 621 const elements = clone.querySelectorAll(selector);
504 622 elements.forEach(el => el.remove());
505 623 });
506 -
624 +
507 625 // Extract MxChat context data attributes before getting text content
508 626 const contextData = [];
509 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
510 628 const contextValue = el.dataset.mxchatContext;
@@ -511,34 +629,34 @@
511 629 if (contextValue && contextValue.trim()) {
512 630 contextData.push(contextValue);
513 631 }
514 632 });
515 -
633 +
516 634 // Get text content and clean it up
517 635 pageContent = clone.textContent || clone.innerText || '';
518 -
636 +
519 637 // Add context data to page content if any were found
520 638 if (contextData.length > 0) {
521 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
522 640 }
523 -
641 +
524 642 // Clean up whitespace and limit length
525 643 pageContent = pageContent
526 644 .replace(/\s+/g, ' ')
527 645 .trim()
528 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;
529 656 }
530 -
531 - // Only return context if we have meaningful content
532 - if (!pageContent || pageContent.length < 50) {
533 - return null;
534 - }
535 -
536 - return {
537 - url: pageUrl,
538 - title: pageTitle,
539 - content: pageContent
540 - };
657 +
658 + return null;
541 659 }
542 660
543 661 // Track originating page when chat starts
544 662 function trackOriginatingPage() {
@@ -589,8 +707,26 @@
589 707 sendButton.style.pointerEvents = 'none';
590 708 }
591 709 }
592 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 +
593 729 function enableChatInput(botId) {
594 730 botId = botId || 'default';
595 731 var chatInput = getElementDOM(botId, 'chat-input');
596 732 var sendButton = getElementDOM(botId, 'send-button');
@@ -596,9 +732,11 @@
596 732 var sendButton = getElementDOM(botId, 'send-button');
597 733 if (chatInput) {
598 734 chatInput.disabled = false;
599 735 chatInput.style.opacity = '1';
600 - chatInput.focus();
736 + if (mxchatShouldAutofocusAfterReply()) {
737 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 + }
601 739 }
602 740 if (sendButton) {
603 741 sendButton.disabled = false;
604 742 sendButton.style.opacity = '1';
@@ -709,9 +847,12 @@
709 847 if (typeof customMxChatFilter === 'function') {
710 848 message = customMxChatFilter(message, "prompt");
711 849 }
712 850
713 - 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() !== '') {
714 855 // Don't disable input in live agent mode - let users chat freely
715 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
716 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
717 858 if (!isAgentMode) {
@@ -728,14 +869,16 @@
728 869 }
729 870 appendThinkingMessage(botId);
730 871 scrollToBottom(botId);
731 872
732 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
873 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
733 874
734 875 // Check if streaming is enabled AND supported for this model
735 876 if (shouldUseStreaming(currentModel)) {
736 877 callMxChatStream(message, function(response) {
737 - 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');
738 881 }, botId);
739 882 } else {
740 883 callMxChat(message, function(response) {
741 884 replaceLastMessage("bot", response, '', [], botId);
@@ -753,8 +896,14 @@
753 896 if (typeof customMxChatFilter === 'function') {
754 897 message = customMxChatFilter(message, "prompt");
755 898 }
756 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 +
757 906 // Don't disable input in live agent mode - let users chat freely
758 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
759 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
760 909 if (!isAgentMode) {
@@ -768,14 +917,15 @@
768 917 }
769 918 appendThinkingMessage(botId);
770 919 scrollToBottom(botId);
771 920
772 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
921 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
773 922
774 923 // Check if streaming is enabled AND supported for this model
775 924 if (shouldUseStreaming(currentModel)) {
776 925 callMxChatStream(message, function(response) {
777 - 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');
778 928 }, botId);
779 929 } else {
780 930 callMxChat(message, function(response) {
781 931 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -945,12 +1095,13 @@
945 1095 // Re-send the original message with the new session (user message is already displayed)
946 1096 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
947 1097 if (originalMessage) {
948 1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
949 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1099 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
950 1100 if (shouldUseStreaming(currentModel)) {
951 1101 callMxChatStream(originalMessage, function(response) {
952 - 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');
953 1104 }, botId);
954 1105 } else {
955 1106 callMxChat(originalMessage, function(response) {
956 1107 replaceLastMessage("bot", response, '', [], botId);
@@ -1098,9 +1249,9 @@
1098 1249
1099 1250 // Store the message in case we need to retry after session reset
1100 1251 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1101 1252
1102 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1253 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
1103 1254 if (!isStreamingSupported(currentModel)) {
1104 1255 callMxChat(message, callback, botId);
1105 1256 return;
1106 1257 }
@@ -1153,8 +1304,12 @@
1153 1304
1154 1305 let accumulatedContent = '';
1155 1306 let testingDataReceived = false;
1156 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 [DONE] so the embed always lands BELOW the streamed text.
1311 + let pendingAppendHtml = '';
1157 1312
1158 1313 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1159 1314 // The Stop control (send button swapped in place) aborts both the read
1160 1315 // loop and the underlying request.
@@ -1268,8 +1423,17 @@
1268 1423
1269 1424 // Re-enable chat input after streaming completes
1270 1425 enableChatInput(botId);
1271 1426
1427 + // Render any server-pushed appendix html (e.g. the
1428 + // YouTube embed) as its own bot bubble below the
1429 + // streamed text — mirrors how it is saved in the
1430 + // transcript, so history replays identically.
1431 + if (pendingAppendHtml) {
1432 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1433 + pendingAppendHtml = '';
1434 + }
1435 +
1272 1436 // Scroll the user's last message to the top now
1273 1437 // that the bot's full reply has rendered.
1274 1438 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1275 1439 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1303,8 +1467,21 @@
1303 1467 streamingStarted = true;
1304 1468 accumulatedContent += json.content;
1305 1469 updateStreamingMessage(accumulatedContent, botId);
1306 1470 }
1471 + // Stash appendix html (e.g. video embed) for [DONE]
1472 + else if (json.append_html) {
1473 + pendingAppendHtml = json.append_html;
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 + }
1307 1484 // Handle complete response in stream (fallback response)
1308 1485 else if (json.text || json.message || json.html) {
1309 1486 handleNonStreamResponse(json, callback, botId);
1310 1487 return;
@@ -1406,9 +1583,9 @@
1406 1583 // Re-send the original message with the new session (user message is already displayed)
1407 1584 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1408 1585 if (originalMessage) {
1409 1586 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1410 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1587 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1411 1588 if (shouldUseStreaming(currentModel)) {
1412 1589 callMxChatStream(originalMessage, callback, botId);
1413 1590 } else {
1414 1591 callMxChat(originalMessage, callback, botId);
@@ -1541,8 +1718,15 @@
1541 1718 var $chatBox = getElement(botId, 'chat-box');
1542 1719 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1543 1720
1544 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 + }
1545 1729 // Update existing message
1546 1730 tempMessage.html(formattedContent);
1547 1731 } else {
1548 1732 // Create new temporary message if it doesn't exist
@@ -1578,25 +1762,21 @@
1578 1762 if (this.__mxchatStopJustShown) return;
1579 1763 mxchatStopStreaming(botId);
1580 1764 return;
1581 1765 }
1582 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1583 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1584 - disableChatInput(botId);
1585 - }
1766 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1586 1767 sendMessage(botId);
1587 1768 });
1588 1769
1589 -// 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).
1590 1775 $(document).on('keypress', '.chat-input', function(e) {
1591 1776 if (e.which == 13 && !e.shiftKey) {
1592 1777 e.preventDefault();
1593 - var botId = getBotIdFromElement(this);
1594 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1595 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1596 - disableChatInput(botId);
1597 - }
1598 - sendMessage(botId);
1778 + sendMessage(getBotIdFromElement(this));
1599 1779 }
1600 1780 });
1601 1781
1602 1782 // Chat input character counter + soft limit feedback (plan 7091a2).
@@ -2036,9 +2216,11 @@
2036 2216
2037 2217 messageDiv.html(fullMessage);
2038 2218
2039 2219 if (isTemporary) {
2040 - 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');
2041 2223 }
2042 2224
2043 2225 // Append to the correct chatbot instance's chat-box
2044 2226 var $chatBox = getElement(botId, 'chat-box');
@@ -2160,13 +2342,16 @@
2160 2342
2161 2343 if (images.length > 0) {
2162 2344 fullMessage += '<div class="image-gallery" dir="auto">';
2163 2345 images.forEach(img => {
2346 + const safeTitle = sanitizeUserInput(img.title);
2347 + const safeUrl = escapeAttr(img.image_url);
2348 + const safeThumbnail = escapeAttr(img.thumbnail_url);
2164 2349 fullMessage += `
2165 2350 <div style="margin-bottom: 10px;">
2166 - <strong>${img.title}</strong><br>
2167 - <a href="${img.image_url}" target="_blank">
2168 - <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;" />
2169 2354 </a>
2170 2355 </div>`;
2171 2356 });
2172 2357 fullMessage += '</div>';
@@ -2173,13 +2358,16 @@
2173 2358 }
2174 2359
2175 2360 if (lastMessageDiv.length) {
2176 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).
2177 2364 lastMessageDiv
2178 2365 .html(fullMessage)
2179 2366 .removeClass('bot-message user-message temporary-message')
2180 2367 .addClass(messageClass)
2181 - .attr('dir', 'auto');
2368 + .attr('dir', 'auto')
2369 + .attr('aria-busy', 'false');
2182 2370
2183 2371 // Only apply inline colors if AI theme is not active (let CSS handle it)
2184 2372 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2185 2373 if (!skipColors) {
@@ -2239,10 +2427,15 @@
2239 2427 var botMessageFontColor = mxchatChat.bot_message_font_color;
2240 2428 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2241 2429
2242 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).
2243 2434 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2244 - 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">' +
2245 2438 '<div class="thinking-dots">' +
2246 2439 '<span class="dot"' + dotStyle + '></span>' +
2247 2440 '<span class="dot"' + dotStyle + '></span>' +
2248 2441 '<span class="dot"' + dotStyle + '></span>' +
@@ -2277,27 +2470,42 @@
2277 2470 // Check for % followed by exactly 2 hex digits
2278 2471 return /%[0-9a-fA-F]{2}/.test(url);
2279 2472 }
2280 2473
2281 - // 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.
2282 2480 function safeEncodeUrl(url) {
2283 - // If URL already contains encoded characters, return as-is
2284 - if (isUrlEncoded(url)) {
2285 - return url;
2286 - }
2287 - // Otherwise, encode it
2288 - return encodeURI(url);
2481 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2482 + return escapeAttr(encoded);
2289 2483 }
2290 2484
2291 - // Process markdown headers FIRST
2292 - let processedText = formatMarkdownHeaders(inputText);
2293 -
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 +
2294 2498 // Process text styling (bold, italic, strikethrough)
2295 2499 processedText = formatTextStyling(processedText);
2296 -
2297 - // 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
2298 2503 processedText = formatCodeBlocks(processedText);
2299 2504
2505 + // Put the rendered code and the escaped characters back
2506 + processedText = literals.restore(processedText);
2507 +
2300 2508 // Process markdown tables BEFORE converting newlines to paragraphs
2301 2509 processedText = formatMarkdownTables(processedText);
2302 2510
2303 2511 // NOW convert to paragraphs
@@ -2309,10 +2517,13 @@
2309 2517 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2310 2518 // Clean the URL of any trailing punctuation
2311 2519 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2312 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;');
2313 2524 // Return as a proper link without the brackets
2314 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2525 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2315 2526 });
2316 2527
2317 2528 // Process markdown links: [text](url) and [](url)
2318 2529 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2416,8 +2627,30 @@
2416 2627
2417 2628 return processedText;
2418 2629 }
2419 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 +
2420 2653 function formatMarkdownHeaders(text) {
2421 2654 // Handle h1 to h6 headers
2422 2655 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2423 2656 const level = hashes.length;
@@ -2459,11 +2692,24 @@
2459 2692 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2460 2693 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2461 2694 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2462 2695
2463 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2464 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2465 - 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 + });
2466 2712
2467 2713 // Handle strikethrough (~~text~~)
2468 2714 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2469 2715
@@ -2597,8 +2843,24 @@
2597 2843 div.textContent = text;
2598 2844 return div.innerHTML;
2599 2845 }
2600 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 +
2601 2863 function escapeHtml(unsafe) {
2602 2864 // Skip escaping if it's already escaped or contains HTML code block markup
2603 2865 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2604 2866 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -2942,9 +3204,30 @@
2942 3204 }
2943 3205
2944 3206 // Only process if there are actual messages
2945 3207 if (response.data.conversation.length > 0) {
2946 - // 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();
2947 3230 $chatBox.empty();
2948 3231
2949 3232 $.each(response.data.conversation, function(index, message) {
2950 3233 // Skip agent messages if persistence is off
@@ -2981,15 +3264,28 @@
2981 3264 var content = message.content;
2982 3265 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2983 3266 content = decodeHTMLEntities(content);
2984 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 +
2985 3277 // Skip linkify for messages containing structured HTML
2986 3278 // (forms, product cards, galleries, etc.) to avoid
2987 3279 // markdown formatting corrupting HTML attributes
2988 - // (e.g. underscores in name="field_name" becoming <em> tags)
2989 - if (content.includes("mxchat-product-card") ||
2990 - content.includes("mxchat-image-gallery") ||
2991 - content.includes("mxchat-featured-products") ||
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) ||
2992 3288 content.includes("<form") ||
2993 3289 content.includes("<input") ||
2994 3290 content.includes("<select") ||
2995 3291 content.includes("<textarea")) {
@@ -3007,12 +3303,29 @@
3007 3303 instance.processedMessageIds.add(message.id);
3008 3304 }
3009 3305 });
3010 3306
3011 - // 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 + }
3012 3315 $chatBox.append($fragment);
3013 3316 scrollToBottom(botId, true);
3014 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 +
3015 3328 // Collapse quick questions if we have conversation history
3016 3329 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3017 3330 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3018 3331 collapseQuickQuestions(botId);
@@ -3302,8 +3615,30 @@
3302 3615 e.stopPropagation();
3303 3616 var botId = getBotIdFromElement(this);
3304 3617 collapseQuickQuestions(botId);
3305 3618 });
3619 +
3620 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3621 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3622 +// Delegated so it also works for embeds restored from chat history.
3623 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3624 + e.preventDefault();
3625 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3626 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3627 + if (!videoId) {
3628 + return;
3629 + }
3630 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3631 + var $iframe = $('<iframe>', {
3632 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3633 + title: title,
3634 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3635 + allowfullscreen: true,
3636 + frameborder: 0
3637 + }).addClass('mxchat-youtube-iframe');
3638 + $wrap.addClass('mxchat-youtube-playing');
3639 + $(this).replaceWith($iframe);
3640 +});
3306 3641
3307 3642 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3308 3643 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3309 3644 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
@@ -3322,8 +3657,9 @@
3322 3657 $(this).addClass('hidden').attr('aria-expanded', 'true');
3323 3658 $badge.hide(); // Hide notification when opening chat
3324 3659 disableScroll();
3325 3660 $preChat.fadeOut(250);
3661 + mxchatFireOpened(botId);
3326 3662
3327 3663 // First open per page load: re-fetch behavior settings in case
3328 3664 // this page's inline values came from a stale full-page cache
3329 3665 // (plan-32db95). Idempotent — later opens are a no-op.
@@ -3592,9 +3928,12 @@
3592 3928 $('.chat-toolbar').show();
3593 3929 } else {
3594 3930 $('.chat-toolbar').hide();
3595 3931 }
3596 -
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 +
3597 3936 // Apply toolbar icon colors
3598 3937 const toolbarElements = [
3599 3938 '#mxchat-chatbot .toolbar-btn svg',
3600 3939 '#mxchat-chatbot .active-pdf-name',
@@ -3719,10 +4058,13 @@
3719 4058 function replaceVisitorNamePlaceholder(botId, visitorName) {
3720 4059 var chatBox = getElementDOM(botId, 'chat-box');
3721 4060 if (!chatBox) return;
3722 4061
3723 - // Find the first bot message (intro message)
3724 - 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');
3725 4067 if (!introMessage) return;
3726 4068
3727 4069 var messageContent = introMessage.querySelector('div[dir="auto"]');
3728 4070 if (!messageContent) return;
@@ -3866,14 +4208,25 @@
3866 4208 return response.json();
3867 4209 })
3868 4210 .then((data) => {
3869 4211 if (data.success) {
3870 - 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) {
3871 4217 showChatContainerForBot(botId);
3872 4218 } else {
3873 4219 showEmailFormForBot(botId);
3874 4220 }
3875 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 + }
3876 4229 showEmailFormForBot(botId);
3877 4230 }
3878 4231 })
3879 4232 .catch((error) => {
@@ -3880,8 +4233,19 @@
3880 4233 showEmailFormForBot(botId);
3881 4234 });
3882 4235 }
3883 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 +
3884 4248 // Event delegation for email form submission
3885 4249 $(document).on('submit', '.email-collection-form', function(e) {
3886 4250 e.preventDefault();
3887 4251 e.stopPropagation();
@@ -3894,23 +4258,34 @@
3894 4258 }
3895 4259
3896 4260 var emailInput = getElementDOM(botId, 'user-email');
3897 4261 var nameInput = getElementDOM(botId, 'user-name');
4262 + var consentInput = getElementDOM(botId, 'user-consent');
3898 4263 var userEmail = emailInput ? emailInput.value.trim() : '';
3899 4264 var userName = nameInput ? nameInput.value.trim() : '';
3900 4265 var sessionId = MxChatInstances.ensureSession(botId);
3901 4266
3902 - // Validate email
3903 - 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) {
3904 4274 showEmailError(botId, 'Please enter your email address.');
3905 4275 return false;
3906 4276 }
3907 4277
3908 - if (!isValidEmailAddress(userEmail)) {
4278 + if (userEmail && !isValidEmailAddress(userEmail)) {
3909 4279 showEmailError(botId, 'Please enter a valid email address.');
3910 4280 return false;
3911 4281 }
3912 4282
4283 + if (!emailRequired && !userEmail && !userName) {
4284 + showEmailError(botId, 'Please enter your name or email address.');
4285 + return false;
4286 + }
4287 +
3913 4288 // Validate name if field exists and has content
3914 4289 if (nameInput && userName && !isValidNameInput(userName)) {
3915 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3916 4291 return false;
@@ -3915,8 +4290,15 @@
3915 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3916 4291 return false;
3917 4292 }
3918 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 +
3919 4301 clearEmailError(botId);
3920 4302 setEmailSubmissionState(botId, true);
3921 4303
3922 4304 // Prepare form data
@@ -3930,8 +4312,14 @@
3930 4312 if (userName) {
3931 4313 formData.append('name', userName);
3932 4314 }
3933 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 +
3934 4322 fetch(mxchatChat.ajax_url, {
3935 4323 method: 'POST',
3936 4324 headers: {
3937 4325 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4053,8 +4441,9 @@
4053 4441 $chatbot.removeClass('hidden').addClass('visible');
4054 4442 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4055 4443 handlePreChatDismissal(botId);
4056 4444 disableScroll(); // Disable scroll when chatbot opens
4445 + mxchatFireOpened(botId);
4057 4446
4058 4447 // Load chat history for returning visitors (persistence)
4059 4448 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4060 4449 if (chatPersistenceEnabled) {
@@ -4220,8 +4609,15 @@
4220 4609 // Initialize chat visibility for all instances
4221 4610 $('.mxchat-chatbot-wrapper').each(function() {
4222 4611 var botId = $(this).data('bot-id') || 'default';
4223 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 + }
4224 4620 });
4225 4621
4226 4622 // Make functions globally available for add-ons
4227 4623 window.hasQuickQuestions = hasQuickQuestions;