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 +1304 -109 3.2.4trunk View file →
@@ -1,20 +1,163 @@
1 1 jQuery(document).ready(function($) {
2 2
3 - // Nonce refresh is deferred until first user interaction (ensureSession)
4 - // to avoid admin-ajax calls on passive page loads.
5 - var nonceRefreshed = false;
6 - function refreshNonceIfNeeded(callback) {
7 - if (nonceRefreshed || typeof mxchatChat === 'undefined' || !mxchatChat.ajax_url) {
3 + // Nonce refresh — v2 (plan-6a68c9).
4 + //
5 + // The widget no longer relies on a nonce embedded in inline cached HTML.
6 + // Before each chat-send / stream-send / upload, we call the REST endpoint
7 + // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The
8 + // endpoint creates the nonce with action `mxchat_chat_send`; the server-side
9 + // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a
10 + // 30-day backwards-compat window so cached pages still in users' browsers
11 + // (which carry the legacy inline-localized nonce) keep working.
12 + //
13 + // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are
14 + // 24h but we refetch at half-life so a freshly-cached-page user never sees
15 + // a borderline-stale nonce).
16 + var cachedFreshNonce = null;
17 + var cachedFreshNonceFetchedAt = 0;
18 + var NONCE_TTL_MS = 12 * 60 * 60 * 1000;
19 + var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
20 + var nonceRefreshCallbacks = [];
21 +
22 + function getRestNonceUrl() {
23 + if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) {
24 + return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce';
25 + }
26 + // Fallback: derive from current origin if mxchatChat.rest_url isn't set.
27 + return window.location.origin + '/wp-json/mxchat/v1/nonce';
28 + }
29 +
30 + function fetchFreshNonceFromRest() {
31 + return fetch(getRestNonceUrl(), {
32 + credentials: 'same-origin',
33 + headers: { 'Accept': 'application/json' }
34 + }).then(function (resp) {
35 + if (!resp.ok) {
36 + throw new Error('REST nonce fetch failed: ' + resp.status);
37 + }
38 + return resp.json();
39 + }).then(function (data) {
40 + if (data && data.nonce) {
41 + return data.nonce;
42 + }
43 + throw new Error('REST nonce response had no nonce field.');
44 + });
45 + }
46 +
47 + /**
48 + * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
49 + * Tries REST endpoint first (cache-bypass design); falls back to the legacy
50 + * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
51 + * calls share the same in-flight refresh.
52 + */
53 + function withFreshNonce(callback) {
54 + if (typeof mxchatChat === 'undefined') {
8 55 if (callback) callback();
9 56 return;
10 57 }
11 - nonceRefreshed = true;
12 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
13 - if (res && res.success && res.data && res.data.nonce) {
14 - mxchatChat.nonce = res.data.nonce;
58 + var now = Date.now();
59 + if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 + mxchatChat.nonce = cachedFreshNonce;
61 + if (callback) callback();
62 + return;
63 + }
64 + if (callback) nonceRefreshCallbacks.push(callback);
65 + if (nonceRefreshState === 'pending') return;
66 + nonceRefreshState = 'pending';
67 +
68 + var resolved = function (nonce) {
69 + if (nonce) {
70 + cachedFreshNonce = nonce;
71 + cachedFreshNonceFetchedAt = Date.now();
72 + mxchatChat.nonce = nonce;
15 73 }
16 - if (callback) callback();
74 + nonceRefreshState = 'done';
75 + var pending = nonceRefreshCallbacks;
76 + nonceRefreshCallbacks = [];
77 + pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78 + };
79 +
80 + fetchFreshNonceFromRest()
81 + .then(resolved)
82 + .catch(function () {
83 + // Fallback to the legacy admin-ajax refresh path (issued with the
84 + // old action `mxchat_chat_nonce`; the server still accepts both
85 + // during the compat window).
86 + if (mxchatChat.ajax_url) {
87 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88 + .done(function (res) {
89 + if (res && res.success && res.data && res.data.nonce) {
90 + resolved(res.data.nonce);
91 + return;
92 + }
93 + resolved(null);
94 + })
95 + .fail(function () { resolved(null); });
96 + } else {
97 + resolved(null);
98 + }
99 + });
100 + }
101 +
102 + // Backwards-compat alias — every existing caller in this file (and any
103 + // out-of-tree consumer that hit this internal API) keeps working unchanged.
104 + function refreshNonceIfNeeded(callback) {
105 + return withFreshNonce(callback);
106 + }
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 + }
17 160 });
18 161 }
19 162
20 163 // ====================================
@@ -141,9 +284,20 @@
141 284 var newSessionId = generateSessionId();
142 285 this.setChatSession(botId, newSessionId);
143 286 var $chatBox = getElement(botId, 'chat-box');
144 287 if ($chatBox.length) {
145 - $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 + }
146 300 }
147 301 if (this.instances[botId]) {
148 302 this.instances[botId].chatHistoryLoaded = false;
149 303 this.instances[botId].processedMessageIds = new Set();
@@ -200,8 +354,20 @@
200 354 var id = $floating.attr('id') || '';
201 355 var match = id.match(/floating-chatbot-(.+)/);
202 356 if (match) return match[1];
203 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 + }
204 370 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
205 371 var elementId = $(element).attr('id') || '';
206 372 if (elementId) {
207 373 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -241,9 +407,27 @@
241 407 if (parts.length == 2) return parts.pop().split(";").shift();
242 408 }
243 409
244 410 function generateSessionId() {
245 - 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;
246 430 }
247 431
248 432 // Legacy function - now delegates to instance manager
249 433 function getChatSession(botId) {
@@ -446,8 +630,26 @@
446 630 sendButton.style.pointerEvents = 'none';
447 631 }
448 632 }
449 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 +
450 652 function enableChatInput(botId) {
451 653 botId = botId || 'default';
452 654 var chatInput = getElementDOM(botId, 'chat-input');
453 655 var sendButton = getElementDOM(botId, 'send-button');
@@ -453,9 +655,11 @@
453 655 var sendButton = getElementDOM(botId, 'send-button');
454 656 if (chatInput) {
455 657 chatInput.disabled = false;
456 658 chatInput.style.opacity = '1';
457 - chatInput.focus();
659 + if (mxchatShouldAutofocusAfterReply()) {
660 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 + }
458 662 }
459 663 if (sendButton) {
460 664 sendButton.disabled = false;
461 665 sendButton.style.opacity = '1';
@@ -460,10 +664,102 @@
460 664 sendButton.disabled = false;
461 665 sendButton.style.opacity = '1';
462 666 sendButton.style.pointerEvents = 'auto';
463 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);
464 671 }
465 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 +
466 762 // Update your existing sendMessage function
467 763 function sendMessage(botId) {
468 764 botId = botId || 'default';
469 765 MxChatInstances.ensureSession(botId);
@@ -484,8 +780,9 @@
484 780 }
485 781
486 782 appendMessage("user", message, '', [], false, botId);
487 783 $chatInput.val('');
784 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
488 785 $chatInput.css('height', 'auto');
489 786
490 787 if (hasQuickQuestions(botId)) {
491 788 collapseQuickQuestions(botId);
@@ -492,14 +789,16 @@
492 789 }
493 790 appendThinkingMessage(botId);
494 791 scrollToBottom(botId);
495 792
496 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
793 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
497 794
498 795 // Check if streaming is enabled AND supported for this model
499 796 if (shouldUseStreaming(currentModel)) {
500 797 callMxChatStream(message, function(response) {
501 - 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');
502 801 }, botId);
503 802 } else {
504 803 callMxChat(message, function(response) {
505 804 replaceLastMessage("bot", response, '', [], botId);
@@ -532,14 +831,15 @@
532 831 }
533 832 appendThinkingMessage(botId);
534 833 scrollToBottom(botId);
535 834
536 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
835 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
537 836
538 837 // Check if streaming is enabled AND supported for this model
539 838 if (shouldUseStreaming(currentModel)) {
540 839 callMxChatStream(message, function(response) {
541 - 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');
542 842 }, botId);
543 843 } else {
544 844 callMxChat(message, function(response) {
545 845 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -603,8 +903,15 @@
603 903
604 904 function callMxChat(message, callback, botId) {
605 905 botId = botId || getMxChatBotId();
606 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 +
607 914 // Store the message in case we need to retry after session reset
608 915 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
609 916
610 917 // Get page context if contextual awareness is enabled
@@ -622,8 +929,13 @@
622 929 sessionId = generateSessionId();
623 930 MxChatInstances.setChatSession(botId, sessionId);
624 931 }
625 932
933 + // Wait for the page-cache nonce refresh to complete before firing the
934 + // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
935 + // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
936 + // callback guarantees we read the fresh value. See plan-c5457f.
937 + refreshNonceIfNeeded(function() {
626 938 // Prepare AJAX data
627 939 const ajaxData = {
628 940 action: 'mxchat_handle_chat_request',
629 941 message: message,
@@ -634,14 +946,14 @@
634 946 bot_id: botId,
635 947 // Pass session start timestamp so AI context matches what user sees
636 948 session_start_timestamp: instance.sessionStartTimestamp || 0
637 949 };
638 -
950 +
639 951 // Add page context if available
640 952 if (pageContext) {
641 953 ajaxData.page_context = JSON.stringify(pageContext);
642 954 }
643 -
955 +
644 956 // CHECK FOR VISION FLAGS AND ADD THEM
645 957 if (window.mxchatVisionProcessed) {
646 958 ajaxData.vision_processed = true;
647 959 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -650,9 +962,9 @@
650 962 window.mxchatVisionProcessed = false;
651 963 window.mxchatOriginalMessage = null;
652 964 window.mxchatVisionImagesCount = 0;
653 965 }
654 -
966 +
655 967 $.ajax({
656 968 url: mxchatChat.ajax_url,
657 969 type: 'POST',
658 970 dataType: 'json',
@@ -697,12 +1009,13 @@
697 1009 // Re-send the original message with the new session (user message is already displayed)
698 1010 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
699 1011 if (originalMessage) {
700 1012 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
701 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1013 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
702 1014 if (shouldUseStreaming(currentModel)) {
703 1015 callMxChatStream(originalMessage, function(response) {
704 - 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');
705 1018 }, botId);
706 1019 } else {
707 1020 callMxChat(originalMessage, function(response) {
708 1021 replaceLastMessage("bot", response, '', [], botId);
@@ -841,8 +1154,9 @@
841 1154
842 1155 replaceLastMessage("bot", errorMessage, '', [], botId);
843 1156 }
844 1157 });
1158 + }); // refreshNonceIfNeeded
845 1159 }
846 1160
847 1161 function callMxChatStream(message, callback, botId) {
848 1162 botId = botId || getMxChatBotId();
@@ -849,9 +1163,9 @@
849 1163
850 1164 // Store the message in case we need to retry after session reset
851 1165 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
852 1166
853 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1167 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
854 1168 if (!isStreamingSupported(currentModel)) {
855 1169 callMxChat(message, callback, botId);
856 1170 return;
857 1171 }
@@ -871,8 +1185,11 @@
871 1185 streamSessionId = generateSessionId();
872 1186 MxChatInstances.setChatSession(botId, streamSessionId);
873 1187 }
874 1188
1189 + // Wait for the page-cache nonce refresh before constructing formData (which
1190 + // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1191 + refreshNonceIfNeeded(function() {
875 1192 const formData = new FormData();
876 1193 formData.append('action', 'mxchat_stream_chat');
877 1194 formData.append('message', message);
878 1195 formData.append('session_id', streamSessionId);
@@ -901,13 +1218,25 @@
901 1218
902 1219 let accumulatedContent = '';
903 1220 let testingDataReceived = false;
904 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 = '';
905 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 +
906 1234 fetch(mxchatChat.ajax_url, {
907 1235 method: 'POST',
908 1236 body: formData,
909 - credentials: 'same-origin'
1237 + credentials: 'same-origin',
1238 + signal: streamControl.controller.signal
910 1239 })
911 1240 .then(response => {
912 1241 // Store the response for potential fallback handling
913 1242 const responseClone = response.clone();
@@ -1008,8 +1337,17 @@
1008 1337
1009 1338 // Re-enable chat input after streaming completes
1010 1339 enableChatInput(botId);
1011 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 +
1012 1350 // Scroll the user's last message to the top now
1013 1351 // that the bot's full reply has rendered.
1014 1352 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1015 1353 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1043,8 +1381,21 @@
1043 1381 streamingStarted = true;
1044 1382 accumulatedContent += json.content;
1045 1383 updateStreamingMessage(accumulatedContent, botId);
1046 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 + }
1047 1398 // Handle complete response in stream (fallback response)
1048 1399 else if (json.text || json.message || json.html) {
1049 1400 handleNonStreamResponse(json, callback, botId);
1050 1401 return;
@@ -1074,8 +1425,9 @@
1074 1425 }
1075 1426
1076 1427 processStream();
1077 1428 }).catch(streamError => {
1429 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1078 1430 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1079 1431 callMxChat(message, callback, botId);
1080 1432 });
1081 1433 }
@@ -1082,8 +1434,9 @@
1082 1434
1083 1435 processStream();
1084 1436 })
1085 1437 .catch(error => {
1438 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1086 1439 // Check if we have server error data with chat mode
1087 1440 if (error && error.isServerError && error.data) {
1088 1441 // Check for chat mode in error data
1089 1442 if (error.data.chat_mode) {
@@ -1096,8 +1449,9 @@
1096 1449 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1097 1450 callMxChat(message, callback, botId);
1098 1451 }
1099 1452 });
1453 + }); // refreshNonceIfNeeded
1100 1454 }
1101 1455
1102 1456 // Helper function to handle non-streaming responses
1103 1457 function handleNonStreamResponse(data, callback, botId) {
@@ -1143,9 +1497,9 @@
1143 1497 // Re-send the original message with the new session (user message is already displayed)
1144 1498 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1145 1499 if (originalMessage) {
1146 1500 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1147 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1501 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1148 1502 if (shouldUseStreaming(currentModel)) {
1149 1503 callMxChatStream(originalMessage, callback, botId);
1150 1504 } else {
1151 1505 callMxChat(originalMessage, callback, botId);
@@ -1278,8 +1632,15 @@
1278 1632 var $chatBox = getElement(botId, 'chat-box');
1279 1633 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1280 1634
1281 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 + }
1282 1643 // Update existing message
1283 1644 tempMessage.html(formattedContent);
1284 1645 } else {
1285 1646 // Create new temporary message if it doesn't exist
@@ -1306,8 +1667,17 @@
1306 1667 // Update the event handlers to use the correct function names (using event delegation)
1307 1668 // Use class-based selectors for multi-instance support
1308 1669 $(document).on('click', '.send-button', function() {
1309 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 + }
1310 1680 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1311 1681 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1312 1682 disableChatInput(botId);
1313 1683 }
@@ -1326,9 +1696,370 @@
1326 1696 sendMessage(botId);
1327 1697 }
1328 1698 });
1329 1699
1330 -
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 +
1765 +// Builds the list of overflow-menu items for a given bot.
1766 +// Adding a future item is one push to this array — do NOT hardcode "only download."
1767 +function mxchatGetHeaderMenuItems(botId) {
1768 + var items = [];
1769 + var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1770 +
1771 + // The `print_button_*` keys still gate this item for back-compat with
1772 + // existing user options. The action is now a transcript download, not print.
1773 + if (settings.print_button_enabled === 'on') {
1774 + items.push({
1775 + id: 'download-transcript',
1776 + label: settings.print_button_label || 'Download Transcript',
1777 + 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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1778 + action: function() {
1779 + mxchatDownloadTranscript(botId);
1780 + }
1781 + });
1782 + }
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 +
1802 + return items;
1803 +}
1804 +
1805 +// Builds a clean markdown transcript of the current conversation and triggers
1806 +// a file download. Used by the "Download Transcript" menu item.
1807 +function mxchatDownloadTranscript(botId) {
1808 + var $chatBox = getElement(botId, 'chat-box');
1809 + if (!$chatBox || !$chatBox.length) return;
1810 +
1811 + var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1812 + var headerTitle = settings.print_header_title || 'Chat transcript';
1813 + var now = new Date();
1814 + var stamp = now.toLocaleString();
1815 +
1816 + var lines = [];
1817 + lines.push('# ' + headerTitle);
1818 + lines.push('');
1819 + lines.push('Exported: ' + stamp);
1820 + lines.push('');
1821 + lines.push('---');
1822 + lines.push('');
1823 +
1824 + $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1825 + var $msg = $(this);
1826 + // Skip thinking placeholders and any in-flight temporary messages.
1827 + if ($msg.find('.thinking-dots').length) return;
1828 + if ($msg.hasClass('temporary-message')) return;
1829 +
1830 + var sender;
1831 + if ($msg.hasClass('user-message')) sender = 'User';
1832 + else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1833 + else sender = 'AI Agent';
1834 +
1835 + // Strip interactive UI from the cloned message so we get the conversation text.
1836 + var $clone = $msg.clone();
1837 + $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1838 + var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1839 + if (!text) return;
1840 +
1841 + lines.push('**' + sender + '**');
1842 + lines.push('');
1843 + lines.push(text);
1844 + lines.push('');
1845 + });
1846 +
1847 + var content = lines.join('\n');
1848 + var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1849 + var fname = 'mxchat-transcript-' + iso + '.md';
1850 + var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1851 + var url = URL.createObjectURL(blob);
1852 + var a = document.createElement('a');
1853 + a.href = url;
1854 + a.download = fname;
1855 + a.style.display = 'none';
1856 + document.body.appendChild(a);
1857 + a.click();
1858 + setTimeout(function() {
1859 + if (a.parentNode) a.parentNode.removeChild(a);
1860 + URL.revokeObjectURL(url);
1861 + }, 100);
1862 +}
1863 +
1864 +// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1865 +// on the menu wrap, so the dropdown matches whatever paints the bubble —
1866 +// saved options, AI theme CSS, or the mxchat-theme add-on.
1867 +function mxchatSyncMenuColors(botId, $wrap) {
1868 + if (!$wrap || !$wrap.length) return;
1869 + var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1870 + if (!$bot.length) return;
1871 + var cs = window.getComputedStyle($bot[0]);
1872 + if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1873 + $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1874 + }
1875 + // Bot text color usually lives on a child div, not .bot-message itself.
1876 + var $textChild = $bot.find('[style*="color"]').first();
1877 + var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1878 + if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1879 +}
1880 +
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) {
1887 + var $trigger = $wrap.find('.mxchat-menu-trigger');
1888 + var $menu = $wrap.find('.mxchat-header-menu');
1889 + var items = mxchatGetHeaderMenuItems(botId);
1890 +
1891 + $menu.empty();
1892 +
1893 + if (!items.length) {
1894 + $trigger.hide();
1895 + $menu.hide();
1896 + return;
1897 + }
1898 +
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 +
1904 + items.forEach(function(item, idx) {
1905 + var $btn = $('<button>', {
1906 + type: 'button',
1907 + 'class': 'mxchat-menu-item',
1908 + 'role': 'menuitem',
1909 + 'tabindex': '-1',
1910 + 'data-menu-id': item.id,
1911 + html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1912 + '<span class="mxchat-menu-item-label"></span>'
1913 + });
1914 + $btn.find('.mxchat-menu-item-label').text(item.label);
1915 + $btn.on('click', function(e) {
1916 + e.preventDefault();
1917 + e.stopPropagation();
1918 + if (closeMenuFn) closeMenuFn();
1919 + try { item.action(); } catch (err) { /* no-op */ }
1920 + });
1921 + $menu.append($btn);
1922 + });
1923 +}
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 +
1960 + function openMenu() {
1961 + // Re-sync each open in case the active theme changed since init.
1962 + mxchatSyncMenuColors(botId, $wrap);
1963 + $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1964 + $trigger.attr('aria-expanded', 'true');
1965 + // Focus the first item for keyboard users
1966 + setTimeout(function() {
1967 + $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1968 + }, 0);
1969 + }
1970 + function closeMenu(returnFocus) {
1971 + $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1972 + $trigger.attr('aria-expanded', 'false');
1973 + $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
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 + }
1981 + }
1982 +
1983 + // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1984 + // click-to-collapse handler does not fire.
1985 + $trigger.on('click', function(e) {
1986 + e.preventDefault();
1987 + e.stopPropagation();
1988 + if ($menu.hasClass('is-open')) closeMenu();
1989 + else openMenu();
1990 + });
1991 +
1992 + // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1993 + $menu.on('click', function(e) {
1994 + e.stopPropagation();
1995 + });
1996 +
1997 + // Outside click closes the menu.
1998 + $(document).on('click.mxchatMenu-' + botId, function(e) {
1999 + if (!$menu.hasClass('is-open')) return;
2000 + if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
2001 + closeMenu();
2002 + });
2003 +
2004 + // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
2005 + $menu.on('keydown', '.mxchat-menu-item', function(e) {
2006 + var $items = $menu.find('.mxchat-menu-item');
2007 + var idx = $items.index(this);
2008 + if (e.key === 'Escape') {
2009 + e.preventDefault();
2010 + closeMenu(true);
2011 + } else if (e.key === 'ArrowDown') {
2012 + e.preventDefault();
2013 + var $next = $items.eq((idx + 1) % $items.length);
2014 + $items.attr('tabindex', '-1');
2015 + $next.attr('tabindex', '0').trigger('focus');
2016 + } else if (e.key === 'ArrowUp') {
2017 + e.preventDefault();
2018 + var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2019 + $items.attr('tabindex', '-1');
2020 + $prev.attr('tabindex', '0').trigger('focus');
2021 + } else if (e.key === 'Enter' || e.key === ' ') {
2022 + e.preventDefault();
2023 + $(this).trigger('click');
2024 + }
2025 + });
2026 + $trigger.on('keydown', function(e) {
2027 + if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2028 + e.preventDefault();
2029 + closeMenu(true);
2030 + } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2031 + e.preventDefault();
2032 + openMenu();
2033 + }
2034 + });
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 +
2041 + $wrap.data('mxchatMenuReady', true);
2042 +}
2043 +
2044 +// Initialize header menus for every rendered widget on DOM ready.
2045 +$(function() {
2046 + $('.mxchat-header-menu-wrap').each(function() {
2047 + var botId = $(this).data('bot-id');
2048 + if (botId) mxchatInitHeaderMenu(botId);
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 + }
2060 +});
2061 +
1331 2062 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1332 2063 try {
1333 2064 // Determine styles based on sender type
1334 2065 let messageClass, bgColor, fontColor;
@@ -1403,9 +2134,11 @@
1403 2134
1404 2135 messageDiv.html(fullMessage);
1405 2136
1406 2137 if (isTemporary) {
1407 - 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');
1408 2141 }
1409 2142
1410 2143 // Append to the correct chatbot instance's chat-box
1411 2144 var $chatBox = getElement(botId, 'chat-box');
@@ -1420,8 +2153,12 @@
1420 2153 if (lastUserMessage.length) {
1421 2154 scrollElementToTop(lastUserMessage, botId);
1422 2155 }
1423 2156 }
2157 +
2158 + if ((sender === "bot" || sender === "agent") && !isTemporary) {
2159 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2160 + }
1424 2161 });
1425 2162
1426 2163 if (messageText.id) {
1427 2164 var instance = MxChatInstances.get(botId);
@@ -1536,13 +2273,16 @@
1536 2273 }
1537 2274
1538 2275 if (lastMessageDiv.length) {
1539 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).
1540 2279 lastMessageDiv
1541 2280 .html(fullMessage)
1542 2281 .removeClass('bot-message user-message temporary-message')
1543 2282 .addClass(messageClass)
1544 - .attr('dir', 'auto');
2283 + .attr('dir', 'auto')
2284 + .attr('aria-busy', 'false');
1545 2285
1546 2286 // Only apply inline colors if AI theme is not active (let CSS handle it)
1547 2287 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1548 2288 if (!skipColors) {
@@ -1568,8 +2308,12 @@
1568 2308 }
1569 2309
1570 2310 // Re-enable chat input after response is displayed
1571 2311 enableChatInput(botId);
2312 +
2313 + if (sender === "bot" || sender === "agent") {
2314 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2315 + }
1572 2316 } else {
1573 2317 appendMessage(sender, responseText, responseHtml, images, false, botId);
1574 2318 // Re-enable chat input after response is displayed
1575 2319 enableChatInput(botId);
@@ -1598,10 +2342,15 @@
1598 2342 var botMessageFontColor = mxchatChat.bot_message_font_color;
1599 2343 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1600 2344
1601 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).
1602 2349 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1603 - 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">' +
1604 2353 '<div class="thinking-dots">' +
1605 2354 '<span class="dot"' + dotStyle + '></span>' +
1606 2355 '<span class="dot"' + dotStyle + '></span>' +
1607 2356 '<span class="dot"' + dotStyle + '></span>' +
@@ -2301,9 +3050,30 @@
2301 3050 }
2302 3051
2303 3052 // Only process if there are actual messages
2304 3053 if (response.data.conversation.length > 0) {
2305 - // 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();
2306 3076 $chatBox.empty();
2307 3077
2308 3078 $.each(response.data.conversation, function(index, message) {
2309 3079 // Skip agent messages if persistence is off
@@ -2343,12 +3113,16 @@
2343 3113
2344 3114 // Skip linkify for messages containing structured HTML
2345 3115 // (forms, product cards, galleries, etc.) to avoid
2346 3116 // markdown formatting corrupting HTML attributes
2347 - // (e.g. underscores in name="field_name" becoming <em> tags)
2348 - if (content.includes("mxchat-product-card") ||
2349 - content.includes("mxchat-image-gallery") ||
2350 - 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) ||
2351 3125 content.includes("<form") ||
2352 3126 content.includes("<input") ||
2353 3127 content.includes("<select") ||
2354 3128 content.includes("<textarea")) {
@@ -2366,12 +3140,29 @@
2366 3140 instance.processedMessageIds.add(message.id);
2367 3141 }
2368 3142 });
2369 3143
2370 - // 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 + }
2371 3152 $chatBox.append($fragment);
2372 3153 scrollToBottom(botId, true);
2373 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 +
2374 3165 // Collapse quick questions if we have conversation history
2375 3166 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2376 3167 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2377 3168 collapseQuickQuestions(botId);
@@ -2466,10 +3257,10 @@
2466 3257 .then(data => {
2467 3258 if (data.success) {
2468 3259 container.style.display = 'none';
2469 3260 nameElement.textContent = '';
2470 - activePdfFile = null;
2471 - appendMessage('bot', 'PDF removed.');
3261 + instance.activePdfFile = null;
3262 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2472 3263 }
2473 3264 })
2474 3265 .catch(error => {
2475 3266 // Error removing PDF - silently continue
@@ -2475,14 +3266,16 @@
2475 3266 // Error removing PDF - silently continue
2476 3267 });
2477 3268 }
2478 3269
2479 - function removeActiveWord() {
2480 - const container = document.getElementById('active-word-container');
2481 - const nameElement = document.getElementById('active-word-name');
2482 -
2483 - if (!container || !nameElement || !activeWordFile) return;
2484 -
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 +
2485 3278 fetch(mxchatChat.ajax_url, {
2486 3279 method: 'POST',
2487 3280 headers: {
2488 3281 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2488,9 +3281,9 @@
2488 3281 'Content-Type': 'application/x-www-form-urlencoded',
2489 3282 },
2490 3283 body: new URLSearchParams({
2491 3284 'action': 'mxchat_remove_word',
2492 - 'session_id': sessionId,
3285 + 'session_id': getChatSession(botId),
2493 3286 'nonce': mxchatChat.nonce
2494 3287 })
2495 3288 })
2496 3289 .then(response => response.json())
@@ -2497,10 +3290,10 @@
2497 3290 .then(data => {
2498 3291 if (data.success) {
2499 3292 container.style.display = 'none';
2500 3293 nameElement.textContent = '';
2501 - activeWordFile = null;
2502 - appendMessage('bot', 'Word document removed.');
3294 + instance.activeWordFile = null;
3295 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2503 3296 }
2504 3297 })
2505 3298 .catch(error => {
2506 3299 // Error removing Word document - silently continue
@@ -2659,11 +3452,38 @@
2659 3452 e.stopPropagation();
2660 3453 var botId = getBotIdFromElement(this);
2661 3454 collapseQuickQuestions(botId);
2662 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 +});
2663 3478
2664 3479 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2665 - $(document).on('click', '.floating-chatbot-button', function() {
3480 + // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3481 + $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3482 + if (e.type === 'keydown') {
3483 + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3484 + e.preventDefault();
3485 + }
2666 3486 var botId = getBotIdFromElement(this);
2667 3487 var $chatbot = getElement(botId, 'floating-chatbot');
2668 3488 var $badge = getElement(botId, 'chat-notification-badge');
2669 3489 var $preChat = getElement(botId, 'pre-chat-message');
@@ -2668,14 +3488,20 @@
2668 3488 var $badge = getElement(botId, 'chat-notification-badge');
2669 3489 var $preChat = getElement(botId, 'pre-chat-message');
2670 3490
2671 3491 if ($chatbot.hasClass('hidden')) {
2672 - $chatbot.removeClass('hidden').addClass('visible');
2673 - $(this).addClass('hidden');
3492 + $chatbot.removeClass('hidden').addClass('visible')
3493 + .attr('aria-modal', 'true').attr('role', 'dialog');
3494 + $(this).addClass('hidden').attr('aria-expanded', 'true');
2674 3495 $badge.hide(); // Hide notification when opening chat
2675 3496 disableScroll();
2676 3497 $preChat.fadeOut(250);
2677 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 +
2678 3504 // Load chat history for returning visitors (persistence)
2679 3505 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
2680 3506 if (chatPersistenceEnabled) {
2681 3507 MxChatInstances.ensureSession(botId);
@@ -2691,24 +3517,55 @@
2691 3517 // No email collection — still route through showChatContainerForBot
2692 3518 // so the loader is shown while chat history loads
2693 3519 showChatContainerForBot(botId);
2694 3520 }
3521 +
3522 + // Move keyboard focus into the message input after the open transition.
3523 + setTimeout(function() {
3524 + var chatInput = getElementDOM(botId, 'chat-input');
3525 + if (chatInput && !chatInput.disabled) {
3526 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3527 + }
3528 + }, 300);
2695 3529 } else {
2696 - $chatbot.removeClass('visible').addClass('hidden');
2697 - $(this).removeClass('hidden');
3530 + $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3531 + $(this).removeClass('hidden').attr('aria-expanded', 'false');
2698 3532 enableScroll();
2699 3533 checkPreChatDismissal(botId);
2700 3534 }
2701 3535 });
2702 3536
2703 - // Allow clicking anywhere on the title bar to close the chatbot
3537 + // Allow clicking anywhere on the title bar to close the chatbot.
3538 + // Returns keyboard focus to the launcher so keyboard users don't get
3539 + // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3540 + // heuristic-based so mouse-triggered close won't show a focus ring.
2704 3541 $(document).on('click', '.chatbot-top-bar', function() {
2705 3542 var botId = getBotIdFromElement(this);
2706 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2707 - getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3543 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3544 + var $launcher = getElement(botId, 'floating-chatbot-button');
3545 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2708 3546 enableScroll();
3547 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
2709 3548 });
2710 3549
3550 + // Global Escape-key handler — closes any visible chat widget and
3551 + // returns focus to its launcher. Standard modal-dismissal pattern;
3552 + // pairs with aria-modal="true" set on the widget when it opens.
3553 + $(document).on('keydown', function(e) {
3554 + if (e.key !== 'Escape' && e.key !== 'Esc') return;
3555 + var $visible = $('.floating-chatbot.visible');
3556 + if (!$visible.length) return;
3557 + e.preventDefault();
3558 + $visible.each(function() {
3559 + var botId = getBotIdFromElement(this);
3560 + $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3561 + var $launcher = getElement(botId, 'floating-chatbot-button');
3562 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3563 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3564 + });
3565 + enableScroll();
3566 + });
3567 +
2711 3568 $(document).on('click', '.close-pre-chat-message', function(e) {
2712 3569 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
2713 3570 var botId = getBotIdFromElement(this);
2714 3571 handlePreChatDismissal(botId);
@@ -2728,17 +3585,20 @@
2728 3585 var wordInput = getElementDOM(botId, 'word-upload');
2729 3586 if (wordInput) wordInput.click();
2730 3587 });
2731 3588
2732 - // PDF file input change handler
2733 - addSafeEventListener('pdf-upload', 'change', async function(e) {
2734 - const file = e.target.files[0];
2735 -
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 +
2736 3596 if (!file || file.type !== 'application/pdf') {
2737 3597 alert('Please select a valid PDF file.');
2738 3598 return;
2739 3599 }
2740 -
3600 +
2741 3601 if (!sessionId) {
2742 3602 alert('Error: No session ID found');
2743 3603 return;
2744 3604 }
@@ -2746,47 +3606,49 @@
2746 3606 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
2747 3607 alert('Error: Ajax configuration missing');
2748 3608 return;
2749 3609 }
2750 -
3610 +
2751 3611 // Disable buttons and show loading state
2752 - const uploadBtn = document.getElementById('pdf-upload-btn');
2753 - 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;
2754 3615 const originalBtnContent = uploadBtn.innerHTML;
2755 -
3616 +
2756 3617 try {
3618 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3619 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2757 3620 const formData = new FormData();
2758 3621 formData.append('action', 'mxchat_upload_pdf');
2759 3622 formData.append('pdf_file', file);
2760 3623 formData.append('session_id', sessionId);
2761 3624 formData.append('nonce', mxchatChat.nonce);
2762 -
3625 +
2763 3626 uploadBtn.disabled = true;
2764 - sendBtn.disabled = true;
3627 + if (sendBtn) sendBtn.disabled = true;
2765 3628 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
2766 3629 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
2767 3630 </svg>`;
2768 -
3631 +
2769 3632 const response = await fetch(mxchatChat.ajax_url, {
2770 3633 method: 'POST',
2771 3634 body: formData
2772 3635 });
2773 -
3636 +
2774 3637 const data = await response.json();
2775 -
3638 +
2776 3639 if (data.success) {
2777 3640 // Hide popular questions if they exist
2778 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2779 - if (hasQuickQuestions()) {
2780 - collapseQuickQuestions();
3641 + if (hasQuickQuestions(botId)) {
3642 + collapseQuickQuestions(botId);
2781 3643 }
2782 -
3644 +
2783 3645 // Show the active PDF name
2784 - showActivePdf(data.data.filename);
2785 -
2786 - appendMessage('bot', data.data.message);
2787 - scrollToBottom();
2788 - 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;
2789 3651 } else {
2790 3652 alert('Failed to upload PDF. Please try again.');
2791 3653 }
2792 3654 } catch (error) {
@@ -2792,66 +3654,76 @@
2792 3654 } catch (error) {
2793 3655 alert('Error uploading file. Please try again.');
2794 3656 } finally {
2795 3657 uploadBtn.disabled = false;
2796 - sendBtn.disabled = false;
3658 + if (sendBtn) sendBtn.disabled = false;
2797 3659 uploadBtn.innerHTML = originalBtnContent;
2798 3660 this.value = ''; // Reset file input
2799 3661 }
2800 3662 });
2801 3663
2802 - // Word file input change handler
2803 - addSafeEventListener('word-upload', 'change', async function(e) {
2804 - const file = e.target.files[0];
2805 -
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 +
2806 3671 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
2807 3672 alert('Please select a valid Word document (.docx).');
2808 3673 return;
2809 3674 }
2810 -
3675 +
2811 3676 if (!sessionId) {
2812 3677 alert('Error: No session ID found');
2813 3678 return;
2814 3679 }
2815 3680
3681 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3682 + alert('Error: Ajax configuration missing');
3683 + return;
3684 + }
3685 +
2816 3686 // Disable buttons and show loading state
2817 - const uploadBtn = document.getElementById('word-upload-btn');
2818 - 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;
2819 3690 const originalBtnContent = uploadBtn.innerHTML;
2820 -
3691 +
2821 3692 try {
3693 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3694 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2822 3695 const formData = new FormData();
2823 3696 formData.append('action', 'mxchat_upload_word');
2824 3697 formData.append('word_file', file);
2825 3698 formData.append('session_id', sessionId);
2826 3699 formData.append('nonce', mxchatChat.nonce);
2827 -
3700 +
2828 3701 uploadBtn.disabled = true;
2829 - sendBtn.disabled = true;
3702 + if (sendBtn) sendBtn.disabled = true;
2830 3703 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
2831 3704 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
2832 3705 </svg>`;
2833 -
3706 +
2834 3707 const response = await fetch(mxchatChat.ajax_url, {
2835 3708 method: 'POST',
2836 3709 body: formData
2837 3710 });
2838 -
3711 +
2839 3712 const data = await response.json();
2840 -
3713 +
2841 3714 if (data.success) {
2842 3715 // Hide popular questions if they exist
2843 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2844 - if (hasQuickQuestions()) {
2845 - collapseQuickQuestions();
3716 + if (hasQuickQuestions(botId)) {
3717 + collapseQuickQuestions(botId);
2846 3718 }
2847 -
3719 +
2848 3720 // Show the active Word document name
2849 - showActiveWord(data.data.filename);
2850 -
2851 - appendMessage('bot', data.data.message);
2852 - scrollToBottom();
2853 - 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;
2854 3726 } else {
2855 3727 alert('Failed to upload Word document. Please try again.');
2856 3728 }
2857 3729 } catch (error) {
@@ -2857,25 +3729,25 @@
2857 3729 } catch (error) {
2858 3730 alert('Error uploading file. Please try again.');
2859 3731 } finally {
2860 3732 uploadBtn.disabled = false;
2861 - sendBtn.disabled = false;
3733 + if (sendBtn) sendBtn.disabled = false;
2862 3734 uploadBtn.innerHTML = originalBtnContent;
2863 3735 this.value = ''; // Reset file input
2864 3736 }
2865 3737 });
2866 3738
2867 - // Remove button click handlers
2868 - 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) {
2869 3741 e.preventDefault();
2870 3742 e.stopPropagation();
2871 - removeActivePdf();
3743 + removeActivePdf(getBotIdFromElement(this));
2872 3744 });
2873 -
2874 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3745 +
3746 + $(document).on('click', '.remove-word-btn', function(e) {
2875 3747 e.preventDefault();
2876 3748 e.stopPropagation();
2877 - removeActiveWord();
3749 + removeActiveWord(getBotIdFromElement(this));
2878 3750 });
2879 3751
2880 3752 // Window resize handlers
2881 3753 $(window).on('resize orientationchange', function() {
@@ -3019,10 +3891,13 @@
3019 3891 function replaceVisitorNamePlaceholder(botId, visitorName) {
3020 3892 var chatBox = getElementDOM(botId, 'chat-box');
3021 3893 if (!chatBox) return;
3022 3894
3023 - // Find the first bot message (intro message)
3024 - 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');
3025 3900 if (!introMessage) return;
3026 3901
3027 3902 var messageContent = introMessage.querySelector('div[dir="auto"]');
3028 3903 if (!messageContent) return;
@@ -3194,8 +4069,9 @@
3194 4069 }
3195 4070
3196 4071 var emailInput = getElementDOM(botId, 'user-email');
3197 4072 var nameInput = getElementDOM(botId, 'user-name');
4073 + var consentInput = getElementDOM(botId, 'user-consent');
3198 4074 var userEmail = emailInput ? emailInput.value.trim() : '';
3199 4075 var userName = nameInput ? nameInput.value.trim() : '';
3200 4076 var sessionId = MxChatInstances.ensureSession(botId);
3201 4077
@@ -3215,8 +4091,15 @@
3215 4091 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3216 4092 return false;
3217 4093 }
3218 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 +
3219 4102 clearEmailError(botId);
3220 4103 setEmailSubmissionState(botId, true);
3221 4104
3222 4105 // Prepare form data
@@ -3230,8 +4113,14 @@
3230 4113 if (userName) {
3231 4114 formData.append('name', userName);
3232 4115 }
3233 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 +
3234 4123 fetch(mxchatChat.ajax_url, {
3235 4124 method: 'POST',
3236 4125 headers: {
3237 4126 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3569,6 +4458,312 @@
3569 4458 }, 2000);
3570 4459 });
3571 4460 }
3572 4461 }
4462 +});
4463 +
4464 +// ============================================================================
4465 +// SATISFACTION RATING (v3.2.6)
4466 +// ============================================================================
4467 +// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4468 +// inactivity following a bot reply. One prompt per session, deduped via
4469 +// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4470 +// the option (default off) is authoritative.
4471 +jQuery(function($) {
4472 + if (typeof mxchatChat === 'undefined') 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;
4487 +
4488 + // wp_localize_script stringifies ints, so accept both number and numeric string.
4489 + var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4490 + var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4491 + if (!isFinite(idleSeconds)) idleSeconds = 60;
4492 + if (idleSeconds < 5) idleSeconds = 5;
4493 + if (idleSeconds > 600) idleSeconds = 600;
4494 + var IDLE_MS = idleSeconds * 1000;
4495 + var MIN_BOT_REPLIES = 2;
4496 + var ratingState = {};
4497 +
4498 + function getState(botId) {
4499 + if (!ratingState[botId]) {
4500 + ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4501 + }
4502 + return ratingState[botId];
4503 + }
4504 +
4505 + function getSessionId(botId) {
4506 + if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4507 + return MxChatInstances.getChatSession(botId);
4508 + }
4509 + return null;
4510 + }
4511 +
4512 + function isAlreadyRated(sessionId) {
4513 + if (!sessionId) return false;
4514 + try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4515 + }
4516 +
4517 + function markRated(sessionId) {
4518 + if (!sessionId) return;
4519 + try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4520 + }
4521 +
4522 + function esc(s) {
4523 + return String(s == null ? '' : s)
4524 + .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4525 + .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4526 + }
4527 +
4528 + // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4529 + function ratingSkipInlineColors(botId) {
4530 + if (mxchatChat.skip_inline_colors) return true;
4531 + var botAssignments = mxchatChat.bot_theme_assignments || {};
4532 + return botAssignments.hasOwnProperty(botId);
4533 + }
4534 +
4535 + function botBubbleStyleAttr(botId) {
4536 + if (ratingSkipInlineColors(botId)) return '';
4537 + var bg = mxchatChat.bot_message_bg_color;
4538 + var fg = mxchatChat.bot_message_font_color;
4539 + if (!bg && !fg) return '';
4540 + return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4541 + }
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 +
4574 + function copy(key) {
4575 + var c = mxchatChat.satisfaction_rating_copy || {};
4576 + var d = {
4577 + question: 'Was this helpful?',
4578 + helpful: 'Helpful',
4579 + not_helpful: 'Not helpful',
4580 + dismiss: 'Dismiss',
4581 + thanks: 'Thanks! Anything we should improve? (optional)',
4582 + placeholder: 'Tell us what could be better…',
4583 + send: 'Send',
4584 + skip: 'Skip',
4585 + saved: 'Thanks for the feedback.'
4586 + };
4587 + return c[key] || d[key];
4588 + }
4589 +
4590 + function thumbUpSvg() {
4591 + return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
4592 + }
4593 + function thumbDownSvg() {
4594 + return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
4595 + }
4596 +
4597 + function buildPromptHtml(botId) {
4598 + var styleAttr = botBubbleStyleAttr(botId);
4599 + return ''
4600 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4601 + + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4602 + + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4603 + + '<div class="mxchat-rating-actions">'
4604 + + '<span class="mxchat-rating-buttons">'
4605 + + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4606 + + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4607 + + '</span>'
4608 + + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4609 + + '</div>'
4610 + + '</div>'
4611 + + '</div>';
4612 + }
4613 +
4614 + function buildFeedbackHtml(botId, rating) {
4615 + var styleAttr = botBubbleStyleAttr(botId);
4616 + return ''
4617 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4618 + + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4619 + + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4620 + + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4621 + + '<div class="mxchat-rating-feedback-actions">'
4622 + + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4623 + + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4624 + + '</div>'
4625 + + '</div>'
4626 + + '</div>';
4627 + }
4628 +
4629 + function buildSavedHtml(botId) {
4630 + var styleAttr = botBubbleStyleAttr(botId);
4631 + return ''
4632 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4633 + + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4634 + + '</div>';
4635 + }
4636 +
4637 + function getChatBoxByBotId(botId) {
4638 + var $byId = $('#chat-box-' + botId);
4639 + if ($byId.length) return $byId.first();
4640 + return $('.chat-box').first();
4641 + }
4642 +
4643 + function scrollChatBoxToBottom($chatBox) {
4644 + if (!$chatBox || !$chatBox.length) return;
4645 + $chatBox.scrollTop($chatBox[0].scrollHeight);
4646 + }
4647 +
4648 + function showPrompt(botId) {
4649 + var s = getState(botId);
4650 + if (s.promptShown || s.dismissed) return;
4651 + var sessionId = getSessionId(botId);
4652 + if (!sessionId) return;
4653 + if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4654 + var $chatBox = getChatBoxByBotId(botId);
4655 + if (!$chatBox.length) return;
4656 + if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4657 + $chatBox.append(buildPromptHtml(botId));
4658 + syncRatingBubbleColors(botId);
4659 + s.promptShown = true;
4660 + scrollChatBoxToBottom($chatBox);
4661 + }
4662 +
4663 + function submitRating(botId, rating, feedback) {
4664 + var sessionId = getSessionId(botId);
4665 + if (!sessionId) return;
4666 + $.post(mxchatChat.ajax_url, {
4667 + action: 'mxchat_save_rating',
4668 + session_id: sessionId,
4669 + bot_id: botId,
4670 + rating: rating,
4671 + feedback: feedback || ''
4672 + });
4673 + markRated(sessionId);
4674 + }
4675 +
4676 + function onBotReply(botId) {
4677 + var s = getState(botId);
4678 + s.botReplies += 1;
4679 + if (s.promptShown || s.dismissed) return;
4680 + var sessionId = getSessionId(botId);
4681 + if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4682 + if (s.botReplies < MIN_BOT_REPLIES) return;
4683 + if (s.idleTimer) clearTimeout(s.idleTimer);
4684 + s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4685 + }
4686 +
4687 + function onUserMessage(botId) {
4688 + var s = getState(botId);
4689 + if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4690 + }
4691 +
4692 + function botIdFromChatBox(el) {
4693 + var id = el && el.id ? el.id : '';
4694 + return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4695 + }
4696 +
4697 + function setupObserver(chatBox) {
4698 + var botId = botIdFromChatBox(chatBox);
4699 + try {
4700 + var observer = new MutationObserver(function(mutations) {
4701 + mutations.forEach(function(m) {
4702 + for (var i = 0; i < m.addedNodes.length; i++) {
4703 + var node = m.addedNodes[i];
4704 + if (!node || node.nodeType !== 1) continue;
4705 + var $n = $(node);
4706 + if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4707 + if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
4708 + else if ($n.hasClass('user-message')) onUserMessage(botId);
4709 + }
4710 + });
4711 + });
4712 + observer.observe(chatBox, { childList: true });
4713 + } catch (e) { /* noop */ }
4714 + }
4715 +
4716 + $('.chat-box').each(function() { setupObserver(this); });
4717 +
4718 + $(document).on('click', '.mxchat-rating-btn', function(e) {
4719 + e.preventDefault();
4720 + var $btn = $(this);
4721 + var $prompt = $btn.closest('.mxchat-rating-prompt');
4722 + var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4723 + var botId = $prompt.data('bot-id') || 'default';
4724 + var rating = parseInt($btn.attr('data-rating'), 10);
4725 + if (rating !== 1 && rating !== -1) return;
4726 + submitRating(botId, rating, '');
4727 + ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4728 + syncRatingBubbleColors(botId);
4729 + scrollChatBoxToBottom(getChatBoxByBotId(botId));
4730 + });
4731 +
4732 + $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4733 + e.preventDefault();
4734 + var $prompt = $(this).closest('.mxchat-rating-prompt');
4735 + var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4736 + var botId = $prompt.data('bot-id') || 'default';
4737 + var s = getState(botId);
4738 + s.dismissed = true;
4739 + markRated(getSessionId(botId));
4740 + ($wrap.length ? $wrap : $prompt).remove();
4741 + });
4742 +
4743 + function closeFeedback($fb) {
4744 + var botId = $fb.data('bot-id') || 'default';
4745 + var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4746 + ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4747 + syncRatingBubbleColors(botId);
4748 + scrollChatBoxToBottom(getChatBoxByBotId(botId));
4749 + }
4750 +
4751 + $(document).on('click', '.mxchat-rating-skip', function(e) {
4752 + e.preventDefault();
4753 + closeFeedback($(this).closest('.mxchat-rating-feedback'));
4754 + });
4755 +
4756 + $(document).on('click', '.mxchat-rating-submit', function(e) {
4757 + e.preventDefault();
4758 + var $fb = $(this).closest('.mxchat-rating-feedback');
4759 + var botId = $fb.data('bot-id') || 'default';
4760 + var rating = parseInt($fb.attr('data-rating'), 10);
4761 + if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4762 + var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4763 + if (text !== '') {
4764 + submitRating(botId, rating, text);
4765 + }
4766 + closeFeedback($fb);
4767 + });
3573 4768 });
3574 4769