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 +1360 -115 3.2.2trunk 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 // ====================================
@@ -60,10 +203,10 @@
60 203 return Object.keys(this.instances);
61 204 },
62 205
63 206 // Session management per bot
64 - // Returns existing session ID from cookie or localStorage, or null if none exists.
65 - // Does NOT create a new session — use ensureSession() for that.
207 + // Returns existing session ID from cookie or localStorage (with in-memory fallback),
208 + // or null if none exists. Does NOT create a new session — use ensureSession() for that.
66 209 getChatSession: function(botId) {
67 210 var cookieName = 'mxchat_session_id_' + botId;
68 211 var storageKey = 'mxchat_session_id_' + botId;
69 212 var sessionId = getCookie(cookieName);
@@ -72,8 +215,21 @@
72 215 if (!sessionId) {
73 216 try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
74 217 }
75 218
219 + // Fallback to in-memory instance when cookie AND localStorage are both blocked
220 + // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned
221 + // storage). Without this, ensureSession() can generate and store an ID that
222 + // getChatSession() then can't read back, causing null session_ids on send.
223 + if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) {
224 + sessionId = this.instances[botId].sessionId;
225 + }
226 +
227 + // Guard against stored sentinel values that indicate earlier broken writes.
228 + if (sessionId === 'null' || sessionId === 'undefined') {
229 + sessionId = null;
230 + }
231 +
76 232 // Re-sync cookie from localStorage if cookie was lost
77 233 if (sessionId && !getCookie(cookieName)) {
78 234 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
79 235 }
@@ -128,9 +284,20 @@
128 284 var newSessionId = generateSessionId();
129 285 this.setChatSession(botId, newSessionId);
130 286 var $chatBox = getElement(botId, 'chat-box');
131 287 if ($chatBox.length) {
132 - $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 + }
133 300 }
134 301 if (this.instances[botId]) {
135 302 this.instances[botId].chatHistoryLoaded = false;
136 303 this.instances[botId].processedMessageIds = new Set();
@@ -187,8 +354,20 @@
187 354 var id = $floating.attr('id') || '';
188 355 var match = id.match(/floating-chatbot-(.+)/);
189 356 if (match) return match[1];
190 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 + }
191 370 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
192 371 var elementId = $(element).attr('id') || '';
193 372 if (elementId) {
194 373 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -228,9 +407,27 @@
228 407 if (parts.length == 2) return parts.pop().split(";").shift();
229 408 }
230 409
231 410 function generateSessionId() {
232 - 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;
233 430 }
234 431
235 432 // Legacy function - now delegates to instance manager
236 433 function getChatSession(botId) {
@@ -433,8 +630,26 @@
433 630 sendButton.style.pointerEvents = 'none';
434 631 }
435 632 }
436 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 +
437 652 function enableChatInput(botId) {
438 653 botId = botId || 'default';
439 654 var chatInput = getElementDOM(botId, 'chat-input');
440 655 var sendButton = getElementDOM(botId, 'send-button');
@@ -440,9 +655,11 @@
440 655 var sendButton = getElementDOM(botId, 'send-button');
441 656 if (chatInput) {
442 657 chatInput.disabled = false;
443 658 chatInput.style.opacity = '1';
444 - chatInput.focus();
659 + if (mxchatShouldAutofocusAfterReply()) {
660 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 + }
445 662 }
446 663 if (sendButton) {
447 664 sendButton.disabled = false;
448 665 sendButton.style.opacity = '1';
@@ -447,10 +664,102 @@
447 664 sendButton.disabled = false;
448 665 sendButton.style.opacity = '1';
449 666 sendButton.style.pointerEvents = 'auto';
450 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);
451 671 }
452 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 +
453 762 // Update your existing sendMessage function
454 763 function sendMessage(botId) {
455 764 botId = botId || 'default';
456 765 MxChatInstances.ensureSession(botId);
@@ -471,8 +780,9 @@
471 780 }
472 781
473 782 appendMessage("user", message, '', [], false, botId);
474 783 $chatInput.val('');
784 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
475 785 $chatInput.css('height', 'auto');
476 786
477 787 if (hasQuickQuestions(botId)) {
478 788 collapseQuickQuestions(botId);
@@ -479,14 +789,16 @@
479 789 }
480 790 appendThinkingMessage(botId);
481 791 scrollToBottom(botId);
482 792
483 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
793 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
484 794
485 795 // Check if streaming is enabled AND supported for this model
486 796 if (shouldUseStreaming(currentModel)) {
487 797 callMxChatStream(message, function(response) {
488 - 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');
489 801 }, botId);
490 802 } else {
491 803 callMxChat(message, function(response) {
492 804 replaceLastMessage("bot", response, '', [], botId);
@@ -519,14 +831,15 @@
519 831 }
520 832 appendThinkingMessage(botId);
521 833 scrollToBottom(botId);
522 834
523 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
835 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
524 836
525 837 // Check if streaming is enabled AND supported for this model
526 838 if (shouldUseStreaming(currentModel)) {
527 839 callMxChatStream(message, function(response) {
528 - 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');
529 842 }, botId);
530 843 } else {
531 844 callMxChat(message, function(response) {
532 845 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -590,8 +903,15 @@
590 903
591 904 function callMxChat(message, callback, botId) {
592 905 botId = botId || getMxChatBotId();
593 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 +
594 914 // Store the message in case we need to retry after session reset
595 915 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
596 916
597 917 // Get page context if contextual awareness is enabled
@@ -599,13 +919,28 @@
599 919
600 920 // Get instance for session start timestamp (used when persistence is OFF)
601 921 var instance = MxChatInstances.get(botId);
602 922
923 + // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
924 + // and returns the guaranteed-present session id from the in-memory instance even when
925 + // cookie/localStorage writes are silently blocked by the browser.
926 + var sessionId = MxChatInstances.ensureSession(botId);
927 + if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
928 + // Last-resort generation to ensure we never POST a null marker.
929 + sessionId = generateSessionId();
930 + MxChatInstances.setChatSession(botId, sessionId);
931 + }
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() {
603 938 // Prepare AJAX data
604 939 const ajaxData = {
605 940 action: 'mxchat_handle_chat_request',
606 941 message: message,
607 - session_id: getChatSession(botId),
942 + session_id: sessionId,
608 943 nonce: mxchatChat.nonce,
609 944 current_page_url: window.location.href,
610 945 current_page_title: document.title,
611 946 bot_id: botId,
@@ -611,14 +946,14 @@
611 946 bot_id: botId,
612 947 // Pass session start timestamp so AI context matches what user sees
613 948 session_start_timestamp: instance.sessionStartTimestamp || 0
614 949 };
615 -
950 +
616 951 // Add page context if available
617 952 if (pageContext) {
618 953 ajaxData.page_context = JSON.stringify(pageContext);
619 954 }
620 -
955 +
621 956 // CHECK FOR VISION FLAGS AND ADD THEM
622 957 if (window.mxchatVisionProcessed) {
623 958 ajaxData.vision_processed = true;
624 959 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -627,9 +962,9 @@
627 962 window.mxchatVisionProcessed = false;
628 963 window.mxchatOriginalMessage = null;
629 964 window.mxchatVisionImagesCount = 0;
630 965 }
631 -
966 +
632 967 $.ajax({
633 968 url: mxchatChat.ajax_url,
634 969 type: 'POST',
635 970 dataType: 'json',
@@ -674,12 +1009,13 @@
674 1009 // Re-send the original message with the new session (user message is already displayed)
675 1010 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
676 1011 if (originalMessage) {
677 1012 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
678 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1013 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
679 1014 if (shouldUseStreaming(currentModel)) {
680 1015 callMxChatStream(originalMessage, function(response) {
681 - 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');
682 1018 }, botId);
683 1019 } else {
684 1020 callMxChat(originalMessage, function(response) {
685 1021 replaceLastMessage("bot", response, '', [], botId);
@@ -818,8 +1154,9 @@
818 1154
819 1155 replaceLastMessage("bot", errorMessage, '', [], botId);
820 1156 }
821 1157 });
1158 + }); // refreshNonceIfNeeded
822 1159 }
823 1160
824 1161 function callMxChatStream(message, callback, botId) {
825 1162 botId = botId || getMxChatBotId();
@@ -826,9 +1163,9 @@
826 1163
827 1164 // Store the message in case we need to retry after session reset
828 1165 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
829 1166
830 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1167 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
831 1168 if (!isStreamingSupported(currentModel)) {
832 1169 callMxChat(message, callback, botId);
833 1170 return;
834 1171 }
@@ -838,12 +1175,25 @@
838 1175
839 1176 // Get instance for session start timestamp (used when persistence is OFF)
840 1177 var instance = MxChatInstances.get(botId);
841 1178
1179 + // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1180 + // non-string value via String(), so passing `null` would POST the literal string "null"
1181 + // and land in the transcripts table as a ghost session. ensureSession() always returns
1182 + // a real string even when cookies/localStorage are blocked.
1183 + var streamSessionId = MxChatInstances.ensureSession(botId);
1184 + if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1185 + streamSessionId = generateSessionId();
1186 + MxChatInstances.setChatSession(botId, streamSessionId);
1187 + }
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() {
842 1192 const formData = new FormData();
843 1193 formData.append('action', 'mxchat_stream_chat');
844 1194 formData.append('message', message);
845 - formData.append('session_id', getChatSession(botId));
1195 + formData.append('session_id', streamSessionId);
846 1196 formData.append('nonce', mxchatChat.nonce);
847 1197 formData.append('current_page_url', window.location.href);
848 1198 formData.append('current_page_title', document.title);
849 1199 formData.append('bot_id', botId);
@@ -868,13 +1218,25 @@
868 1218
869 1219 let accumulatedContent = '';
870 1220 let testingDataReceived = false;
871 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 = '';
872 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 +
873 1234 fetch(mxchatChat.ajax_url, {
874 1235 method: 'POST',
875 1236 body: formData,
876 - credentials: 'same-origin'
1237 + credentials: 'same-origin',
1238 + signal: streamControl.controller.signal
877 1239 })
878 1240 .then(response => {
879 1241 // Store the response for potential fallback handling
880 1242 const responseClone = response.clone();
@@ -943,8 +1305,16 @@
943 1305
944 1306 // Re-enable chat input when stream ends with content
945 1307 enableChatInput(botId);
946 1308
1309 + // Scroll the user's last message to the top now that the
1310 + // bot's full reply has rendered (gives max reading room).
1311 + var $chatBoxDone = getElement(botId, 'chat-box');
1312 + var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1313 + if ($lastUserMsgDone.length) {
1314 + scrollElementToTop($lastUserMsgDone, botId);
1315 + }
1316 +
947 1317 if (callback) {
948 1318 callback(accumulatedContent);
949 1319 }
950 1320 return;
@@ -967,8 +1337,25 @@
967 1337
968 1338 // Re-enable chat input after streaming completes
969 1339 enableChatInput(botId);
970 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 +
1350 + // Scroll the user's last message to the top now
1351 + // that the bot's full reply has rendered.
1352 + var $chatBoxStreamDone = getElement(botId, 'chat-box');
1353 + var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1354 + if ($lastUserMsgStreamDone.length) {
1355 + scrollElementToTop($lastUserMsgStreamDone, botId);
1356 + }
1357 +
971 1358 if (callback) {
972 1359 callback(accumulatedContent);
973 1360 }
974 1361 return;
@@ -994,8 +1381,21 @@
994 1381 streamingStarted = true;
995 1382 accumulatedContent += json.content;
996 1383 updateStreamingMessage(accumulatedContent, botId);
997 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 + }
998 1398 // Handle complete response in stream (fallback response)
999 1399 else if (json.text || json.message || json.html) {
1000 1400 handleNonStreamResponse(json, callback, botId);
1001 1401 return;
@@ -1025,8 +1425,9 @@
1025 1425 }
1026 1426
1027 1427 processStream();
1028 1428 }).catch(streamError => {
1429 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1029 1430 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1030 1431 callMxChat(message, callback, botId);
1031 1432 });
1032 1433 }
@@ -1033,8 +1434,9 @@
1033 1434
1034 1435 processStream();
1035 1436 })
1036 1437 .catch(error => {
1438 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1037 1439 // Check if we have server error data with chat mode
1038 1440 if (error && error.isServerError && error.data) {
1039 1441 // Check for chat mode in error data
1040 1442 if (error.data.chat_mode) {
@@ -1047,8 +1449,9 @@
1047 1449 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1048 1450 callMxChat(message, callback, botId);
1049 1451 }
1050 1452 });
1453 + }); // refreshNonceIfNeeded
1051 1454 }
1052 1455
1053 1456 // Helper function to handle non-streaming responses
1054 1457 function handleNonStreamResponse(data, callback, botId) {
@@ -1094,9 +1497,9 @@
1094 1497 // Re-send the original message with the new session (user message is already displayed)
1095 1498 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1096 1499 if (originalMessage) {
1097 1500 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1098 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1501 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1099 1502 if (shouldUseStreaming(currentModel)) {
1100 1503 callMxChatStream(originalMessage, callback, botId);
1101 1504 } else {
1102 1505 callMxChat(originalMessage, callback, botId);
@@ -1229,8 +1632,15 @@
1229 1632 var $chatBox = getElement(botId, 'chat-box');
1230 1633 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1231 1634
1232 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 + }
1233 1643 // Update existing message
1234 1644 tempMessage.html(formattedContent);
1235 1645 } else {
1236 1646 // Create new temporary message if it doesn't exist
@@ -1257,8 +1667,17 @@
1257 1667 // Update the event handlers to use the correct function names (using event delegation)
1258 1668 // Use class-based selectors for multi-instance support
1259 1669 $(document).on('click', '.send-button', function() {
1260 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 + }
1261 1680 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1262 1681 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1263 1682 disableChatInput(botId);
1264 1683 }
@@ -1277,9 +1696,370 @@
1277 1696 sendMessage(botId);
1278 1697 }
1279 1698 });
1280 1699
1281 -
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 +
1282 2062 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1283 2063 try {
1284 2064 // Determine styles based on sender type
1285 2065 let messageClass, bgColor, fontColor;
@@ -1354,9 +2134,11 @@
1354 2134
1355 2135 messageDiv.html(fullMessage);
1356 2136
1357 2137 if (isTemporary) {
1358 - 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');
1359 2141 }
1360 2142
1361 2143 // Append to the correct chatbot instance's chat-box
1362 2144 var $chatBox = getElement(botId, 'chat-box');
@@ -1371,8 +2153,12 @@
1371 2153 if (lastUserMessage.length) {
1372 2154 scrollElementToTop(lastUserMessage, botId);
1373 2155 }
1374 2156 }
2157 +
2158 + if ((sender === "bot" || sender === "agent") && !isTemporary) {
2159 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2160 + }
1375 2161 });
1376 2162
1377 2163 if (messageText.id) {
1378 2164 var instance = MxChatInstances.get(botId);
@@ -1487,13 +2273,16 @@
1487 2273 }
1488 2274
1489 2275 if (lastMessageDiv.length) {
1490 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).
1491 2279 lastMessageDiv
1492 2280 .html(fullMessage)
1493 2281 .removeClass('bot-message user-message temporary-message')
1494 2282 .addClass(messageClass)
1495 - .attr('dir', 'auto');
2283 + .attr('dir', 'auto')
2284 + .attr('aria-busy', 'false');
1496 2285
1497 2286 // Only apply inline colors if AI theme is not active (let CSS handle it)
1498 2287 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1499 2288 if (!skipColors) {
@@ -1519,8 +2308,12 @@
1519 2308 }
1520 2309
1521 2310 // Re-enable chat input after response is displayed
1522 2311 enableChatInput(botId);
2312 +
2313 + if (sender === "bot" || sender === "agent") {
2314 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2315 + }
1523 2316 } else {
1524 2317 appendMessage(sender, responseText, responseHtml, images, false, botId);
1525 2318 // Re-enable chat input after response is displayed
1526 2319 enableChatInput(botId);
@@ -1549,10 +2342,15 @@
1549 2342 var botMessageFontColor = mxchatChat.bot_message_font_color;
1550 2343 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1551 2344
1552 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).
1553 2349 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1554 - 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">' +
1555 2353 '<div class="thinking-dots">' +
1556 2354 '<span class="dot"' + dotStyle + '></span>' +
1557 2355 '<span class="dot"' + dotStyle + '></span>' +
1558 2356 '<span class="dot"' + dotStyle + '></span>' +
@@ -1973,13 +2771,14 @@
1973 2771 requestAnimationFrame(smoothScroll);
1974 2772 }
1975 2773 }
1976 2774
1977 - function scrollElementToTop(element, botId) {
2775 + function scrollElementToTop(element, botId, topOffset) {
1978 2776 botId = botId || 'default';
2777 + topOffset = (typeof topOffset === 'number') ? topOffset : 2;
1979 2778 var chatBox = getElement(botId, 'chat-box');
1980 2779 var elementTop = element.position().top + chatBox.scrollTop();
1981 - chatBox.animate({ scrollTop: elementTop }, 500);
2780 + chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1982 2781 }
1983 2782
1984 2783 function showChatWidget(botId) {
1985 2784 botId = botId || 'default';
@@ -2251,9 +3050,30 @@
2251 3050 }
2252 3051
2253 3052 // Only process if there are actual messages
2254 3053 if (response.data.conversation.length > 0) {
2255 - // 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();
2256 3076 $chatBox.empty();
2257 3077
2258 3078 $.each(response.data.conversation, function(index, message) {
2259 3079 // Skip agent messages if persistence is off
@@ -2293,12 +3113,16 @@
2293 3113
2294 3114 // Skip linkify for messages containing structured HTML
2295 3115 // (forms, product cards, galleries, etc.) to avoid
2296 3116 // markdown formatting corrupting HTML attributes
2297 - // (e.g. underscores in name="field_name" becoming <em> tags)
2298 - if (content.includes("mxchat-product-card") ||
2299 - content.includes("mxchat-image-gallery") ||
2300 - 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) ||
2301 3125 content.includes("<form") ||
2302 3126 content.includes("<input") ||
2303 3127 content.includes("<select") ||
2304 3128 content.includes("<textarea")) {
@@ -2316,12 +3140,29 @@
2316 3140 instance.processedMessageIds.add(message.id);
2317 3141 }
2318 3142 });
2319 3143
2320 - // 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 + }
2321 3152 $chatBox.append($fragment);
2322 3153 scrollToBottom(botId, true);
2323 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 +
2324 3165 // Collapse quick questions if we have conversation history
2325 3166 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2326 3167 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2327 3168 collapseQuickQuestions(botId);
@@ -2416,10 +3257,10 @@
2416 3257 .then(data => {
2417 3258 if (data.success) {
2418 3259 container.style.display = 'none';
2419 3260 nameElement.textContent = '';
2420 - activePdfFile = null;
2421 - appendMessage('bot', 'PDF removed.');
3261 + instance.activePdfFile = null;
3262 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2422 3263 }
2423 3264 })
2424 3265 .catch(error => {
2425 3266 // Error removing PDF - silently continue
@@ -2425,14 +3266,16 @@
2425 3266 // Error removing PDF - silently continue
2426 3267 });
2427 3268 }
2428 3269
2429 - function removeActiveWord() {
2430 - const container = document.getElementById('active-word-container');
2431 - const nameElement = document.getElementById('active-word-name');
2432 -
2433 - if (!container || !nameElement || !activeWordFile) return;
2434 -
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 +
2435 3278 fetch(mxchatChat.ajax_url, {
2436 3279 method: 'POST',
2437 3280 headers: {
2438 3281 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2438,9 +3281,9 @@
2438 3281 'Content-Type': 'application/x-www-form-urlencoded',
2439 3282 },
2440 3283 body: new URLSearchParams({
2441 3284 'action': 'mxchat_remove_word',
2442 - 'session_id': sessionId,
3285 + 'session_id': getChatSession(botId),
2443 3286 'nonce': mxchatChat.nonce
2444 3287 })
2445 3288 })
2446 3289 .then(response => response.json())
@@ -2447,10 +3290,10 @@
2447 3290 .then(data => {
2448 3291 if (data.success) {
2449 3292 container.style.display = 'none';
2450 3293 nameElement.textContent = '';
2451 - activeWordFile = null;
2452 - appendMessage('bot', 'Word document removed.');
3294 + instance.activeWordFile = null;
3295 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2453 3296 }
2454 3297 })
2455 3298 .catch(error => {
2456 3299 // Error removing Word document - silently continue
@@ -2609,11 +3452,38 @@
2609 3452 e.stopPropagation();
2610 3453 var botId = getBotIdFromElement(this);
2611 3454 collapseQuickQuestions(botId);
2612 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 +});
2613 3478
2614 3479 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2615 - $(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 + }
2616 3486 var botId = getBotIdFromElement(this);
2617 3487 var $chatbot = getElement(botId, 'floating-chatbot');
2618 3488 var $badge = getElement(botId, 'chat-notification-badge');
2619 3489 var $preChat = getElement(botId, 'pre-chat-message');
@@ -2618,14 +3488,20 @@
2618 3488 var $badge = getElement(botId, 'chat-notification-badge');
2619 3489 var $preChat = getElement(botId, 'pre-chat-message');
2620 3490
2621 3491 if ($chatbot.hasClass('hidden')) {
2622 - $chatbot.removeClass('hidden').addClass('visible');
2623 - $(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');
2624 3495 $badge.hide(); // Hide notification when opening chat
2625 3496 disableScroll();
2626 3497 $preChat.fadeOut(250);
2627 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 +
2628 3504 // Load chat history for returning visitors (persistence)
2629 3505 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
2630 3506 if (chatPersistenceEnabled) {
2631 3507 MxChatInstances.ensureSession(botId);
@@ -2641,24 +3517,55 @@
2641 3517 // No email collection — still route through showChatContainerForBot
2642 3518 // so the loader is shown while chat history loads
2643 3519 showChatContainerForBot(botId);
2644 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);
2645 3529 } else {
2646 - $chatbot.removeClass('visible').addClass('hidden');
2647 - $(this).removeClass('hidden');
3530 + $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3531 + $(this).removeClass('hidden').attr('aria-expanded', 'false');
2648 3532 enableScroll();
2649 3533 checkPreChatDismissal(botId);
2650 3534 }
2651 3535 });
2652 3536
2653 - // 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.
2654 3541 $(document).on('click', '.chatbot-top-bar', function() {
2655 3542 var botId = getBotIdFromElement(this);
2656 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2657 - 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');
2658 3546 enableScroll();
3547 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
2659 3548 });
2660 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 +
2661 3568 $(document).on('click', '.close-pre-chat-message', function(e) {
2662 3569 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
2663 3570 var botId = getBotIdFromElement(this);
2664 3571 handlePreChatDismissal(botId);
@@ -2678,17 +3585,20 @@
2678 3585 var wordInput = getElementDOM(botId, 'word-upload');
2679 3586 if (wordInput) wordInput.click();
2680 3587 });
2681 3588
2682 - // PDF file input change handler
2683 - addSafeEventListener('pdf-upload', 'change', async function(e) {
2684 - const file = e.target.files[0];
2685 -
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 +
2686 3596 if (!file || file.type !== 'application/pdf') {
2687 3597 alert('Please select a valid PDF file.');
2688 3598 return;
2689 3599 }
2690 -
3600 +
2691 3601 if (!sessionId) {
2692 3602 alert('Error: No session ID found');
2693 3603 return;
2694 3604 }
@@ -2696,47 +3606,49 @@
2696 3606 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
2697 3607 alert('Error: Ajax configuration missing');
2698 3608 return;
2699 3609 }
2700 -
3610 +
2701 3611 // Disable buttons and show loading state
2702 - const uploadBtn = document.getElementById('pdf-upload-btn');
2703 - 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;
2704 3615 const originalBtnContent = uploadBtn.innerHTML;
2705 -
3616 +
2706 3617 try {
3618 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3619 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2707 3620 const formData = new FormData();
2708 3621 formData.append('action', 'mxchat_upload_pdf');
2709 3622 formData.append('pdf_file', file);
2710 3623 formData.append('session_id', sessionId);
2711 3624 formData.append('nonce', mxchatChat.nonce);
2712 -
3625 +
2713 3626 uploadBtn.disabled = true;
2714 - sendBtn.disabled = true;
3627 + if (sendBtn) sendBtn.disabled = true;
2715 3628 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
2716 3629 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
2717 3630 </svg>`;
2718 -
3631 +
2719 3632 const response = await fetch(mxchatChat.ajax_url, {
2720 3633 method: 'POST',
2721 3634 body: formData
2722 3635 });
2723 -
3636 +
2724 3637 const data = await response.json();
2725 -
3638 +
2726 3639 if (data.success) {
2727 3640 // Hide popular questions if they exist
2728 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2729 - if (hasQuickQuestions()) {
2730 - collapseQuickQuestions();
3641 + if (hasQuickQuestions(botId)) {
3642 + collapseQuickQuestions(botId);
2731 3643 }
2732 -
3644 +
2733 3645 // Show the active PDF name
2734 - showActivePdf(data.data.filename);
2735 -
2736 - appendMessage('bot', data.data.message);
2737 - scrollToBottom();
2738 - 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;
2739 3651 } else {
2740 3652 alert('Failed to upload PDF. Please try again.');
2741 3653 }
2742 3654 } catch (error) {
@@ -2742,66 +3654,76 @@
2742 3654 } catch (error) {
2743 3655 alert('Error uploading file. Please try again.');
2744 3656 } finally {
2745 3657 uploadBtn.disabled = false;
2746 - sendBtn.disabled = false;
3658 + if (sendBtn) sendBtn.disabled = false;
2747 3659 uploadBtn.innerHTML = originalBtnContent;
2748 3660 this.value = ''; // Reset file input
2749 3661 }
2750 3662 });
2751 3663
2752 - // Word file input change handler
2753 - addSafeEventListener('word-upload', 'change', async function(e) {
2754 - const file = e.target.files[0];
2755 -
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 +
2756 3671 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
2757 3672 alert('Please select a valid Word document (.docx).');
2758 3673 return;
2759 3674 }
2760 -
3675 +
2761 3676 if (!sessionId) {
2762 3677 alert('Error: No session ID found');
2763 3678 return;
2764 3679 }
2765 3680
3681 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3682 + alert('Error: Ajax configuration missing');
3683 + return;
3684 + }
3685 +
2766 3686 // Disable buttons and show loading state
2767 - const uploadBtn = document.getElementById('word-upload-btn');
2768 - 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;
2769 3690 const originalBtnContent = uploadBtn.innerHTML;
2770 -
3691 +
2771 3692 try {
3693 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3694 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2772 3695 const formData = new FormData();
2773 3696 formData.append('action', 'mxchat_upload_word');
2774 3697 formData.append('word_file', file);
2775 3698 formData.append('session_id', sessionId);
2776 3699 formData.append('nonce', mxchatChat.nonce);
2777 -
3700 +
2778 3701 uploadBtn.disabled = true;
2779 - sendBtn.disabled = true;
3702 + if (sendBtn) sendBtn.disabled = true;
2780 3703 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
2781 3704 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
2782 3705 </svg>`;
2783 -
3706 +
2784 3707 const response = await fetch(mxchatChat.ajax_url, {
2785 3708 method: 'POST',
2786 3709 body: formData
2787 3710 });
2788 -
3711 +
2789 3712 const data = await response.json();
2790 -
3713 +
2791 3714 if (data.success) {
2792 3715 // Hide popular questions if they exist
2793 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2794 - if (hasQuickQuestions()) {
2795 - collapseQuickQuestions();
3716 + if (hasQuickQuestions(botId)) {
3717 + collapseQuickQuestions(botId);
2796 3718 }
2797 -
3719 +
2798 3720 // Show the active Word document name
2799 - showActiveWord(data.data.filename);
2800 -
2801 - appendMessage('bot', data.data.message);
2802 - scrollToBottom();
2803 - 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;
2804 3726 } else {
2805 3727 alert('Failed to upload Word document. Please try again.');
2806 3728 }
2807 3729 } catch (error) {
@@ -2807,25 +3729,25 @@
2807 3729 } catch (error) {
2808 3730 alert('Error uploading file. Please try again.');
2809 3731 } finally {
2810 3732 uploadBtn.disabled = false;
2811 - sendBtn.disabled = false;
3733 + if (sendBtn) sendBtn.disabled = false;
2812 3734 uploadBtn.innerHTML = originalBtnContent;
2813 3735 this.value = ''; // Reset file input
2814 3736 }
2815 3737 });
2816 3738
2817 - // Remove button click handlers
2818 - 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) {
2819 3741 e.preventDefault();
2820 3742 e.stopPropagation();
2821 - removeActivePdf();
3743 + removeActivePdf(getBotIdFromElement(this));
2822 3744 });
2823 -
2824 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3745 +
3746 + $(document).on('click', '.remove-word-btn', function(e) {
2825 3747 e.preventDefault();
2826 3748 e.stopPropagation();
2827 - removeActiveWord();
3749 + removeActiveWord(getBotIdFromElement(this));
2828 3750 });
2829 3751
2830 3752 // Window resize handlers
2831 3753 $(window).on('resize orientationchange', function() {
@@ -2969,10 +3891,13 @@
2969 3891 function replaceVisitorNamePlaceholder(botId, visitorName) {
2970 3892 var chatBox = getElementDOM(botId, 'chat-box');
2971 3893 if (!chatBox) return;
2972 3894
2973 - // Find the first bot message (intro message)
2974 - 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');
2975 3900 if (!introMessage) return;
2976 3901
2977 3902 var messageContent = introMessage.querySelector('div[dir="auto"]');
2978 3903 if (!messageContent) return;
@@ -3144,8 +4069,9 @@
3144 4069 }
3145 4070
3146 4071 var emailInput = getElementDOM(botId, 'user-email');
3147 4072 var nameInput = getElementDOM(botId, 'user-name');
4073 + var consentInput = getElementDOM(botId, 'user-consent');
3148 4074 var userEmail = emailInput ? emailInput.value.trim() : '';
3149 4075 var userName = nameInput ? nameInput.value.trim() : '';
3150 4076 var sessionId = MxChatInstances.ensureSession(botId);
3151 4077
@@ -3165,8 +4091,15 @@
3165 4091 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3166 4092 return false;
3167 4093 }
3168 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 +
3169 4102 clearEmailError(botId);
3170 4103 setEmailSubmissionState(botId, true);
3171 4104
3172 4105 // Prepare form data
@@ -3180,8 +4113,14 @@
3180 4113 if (userName) {
3181 4114 formData.append('name', userName);
3182 4115 }
3183 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 +
3184 4123 fetch(mxchatChat.ajax_url, {
3185 4124 method: 'POST',
3186 4125 headers: {
3187 4126 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3519,6 +4458,312 @@
3519 4458 }, 2000);
3520 4459 });
3521 4460 }
3522 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 + });
3523 4768 });
3524 4769