PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
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 +656 -140 3.2.9 → 3.2.25 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) {
@@ -719,8 +860,9 @@
719 860 }
720 861
721 862 appendMessage("user", message, '', [], false, botId);
722 863 $chatInput.val('');
864 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
723 865 $chatInput.css('height', 'auto');
724 866
725 867 if (hasQuickQuestions(botId)) {
726 868 collapseQuickQuestions(botId);
@@ -727,14 +869,16 @@
727 869 }
728 870 appendThinkingMessage(botId);
729 871 scrollToBottom(botId);
730 872
731 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
873 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
732 874
733 875 // Check if streaming is enabled AND supported for this model
734 876 if (shouldUseStreaming(currentModel)) {
735 877 callMxChatStream(message, function(response) {
736 - 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');
737 881 }, botId);
738 882 } else {
739 883 callMxChat(message, function(response) {
740 884 replaceLastMessage("bot", response, '', [], botId);
@@ -752,8 +896,14 @@
752 896 if (typeof customMxChatFilter === 'function') {
753 897 message = customMxChatFilter(message, "prompt");
754 898 }
755 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 +
756 906 // Don't disable input in live agent mode - let users chat freely
757 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
758 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
759 909 if (!isAgentMode) {
@@ -767,14 +917,15 @@
767 917 }
768 918 appendThinkingMessage(botId);
769 919 scrollToBottom(botId);
770 920
771 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
921 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
772 922
773 923 // Check if streaming is enabled AND supported for this model
774 924 if (shouldUseStreaming(currentModel)) {
775 925 callMxChatStream(message, function(response) {
776 - 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');
777 928 }, botId);
778 929 } else {
779 930 callMxChat(message, function(response) {
780 931 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -944,12 +1095,13 @@
944 1095 // Re-send the original message with the new session (user message is already displayed)
945 1096 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
946 1097 if (originalMessage) {
947 1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
948 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1099 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
949 1100 if (shouldUseStreaming(currentModel)) {
950 1101 callMxChatStream(originalMessage, function(response) {
951 - 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');
952 1104 }, botId);
953 1105 } else {
954 1106 callMxChat(originalMessage, function(response) {
955 1107 replaceLastMessage("bot", response, '', [], botId);
@@ -1097,9 +1249,9 @@
1097 1249
1098 1250 // Store the message in case we need to retry after session reset
1099 1251 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1100 1252
1101 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1253 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
1102 1254 if (!isStreamingSupported(currentModel)) {
1103 1255 callMxChat(message, callback, botId);
1104 1256 return;
1105 1257 }
@@ -1152,9 +1304,28 @@
1152 1304
1153 1305 let accumulatedContent = '';
1154 1306 let testingDataReceived = false;
1155 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 = '';
1156 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 +
1157 1328 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1158 1329 // The Stop control (send button swapped in place) aborts both the read
1159 1330 // loop and the underlying request.
1160 1331 var streamControl = { controller: new AbortController(), aborted: false };
@@ -1235,8 +1406,10 @@
1235 1406
1236 1407 // Re-enable chat input when stream ends with content
1237 1408 enableChatInput(botId);
1238 1409
1410 + flushAppendHtml();
1411 +
1239 1412 // Scroll the user's last message to the top now that the
1240 1413 // bot's full reply has rendered (gives max reading room).
1241 1414 var $chatBoxDone = getElement(botId, 'chat-box');
1242 1415 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
@@ -1267,8 +1440,10 @@
1267 1440
1268 1441 // Re-enable chat input after streaming completes
1269 1442 enableChatInput(botId);
1270 1443
1444 + flushAppendHtml();
1445 +
1271 1446 // Scroll the user's last message to the top now
1272 1447 // that the bot's full reply has rendered.
1273 1448 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1274 1449 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1302,8 +1477,21 @@
1302 1477 streamingStarted = true;
1303 1478 accumulatedContent += json.content;
1304 1479 updateStreamingMessage(accumulatedContent, botId);
1305 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 + }
1306 1494 // Handle complete response in stream (fallback response)
1307 1495 else if (json.text || json.message || json.html) {
1308 1496 handleNonStreamResponse(json, callback, botId);
1309 1497 return;
@@ -1405,9 +1593,9 @@
1405 1593 // Re-send the original message with the new session (user message is already displayed)
1406 1594 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1407 1595 if (originalMessage) {
1408 1596 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1409 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1597 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1410 1598 if (shouldUseStreaming(currentModel)) {
1411 1599 callMxChatStream(originalMessage, callback, botId);
1412 1600 } else {
1413 1601 callMxChat(originalMessage, callback, botId);
@@ -1540,8 +1728,15 @@
1540 1728 var $chatBox = getElement(botId, 'chat-box');
1541 1729 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1542 1730
1543 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 + }
1544 1739 // Update existing message
1545 1740 tempMessage.html(formattedContent);
1546 1741 } else {
1547 1742 // Create new temporary message if it doesn't exist
@@ -1577,28 +1772,89 @@
1577 1772 if (this.__mxchatStopJustShown) return;
1578 1773 mxchatStopStreaming(botId);
1579 1774 return;
1580 1775 }
1581 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1582 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1583 - disableChatInput(botId);
1584 - }
1776 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1585 1777 sendMessage(botId);
1586 1778 });
1587 1779
1588 -// 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).
1589 1785 $(document).on('keypress', '.chat-input', function(e) {
1590 1786 if (e.which == 13 && !e.shiftKey) {
1591 1787 e.preventDefault();
1592 - var botId = getBotIdFromElement(this);
1593 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1594 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1595 - disableChatInput(botId);
1596 - }
1597 - sendMessage(botId);
1788 + sendMessage(getBotIdFromElement(this));
1598 1789 }
1599 1790 });
1600 1791
1792 +// Chat input character counter + soft limit feedback (plan 7091a2).
1793 +// Language-neutral: numbers + color only, no translatable strings. The counter
1794 +// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1795 +// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1796 +// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1797 +function mxchatUpdateCharCounter(inputEl) {
1798 + if (!inputEl || !inputEl.closest) return;
1799 + var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1800 + var container = inputEl.closest('.input-container');
1801 + if (!container || !max || max <= 0) return;
1802 + var counter = container.querySelector('.mxchat-char-counter');
1803 + if (!counter) return;
1804 + var len = inputEl.value.length;
1805 + var ratio = len / max;
1806 + var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1807 + var cur = counter.querySelector('.mxchat-char-counter-current');
1808 + if (cur) cur.textContent = len;
1809 + var warn = ratio >= nearThreshold && len < max;
1810 + var full = len >= max;
1811 + counter.classList.toggle('is-visible', ratio >= nearThreshold);
1812 + counter.classList.toggle('is-warn', warn);
1813 + counter.classList.toggle('is-full', full);
1814 + container.classList.toggle('mxchat-input-near-limit', warn);
1815 + container.classList.toggle('mxchat-input-at-limit', full);
1816 +}
1817 +
1818 +function mxchatBumpInput(inputEl) {
1819 + var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1820 + if (!container) return;
1821 + container.classList.remove('mxchat-input-bump');
1822 + void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1823 + container.classList.add('mxchat-input-bump');
1824 + clearTimeout($(container).data('mxchatBumpTimeout'));
1825 + var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1826 + $(container).data('mxchatBumpTimeout', t);
1827 +}
1828 +
1829 +// Live counter update on every input.
1830 +$(document).on('input', '.chat-input', function() {
1831 + mxchatUpdateCharCounter(this);
1832 +});
1833 +
1834 +// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1835 +// rejected at the cap (maxlength silently swallows it otherwise).
1836 +$(document).on('keydown', '.chat-input', function(e) {
1837 + var max = parseInt(this.getAttribute('maxlength'), 10);
1838 + if (!max || max <= 0 || this.value.length < max) return;
1839 + if (e.ctrlKey || e.metaKey || e.altKey) return;
1840 + // A single printable char with no selection to overwrite WILL be rejected.
1841 + if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1842 + mxchatBumpInput(this);
1843 + }
1844 +});
1845 +
1846 +// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1847 +$(document).on('paste', '.chat-input', function() {
1848 + var el = this;
1849 + var max = parseInt(el.getAttribute('maxlength'), 10);
1850 + if (!max || max <= 0) return;
1851 + setTimeout(function() {
1852 + mxchatUpdateCharCounter(el);
1853 + if (el.value.length >= max) mxchatBumpInput(el);
1854 + }, 0);
1855 +});
1856 +
1601 1857 // Builds the list of overflow-menu items for a given bot.
1602 1858 // Adding a future item is one push to this array — do NOT hardcode "only download."
1603 1859 function mxchatGetHeaderMenuItems(botId) {
1604 1860 var items = [];
@@ -1616,8 +1872,26 @@
1616 1872 }
1617 1873 });
1618 1874 }
1619 1875
1876 + // "Start new chat" — surfaces the EXISTING per-conversation reset
1877 + // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1878 + // without the site owner disabling chat persistence globally. Default OFF;
1879 + // gated by the reset_chat_enabled option. plan ac2e81.
1880 + if (settings.reset_chat_enabled === 'on') {
1881 + items.push({
1882 + id: 'reset-chat',
1883 + label: settings.reset_chat_label || 'Start new chat',
1884 + icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>',
1885 + action: function() {
1886 + var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1887 + if (window.confirm(confirmMsg)) {
1888 + MxChatInstances.resetChatSession(botId);
1889 + }
1890 + }
1891 + });
1892 + }
1893 +
1620 1894 return items;
1621 1895 }
1622 1896
1623 1897 // Builds a clean markdown transcript of the current conversation and triggers
@@ -1952,9 +2226,11 @@
1952 2226
1953 2227 messageDiv.html(fullMessage);
1954 2228
1955 2229 if (isTemporary) {
1956 - 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');
1957 2233 }
1958 2234
1959 2235 // Append to the correct chatbot instance's chat-box
1960 2236 var $chatBox = getElement(botId, 'chat-box');
@@ -1985,8 +2261,56 @@
1985 2261 // Error rendering message - silently continue
1986 2262 }
1987 2263 }
1988 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 +
1989 2313 // Helper function to attach link tracking with proper event handling
1990 2314 function attachLinkTracking(messageDiv, messageText, botId) {
1991 2315 botId = botId || 'default';
1992 2316 // Use a slight delay to ensure DOM is ready
@@ -2004,11 +2328,13 @@
2004 2328 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
2005 2329 // Remove any existing click handlers first
2006 2330 $link.off('click.tracking');
2007 2331
2332 + // The anchor carries its own target: the browser follows it
2333 + prepareTrackedLink($link);
2334 +
2008 2335 // Add new click handler with namespace
2009 2336 $link.on('click.tracking', function(e) {
2010 - e.preventDefault();
2011 2337 e.stopPropagation();
2012 2338
2013 2339 const messageContext = typeof messageText === 'string'
2014 2340 ? messageText.substring(0, 200)
@@ -2013,30 +2339,10 @@
2013 2339 const messageContext = typeof messageText === 'string'
2014 2340 ? messageText.substring(0, 200)
2015 2341 : '';
2016 2342
2017 - // Track the click
2018 - $.ajax({
2019 - url: mxchatChat.ajax_url,
2020 - type: 'POST',
2021 - data: {
2022 - action: 'mxchat_track_url_click',
2023 - session_id: getChatSession(botId),
2024 - url: originalHref,
2025 - message_context: messageContext,
2026 - nonce: mxchatChat.nonce
2027 - },
2028 - complete: function() {
2029 - // Always redirect, even if tracking fails
2030 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
2031 - window.open(originalHref, '_blank');
2032 - } else {
2033 - window.location.href = originalHref;
2034 - }
2035 - }
2036 - });
2037 -
2038 - return false; // Extra insurance to prevent default
2343 + prepareTrackedLink($link);
2344 + trackLinkClick(originalHref, messageContext, botId);
2039 2345 });
2040 2346 }
2041 2347 });
2042 2348 }, 100); // Small delay to ensure DOM is ready
@@ -2076,13 +2382,16 @@
2076 2382
2077 2383 if (images.length > 0) {
2078 2384 fullMessage += '<div class="image-gallery" dir="auto">';
2079 2385 images.forEach(img => {
2386 + const safeTitle = sanitizeUserInput(img.title);
2387 + const safeUrl = escapeAttr(img.image_url);
2388 + const safeThumbnail = escapeAttr(img.thumbnail_url);
2080 2389 fullMessage += `
2081 2390 <div style="margin-bottom: 10px;">
2082 - <strong>${img.title}</strong><br>
2083 - <a href="${img.image_url}" target="_blank">
2084 - <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;" />
2085 2394 </a>
2086 2395 </div>`;
2087 2396 });
2088 2397 fullMessage += '</div>';
@@ -2089,13 +2398,16 @@
2089 2398 }
2090 2399
2091 2400 if (lastMessageDiv.length) {
2092 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).
2093 2404 lastMessageDiv
2094 2405 .html(fullMessage)
2095 2406 .removeClass('bot-message user-message temporary-message')
2096 2407 .addClass(messageClass)
2097 - .attr('dir', 'auto');
2408 + .attr('dir', 'auto')
2409 + .attr('aria-busy', 'false');
2098 2410
2099 2411 // Only apply inline colors if AI theme is not active (let CSS handle it)
2100 2412 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2101 2413 if (!skipColors) {
@@ -2155,10 +2467,15 @@
2155 2467 var botMessageFontColor = mxchatChat.bot_message_font_color;
2156 2468 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2157 2469
2158 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).
2159 2474 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2160 - 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">' +
2161 2478 '<div class="thinking-dots">' +
2162 2479 '<span class="dot"' + dotStyle + '></span>' +
2163 2480 '<span class="dot"' + dotStyle + '></span>' +
2164 2481 '<span class="dot"' + dotStyle + '></span>' +
@@ -2193,27 +2510,42 @@
2193 2510 // Check for % followed by exactly 2 hex digits
2194 2511 return /%[0-9a-fA-F]{2}/.test(url);
2195 2512 }
2196 2513
2197 - // 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.
2198 2520 function safeEncodeUrl(url) {
2199 - // If URL already contains encoded characters, return as-is
2200 - if (isUrlEncoded(url)) {
2201 - return url;
2202 - }
2203 - // Otherwise, encode it
2204 - return encodeURI(url);
2521 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2522 + return escapeAttr(encoded);
2205 2523 }
2206 2524
2207 - // Process markdown headers FIRST
2208 - let processedText = formatMarkdownHeaders(inputText);
2209 -
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 +
2210 2538 // Process text styling (bold, italic, strikethrough)
2211 2539 processedText = formatTextStyling(processedText);
2212 -
2213 - // 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
2214 2543 processedText = formatCodeBlocks(processedText);
2215 2544
2545 + // Put the rendered code and the escaped characters back
2546 + processedText = literals.restore(processedText);
2547 +
2216 2548 // Process markdown tables BEFORE converting newlines to paragraphs
2217 2549 processedText = formatMarkdownTables(processedText);
2218 2550
2219 2551 // NOW convert to paragraphs
@@ -2225,10 +2557,13 @@
2225 2557 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2226 2558 // Clean the URL of any trailing punctuation
2227 2559 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2228 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;');
2229 2564 // Return as a proper link without the brackets
2230 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2565 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2231 2566 });
2232 2567
2233 2568 // Process markdown links: [text](url) and [](url)
2234 2569 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2302,9 +2637,17 @@
2302 2637 });
2303 2638
2304 2639 // Process standalone URLs - but NOT if they're already in <a> tags or brackets
2305 2640 // Updated pattern to be more careful about what it matches
2306 - const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2641 + //
2642 + // The prefix group (plan 78618d) is the start of a line, an ordinary
2643 + // character, OR a tag this formatter itself opened or closed (paragraph,
2644 + // list item, table cell, heading, emphasis, line break), so an address
2645 + // that opens a paragraph or sits inside **bold** is linked too. Never
2646 + // an anchor (its text would be linked twice; the lookahead guards that
2647 + // as well) and never a code span, which stays as typed. No lookbehind,
2648 + // for Safari.
2649 + const urlPattern = /(^|[^">=\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2307 2650 processedText = processedText.replace(urlPattern, (match, prefix, url) => {
2308 2651 // Extra check: make sure this isn't already linked
2309 2652 if (match.includes('href=') || match.includes('</a>')) {
2310 2653 return match;
@@ -2315,10 +2658,14 @@
2315 2658 const safeUrl = safeEncodeUrl(cleanUrl);
2316 2659 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2317 2660 });
2318 2661
2319 - // Process www. URLs - but NOT if they're already in <a> tags or brackets
2320 - const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim;
2662 + // Process www. URLs - but NOT if they're already in <a> tags or brackets.
2663 + // The address stops at the same characters the https pattern stops at
2664 + // (plan 78618d): it used to run through the closing paragraph tag, so
2665 + // "Try www.example.com/page." linked to www.example.com/page.%3C/p%3E.
2666 + // Same prefix group as the https pattern, same reasons.
2667 + const wwwPattern = /(^|[^">/\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(www\.[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2321 2668 processedText = processedText.replace(wwwPattern, (match, prefix, url) => {
2322 2669 // Extra check: make sure this isn't already linked
2323 2670 if (match.includes('href=') || match.includes('</a>')) {
2324 2671 return match;
@@ -2332,8 +2679,30 @@
2332 2679
2333 2680 return processedText;
2334 2681 }
2335 2682
2683 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2684 + // A single alternation keeps the two honest with each other: an escaped
2685 + // backtick is consumed as an escape before it can open a code span, and a
2686 + // code span is consumed whole so the escapes inside it stay verbatim.
2687 + // Placeholders are private-use characters — no underscore, asterisk,
2688 + // backtick, hash, pipe or bracket for a later rule to match.
2689 + function protectLiterals(text) {
2690 + const store = [];
2691 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2692 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2693 + return '\uE000' + (store.length - 1) + '\uE001';
2694 + });
2695 + return {
2696 + text: out,
2697 + restore: function(t) {
2698 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2699 + return store[Number(i)];
2700 + });
2701 + }
2702 + };
2703 + }
2704 +
2336 2705 function formatMarkdownHeaders(text) {
2337 2706 // Handle h1 to h6 headers
2338 2707 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2339 2708 const level = hashes.length;
@@ -2360,13 +2729,19 @@
2360 2729 protectedSegments.push(match);
2361 2730 return placeholder;
2362 2731 });
2363 2732
2364 - // Step 1c: Also protect bare URLs that might exist
2733 + // Step 1c: Also protect bare URLs that might exist. Trailing emphasis
2734 + // markers are not part of the address (plan 78618d): **https://x.com**
2735 + // used to protect "https://x.com**", so the bold never paired and the
2736 + // asterisks ended up inside the link. The markers stay outside the
2737 + // placeholder so the styling rules below can pair them.
2365 2738 protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) {
2739 + const markers = match.match(/[*_~]+$/);
2740 + const url = markers ? match.slice(0, match.length - markers[0].length) : match;
2366 2741 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2367 - protectedSegments.push(match);
2368 - return placeholder;
2742 + protectedSegments.push(url);
2743 + return placeholder + (markers ? markers[0] : '');
2369 2744 });
2370 2745
2371 2746 // Step 2: Now apply text styling to the protected text
2372 2747 // Handle bold text (**text**)
@@ -2375,11 +2750,24 @@
2375 2750 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2376 2751 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2377 2752 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2378 2753
2379 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2380 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2381 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2754 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2755 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2756 + // text or a non-word character, the closer must precede the end or a
2757 + // non-word character, and the content may not start or end with a
2758 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2759 + // while "use _this_ word" still italicises. A token whose content is
2760 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2761 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2762 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2763 + // adjacent to another underscore or a word character.
2764 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2765 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2766 + return match;
2767 + }
2768 + return before + '<em>' + content + '</em>';
2769 + });
2382 2770
2383 2771 // Handle strikethrough (~~text~~)
2384 2772 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2385 2773
@@ -2513,8 +2901,24 @@
2513 2901 div.textContent = text;
2514 2902 return div.innerHTML;
2515 2903 }
2516 2904
2905 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2906 + // Percent-encodes the characters that can end or extend an attribute.
2907 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2908 + // never changes where a legitimate link resolves — encodeURI() already
2909 + // produces most of these, this just guarantees them for URLs that skip
2910 + // encodeURI() because they were percent-encoded on arrival.
2911 + function escapeAttr(url) {
2912 + return String(url)
2913 + .replace(/"/g, '%22')
2914 + .replace(/'/g, '%27')
2915 + .replace(/</g, '%3C')
2916 + .replace(/>/g, '%3E')
2917 + .replace(/`/g, '%60')
2918 + .replace(/ /g, '%20');
2919 + }
2920 +
2517 2921 function escapeHtml(unsafe) {
2518 2922 // Skip escaping if it's already escaped or contains HTML code block markup
2519 2923 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2520 2924 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -2858,9 +3262,30 @@
2858 3262 }
2859 3263
2860 3264 // Only process if there are actual messages
2861 3265 if (response.data.conversation.length > 0) {
2862 - // IMPORTANT: Clear existing messages before loading history
3266 + // Restored history must be SILENT to screen readers
3267 + // (plan 67f126): these are DOM additions inside the
3268 + // live region and would otherwise announce as if
3269 + // they just arrived. Lift aria-live for the batch
3270 + // repopulate, restore it after the browser has
3271 + // processed the mutations.
3272 + var mxLiveRegionEl = $chatBox.get(0);
3273 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3274 + if (mxLiveRegionEl) {
3275 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3276 + }
3277 +
3278 + // IMPORTANT: Clear existing messages before loading history.
3279 + // Detach the greeting first and put it back below —
3280 + // it is server-rendered and never stored in the
3281 + // transcript, so the old unconditional .empty()
3282 + // deleted it for the rest of the page life (plan
3283 + // a1a79b). Detach rather than rebuild: intro_message
3284 + // is not localized to JS, and this node already
3285 + // carries the per-bot inline colors and any
3286 + // {visitor_name} substitution already applied to it.
3287 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2863 3288 $chatBox.empty();
2864 3289
2865 3290 $.each(response.data.conversation, function(index, message) {
2866 3291 // Skip agent messages if persistence is off
@@ -2897,15 +3322,28 @@
2897 3322 var content = message.content;
2898 3323 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2899 3324 content = decodeHTMLEntities(content);
2900 3325
3326 + // A visitor's own rows get the same treatment as the
3327 + // live turn (appendMessage escapes user text before
3328 + // linkify). Without this a replayed user message came
3329 + // back as live markup after a refresh (plan 2db182).
3330 + // Bot/agent rows are trusted HTML and stay as they were.
3331 + if (message.role === 'user') {
3332 + content = sanitizeUserInput(content);
3333 + }
3334 +
2901 3335 // Skip linkify for messages containing structured HTML
2902 3336 // (forms, product cards, galleries, etc.) to avoid
2903 3337 // markdown formatting corrupting HTML attributes
2904 - // (e.g. underscores in name="field_name" becoming <em> tags)
2905 - if (content.includes("mxchat-product-card") ||
2906 - content.includes("mxchat-image-gallery") ||
2907 - content.includes("mxchat-featured-products") ||
3338 + // (e.g. underscores in name="field_name" becoming <em> tags).
3339 + // One family check instead of a per-card literal list: any
3340 + // element carrying an mxchat- prefixed class is MxChat-generated
3341 + // structured markup and replays raw. The old list drifted every
3342 + // time an add-on minted a new card class — the filtered-search
3343 + // card ("mxchat-filtered-product-card") missed it and replayed
3344 + // through linkify as visible markup.
3345 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
2908 3346 content.includes("<form") ||
2909 3347 content.includes("<input") ||
2910 3348 content.includes("<select") ||
2911 3349 content.includes("<textarea")) {
@@ -2923,12 +3361,29 @@
2923 3361 instance.processedMessageIds.add(message.id);
2924 3362 }
2925 3363 });
2926 3364
2927 - // Only append messages and scroll if we have content
3365 + // Only append messages and scroll if we have content.
3366 + // Greeting goes back FIRST, above the restored
3367 + // history: "Hello — [earlier conversation]" is the
3368 + // natural reading and matches the order a fresh
3369 + // visitor sees (plan a1a79b).
3370 + if ($mxIntro && $mxIntro.length) {
3371 + $chatBox.append($mxIntro);
3372 + }
2928 3373 $chatBox.append($fragment);
2929 3374 scrollToBottom(botId, true);
2930 3375
3376 + // Re-attach live semantics AFTER the rehydration
3377 + // mutations have been processed with the region off
3378 + // (plan 67f126). Restoring later announces nothing
3379 + // retroactively; new turns announce normally.
3380 + if (mxLiveRegionEl) {
3381 + setTimeout(function() {
3382 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3383 + }, 200);
3384 + }
3385 +
2931 3386 // Collapse quick questions if we have conversation history
2932 3387 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2933 3388 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2934 3389 collapseQuickQuestions(botId);
@@ -3218,8 +3673,30 @@
3218 3673 e.stopPropagation();
3219 3674 var botId = getBotIdFromElement(this);
3220 3675 collapseQuickQuestions(botId);
3221 3676 });
3677 +
3678 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3679 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3680 +// Delegated so it also works for embeds restored from chat history.
3681 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3682 + e.preventDefault();
3683 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3684 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3685 + if (!videoId) {
3686 + return;
3687 + }
3688 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3689 + var $iframe = $('<iframe>', {
3690 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3691 + title: title,
3692 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3693 + allowfullscreen: true,
3694 + frameborder: 0
3695 + }).addClass('mxchat-youtube-iframe');
3696 + $wrap.addClass('mxchat-youtube-playing');
3697 + $(this).replaceWith($iframe);
3698 +});
3222 3699
3223 3700 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3224 3701 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3225 3702 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
@@ -3238,8 +3715,9 @@
3238 3715 $(this).addClass('hidden').attr('aria-expanded', 'true');
3239 3716 $badge.hide(); // Hide notification when opening chat
3240 3717 disableScroll();
3241 3718 $preChat.fadeOut(250);
3719 + mxchatFireOpened(botId);
3242 3720
3243 3721 // First open per page load: re-fetch behavior settings in case
3244 3722 // this page's inline values came from a stale full-page cache
3245 3723 // (plan-32db95). Idempotent — later opens are a no-op.
@@ -3508,9 +3986,12 @@
3508 3986 $('.chat-toolbar').show();
3509 3987 } else {
3510 3988 $('.chat-toolbar').hide();
3511 3989 }
3512 -
3990 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3991 + MxChatInstances.toolbarReady = true;
3992 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3993 +
3513 3994 // Apply toolbar icon colors
3514 3995 const toolbarElements = [
3515 3996 '#mxchat-chatbot .toolbar-btn svg',
3516 3997 '#mxchat-chatbot .active-pdf-name',
@@ -3635,10 +4116,13 @@
3635 4116 function replaceVisitorNamePlaceholder(botId, visitorName) {
3636 4117 var chatBox = getElementDOM(botId, 'chat-box');
3637 4118 if (!chatBox) return;
3638 4119
3639 - // Find the first bot message (intro message)
3640 - var introMessage = chatBox.querySelector('.bot-message');
4120 + // Find the greeting by its marker, not by position (plan a1a79b) —
4121 + // after a persistence restore the first .bot-message is a restored
4122 + // reply, and {visitor_name} was being substituted into that instead.
4123 + // Positional fallback for HTML cached before this release only.
4124 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3641 4125 if (!introMessage) return;
3642 4126
3643 4127 var messageContent = introMessage.querySelector('div[dir="auto"]');
3644 4128 if (!messageContent) return;
@@ -3782,14 +4266,25 @@
3782 4266 return response.json();
3783 4267 })
3784 4268 .then((data) => {
3785 4269 if (data.success) {
3786 - if (data.data.logged_in || data.data.email) {
4270 + // A success response means the server has already decided the
4271 + // required info is on file. With email optional (c0cfaf) that
4272 + // info can be a name and no address, so keying on data.email
4273 + // alone re-prompted a name-only visitor on every page load.
4274 + if (data.data.logged_in || data.data.email || data.data.name) {
3787 4275 showChatContainerForBot(botId);
3788 4276 } else {
3789 4277 showEmailFormForBot(botId);
3790 4278 }
3791 4279 } else {
4280 + // Pre-fill for a signed-in visitor being shown the form
4281 + // (c0cfaf). It arrives on this nocache'd response rather than
4282 + // in the page HTML, which is cacheable. Never overwrite what
4283 + // the visitor has already typed.
4284 + if (data.data) {
4285 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4286 + }
3792 4287 showEmailFormForBot(botId);
3793 4288 }
3794 4289 })
3795 4290 .catch((error) => {
@@ -3796,8 +4291,19 @@
3796 4291 showEmailFormForBot(botId);
3797 4292 });
3798 4293 }
3799 4294
4295 + function prefillEmailForm(botId, name, email) {
4296 + var nameInput = getElementDOM(botId, 'user-name');
4297 + var emailInput = getElementDOM(botId, 'user-email');
4298 + if (nameInput && name && !nameInput.value) {
4299 + nameInput.value = name;
4300 + }
4301 + if (emailInput && email && !emailInput.value) {
4302 + emailInput.value = email;
4303 + }
4304 + }
4305 +
3800 4306 // Event delegation for email form submission
3801 4307 $(document).on('submit', '.email-collection-form', function(e) {
3802 4308 e.preventDefault();
3803 4309 e.stopPropagation();
@@ -3810,23 +4316,34 @@
3810 4316 }
3811 4317
3812 4318 var emailInput = getElementDOM(botId, 'user-email');
3813 4319 var nameInput = getElementDOM(botId, 'user-name');
4320 + var consentInput = getElementDOM(botId, 'user-consent');
3814 4321 var userEmail = emailInput ? emailInput.value.trim() : '';
3815 4322 var userName = nameInput ? nameInput.value.trim() : '';
3816 4323 var sessionId = MxChatInstances.ensureSession(botId);
3817 4324
3818 - // Validate email
3819 - if (!userEmail) {
4325 + // Validate email. The address is optional when the owner turned
4326 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4327 + // still has to be a valid address. The server enforces both rules
4328 + // independently.
4329 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4330 +
4331 + if (emailRequired && !userEmail) {
3820 4332 showEmailError(botId, 'Please enter your email address.');
3821 4333 return false;
3822 4334 }
3823 4335
3824 - if (!isValidEmailAddress(userEmail)) {
4336 + if (userEmail && !isValidEmailAddress(userEmail)) {
3825 4337 showEmailError(botId, 'Please enter a valid email address.');
3826 4338 return false;
3827 4339 }
3828 4340
4341 + if (!emailRequired && !userEmail && !userName) {
4342 + showEmailError(botId, 'Please enter your name or email address.');
4343 + return false;
4344 + }
4345 +
3829 4346 // Validate name if field exists and has content
3830 4347 if (nameInput && userName && !isValidNameInput(userName)) {
3831 4348 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3832 4349 return false;
@@ -3831,8 +4348,15 @@
3831 4348 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3832 4349 return false;
3833 4350 }
3834 4351
4352 + // Consent checkbox (b062c4): backstop behind the native required
4353 + // attribute; the server enforces this independently either way.
4354 + if (consentInput && consentInput.required && !consentInput.checked) {
4355 + showEmailError(botId, 'Please tick the consent box to continue.');
4356 + return false;
4357 + }
4358 +
3835 4359 clearEmailError(botId);
3836 4360 setEmailSubmissionState(botId, true);
3837 4361
3838 4362 // Prepare form data
@@ -3846,8 +4370,14 @@
3846 4370 if (userName) {
3847 4371 formData.append('name', userName);
3848 4372 }
3849 4373
4374 + // Ticked/unticked both travel when the checkbox is rendered, so an
4375 + // optional-consent "no" is recorded as a decision, not an absence.
4376 + if (consentInput) {
4377 + formData.append('consent', consentInput.checked ? '1' : '0');
4378 + }
4379 +
3850 4380 fetch(mxchatChat.ajax_url, {
3851 4381 method: 'POST',
3852 4382 headers: {
3853 4383 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3969,8 +4499,9 @@
3969 4499 $chatbot.removeClass('hidden').addClass('visible');
3970 4500 getElement(botId, 'floating-chatbot-button').addClass('hidden');
3971 4501 handlePreChatDismissal(botId);
3972 4502 disableScroll(); // Disable scroll when chatbot opens
4503 + mxchatFireOpened(botId);
3973 4504
3974 4505 // Load chat history for returning visitors (persistence)
3975 4506 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3976 4507 if (chatPersistenceEnabled) {
@@ -4077,14 +4608,8 @@
4077 4608 if (messageDiv.length > 0) {
4078 4609 const originalHref = $link.attr('href');
4079 4610
4080 4611 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
4081 - e.preventDefault();
4082 - e.stopPropagation();
4083 -
4084 - // Mark as tracked
4085 - $link.attr('data-tracked', 'true');
4086 -
4087 4612 // Get bot ID from the chat box context
4088 4613 var botId = getBotIdFromElement(this);
4089 4614
4090 4615 // Get message context from the message div
@@ -4089,28 +4614,12 @@
4089 4614
4090 4615 // Get message context from the message div
4091 4616 const messageText = messageDiv.text().substring(0, 200);
4092 4617
4093 - $.ajax({
4094 - url: mxchatChat.ajax_url,
4095 - type: 'POST',
4096 - data: {
4097 - action: 'mxchat_track_url_click',
4098 - session_id: getChatSession(botId),
4099 - url: originalHref,
4100 - message_context: messageText,
4101 - nonce: mxchatChat.nonce
4102 - },
4103 - complete: function() {
4104 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
4105 - window.open(originalHref, '_blank');
4106 - } else {
4107 - window.location.href = originalHref;
4108 - }
4109 - }
4110 - });
4111 -
4112 - return false;
4618 + // Same path as attachLinkTracking(): set the target, record the
4619 + // click, and let the browser follow the anchor.
4620 + prepareTrackedLink($link);
4621 + trackLinkClick(originalHref, messageText, botId);
4113 4622 }
4114 4623 }
4115 4624 });
4116 4625
@@ -4136,8 +4645,15 @@
4136 4645 // Initialize chat visibility for all instances
4137 4646 $('.mxchat-chatbot-wrapper').each(function() {
4138 4647 var botId = $(this).data('bot-id') || 'default';
4139 4648 initializeChatVisibility(botId);
4649 + // An embedded widget is on screen from the first paint; a floating one
4650 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4651 + if (isEmbeddedBot(botId)) {
4652 + mxchatFireOpened(botId);
4653 + } else {
4654 + mxchatWatchForOpen(botId);
4655 + }
4140 4656 });
4141 4657
4142 4658 // Make functions globally available for add-ons
4143 4659 window.hasQuickQuestions = hasQuickQuestions;