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 +671 -102 3.2.7trunk View file →
@@ -104,8 +104,63 @@
104 104 function refreshNonceIfNeeded(callback) {
105 105 return withFreshNonce(callback);
106 106 }
107 107
108 + // Dynamic-settings refresh (plan-32db95).
109 + //
110 + // Every widget setting ships inline in cached page HTML, so behind a
111 + // full-page cache the site owner can't purge (host cache, CDN, the
112 + // browser itself) a toggled setting looks broken until the cache turns
113 + // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114 + // on the FIRST widget open per page load we ask the nonce endpoint for
115 + // the current behavior-gate settings (?with_settings=1), merge them over
116 + // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117 + // they're server-inline-styled, so a runtime swap would visibly flash.
118 + // On any failure we keep the inline values silently (nonce-fallback
119 + // posture). At most one request per page load, only if a widget opens.
120 + var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121 +
122 + function mxchatRefreshDynamicSettings() {
123 + if (dynamicSettingsState !== 'idle') return;
124 + if (typeof mxchatChat === 'undefined') return;
125 + dynamicSettingsState = 'pending';
126 +
127 + var applied = function (data) {
128 + dynamicSettingsState = 'done';
129 + if (!data) return; // endpoint unavailable — inline values stand.
130 + if (data.nonce) {
131 + // Seed the nonce cache too: saves the first send's REST
132 + // round-trip and keeps us under the endpoint's rate limit.
133 + cachedFreshNonce = data.nonce;
134 + cachedFreshNonceFetchedAt = Date.now();
135 + mxchatChat.nonce = data.nonce;
136 + }
137 + if (data.settings && typeof data.settings === 'object') {
138 + $.extend(mxchatChat, data.settings);
139 + mxchatRebuildHeaderMenus();
140 + }
141 + };
142 +
143 + fetch(getRestNonceUrl() + '?with_settings=1', {
144 + credentials: 'same-origin',
145 + headers: { 'Accept': 'application/json' }
146 + }).then(function (resp) {
147 + if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148 + return resp.json();
149 + }).then(applied).catch(function () {
150 + // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151 + if (mxchatChat.ajax_url) {
152 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153 + .done(function (res) {
154 + applied(res && res.success && res.data ? res.data : null);
155 + })
156 + .fail(function () { applied(null); });
157 + } else {
158 + applied(null);
159 + }
160 + });
161 + }
162 +
108 163 // ====================================
109 164 // MULTI-INSTANCE MANAGEMENT SYSTEM
110 165 // ====================================
111 166
@@ -229,9 +284,20 @@
229 284 var newSessionId = generateSessionId();
230 285 this.setChatSession(botId, newSessionId);
231 286 var $chatBox = getElement(botId, 'chat-box');
232 287 if ($chatBox.length) {
233 - $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 + }
234 300 }
235 301 if (this.instances[botId]) {
236 302 this.instances[botId].chatHistoryLoaded = false;
237 303 this.instances[botId].processedMessageIds = new Set();
@@ -288,8 +354,20 @@
288 354 var id = $floating.attr('id') || '';
289 355 var match = id.match(/floating-chatbot-(.+)/);
290 356 if (match) return match[1];
291 357 }
358 + // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
359 + // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
360 + // .close-pre-chat-message button, which carries only a class and no id — miss both
361 + // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
362 + // and read the suffix. (closest() includes the element itself, so a click directly on
363 + // #pre-chat-message-{bot_id} resolves here too.)
364 + var $preChat = $(element).closest('[id^="pre-chat-message-"]');
365 + if ($preChat.length) {
366 + var preId = $preChat.attr('id') || '';
367 + var preMatch = preId.match(/^pre-chat-message-(.+)$/);
368 + if (preMatch) return preMatch[1];
369 + }
292 370 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
293 371 var elementId = $(element).attr('id') || '';
294 372 if (elementId) {
295 373 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -329,9 +407,27 @@
329 407 if (parts.length == 2) return parts.pop().split(";").shift();
330 408 }
331 409
332 410 function generateSessionId() {
333 - 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;
334 430 }
335 431
336 432 // Legacy function - now delegates to instance manager
337 433 function getChatSession(botId) {
@@ -534,8 +630,26 @@
534 630 sendButton.style.pointerEvents = 'none';
535 631 }
536 632 }
537 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 +
538 652 function enableChatInput(botId) {
539 653 botId = botId || 'default';
540 654 var chatInput = getElementDOM(botId, 'chat-input');
541 655 var sendButton = getElementDOM(botId, 'send-button');
@@ -541,9 +655,11 @@
541 655 var sendButton = getElementDOM(botId, 'send-button');
542 656 if (chatInput) {
543 657 chatInput.disabled = false;
544 658 chatInput.style.opacity = '1';
545 - chatInput.focus();
659 + if (mxchatShouldAutofocusAfterReply()) {
660 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 + }
546 662 }
547 663 if (sendButton) {
548 664 sendButton.disabled = false;
549 665 sendButton.style.opacity = '1';
@@ -548,10 +664,102 @@
548 664 sendButton.disabled = false;
549 665 sendButton.style.opacity = '1';
550 666 sendButton.style.pointerEvents = 'auto';
551 667 }
668 + // Every completion path re-enables input, so this is the single restore
669 + // point for the streaming Stop affordance (no-op when not in stop mode).
670 + mxchatRestoreSendButton(botId);
552 671 }
553 672
673 +// --- Streaming Stop control -------------------------------------------------
674 +// One live stream handle per bot instance, so Stop on one widget never aborts
675 +// another bot on the same page.
676 +var mxchatActiveStreams = {};
677 +// Original send-button markup, captured once per bot the first time the Stop
678 +// state is shown (never captured while already in stop mode, so a rapid
679 +// stop-then-resend can't save the stop glyph as the "original").
680 +var mxchatSendMarkup = {};
681 +
682 +function mxchatShowStopButton(botId) {
683 + var btn = getElementDOM(botId, 'send-button');
684 + if (!btn) return;
685 + if (!btn.classList.contains('mxchat-stop-mode')) {
686 + mxchatSendMarkup[botId] = {
687 + html: btn.innerHTML,
688 + label: btn.getAttribute('aria-label')
689 + };
690 + }
691 +
692 + // Mirror the send icon's rendered size + color so the stop glyph looks
693 + // native, including custom send images/colors and theme overrides.
694 + var child = btn.querySelector('svg, img');
695 + var size = 25;
696 + var color = '';
697 + if (child) {
698 + var rect = child.getBoundingClientRect();
699 + if (rect.width) {
700 + size = Math.round(Math.min(rect.width, rect.height));
701 + }
702 + var cs = window.getComputedStyle(child);
703 + color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
704 + }
705 + var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
706 + btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" style="width:' + size + 'px;height:' + size + 'px;' + (color ? 'fill:' + color + ';' : '') + '"><rect x="5" y="5" width="14" height="14" rx="3"></rect></svg>';
707 + // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
708 + // start a stream synchronously while the originating click is still
709 + // bubbling up to our delegated handler. Without this guard, that handler
710 + // reads the just-added stop-mode class as a user Stop press and aborts the
711 + // brand-new stream — the user's message renders but no reply ever fires
712 + // (plan-4bba64 silent message loss). The flag only spans the current event
713 + // dispatch: cleared on the next macrotask, long before a real Stop click.
714 + btn.__mxchatStopJustShown = true;
715 + setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
716 + btn.classList.add('mxchat-stop-mode');
717 + btn.setAttribute('aria-label', stopLabel);
718 + btn.setAttribute('title', stopLabel);
719 + // disableChatInput() ran when the turn was sent; the Stop control itself
720 + // must stay clickable while the textarea remains disabled.
721 + btn.disabled = false;
722 + btn.style.opacity = '1';
723 + btn.style.pointerEvents = 'auto';
724 +}
725 +
726 +function mxchatRestoreSendButton(botId) {
727 + var btn = getElementDOM(botId, 'send-button');
728 + var saved = mxchatSendMarkup[botId];
729 + if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
730 + btn.innerHTML = saved.html;
731 + btn.classList.remove('mxchat-stop-mode');
732 + btn.removeAttribute('title');
733 + if (saved.label) {
734 + btn.setAttribute('aria-label', saved.label);
735 + }
736 +}
737 +
738 +function mxchatStopStreaming(botId) {
739 + var entry = mxchatActiveStreams[botId];
740 + if (!entry || !entry.controller) return;
741 + entry.aborted = true;
742 + try { entry.controller.abort(); } catch (e) {}
743 +}
744 +
745 +// Returns true when a stream rejection came from an intentional Stop click:
746 +// keep the partial text as the turn's answer — no error UI, no fallback resend.
747 +function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
748 + var entry = mxchatActiveStreams[botId];
749 + if (!entry || !entry.aborted) return false;
750 + delete mxchatActiveStreams[botId];
751 + if (!accumulatedContent) {
752 + // Stopped before the first chunk: drop the thinking bubble, no orphan message.
753 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
754 + }
755 + enableChatInput(botId); // also restores the send icon
756 + if (callback) {
757 + callback(accumulatedContent || '');
758 + }
759 + return true;
760 +}
761 +
554 762 // Update your existing sendMessage function
555 763 function sendMessage(botId) {
556 764 botId = botId || 'default';
557 765 MxChatInstances.ensureSession(botId);
@@ -572,8 +780,9 @@
572 780 }
573 781
574 782 appendMessage("user", message, '', [], false, botId);
575 783 $chatInput.val('');
784 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
576 785 $chatInput.css('height', 'auto');
577 786
578 787 if (hasQuickQuestions(botId)) {
579 788 collapseQuickQuestions(botId);
@@ -580,14 +789,16 @@
580 789 }
581 790 appendThinkingMessage(botId);
582 791 scrollToBottom(botId);
583 792
584 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
793 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
585 794
586 795 // Check if streaming is enabled AND supported for this model
587 796 if (shouldUseStreaming(currentModel)) {
588 797 callMxChatStream(message, function(response) {
589 - 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');
590 801 }, botId);
591 802 } else {
592 803 callMxChat(message, function(response) {
593 804 replaceLastMessage("bot", response, '', [], botId);
@@ -620,14 +831,15 @@
620 831 }
621 832 appendThinkingMessage(botId);
622 833 scrollToBottom(botId);
623 834
624 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
835 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
625 836
626 837 // Check if streaming is enabled AND supported for this model
627 838 if (shouldUseStreaming(currentModel)) {
628 839 callMxChatStream(message, function(response) {
629 - 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');
630 842 }, botId);
631 843 } else {
632 844 callMxChat(message, function(response) {
633 845 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -691,8 +903,15 @@
691 903
692 904 function callMxChat(message, callback, botId) {
693 905 botId = botId || getMxChatBotId();
694 906
907 + // Streaming fallbacks land here: drop any leftover stream handle and
908 + // return the button to its send state (no-op for plain non-stream turns).
909 + if (mxchatActiveStreams[botId]) {
910 + delete mxchatActiveStreams[botId];
911 + }
912 + mxchatRestoreSendButton(botId);
913 +
695 914 // Store the message in case we need to retry after session reset
696 915 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
697 916
698 917 // Get page context if contextual awareness is enabled
@@ -790,12 +1009,13 @@
790 1009 // Re-send the original message with the new session (user message is already displayed)
791 1010 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
792 1011 if (originalMessage) {
793 1012 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
794 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1013 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
795 1014 if (shouldUseStreaming(currentModel)) {
796 1015 callMxChatStream(originalMessage, function(response) {
797 - 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');
798 1018 }, botId);
799 1019 } else {
800 1020 callMxChat(originalMessage, function(response) {
801 1021 replaceLastMessage("bot", response, '', [], botId);
@@ -943,9 +1163,9 @@
943 1163
944 1164 // Store the message in case we need to retry after session reset
945 1165 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
946 1166
947 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1167 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
948 1168 if (!isStreamingSupported(currentModel)) {
949 1169 callMxChat(message, callback, botId);
950 1170 return;
951 1171 }
@@ -998,13 +1218,25 @@
998 1218
999 1219 let accumulatedContent = '';
1000 1220 let testingDataReceived = false;
1001 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 = '';
1002 1226
1227 + // Abortable stream: a fresh controller per turn, keyed by bot instance.
1228 + // The Stop control (send button swapped in place) aborts both the read
1229 + // loop and the underlying request.
1230 + var streamControl = { controller: new AbortController(), aborted: false };
1231 + mxchatActiveStreams[botId] = streamControl;
1232 + mxchatShowStopButton(botId);
1233 +
1003 1234 fetch(mxchatChat.ajax_url, {
1004 1235 method: 'POST',
1005 1236 body: formData,
1006 - credentials: 'same-origin'
1237 + credentials: 'same-origin',
1238 + signal: streamControl.controller.signal
1007 1239 })
1008 1240 .then(response => {
1009 1241 // Store the response for potential fallback handling
1010 1242 const responseClone = response.clone();
@@ -1105,8 +1337,17 @@
1105 1337
1106 1338 // Re-enable chat input after streaming completes
1107 1339 enableChatInput(botId);
1108 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 +
1109 1350 // Scroll the user's last message to the top now
1110 1351 // that the bot's full reply has rendered.
1111 1352 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1112 1353 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1140,8 +1381,21 @@
1140 1381 streamingStarted = true;
1141 1382 accumulatedContent += json.content;
1142 1383 updateStreamingMessage(accumulatedContent, botId);
1143 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 + }
1144 1398 // Handle complete response in stream (fallback response)
1145 1399 else if (json.text || json.message || json.html) {
1146 1400 handleNonStreamResponse(json, callback, botId);
1147 1401 return;
@@ -1171,8 +1425,9 @@
1171 1425 }
1172 1426
1173 1427 processStream();
1174 1428 }).catch(streamError => {
1429 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1175 1430 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1176 1431 callMxChat(message, callback, botId);
1177 1432 });
1178 1433 }
@@ -1179,8 +1434,9 @@
1179 1434
1180 1435 processStream();
1181 1436 })
1182 1437 .catch(error => {
1438 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1183 1439 // Check if we have server error data with chat mode
1184 1440 if (error && error.isServerError && error.data) {
1185 1441 // Check for chat mode in error data
1186 1442 if (error.data.chat_mode) {
@@ -1241,9 +1497,9 @@
1241 1497 // Re-send the original message with the new session (user message is already displayed)
1242 1498 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1243 1499 if (originalMessage) {
1244 1500 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1245 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1501 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1246 1502 if (shouldUseStreaming(currentModel)) {
1247 1503 callMxChatStream(originalMessage, callback, botId);
1248 1504 } else {
1249 1505 callMxChat(originalMessage, callback, botId);
@@ -1376,8 +1632,15 @@
1376 1632 var $chatBox = getElement(botId, 'chat-box');
1377 1633 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1378 1634
1379 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 + }
1380 1643 // Update existing message
1381 1644 tempMessage.html(formattedContent);
1382 1645 } else {
1383 1646 // Create new temporary message if it doesn't exist
@@ -1404,8 +1667,17 @@
1404 1667 // Update the event handlers to use the correct function names (using event delegation)
1405 1668 // Use class-based selectors for multi-instance support
1406 1669 $(document).on('click', '.send-button', function() {
1407 1670 var botId = getBotIdFromElement(this);
1671 + // While a response is streaming the button is a Stop control.
1672 + if (this.classList.contains('mxchat-stop-mode')) {
1673 + // Same click that just started this stream (an add-on's direct handler
1674 + // ran before this delegated one) — not a Stop press. See
1675 + // mxchatShowStopButton for the full story (plan-4bba64).
1676 + if (this.__mxchatStopJustShown) return;
1677 + mxchatStopStreaming(botId);
1678 + return;
1679 + }
1408 1680 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1409 1681 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1410 1682 disableChatInput(botId);
1411 1683 }
@@ -1424,8 +1696,73 @@
1424 1696 sendMessage(botId);
1425 1697 }
1426 1698 });
1427 1699
1700 +// Chat input character counter + soft limit feedback (plan 7091a2).
1701 +// Language-neutral: numbers + color only, no translatable strings. The counter
1702 +// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1703 +// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1704 +// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1705 +function mxchatUpdateCharCounter(inputEl) {
1706 + if (!inputEl || !inputEl.closest) return;
1707 + var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1708 + var container = inputEl.closest('.input-container');
1709 + if (!container || !max || max <= 0) return;
1710 + var counter = container.querySelector('.mxchat-char-counter');
1711 + if (!counter) return;
1712 + var len = inputEl.value.length;
1713 + var ratio = len / max;
1714 + var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1715 + var cur = counter.querySelector('.mxchat-char-counter-current');
1716 + if (cur) cur.textContent = len;
1717 + var warn = ratio >= nearThreshold && len < max;
1718 + var full = len >= max;
1719 + counter.classList.toggle('is-visible', ratio >= nearThreshold);
1720 + counter.classList.toggle('is-warn', warn);
1721 + counter.classList.toggle('is-full', full);
1722 + container.classList.toggle('mxchat-input-near-limit', warn);
1723 + container.classList.toggle('mxchat-input-at-limit', full);
1724 +}
1725 +
1726 +function mxchatBumpInput(inputEl) {
1727 + var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1728 + if (!container) return;
1729 + container.classList.remove('mxchat-input-bump');
1730 + void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1731 + container.classList.add('mxchat-input-bump');
1732 + clearTimeout($(container).data('mxchatBumpTimeout'));
1733 + var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1734 + $(container).data('mxchatBumpTimeout', t);
1735 +}
1736 +
1737 +// Live counter update on every input.
1738 +$(document).on('input', '.chat-input', function() {
1739 + mxchatUpdateCharCounter(this);
1740 +});
1741 +
1742 +// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1743 +// rejected at the cap (maxlength silently swallows it otherwise).
1744 +$(document).on('keydown', '.chat-input', function(e) {
1745 + var max = parseInt(this.getAttribute('maxlength'), 10);
1746 + if (!max || max <= 0 || this.value.length < max) return;
1747 + if (e.ctrlKey || e.metaKey || e.altKey) return;
1748 + // A single printable char with no selection to overwrite WILL be rejected.
1749 + if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1750 + mxchatBumpInput(this);
1751 + }
1752 +});
1753 +
1754 +// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1755 +$(document).on('paste', '.chat-input', function() {
1756 + var el = this;
1757 + var max = parseInt(el.getAttribute('maxlength'), 10);
1758 + if (!max || max <= 0) return;
1759 + setTimeout(function() {
1760 + mxchatUpdateCharCounter(el);
1761 + if (el.value.length >= max) mxchatBumpInput(el);
1762 + }, 0);
1763 +});
1764 +
1428 1765 // Builds the list of overflow-menu items for a given bot.
1429 1766 // Adding a future item is one push to this array — do NOT hardcode "only download."
1430 1767 function mxchatGetHeaderMenuItems(botId) {
1431 1768 var items = [];
@@ -1443,8 +1780,26 @@
1443 1780 }
1444 1781 });
1445 1782 }
1446 1783
1784 + // "Start new chat" — surfaces the EXISTING per-conversation reset
1785 + // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1786 + // without the site owner disabling chat persistence globally. Default OFF;
1787 + // gated by the reset_chat_enabled option. plan ac2e81.
1788 + if (settings.reset_chat_enabled === 'on') {
1789 + items.push({
1790 + id: 'reset-chat',
1791 + label: settings.reset_chat_label || 'Start new chat',
1792 + 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>',
1793 + action: function() {
1794 + var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1795 + if (window.confirm(confirmMsg)) {
1796 + MxChatInstances.resetChatSession(botId);
1797 + }
1798 + }
1799 + });
1800 + }
1801 +
1447 1802 return items;
1448 1803 }
1449 1804
1450 1805 // Builds a clean markdown transcript of the current conversation and triggers
@@ -1522,30 +1877,31 @@
1522 1877 var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1523 1878 if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1524 1879 }
1525 1880
1526 -// One-time per-widget init: renders menu items, wires open/close,
1527 -// outside-click, Escape, and arrow-key navigation. If no items, hides the trigger.
1528 -function mxchatInitHeaderMenu(botId) {
1529 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1530 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1531 -
1881 +// Renders (or re-renders) the item list for one menu wrap. Split out of
1882 +// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1883 +// rebuild items + trigger visibility WITHOUT re-binding the one-time
1884 +// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1885 +// a rebuild before init (never happens, but harmless) just skips it.
1886 +function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1532 1887 var $trigger = $wrap.find('.mxchat-menu-trigger');
1533 1888 var $menu = $wrap.find('.mxchat-header-menu');
1534 1889 var items = mxchatGetHeaderMenuItems(botId);
1535 1890
1536 - // Initial color sync — covers normal page load.
1537 - mxchatSyncMenuColors(botId, $wrap);
1891 + $menu.empty();
1538 1892
1539 1893 if (!items.length) {
1540 1894 $trigger.hide();
1541 1895 $menu.hide();
1542 - $wrap.data('mxchatMenuReady', true);
1543 1896 return;
1544 1897 }
1545 1898
1546 - // Build the menu items.
1547 - $menu.empty();
1899 + // Clear any inline display:none a previous zero-item render left behind —
1900 + // open/close visibility is governed by the hidden prop + is-open class.
1901 + $trigger.css('display', '');
1902 + $menu.css('display', '');
1903 +
1548 1904 items.forEach(function(item, idx) {
1549 1905 var $btn = $('<button>', {
1550 1906 type: 'button',
1551 1907 'class': 'mxchat-menu-item',
@@ -1558,14 +1914,50 @@
1558 1914 $btn.find('.mxchat-menu-item-label').text(item.label);
1559 1915 $btn.on('click', function(e) {
1560 1916 e.preventDefault();
1561 1917 e.stopPropagation();
1562 - closeMenu();
1918 + if (closeMenuFn) closeMenuFn();
1563 1919 try { item.action(); } catch (err) { /* no-op */ }
1564 1920 });
1565 1921 $menu.append($btn);
1566 1922 });
1923 +}
1567 1924
1925 +// Re-render every menu on the page after a dynamic-settings merge
1926 +// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1927 +// swapping items under the user mid-interaction yanks focus — and the
1928 +// rebuild runs when it closes instead (closeMenu checks the pending flag).
1929 +function mxchatRebuildHeaderMenus() {
1930 + $('.mxchat-header-menu-wrap').each(function() {
1931 + var $wrap = $(this);
1932 + var botId = $wrap.data('bot-id');
1933 + if (!botId) return;
1934 + if (!$wrap.data('mxchatMenuReady')) {
1935 + mxchatInitHeaderMenu(botId);
1936 + return;
1937 + }
1938 + if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1939 + $wrap.data('mxchatMenuRebuildPending', true);
1940 + return;
1941 + }
1942 + mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1943 + });
1944 +}
1945 +
1946 +// One-time per-widget init: renders menu items, wires open/close,
1947 +// outside-click, Escape, and arrow-key navigation. If no items, hides the
1948 +// trigger. Wiring happens even when there are zero items at init, so a
1949 +// later dynamic-settings rebuild that adds items has a working trigger.
1950 +function mxchatInitHeaderMenu(botId) {
1951 + var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1952 + if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1953 +
1954 + var $trigger = $wrap.find('.mxchat-menu-trigger');
1955 + var $menu = $wrap.find('.mxchat-header-menu');
1956 +
1957 + // Initial color sync — covers normal page load.
1958 + mxchatSyncMenuColors(botId, $wrap);
1959 +
1568 1960 function openMenu() {
1569 1961 // Re-sync each open in case the active theme changed since init.
1570 1962 mxchatSyncMenuColors(botId, $wrap);
1571 1963 $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
@@ -1579,8 +1971,14 @@
1579 1971 $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1580 1972 $trigger.attr('aria-expanded', 'false');
1581 1973 $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1582 1974 if (returnFocus) $trigger.trigger('focus');
1975 + // A dynamic-settings rebuild that arrived while the menu was open
1976 + // was deferred (mxchatRebuildHeaderMenus) — run it now.
1977 + if ($wrap.data('mxchatMenuRebuildPending')) {
1978 + $wrap.removeData('mxchatMenuRebuildPending');
1979 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1980 + }
1583 1981 }
1584 1982
1585 1983 // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1586 1984 // click-to-collapse handler does not fire.
@@ -1634,8 +2032,13 @@
1634 2032 openMenu();
1635 2033 }
1636 2034 });
1637 2035
2036 + // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2037 + // then do the initial item render.
2038 + $wrap.data('mxchatMenuClose', closeMenu);
2039 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2040 +
1638 2041 $wrap.data('mxchatMenuReady', true);
1639 2042 }
1640 2043
1641 2044 // Initialize header menus for every rendered widget on DOM ready.
@@ -1643,8 +2046,18 @@
1643 2046 $('.mxchat-header-menu-wrap').each(function() {
1644 2047 var botId = $(this).data('bot-id');
1645 2048 if (botId) mxchatInitHeaderMenu(botId);
1646 2049 });
2050 +
2051 + // Embedded (non-floating) widgets are open from the moment the page
2052 + // renders — refresh dynamic settings at init (plan-32db95). Floating
2053 + // widgets refresh on first launcher open instead.
2054 + var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2055 + return !$(this).closest('.floating-chatbot').length;
2056 + }).length > 0;
2057 + if (hasEmbeddedWidget) {
2058 + mxchatRefreshDynamicSettings();
2059 + }
1647 2060 });
1648 2061
1649 2062 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1650 2063 try {
@@ -1721,9 +2134,11 @@
1721 2134
1722 2135 messageDiv.html(fullMessage);
1723 2136
1724 2137 if (isTemporary) {
1725 - 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');
1726 2141 }
1727 2142
1728 2143 // Append to the correct chatbot instance's chat-box
1729 2144 var $chatBox = getElement(botId, 'chat-box');
@@ -1858,13 +2273,16 @@
1858 2273 }
1859 2274
1860 2275 if (lastMessageDiv.length) {
1861 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).
1862 2279 lastMessageDiv
1863 2280 .html(fullMessage)
1864 2281 .removeClass('bot-message user-message temporary-message')
1865 2282 .addClass(messageClass)
1866 - .attr('dir', 'auto');
2283 + .attr('dir', 'auto')
2284 + .attr('aria-busy', 'false');
1867 2285
1868 2286 // Only apply inline colors if AI theme is not active (let CSS handle it)
1869 2287 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1870 2288 if (!skipColors) {
@@ -1924,10 +2342,15 @@
1924 2342 var botMessageFontColor = mxchatChat.bot_message_font_color;
1925 2343 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1926 2344
1927 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).
1928 2349 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1929 - 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">' +
1930 2353 '<div class="thinking-dots">' +
1931 2354 '<span class="dot"' + dotStyle + '></span>' +
1932 2355 '<span class="dot"' + dotStyle + '></span>' +
1933 2356 '<span class="dot"' + dotStyle + '></span>' +
@@ -2627,9 +3050,30 @@
2627 3050 }
2628 3051
2629 3052 // Only process if there are actual messages
2630 3053 if (response.data.conversation.length > 0) {
2631 - // 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();
2632 3076 $chatBox.empty();
2633 3077
2634 3078 $.each(response.data.conversation, function(index, message) {
2635 3079 // Skip agent messages if persistence is off
@@ -2669,12 +3113,16 @@
2669 3113
2670 3114 // Skip linkify for messages containing structured HTML
2671 3115 // (forms, product cards, galleries, etc.) to avoid
2672 3116 // markdown formatting corrupting HTML attributes
2673 - // (e.g. underscores in name="field_name" becoming <em> tags)
2674 - if (content.includes("mxchat-product-card") ||
2675 - content.includes("mxchat-image-gallery") ||
2676 - 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) ||
2677 3125 content.includes("<form") ||
2678 3126 content.includes("<input") ||
2679 3127 content.includes("<select") ||
2680 3128 content.includes("<textarea")) {
@@ -2692,12 +3140,29 @@
2692 3140 instance.processedMessageIds.add(message.id);
2693 3141 }
2694 3142 });
2695 3143
2696 - // 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 + }
2697 3152 $chatBox.append($fragment);
2698 3153 scrollToBottom(botId, true);
2699 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 +
2700 3165 // Collapse quick questions if we have conversation history
2701 3166 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2702 3167 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2703 3168 collapseQuickQuestions(botId);
@@ -2792,10 +3257,10 @@
2792 3257 .then(data => {
2793 3258 if (data.success) {
2794 3259 container.style.display = 'none';
2795 3260 nameElement.textContent = '';
2796 - activePdfFile = null;
2797 - appendMessage('bot', 'PDF removed.');
3261 + instance.activePdfFile = null;
3262 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2798 3263 }
2799 3264 })
2800 3265 .catch(error => {
2801 3266 // Error removing PDF - silently continue
@@ -2801,14 +3266,16 @@
2801 3266 // Error removing PDF - silently continue
2802 3267 });
2803 3268 }
2804 3269
2805 - function removeActiveWord() {
2806 - const container = document.getElementById('active-word-container');
2807 - const nameElement = document.getElementById('active-word-name');
2808 -
2809 - if (!container || !nameElement || !activeWordFile) return;
2810 -
3270 + function removeActiveWord(botId) {
3271 + botId = botId || 'default';
3272 + var instance = MxChatInstances.get(botId);
3273 + const container = getElementDOM(botId, 'active-word-container');
3274 + const nameElement = getElementDOM(botId, 'active-word-name');
3275 +
3276 + if (!container || !nameElement || !instance.activeWordFile) return;
3277 +
2811 3278 fetch(mxchatChat.ajax_url, {
2812 3279 method: 'POST',
2813 3280 headers: {
2814 3281 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2814,9 +3281,9 @@
2814 3281 'Content-Type': 'application/x-www-form-urlencoded',
2815 3282 },
2816 3283 body: new URLSearchParams({
2817 3284 'action': 'mxchat_remove_word',
2818 - 'session_id': sessionId,
3285 + 'session_id': getChatSession(botId),
2819 3286 'nonce': mxchatChat.nonce
2820 3287 })
2821 3288 })
2822 3289 .then(response => response.json())
@@ -2823,10 +3290,10 @@
2823 3290 .then(data => {
2824 3291 if (data.success) {
2825 3292 container.style.display = 'none';
2826 3293 nameElement.textContent = '';
2827 - activeWordFile = null;
2828 - appendMessage('bot', 'Word document removed.');
3294 + instance.activeWordFile = null;
3295 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2829 3296 }
2830 3297 })
2831 3298 .catch(error => {
2832 3299 // Error removing Word document - silently continue
@@ -2985,8 +3452,30 @@
2985 3452 e.stopPropagation();
2986 3453 var botId = getBotIdFromElement(this);
2987 3454 collapseQuickQuestions(botId);
2988 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 +});
2989 3478
2990 3479 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2991 3480 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
2992 3481 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
@@ -3006,8 +3495,13 @@
3006 3495 $badge.hide(); // Hide notification when opening chat
3007 3496 disableScroll();
3008 3497 $preChat.fadeOut(250);
3009 3498
3499 + // First open per page load: re-fetch behavior settings in case
3500 + // this page's inline values came from a stale full-page cache
3501 + // (plan-32db95). Idempotent — later opens are a no-op.
3502 + mxchatRefreshDynamicSettings();
3503 +
3010 3504 // Load chat history for returning visitors (persistence)
3011 3505 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3012 3506 if (chatPersistenceEnabled) {
3013 3507 MxChatInstances.ensureSession(botId);
@@ -3091,17 +3585,20 @@
3091 3585 var wordInput = getElementDOM(botId, 'word-upload');
3092 3586 if (wordInput) wordInput.click();
3093 3587 });
3094 3588
3095 - // PDF file input change handler
3096 - addSafeEventListener('pdf-upload', 'change', async function(e) {
3097 - const file = e.target.files[0];
3098 -
3589 + // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3590 + $(document).on('change', '.pdf-upload', async function(e) {
3591 + var botId = getBotIdFromElement(this);
3592 + var instance = MxChatInstances.get(botId);
3593 + const file = this.files[0];
3594 + const sessionId = MxChatInstances.ensureSession(botId);
3595 +
3099 3596 if (!file || file.type !== 'application/pdf') {
3100 3597 alert('Please select a valid PDF file.');
3101 3598 return;
3102 3599 }
3103 -
3600 +
3104 3601 if (!sessionId) {
3105 3602 alert('Error: No session ID found');
3106 3603 return;
3107 3604 }
@@ -3109,14 +3606,15 @@
3109 3606 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3110 3607 alert('Error: Ajax configuration missing');
3111 3608 return;
3112 3609 }
3113 -
3610 +
3114 3611 // Disable buttons and show loading state
3115 - const uploadBtn = document.getElementById('pdf-upload-btn');
3116 - const sendBtn = document.getElementById('send-button');
3612 + const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3613 + const sendBtn = getElementDOM(botId, 'send-button');
3614 + if (!uploadBtn) return;
3117 3615 const originalBtnContent = uploadBtn.innerHTML;
3118 -
3616 +
3119 3617 try {
3120 3618 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3121 3619 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3122 3620 const formData = new FormData();
@@ -3123,35 +3621,34 @@
3123 3621 formData.append('action', 'mxchat_upload_pdf');
3124 3622 formData.append('pdf_file', file);
3125 3623 formData.append('session_id', sessionId);
3126 3624 formData.append('nonce', mxchatChat.nonce);
3127 -
3625 +
3128 3626 uploadBtn.disabled = true;
3129 - sendBtn.disabled = true;
3627 + if (sendBtn) sendBtn.disabled = true;
3130 3628 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3131 3629 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3132 3630 </svg>`;
3133 -
3631 +
3134 3632 const response = await fetch(mxchatChat.ajax_url, {
3135 3633 method: 'POST',
3136 3634 body: formData
3137 3635 });
3138 -
3636 +
3139 3637 const data = await response.json();
3140 -
3638 +
3141 3639 if (data.success) {
3142 3640 // Hide popular questions if they exist
3143 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3144 - if (hasQuickQuestions()) {
3145 - collapseQuickQuestions();
3641 + if (hasQuickQuestions(botId)) {
3642 + collapseQuickQuestions(botId);
3146 3643 }
3147 -
3644 +
3148 3645 // Show the active PDF name
3149 - showActivePdf(data.data.filename);
3150 -
3151 - appendMessage('bot', data.data.message);
3152 - scrollToBottom();
3153 - activePdfFile = data.data.filename;
3646 + showActivePdf(data.data.filename, botId);
3647 +
3648 + appendMessage('bot', data.data.message, '', [], false, botId);
3649 + scrollToBottom(botId);
3650 + instance.activePdfFile = data.data.filename;
3154 3651 } else {
3155 3652 alert('Failed to upload PDF. Please try again.');
3156 3653 }
3157 3654 } catch (error) {
@@ -3157,33 +3654,42 @@
3157 3654 } catch (error) {
3158 3655 alert('Error uploading file. Please try again.');
3159 3656 } finally {
3160 3657 uploadBtn.disabled = false;
3161 - sendBtn.disabled = false;
3658 + if (sendBtn) sendBtn.disabled = false;
3162 3659 uploadBtn.innerHTML = originalBtnContent;
3163 3660 this.value = ''; // Reset file input
3164 3661 }
3165 3662 });
3166 3663
3167 - // Word file input change handler
3168 - addSafeEventListener('word-upload', 'change', async function(e) {
3169 - const file = e.target.files[0];
3170 -
3664 + // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3665 + $(document).on('change', '.word-upload', async function(e) {
3666 + var botId = getBotIdFromElement(this);
3667 + var instance = MxChatInstances.get(botId);
3668 + const file = this.files[0];
3669 + const sessionId = MxChatInstances.ensureSession(botId);
3670 +
3171 3671 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3172 3672 alert('Please select a valid Word document (.docx).');
3173 3673 return;
3174 3674 }
3175 -
3675 +
3176 3676 if (!sessionId) {
3177 3677 alert('Error: No session ID found');
3178 3678 return;
3179 3679 }
3180 3680
3681 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3682 + alert('Error: Ajax configuration missing');
3683 + return;
3684 + }
3685 +
3181 3686 // Disable buttons and show loading state
3182 - const uploadBtn = document.getElementById('word-upload-btn');
3183 - const sendBtn = document.getElementById('send-button');
3687 + const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3688 + const sendBtn = getElementDOM(botId, 'send-button');
3689 + if (!uploadBtn) return;
3184 3690 const originalBtnContent = uploadBtn.innerHTML;
3185 -
3691 +
3186 3692 try {
3187 3693 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3188 3694 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3189 3695 const formData = new FormData();
@@ -3190,35 +3696,34 @@
3190 3696 formData.append('action', 'mxchat_upload_word');
3191 3697 formData.append('word_file', file);
3192 3698 formData.append('session_id', sessionId);
3193 3699 formData.append('nonce', mxchatChat.nonce);
3194 -
3700 +
3195 3701 uploadBtn.disabled = true;
3196 - sendBtn.disabled = true;
3702 + if (sendBtn) sendBtn.disabled = true;
3197 3703 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3198 3704 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3199 3705 </svg>`;
3200 -
3706 +
3201 3707 const response = await fetch(mxchatChat.ajax_url, {
3202 3708 method: 'POST',
3203 3709 body: formData
3204 3710 });
3205 -
3711 +
3206 3712 const data = await response.json();
3207 -
3713 +
3208 3714 if (data.success) {
3209 3715 // Hide popular questions if they exist
3210 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3211 - if (hasQuickQuestions()) {
3212 - collapseQuickQuestions();
3716 + if (hasQuickQuestions(botId)) {
3717 + collapseQuickQuestions(botId);
3213 3718 }
3214 -
3719 +
3215 3720 // Show the active Word document name
3216 - showActiveWord(data.data.filename);
3217 -
3218 - appendMessage('bot', data.data.message);
3219 - scrollToBottom();
3220 - activeWordFile = data.data.filename;
3721 + showActiveWord(data.data.filename, botId);
3722 +
3723 + appendMessage('bot', data.data.message, '', [], false, botId);
3724 + scrollToBottom(botId);
3725 + instance.activeWordFile = data.data.filename;
3221 3726 } else {
3222 3727 alert('Failed to upload Word document. Please try again.');
3223 3728 }
3224 3729 } catch (error) {
@@ -3224,25 +3729,25 @@
3224 3729 } catch (error) {
3225 3730 alert('Error uploading file. Please try again.');
3226 3731 } finally {
3227 3732 uploadBtn.disabled = false;
3228 - sendBtn.disabled = false;
3733 + if (sendBtn) sendBtn.disabled = false;
3229 3734 uploadBtn.innerHTML = originalBtnContent;
3230 3735 this.value = ''; // Reset file input
3231 3736 }
3232 3737 });
3233 3738
3234 - // Remove button click handlers
3235 - document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3739 + // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3740 + $(document).on('click', '.remove-pdf-btn', function(e) {
3236 3741 e.preventDefault();
3237 3742 e.stopPropagation();
3238 - removeActivePdf();
3743 + removeActivePdf(getBotIdFromElement(this));
3239 3744 });
3240 -
3241 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3745 +
3746 + $(document).on('click', '.remove-word-btn', function(e) {
3242 3747 e.preventDefault();
3243 3748 e.stopPropagation();
3244 - removeActiveWord();
3749 + removeActiveWord(getBotIdFromElement(this));
3245 3750 });
3246 3751
3247 3752 // Window resize handlers
3248 3753 $(window).on('resize orientationchange', function() {
@@ -3386,10 +3891,13 @@
3386 3891 function replaceVisitorNamePlaceholder(botId, visitorName) {
3387 3892 var chatBox = getElementDOM(botId, 'chat-box');
3388 3893 if (!chatBox) return;
3389 3894
3390 - // Find the first bot message (intro message)
3391 - 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');
3392 3900 if (!introMessage) return;
3393 3901
3394 3902 var messageContent = introMessage.querySelector('div[dir="auto"]');
3395 3903 if (!messageContent) return;
@@ -3561,8 +4069,9 @@
3561 4069 }
3562 4070
3563 4071 var emailInput = getElementDOM(botId, 'user-email');
3564 4072 var nameInput = getElementDOM(botId, 'user-name');
4073 + var consentInput = getElementDOM(botId, 'user-consent');
3565 4074 var userEmail = emailInput ? emailInput.value.trim() : '';
3566 4075 var userName = nameInput ? nameInput.value.trim() : '';
3567 4076 var sessionId = MxChatInstances.ensureSession(botId);
3568 4077
@@ -3582,8 +4091,15 @@
3582 4091 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3583 4092 return false;
3584 4093 }
3585 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 +
3586 4102 clearEmailError(botId);
3587 4103 setEmailSubmissionState(botId, true);
3588 4104
3589 4105 // Prepare form data
@@ -3597,8 +4113,14 @@
3597 4113 if (userName) {
3598 4114 formData.append('name', userName);
3599 4115 }
3600 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');
4121 + }
4122 +
3601 4123 fetch(mxchatChat.ajax_url, {
3602 4124 method: 'POST',
3603 4125 headers: {
3604 4126 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3943,13 +4465,26 @@
3943 4465 // SATISFACTION RATING (v3.2.6)
3944 4466 // ============================================================================
3945 4467 // Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
3946 4468 // inactivity following a bot reply. One prompt per session, deduped via
3947 -// localStorage. Disabled site-wide when mxchatChat.satisfaction_rating_enabled
3948 -// is exactly false (default ON).
4469 +// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4470 +// the option (default off) is authoritative.
3949 4471 jQuery(function($) {
3950 4472 if (typeof mxchatChat === 'undefined') return;
3951 - if (mxchatChat.satisfaction_rating_enabled === false || mxchatChat.satisfaction_rating_enabled === 'off') return;
4473 + // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4474 + // '' and true as '1', so this must be an explicit-enable allowlist — the
4475 + // old "disabled when exactly false/'off'" check let '' through and the
4476 + // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4477 + // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4478 + // (boolean-true localizations) working.
4479 + // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4480 + // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4481 + // merges fresh values over mxchatChat (that merge fires on first widget
4482 + // open, after this module has already decided). Re-evaluating would mean
4483 + // restructuring the whole module to late-bind its listeners — not worth it
4484 + // for a prompt that is at worst stale for one page load on a cached page.
4485 + var sre = mxchatChat.satisfaction_rating_enabled;
4486 + if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
3952 4487
3953 4488 // wp_localize_script stringifies ints, so accept both number and numeric string.
3954 4489 var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
3955 4490 var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
@@ -4004,8 +4539,39 @@
4004 4539 if (!bg && !fg) return '';
4005 4540 return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4006 4541 }
4007 4542
4543 + // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4544 + // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4545 + // and paints the filled "Send" pill so it fills with the bot font color and
4546 + // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4547 + // We paint the submit button DIRECTLY (inline longhand) rather than relying
4548 + // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4549 + // unreliably inside a descendant's `background`, so a bubble-level var would
4550 + // silently fall back to the literal (white-block bug all over again). Inline
4551 + // longhand always wins. Same transparent-guard as the menu so we never paint
4552 + // a see-through value — in that case the CSS literal fallbacks keep it legible.
4553 + function syncRatingBubbleColors(botId) {
4554 + var $chatBox = getChatBoxByBotId(botId);
4555 + if (!$chatBox || !$chatBox.length) return;
4556 + var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4557 + if (!bubbleEl) return;
4558 + var cs = window.getComputedStyle(bubbleEl);
4559 + var fg = cs.color;
4560 + var bg = cs.backgroundColor;
4561 + var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4562 + var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4563 + // Expose on the bubble too, for any inheriting styles / future use.
4564 + if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4565 + if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4566 + // Paint the Send pill directly — the part that actually fixes the bug.
4567 + var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4568 + if (submitEl) {
4569 + if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4570 + if (hasBg) submitEl.style.color = bg; // label = bubble background
4571 + }
4572 + }
4573 +
4008 4574 function copy(key) {
4009 4575 var c = mxchatChat.satisfaction_rating_copy || {};
4010 4576 var d = {
4011 4577 question: 'Was this helpful?',
@@ -4088,8 +4654,9 @@
4088 4654 var $chatBox = getChatBoxByBotId(botId);
4089 4655 if (!$chatBox.length) return;
4090 4656 if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4091 4657 $chatBox.append(buildPromptHtml(botId));
4658 + syncRatingBubbleColors(botId);
4092 4659 s.promptShown = true;
4093 4660 scrollChatBoxToBottom($chatBox);
4094 4661 }
4095 4662
@@ -4157,8 +4724,9 @@
4157 4724 var rating = parseInt($btn.attr('data-rating'), 10);
4158 4725 if (rating !== 1 && rating !== -1) return;
4159 4726 submitRating(botId, rating, '');
4160 4727 ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4728 + syncRatingBubbleColors(botId);
4161 4729 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4162 4730 });
4163 4731
4164 4732 $(document).on('click', '.mxchat-rating-dismiss', function(e) {
@@ -4175,8 +4743,9 @@
4175 4743 function closeFeedback($fb) {
4176 4744 var botId = $fb.data('bot-id') || 'default';
4177 4745 var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4178 4746 ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4747 + syncRatingBubbleColors(botId);
4179 4748 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4180 4749 }
4181 4750
4182 4751 $(document).on('click', '.mxchat-rating-skip', function(e) {