PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | js/chat-script.js +199 -22 3.2.13trunk View file →
@@ -284,9 +284,20 @@
284 284 var newSessionId = generateSessionId();
285 285 this.setChatSession(botId, newSessionId);
286 286 var $chatBox = getElement(botId, 'chat-box');
287 287 if ($chatBox.length) {
288 - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
288 + // Keep the greeting, drop everything else. Identify the greeting
289 + // by its marker class, NOT by position (plan a1a79b): after a
290 + // chat-persistence restore the first .bot-message is a real
291 + // reply, so ":not(:first)" left a stale answer sitting at the
292 + // top of an otherwise empty box. The positional fallback only
293 + // runs when the marker is absent — a page served from HTML cache
294 + // that predates this release — and behaves exactly as before.
295 + if ($chatBox.find('.mxchat-intro-message').length) {
296 + $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
297 + } else {
298 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
299 + }
289 300 }
290 301 if (this.instances[botId]) {
291 302 this.instances[botId].chatHistoryLoaded = false;
292 303 this.instances[botId].processedMessageIds = new Set();
@@ -396,9 +407,27 @@
396 407 if (parts.length == 2) return parts.pop().split(";").shift();
397 408 }
398 409
399 410 function generateSessionId() {
400 - return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
411 + // Session IDs function as the de-facto bearer token for an anonymous
412 + // chat, so generate them with a CSPRNG when available. Math.random is a
413 + // legacy fallback for ancient/sandboxed environments that lack
414 + // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
415 + // code pattern-matches on it). (plan-0c17b5)
416 + var rand;
417 + try {
418 + if (window.crypto && window.crypto.getRandomValues) {
419 + var buf = new Uint8Array(16); // 128 bits
420 + window.crypto.getRandomValues(buf);
421 + rand = Array.prototype.map.call(buf, function (b) {
422 + return ('0' + b.toString(16)).slice(-2);
423 + }).join('');
424 + }
425 + } catch (e) {}
426 + if (!rand) {
427 + rand = Math.random().toString(36).substr(2, 9); // legacy fallback
428 + }
429 + return 'mxchat_chat_' + rand;
401 430 }
402 431
403 432 // Legacy function - now delegates to instance manager
404 433 function getChatSession(botId) {
@@ -601,8 +630,26 @@
601 630 sendButton.style.pointerEvents = 'none';
602 631 }
603 632 }
604 633
634 +// Whether the input may grab focus after a completed reply (plan 03799f).
635 +// On coarse-pointer devices focusing a text input summons the on-screen
636 +// keyboard over the answer the visitor is trying to read, so 'auto' (the
637 +// default) focuses only on fine-pointer devices. The site-wide
638 +// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
639 +// NOT used on widget open (:~3424) — that focus is a deliberate act and is
640 +// what makes the widget keyboard-accessible.
641 +function mxchatShouldAutofocusAfterReply() {
642 + var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
643 + if (pref === 'on') return true;
644 + if (pref === 'off') return false;
645 + try {
646 + return !window.matchMedia('(pointer: coarse)').matches;
647 + } catch (err) {
648 + return true;
649 + }
650 +}
651 +
605 652 function enableChatInput(botId) {
606 653 botId = botId || 'default';
607 654 var chatInput = getElementDOM(botId, 'chat-input');
608 655 var sendButton = getElementDOM(botId, 'send-button');
@@ -608,9 +655,11 @@
608 655 var sendButton = getElementDOM(botId, 'send-button');
609 656 if (chatInput) {
610 657 chatInput.disabled = false;
611 658 chatInput.style.opacity = '1';
612 - chatInput.focus();
659 + if (mxchatShouldAutofocusAfterReply()) {
660 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 + }
613 662 }
614 663 if (sendButton) {
615 664 sendButton.disabled = false;
616 665 sendButton.style.opacity = '1';
@@ -740,14 +789,16 @@
740 789 }
741 790 appendThinkingMessage(botId);
742 791 scrollToBottom(botId);
743 792
744 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
793 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
745 794
746 795 // Check if streaming is enabled AND supported for this model
747 796 if (shouldUseStreaming(currentModel)) {
748 797 callMxChatStream(message, function(response) {
749 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
798 + // Content is final: releasing aria-busy lets the live region
799 + // announce the completed reply once (plan 67f126).
800 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
750 801 }, botId);
751 802 } else {
752 803 callMxChat(message, function(response) {
753 804 replaceLastMessage("bot", response, '', [], botId);
@@ -780,14 +831,15 @@
780 831 }
781 832 appendThinkingMessage(botId);
782 833 scrollToBottom(botId);
783 834
784 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
835 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
785 836
786 837 // Check if streaming is enabled AND supported for this model
787 838 if (shouldUseStreaming(currentModel)) {
788 839 callMxChatStream(message, function(response) {
789 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
840 + // Final content — release aria-busy so the reply announces once (67f126).
841 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
790 842 }, botId);
791 843 } else {
792 844 callMxChat(message, function(response) {
793 845 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -957,12 +1009,13 @@
957 1009 // Re-send the original message with the new session (user message is already displayed)
958 1010 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
959 1011 if (originalMessage) {
960 1012 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
961 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1013 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
962 1014 if (shouldUseStreaming(currentModel)) {
963 1015 callMxChatStream(originalMessage, function(response) {
964 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1016 + // Final content — release aria-busy so the reply announces once (67f126).
1017 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
965 1018 }, botId);
966 1019 } else {
967 1020 callMxChat(originalMessage, function(response) {
968 1021 replaceLastMessage("bot", response, '', [], botId);
@@ -1110,9 +1163,9 @@
1110 1163
1111 1164 // Store the message in case we need to retry after session reset
1112 1165 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1113 1166
1114 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1167 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
1115 1168 if (!isStreamingSupported(currentModel)) {
1116 1169 callMxChat(message, callback, botId);
1117 1170 return;
1118 1171 }
@@ -1165,8 +1218,12 @@
1165 1218
1166 1219 let accumulatedContent = '';
1167 1220 let testingDataReceived = false;
1168 1221 let streamingStarted = false;
1222 + // Server-pushed html to append as its OWN bot bubble once the stream
1223 + // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1224 + // deferred to [DONE] so the embed always lands BELOW the streamed text.
1225 + let pendingAppendHtml = '';
1169 1226
1170 1227 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1171 1228 // The Stop control (send button swapped in place) aborts both the read
1172 1229 // loop and the underlying request.
@@ -1280,8 +1337,17 @@
1280 1337
1281 1338 // Re-enable chat input after streaming completes
1282 1339 enableChatInput(botId);
1283 1340
1341 + // Render any server-pushed appendix html (e.g. the
1342 + // YouTube embed) as its own bot bubble below the
1343 + // streamed text — mirrors how it is saved in the
1344 + // transcript, so history replays identically.
1345 + if (pendingAppendHtml) {
1346 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1347 + pendingAppendHtml = '';
1348 + }
1349 +
1284 1350 // Scroll the user's last message to the top now
1285 1351 // that the bot's full reply has rendered.
1286 1352 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1287 1353 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1315,8 +1381,21 @@
1315 1381 streamingStarted = true;
1316 1382 accumulatedContent += json.content;
1317 1383 updateStreamingMessage(accumulatedContent, botId);
1318 1384 }
1385 + // Stash appendix html (e.g. video embed) for [DONE]
1386 + else if (json.append_html) {
1387 + pendingAppendHtml = json.append_html;
1388 + }
1389 + // Server-side final pass changed the assembled text
1390 + // (ffef6f: dead-link stripping) — swap the rendered
1391 + // bubble for the validated version. Arrives at most
1392 + // once, just before [DONE].
1393 + else if (json.replace_content) {
1394 + streamingStarted = true;
1395 + accumulatedContent = json.replace_content;
1396 + updateStreamingMessage(accumulatedContent, botId);
1397 + }
1319 1398 // Handle complete response in stream (fallback response)
1320 1399 else if (json.text || json.message || json.html) {
1321 1400 handleNonStreamResponse(json, callback, botId);
1322 1401 return;
@@ -1418,9 +1497,9 @@
1418 1497 // Re-send the original message with the new session (user message is already displayed)
1419 1498 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1420 1499 if (originalMessage) {
1421 1500 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1422 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1501 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1423 1502 if (shouldUseStreaming(currentModel)) {
1424 1503 callMxChatStream(originalMessage, callback, botId);
1425 1504 } else {
1426 1505 callMxChat(originalMessage, callback, botId);
@@ -1553,8 +1632,15 @@
1553 1632 var $chatBox = getElement(botId, 'chat-box');
1554 1633 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1555 1634
1556 1635 if (tempMessage.length) {
1636 + // aria-busy=true for the whole stream: the bubble is rewritten on
1637 + // every chunk, and without busy a polite live region announces those
1638 + // rewrites continuously. Flipped false once the reply is final, so
1639 + // assistive tech announces the completed message ONCE (plan 67f126).
1640 + if (tempMessage.attr('aria-busy') !== 'true') {
1641 + tempMessage.attr('aria-busy', 'true');
1642 + }
1557 1643 // Update existing message
1558 1644 tempMessage.html(formattedContent);
1559 1645 } else {
1560 1646 // Create new temporary message if it doesn't exist
@@ -2048,9 +2134,11 @@
2048 2134
2049 2135 messageDiv.html(fullMessage);
2050 2136
2051 2137 if (isTemporary) {
2052 - messageDiv.addClass('temporary-message');
2138 + // In-flight bubble: hold aria-busy so the live region stays quiet
2139 + // until the content is finalized (plan 67f126).
2140 + messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
2053 2141 }
2054 2142
2055 2143 // Append to the correct chatbot instance's chat-box
2056 2144 var $chatBox = getElement(botId, 'chat-box');
@@ -2185,13 +2273,16 @@
2185 2273 }
2186 2274
2187 2275 if (lastMessageDiv.length) {
2188 2276 // Replace content immediately to prevent visual gap between thinking dots and response
2277 + // aria-busy released AFTER the final content is set, so the live region
2278 + // announces the finished message once (plan 67f126).
2189 2279 lastMessageDiv
2190 2280 .html(fullMessage)
2191 2281 .removeClass('bot-message user-message temporary-message')
2192 2282 .addClass(messageClass)
2193 - .attr('dir', 'auto');
2283 + .attr('dir', 'auto')
2284 + .attr('aria-busy', 'false');
2194 2285
2195 2286 // Only apply inline colors if AI theme is not active (let CSS handle it)
2196 2287 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2197 2288 if (!skipColors) {
@@ -2251,10 +2342,15 @@
2251 2342 var botMessageFontColor = mxchatChat.bot_message_font_color;
2252 2343 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2253 2344
2254 2345 // Build thinking dots HTML - skip inline colors if AI theme is active
2346 + // The dots are decorative; the sr-only span is what the live region
2347 + // announces for the waiting state (plan 67f126). Server-localized
2348 + // string — safe to inject (esc_html__ output, no user content).
2255 2349 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2256 - var thinkingHtml = '<div class="thinking-dots-container">' +
2350 + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2351 + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2352 + '<div class="thinking-dots-container" aria-hidden="true">' +
2257 2353 '<div class="thinking-dots">' +
2258 2354 '<span class="dot"' + dotStyle + '></span>' +
2259 2355 '<span class="dot"' + dotStyle + '></span>' +
2260 2356 '<span class="dot"' + dotStyle + '></span>' +
@@ -2954,9 +3050,30 @@
2954 3050 }
2955 3051
2956 3052 // Only process if there are actual messages
2957 3053 if (response.data.conversation.length > 0) {
2958 - // IMPORTANT: Clear existing messages before loading history
3054 + // Restored history must be SILENT to screen readers
3055 + // (plan 67f126): these are DOM additions inside the
3056 + // live region and would otherwise announce as if
3057 + // they just arrived. Lift aria-live for the batch
3058 + // repopulate, restore it after the browser has
3059 + // processed the mutations.
3060 + var mxLiveRegionEl = $chatBox.get(0);
3061 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3062 + if (mxLiveRegionEl) {
3063 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3064 + }
3065 +
3066 + // IMPORTANT: Clear existing messages before loading history.
3067 + // Detach the greeting first and put it back below —
3068 + // it is server-rendered and never stored in the
3069 + // transcript, so the old unconditional .empty()
3070 + // deleted it for the rest of the page life (plan
3071 + // a1a79b). Detach rather than rebuild: intro_message
3072 + // is not localized to JS, and this node already
3073 + // carries the per-bot inline colors and any
3074 + // {visitor_name} substitution already applied to it.
3075 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2959 3076 $chatBox.empty();
2960 3077
2961 3078 $.each(response.data.conversation, function(index, message) {
2962 3079 // Skip agent messages if persistence is off
@@ -2996,12 +3113,16 @@
2996 3113
2997 3114 // Skip linkify for messages containing structured HTML
2998 3115 // (forms, product cards, galleries, etc.) to avoid
2999 3116 // markdown formatting corrupting HTML attributes
3000 - // (e.g. underscores in name="field_name" becoming <em> tags)
3001 - if (content.includes("mxchat-product-card") ||
3002 - content.includes("mxchat-image-gallery") ||
3003 - content.includes("mxchat-featured-products") ||
3117 + // (e.g. underscores in name="field_name" becoming <em> tags).
3118 + // One family check instead of a per-card literal list: any
3119 + // element carrying an mxchat- prefixed class is MxChat-generated
3120 + // structured markup and replays raw. The old list drifted every
3121 + // time an add-on minted a new card class — the filtered-search
3122 + // card ("mxchat-filtered-product-card") missed it and replayed
3123 + // through linkify as visible markup.
3124 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3004 3125 content.includes("<form") ||
3005 3126 content.includes("<input") ||
3006 3127 content.includes("<select") ||
3007 3128 content.includes("<textarea")) {
@@ -3019,12 +3140,29 @@
3019 3140 instance.processedMessageIds.add(message.id);
3020 3141 }
3021 3142 });
3022 3143
3023 - // Only append messages and scroll if we have content
3144 + // Only append messages and scroll if we have content.
3145 + // Greeting goes back FIRST, above the restored
3146 + // history: "Hello — [earlier conversation]" is the
3147 + // natural reading and matches the order a fresh
3148 + // visitor sees (plan a1a79b).
3149 + if ($mxIntro && $mxIntro.length) {
3150 + $chatBox.append($mxIntro);
3151 + }
3024 3152 $chatBox.append($fragment);
3025 3153 scrollToBottom(botId, true);
3026 3154
3155 + // Re-attach live semantics AFTER the rehydration
3156 + // mutations have been processed with the region off
3157 + // (plan 67f126). Restoring later announces nothing
3158 + // retroactively; new turns announce normally.
3159 + if (mxLiveRegionEl) {
3160 + setTimeout(function() {
3161 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3162 + }, 200);
3163 + }
3164 +
3027 3165 // Collapse quick questions if we have conversation history
3028 3166 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3029 3167 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3030 3168 collapseQuickQuestions(botId);
@@ -3314,8 +3452,30 @@
3314 3452 e.stopPropagation();
3315 3453 var botId = getBotIdFromElement(this);
3316 3454 collapseQuickQuestions(botId);
3317 3455 });
3456 +
3457 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3458 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3459 +// Delegated so it also works for embeds restored from chat history.
3460 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3461 + e.preventDefault();
3462 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3463 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3464 + if (!videoId) {
3465 + return;
3466 + }
3467 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3468 + var $iframe = $('<iframe>', {
3469 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3470 + title: title,
3471 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3472 + allowfullscreen: true,
3473 + frameborder: 0
3474 + }).addClass('mxchat-youtube-iframe');
3475 + $wrap.addClass('mxchat-youtube-playing');
3476 + $(this).replaceWith($iframe);
3477 +});
3318 3478
3319 3479 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3320 3480 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3321 3481 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
@@ -3731,10 +3891,13 @@
3731 3891 function replaceVisitorNamePlaceholder(botId, visitorName) {
3732 3892 var chatBox = getElementDOM(botId, 'chat-box');
3733 3893 if (!chatBox) return;
3734 3894
3735 - // Find the first bot message (intro message)
3736 - var introMessage = chatBox.querySelector('.bot-message');
3895 + // Find the greeting by its marker, not by position (plan a1a79b) —
3896 + // after a persistence restore the first .bot-message is a restored
3897 + // reply, and {visitor_name} was being substituted into that instead.
3898 + // Positional fallback for HTML cached before this release only.
3899 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3737 3900 if (!introMessage) return;
3738 3901
3739 3902 var messageContent = introMessage.querySelector('div[dir="auto"]');
3740 3903 if (!messageContent) return;
@@ -3906,8 +4069,9 @@
3906 4069 }
3907 4070
3908 4071 var emailInput = getElementDOM(botId, 'user-email');
3909 4072 var nameInput = getElementDOM(botId, 'user-name');
4073 + var consentInput = getElementDOM(botId, 'user-consent');
3910 4074 var userEmail = emailInput ? emailInput.value.trim() : '';
3911 4075 var userName = nameInput ? nameInput.value.trim() : '';
3912 4076 var sessionId = MxChatInstances.ensureSession(botId);
3913 4077
@@ -3927,8 +4091,15 @@
3927 4091 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3928 4092 return false;
3929 4093 }
3930 4094
4095 + // Consent checkbox (b062c4): backstop behind the native required
4096 + // attribute; the server enforces this independently either way.
4097 + if (consentInput && consentInput.required && !consentInput.checked) {
4098 + showEmailError(botId, 'Please tick the consent box to continue.');
4099 + return false;
4100 + }
4101 +
3931 4102 clearEmailError(botId);
3932 4103 setEmailSubmissionState(botId, true);
3933 4104
3934 4105 // Prepare form data
@@ -3940,8 +4111,14 @@
3940 4111 });
3941 4112
3942 4113 if (userName) {
3943 4114 formData.append('name', userName);
4115 + }
4116 +
4117 + // Ticked/unticked both travel when the checkbox is rendered, so an
4118 + // optional-consent "no" is recorded as a decision, not an absence.
4119 + if (consentInput) {
4120 + formData.append('consent', consentInput.checked ? '1' : '0');
3944 4121 }
3945 4122
3946 4123 fetch(mxchatChat.ajax_url, {
3947 4124 method: 'POST',