PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.4
MxChat – AI Chatbot & Content Generation for WordPress v3.1.4
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 +259 -1533 3.2.203.1.4 View file →
@@ -1,163 +1,20 @@
1 1 jQuery(document).ready(function($) {
2 2
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') {
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) {
55 8 if (callback) callback();
56 9 return;
57 10 }
58 - var now = Date.now();
59 - if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 - mxchatChat.nonce = cachedFreshNonce;
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;
15 + }
61 16 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;
73 - }
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 - }
160 17 });
161 18 }
162 19
163 20 // ====================================
@@ -203,39 +60,18 @@
203 60 return Object.keys(this.instances);
204 61 },
205 62
206 63 // Session management per bot
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.
209 64 getChatSession: function(botId) {
210 65 var cookieName = 'mxchat_session_id_' + botId;
211 - var storageKey = 'mxchat_session_id_' + botId;
212 66 var sessionId = getCookie(cookieName);
213 67
214 - // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
215 68 if (!sessionId) {
216 - try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
69 + sessionId = generateSessionId();
70 + this.setChatSession(botId, sessionId);
217 71 }
218 72
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 -
232 - // Re-sync cookie from localStorage if cookie was lost
233 - if (sessionId && !getCookie(cookieName)) {
234 - document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
235 - }
236 -
237 - return sessionId || null;
73 + return sessionId;
238 74 },
239 75
240 76 // Lazy session initializer — called on first user interaction
241 77 ensureSession: function(botId) {
@@ -245,10 +81,11 @@
245 81 if (instance.sessionId) {
246 82 return instance.sessionId;
247 83 }
248 84
249 - // Check for existing session from cookie or localStorage
250 - var existingSession = this.getChatSession(botId);
85 + // Check if a cookie already exists from a prior visit
86 + var cookieName = 'mxchat_session_id_' + botId;
87 + var existingSession = getCookie(cookieName);
251 88
252 89 if (existingSession) {
253 90 instance.sessionId = existingSession;
254 91 } else {
@@ -261,10 +98,12 @@
261 98 // Now that we have a session, do the deferred work
262 99 refreshNonceIfNeeded();
263 100 trackOriginatingPage();
264 101
265 - // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
266 - // so we do NOT call it here to avoid a race condition.
102 + var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
103 + if (chatPersistenceEnabled && mxchatChat.email_collection_enabled !== 'on') {
104 + loadChatHistory(botId);
105 + }
267 106
268 107 return instance.sessionId;
269 108 },
270 109
@@ -269,11 +108,9 @@
269 108 },
270 109
271 110 setChatSession: function(botId, sessionId) {
272 111 var cookieName = 'mxchat_session_id_' + botId;
273 - var storageKey = 'mxchat_session_id_' + botId;
274 112 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
275 - try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
276 113 if (this.instances[botId]) {
277 114 this.instances[botId].sessionId = sessionId;
278 115 }
279 116 },
@@ -278,10 +115,8 @@
278 115 }
279 116 },
280 117
281 118 resetChatSession: function(botId) {
282 - // Clear old session from localStorage before setting new one
283 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
284 119 var newSessionId = generateSessionId();
285 120 this.setChatSession(botId, newSessionId);
286 121 var $chatBox = getElement(botId, 'chat-box');
287 122 if ($chatBox.length) {
@@ -290,20 +125,8 @@
290 125 if (this.instances[botId]) {
291 126 this.instances[botId].chatHistoryLoaded = false;
292 127 this.instances[botId].processedMessageIds = new Set();
293 128 }
294 - },
295 -
296 - // Silent reset — new session ID without clearing the chat UI
297 - // Used when IP changes mid-conversation so the user doesn't see messages vanish
298 - silentResetSession: function(botId) {
299 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
300 - var newSessionId = generateSessionId();
301 - this.setChatSession(botId, newSessionId);
302 - if (this.instances[botId]) {
303 - this.instances[botId].sessionId = newSessionId;
304 - }
305 - return newSessionId;
306 129 }
307 130 };
308 131
309 132 // ====================================
@@ -343,20 +166,8 @@
343 166 var id = $floating.attr('id') || '';
344 167 var match = id.match(/floating-chatbot-(.+)/);
345 168 if (match) return match[1];
346 169 }
347 - // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
348 - // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
349 - // .close-pre-chat-message button, which carries only a class and no id — miss both
350 - // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
351 - // and read the suffix. (closest() includes the element itself, so a click directly on
352 - // #pre-chat-message-{bot_id} resolves here too.)
353 - var $preChat = $(element).closest('[id^="pre-chat-message-"]');
354 - if ($preChat.length) {
355 - var preId = $preChat.attr('id') || '';
356 - var preMatch = preId.match(/^pre-chat-message-(.+)$/);
357 - if (preMatch) return preMatch[1];
358 - }
359 170 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
360 171 var elementId = $(element).attr('id') || '';
361 172 if (elementId) {
362 173 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -396,27 +207,9 @@
396 207 if (parts.length == 2) return parts.pop().split(";").shift();
397 208 }
398 209
399 210 function generateSessionId() {
400 - // Session IDs function as the de-facto bearer token for an anonymous
401 - // chat, so generate them with a CSPRNG when available. Math.random is a
402 - // legacy fallback for ancient/sandboxed environments that lack
403 - // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
404 - // code pattern-matches on it). (plan-0c17b5)
405 - var rand;
406 - try {
407 - if (window.crypto && window.crypto.getRandomValues) {
408 - var buf = new Uint8Array(16); // 128 bits
409 - window.crypto.getRandomValues(buf);
410 - rand = Array.prototype.map.call(buf, function (b) {
411 - return ('0' + b.toString(16)).slice(-2);
412 - }).join('');
413 - }
414 - } catch (e) {}
415 - if (!rand) {
416 - rand = Math.random().toString(36).substr(2, 9); // legacy fallback
417 - }
418 - return 'mxchat_chat_' + rand;
211 + return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
419 212 }
420 213
421 214 // Legacy function - now delegates to instance manager
422 215 function getChatSession(botId) {
@@ -619,26 +412,8 @@
619 412 sendButton.style.pointerEvents = 'none';
620 413 }
621 414 }
622 415
623 -// Whether the input may grab focus after a completed reply (plan 03799f).
624 -// On coarse-pointer devices focusing a text input summons the on-screen
625 -// keyboard over the answer the visitor is trying to read, so 'auto' (the
626 -// default) focuses only on fine-pointer devices. The site-wide
627 -// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
628 -// NOT used on widget open (:~3424) — that focus is a deliberate act and is
629 -// what makes the widget keyboard-accessible.
630 -function mxchatShouldAutofocusAfterReply() {
631 - var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
632 - if (pref === 'on') return true;
633 - if (pref === 'off') return false;
634 - try {
635 - return !window.matchMedia('(pointer: coarse)').matches;
636 - } catch (err) {
637 - return true;
638 - }
639 -}
640 -
641 416 function enableChatInput(botId) {
642 417 botId = botId || 'default';
643 418 var chatInput = getElementDOM(botId, 'chat-input');
644 419 var sendButton = getElementDOM(botId, 'send-button');
@@ -644,11 +419,9 @@
644 419 var sendButton = getElementDOM(botId, 'send-button');
645 420 if (chatInput) {
646 421 chatInput.disabled = false;
647 422 chatInput.style.opacity = '1';
648 - if (mxchatShouldAutofocusAfterReply()) {
649 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
650 - }
423 + chatInput.focus();
651 424 }
652 425 if (sendButton) {
653 426 sendButton.disabled = false;
654 427 sendButton.style.opacity = '1';
@@ -653,102 +426,10 @@
653 426 sendButton.disabled = false;
654 427 sendButton.style.opacity = '1';
655 428 sendButton.style.pointerEvents = 'auto';
656 429 }
657 - // Every completion path re-enables input, so this is the single restore
658 - // point for the streaming Stop affordance (no-op when not in stop mode).
659 - mxchatRestoreSendButton(botId);
660 430 }
661 431
662 -// --- Streaming Stop control -------------------------------------------------
663 -// One live stream handle per bot instance, so Stop on one widget never aborts
664 -// another bot on the same page.
665 -var mxchatActiveStreams = {};
666 -// Original send-button markup, captured once per bot the first time the Stop
667 -// state is shown (never captured while already in stop mode, so a rapid
668 -// stop-then-resend can't save the stop glyph as the "original").
669 -var mxchatSendMarkup = {};
670 -
671 -function mxchatShowStopButton(botId) {
672 - var btn = getElementDOM(botId, 'send-button');
673 - if (!btn) return;
674 - if (!btn.classList.contains('mxchat-stop-mode')) {
675 - mxchatSendMarkup[botId] = {
676 - html: btn.innerHTML,
677 - label: btn.getAttribute('aria-label')
678 - };
679 - }
680 -
681 - // Mirror the send icon's rendered size + color so the stop glyph looks
682 - // native, including custom send images/colors and theme overrides.
683 - var child = btn.querySelector('svg, img');
684 - var size = 25;
685 - var color = '';
686 - if (child) {
687 - var rect = child.getBoundingClientRect();
688 - if (rect.width) {
689 - size = Math.round(Math.min(rect.width, rect.height));
690 - }
691 - var cs = window.getComputedStyle(child);
692 - color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
693 - }
694 - var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
695 - 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>';
696 - // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
697 - // start a stream synchronously while the originating click is still
698 - // bubbling up to our delegated handler. Without this guard, that handler
699 - // reads the just-added stop-mode class as a user Stop press and aborts the
700 - // brand-new stream — the user's message renders but no reply ever fires
701 - // (plan-4bba64 silent message loss). The flag only spans the current event
702 - // dispatch: cleared on the next macrotask, long before a real Stop click.
703 - btn.__mxchatStopJustShown = true;
704 - setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
705 - btn.classList.add('mxchat-stop-mode');
706 - btn.setAttribute('aria-label', stopLabel);
707 - btn.setAttribute('title', stopLabel);
708 - // disableChatInput() ran when the turn was sent; the Stop control itself
709 - // must stay clickable while the textarea remains disabled.
710 - btn.disabled = false;
711 - btn.style.opacity = '1';
712 - btn.style.pointerEvents = 'auto';
713 -}
714 -
715 -function mxchatRestoreSendButton(botId) {
716 - var btn = getElementDOM(botId, 'send-button');
717 - var saved = mxchatSendMarkup[botId];
718 - if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
719 - btn.innerHTML = saved.html;
720 - btn.classList.remove('mxchat-stop-mode');
721 - btn.removeAttribute('title');
722 - if (saved.label) {
723 - btn.setAttribute('aria-label', saved.label);
724 - }
725 -}
726 -
727 -function mxchatStopStreaming(botId) {
728 - var entry = mxchatActiveStreams[botId];
729 - if (!entry || !entry.controller) return;
730 - entry.aborted = true;
731 - try { entry.controller.abort(); } catch (e) {}
732 -}
733 -
734 -// Returns true when a stream rejection came from an intentional Stop click:
735 -// keep the partial text as the turn's answer — no error UI, no fallback resend.
736 -function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
737 - var entry = mxchatActiveStreams[botId];
738 - if (!entry || !entry.aborted) return false;
739 - delete mxchatActiveStreams[botId];
740 - if (!accumulatedContent) {
741 - // Stopped before the first chunk: drop the thinking bubble, no orphan message.
742 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
743 - }
744 - enableChatInput(botId); // also restores the send icon
745 - if (callback) {
746 - callback(accumulatedContent || '');
747 - }
748 - return true;
749 -}
750 -
751 432 // Update your existing sendMessage function
752 433 function sendMessage(botId) {
753 434 botId = botId || 'default';
754 435 MxChatInstances.ensureSession(botId);
@@ -769,9 +450,8 @@
769 450 }
770 451
771 452 appendMessage("user", message, '', [], false, botId);
772 453 $chatInput.val('');
773 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
774 454 $chatInput.css('height', 'auto');
775 455
776 456 if (hasQuickQuestions(botId)) {
777 457 collapseQuickQuestions(botId);
@@ -778,16 +458,14 @@
778 458 }
779 459 appendThinkingMessage(botId);
780 460 scrollToBottom(botId);
781 461
782 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
462 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
783 463
784 464 // Check if streaming is enabled AND supported for this model
785 465 if (shouldUseStreaming(currentModel)) {
786 466 callMxChatStream(message, function(response) {
787 - // Content is final: releasing aria-busy lets the live region
788 - // announce the completed reply once (plan 67f126).
789 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
467 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
790 468 }, botId);
791 469 } else {
792 470 callMxChat(message, function(response) {
793 471 replaceLastMessage("bot", response, '', [], botId);
@@ -820,15 +498,14 @@
820 498 }
821 499 appendThinkingMessage(botId);
822 500 scrollToBottom(botId);
823 501
824 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
502 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
825 503
826 504 // Check if streaming is enabled AND supported for this model
827 505 if (shouldUseStreaming(currentModel)) {
828 506 callMxChatStream(message, function(response) {
829 - // Final content — release aria-busy so the reply announces once (67f126).
830 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
507 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
831 508 }, botId);
832 509 } else {
833 510 callMxChat(message, function(response) {
834 511 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -892,15 +569,8 @@
892 569
893 570 function callMxChat(message, callback, botId) {
894 571 botId = botId || getMxChatBotId();
895 572
896 - // Streaming fallbacks land here: drop any leftover stream handle and
897 - // return the button to its send state (no-op for plain non-stream turns).
898 - if (mxchatActiveStreams[botId]) {
899 - delete mxchatActiveStreams[botId];
900 - }
901 - mxchatRestoreSendButton(botId);
902 -
903 573 // Store the message in case we need to retry after session reset
904 574 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
905 575
906 576 // Get page context if contextual awareness is enabled
@@ -908,28 +578,13 @@
908 578
909 579 // Get instance for session start timestamp (used when persistence is OFF)
910 580 var instance = MxChatInstances.get(botId);
911 581
912 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
913 - // and returns the guaranteed-present session id from the in-memory instance even when
914 - // cookie/localStorage writes are silently blocked by the browser.
915 - var sessionId = MxChatInstances.ensureSession(botId);
916 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
917 - // Last-resort generation to ensure we never POST a null marker.
918 - sessionId = generateSessionId();
919 - MxChatInstances.setChatSession(botId, sessionId);
920 - }
921 -
922 - // Wait for the page-cache nonce refresh to complete before firing the
923 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
924 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
925 - // callback guarantees we read the fresh value. See plan-c5457f.
926 - refreshNonceIfNeeded(function() {
927 582 // Prepare AJAX data
928 583 const ajaxData = {
929 584 action: 'mxchat_handle_chat_request',
930 585 message: message,
931 - session_id: sessionId,
586 + session_id: getChatSession(botId),
932 587 nonce: mxchatChat.nonce,
933 588 current_page_url: window.location.href,
934 589 current_page_title: document.title,
935 590 bot_id: botId,
@@ -935,14 +590,14 @@
935 590 bot_id: botId,
936 591 // Pass session start timestamp so AI context matches what user sees
937 592 session_start_timestamp: instance.sessionStartTimestamp || 0
938 593 };
939 -
594 +
940 595 // Add page context if available
941 596 if (pageContext) {
942 597 ajaxData.page_context = JSON.stringify(pageContext);
943 598 }
944 -
599 +
945 600 // CHECK FOR VISION FLAGS AND ADD THEM
946 601 if (window.mxchatVisionProcessed) {
947 602 ajaxData.vision_processed = true;
948 603 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -951,9 +606,9 @@
951 606 window.mxchatVisionProcessed = false;
952 607 window.mxchatOriginalMessage = null;
953 608 window.mxchatVisionImagesCount = 0;
954 609 }
955 -
610 +
956 611 $.ajax({
957 612 url: mxchatChat.ajax_url,
958 613 type: 'POST',
959 614 dataType: 'json',
@@ -991,20 +646,26 @@
991 646 errorMessage = "An error occurred. Please try again or contact support.";
992 647 }
993 648
994 649 // Handle session reset action (IP changed, session expired, etc.)
995 - // Silent reset — keep chat UI intact, just get a new session and retry
996 650 if (response.data && response.data.action === 'reset_session') {
997 - MxChatInstances.silentResetSession(botId);
998 - // Re-send the original message with the new session (user message is already displayed)
651 + // Clear the old session and generate a new one
652 + resetChatSession(botId);
653 + // Remove the temporary loading message
654 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
655 + // Re-send the original message with the new session
999 656 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1000 657 if (originalMessage) {
1001 658 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1002 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
659 + // Re-add the user message and thinking indicator
660 + appendMessage("user", originalMessage, '', [], false, botId);
661 + appendThinkingMessage(botId);
662 + scrollToBottom(botId);
663 + // Determine whether to use streaming
664 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1003 665 if (shouldUseStreaming(currentModel)) {
1004 666 callMxChatStream(originalMessage, function(response) {
1005 - // Final content — release aria-busy so the reply announces once (67f126).
1006 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
667 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1007 668 }, botId);
1008 669 } else {
1009 670 callMxChat(originalMessage, function(response) {
1010 671 replaceLastMessage("bot", response, '', [], botId);
@@ -1143,9 +804,8 @@
1143 804
1144 805 replaceLastMessage("bot", errorMessage, '', [], botId);
1145 806 }
1146 807 });
1147 - }); // refreshNonceIfNeeded
1148 808 }
1149 809
1150 810 function callMxChatStream(message, callback, botId) {
1151 811 botId = botId || getMxChatBotId();
@@ -1152,9 +812,9 @@
1152 812
1153 813 // Store the message in case we need to retry after session reset
1154 814 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1155 815
1156 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
816 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1157 817 if (!isStreamingSupported(currentModel)) {
1158 818 callMxChat(message, callback, botId);
1159 819 return;
1160 820 }
@@ -1164,25 +824,12 @@
1164 824
1165 825 // Get instance for session start timestamp (used when persistence is OFF)
1166 826 var instance = MxChatInstances.get(botId);
1167 827
1168 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1169 - // non-string value via String(), so passing `null` would POST the literal string "null"
1170 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1171 - // a real string even when cookies/localStorage are blocked.
1172 - var streamSessionId = MxChatInstances.ensureSession(botId);
1173 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1174 - streamSessionId = generateSessionId();
1175 - MxChatInstances.setChatSession(botId, streamSessionId);
1176 - }
1177 -
1178 - // Wait for the page-cache nonce refresh before constructing formData (which
1179 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1180 - refreshNonceIfNeeded(function() {
1181 828 const formData = new FormData();
1182 829 formData.append('action', 'mxchat_stream_chat');
1183 830 formData.append('message', message);
1184 - formData.append('session_id', streamSessionId);
831 + formData.append('session_id', getChatSession(botId));
1185 832 formData.append('nonce', mxchatChat.nonce);
1186 833 formData.append('current_page_url', window.location.href);
1187 834 formData.append('current_page_title', document.title);
1188 835 formData.append('bot_id', botId);
@@ -1207,25 +854,13 @@
1207 854
1208 855 let accumulatedContent = '';
1209 856 let testingDataReceived = false;
1210 857 let streamingStarted = false;
1211 - // Server-pushed html to append as its OWN bot bubble once the stream
1212 - // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1213 - // deferred to [DONE] so the embed always lands BELOW the streamed text.
1214 - let pendingAppendHtml = '';
1215 858
1216 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1217 - // The Stop control (send button swapped in place) aborts both the read
1218 - // loop and the underlying request.
1219 - var streamControl = { controller: new AbortController(), aborted: false };
1220 - mxchatActiveStreams[botId] = streamControl;
1221 - mxchatShowStopButton(botId);
1222 -
1223 859 fetch(mxchatChat.ajax_url, {
1224 860 method: 'POST',
1225 861 body: formData,
1226 - credentials: 'same-origin',
1227 - signal: streamControl.controller.signal
862 + credentials: 'same-origin'
1228 863 })
1229 864 .then(response => {
1230 865 // Store the response for potential fallback handling
1231 866 const responseClone = response.clone();
@@ -1294,16 +929,8 @@
1294 929
1295 930 // Re-enable chat input when stream ends with content
1296 931 enableChatInput(botId);
1297 932
1298 - // Scroll the user's last message to the top now that the
1299 - // bot's full reply has rendered (gives max reading room).
1300 - var $chatBoxDone = getElement(botId, 'chat-box');
1301 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1302 - if ($lastUserMsgDone.length) {
1303 - scrollElementToTop($lastUserMsgDone, botId);
1304 - }
1305 -
1306 933 if (callback) {
1307 934 callback(accumulatedContent);
1308 935 }
1309 936 return;
@@ -1326,25 +953,8 @@
1326 953
1327 954 // Re-enable chat input after streaming completes
1328 955 enableChatInput(botId);
1329 956
1330 - // Render any server-pushed appendix html (e.g. the
1331 - // YouTube embed) as its own bot bubble below the
1332 - // streamed text — mirrors how it is saved in the
1333 - // transcript, so history replays identically.
1334 - if (pendingAppendHtml) {
1335 - appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1336 - pendingAppendHtml = '';
1337 - }
1338 -
1339 - // Scroll the user's last message to the top now
1340 - // that the bot's full reply has rendered.
1341 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1342 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1343 - if ($lastUserMsgStreamDone.length) {
1344 - scrollElementToTop($lastUserMsgStreamDone, botId);
1345 - }
1346 -
1347 957 if (callback) {
1348 958 callback(accumulatedContent);
1349 959 }
1350 960 return;
@@ -1370,21 +980,8 @@
1370 980 streamingStarted = true;
1371 981 accumulatedContent += json.content;
1372 982 updateStreamingMessage(accumulatedContent, botId);
1373 983 }
1374 - // Stash appendix html (e.g. video embed) for [DONE]
1375 - else if (json.append_html) {
1376 - pendingAppendHtml = json.append_html;
1377 - }
1378 - // Server-side final pass changed the assembled text
1379 - // (ffef6f: dead-link stripping) — swap the rendered
1380 - // bubble for the validated version. Arrives at most
1381 - // once, just before [DONE].
1382 - else if (json.replace_content) {
1383 - streamingStarted = true;
1384 - accumulatedContent = json.replace_content;
1385 - updateStreamingMessage(accumulatedContent, botId);
1386 - }
1387 984 // Handle complete response in stream (fallback response)
1388 985 else if (json.text || json.message || json.html) {
1389 986 handleNonStreamResponse(json, callback, botId);
1390 987 return;
@@ -1414,9 +1011,8 @@
1414 1011 }
1415 1012
1416 1013 processStream();
1417 1014 }).catch(streamError => {
1418 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1419 1015 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1420 1016 callMxChat(message, callback, botId);
1421 1017 });
1422 1018 }
@@ -1423,9 +1019,8 @@
1423 1019
1424 1020 processStream();
1425 1021 })
1426 1022 .catch(error => {
1427 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1428 1023 // Check if we have server error data with chat mode
1429 1024 if (error && error.isServerError && error.data) {
1430 1025 // Check for chat mode in error data
1431 1026 if (error.data.chat_mode) {
@@ -1438,9 +1033,8 @@
1438 1033 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1439 1034 callMxChat(message, callback, botId);
1440 1035 }
1441 1036 });
1442 - }); // refreshNonceIfNeeded
1443 1037 }
1444 1038
1445 1039 // Helper function to handle non-streaming responses
1446 1040 function handleNonStreamResponse(data, callback, botId) {
@@ -1479,16 +1073,21 @@
1479 1073 errorMessage = "An error occurred. Please try again or contact support.";
1480 1074 }
1481 1075
1482 1076 // Handle session reset action (IP changed, session expired, etc.)
1483 - // Silent reset — keep chat UI intact, just get a new session and retry
1484 1077 if (data.data && data.data.action === 'reset_session') {
1485 - MxChatInstances.silentResetSession(botId);
1486 - // Re-send the original message with the new session (user message is already displayed)
1078 + // Clear the old session and generate a new one
1079 + resetChatSession(botId);
1080 + // Re-send the original message with the new session
1487 1081 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1488 1082 if (originalMessage) {
1489 1083 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1490 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1084 + // Re-add the user message and thinking indicator
1085 + appendMessage("user", originalMessage, '', [], false, botId);
1086 + appendThinkingMessage(botId);
1087 + scrollToBottom(botId);
1088 + // Determine whether to use streaming
1089 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1491 1090 if (shouldUseStreaming(currentModel)) {
1492 1091 callMxChatStream(originalMessage, callback, botId);
1493 1092 } else {
1494 1093 callMxChat(originalMessage, callback, botId);
@@ -1621,15 +1220,8 @@
1621 1220 var $chatBox = getElement(botId, 'chat-box');
1622 1221 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1623 1222
1624 1223 if (tempMessage.length) {
1625 - // aria-busy=true for the whole stream: the bubble is rewritten on
1626 - // every chunk, and without busy a polite live region announces those
1627 - // rewrites continuously. Flipped false once the reply is final, so
1628 - // assistive tech announces the completed message ONCE (plan 67f126).
1629 - if (tempMessage.attr('aria-busy') !== 'true') {
1630 - tempMessage.attr('aria-busy', 'true');
1631 - }
1632 1224 // Update existing message
1633 1225 tempMessage.html(formattedContent);
1634 1226 } else {
1635 1227 // Create new temporary message if it doesn't exist
@@ -1656,17 +1248,8 @@
1656 1248 // Update the event handlers to use the correct function names (using event delegation)
1657 1249 // Use class-based selectors for multi-instance support
1658 1250 $(document).on('click', '.send-button', function() {
1659 1251 var botId = getBotIdFromElement(this);
1660 - // While a response is streaming the button is a Stop control.
1661 - if (this.classList.contains('mxchat-stop-mode')) {
1662 - // Same click that just started this stream (an add-on's direct handler
1663 - // ran before this delegated one) — not a Stop press. See
1664 - // mxchatShowStopButton for the full story (plan-4bba64).
1665 - if (this.__mxchatStopJustShown) return;
1666 - mxchatStopStreaming(botId);
1667 - return;
1668 - }
1669 1252 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1670 1253 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1671 1254 disableChatInput(botId);
1672 1255 }
@@ -1685,370 +1268,9 @@
1685 1268 sendMessage(botId);
1686 1269 }
1687 1270 });
1688 1271
1689 -// Chat input character counter + soft limit feedback (plan 7091a2).
1690 -// Language-neutral: numbers + color only, no translatable strings. The counter
1691 -// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1692 -// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1693 -// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1694 -function mxchatUpdateCharCounter(inputEl) {
1695 - if (!inputEl || !inputEl.closest) return;
1696 - var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1697 - var container = inputEl.closest('.input-container');
1698 - if (!container || !max || max <= 0) return;
1699 - var counter = container.querySelector('.mxchat-char-counter');
1700 - if (!counter) return;
1701 - var len = inputEl.value.length;
1702 - var ratio = len / max;
1703 - var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1704 - var cur = counter.querySelector('.mxchat-char-counter-current');
1705 - if (cur) cur.textContent = len;
1706 - var warn = ratio >= nearThreshold && len < max;
1707 - var full = len >= max;
1708 - counter.classList.toggle('is-visible', ratio >= nearThreshold);
1709 - counter.classList.toggle('is-warn', warn);
1710 - counter.classList.toggle('is-full', full);
1711 - container.classList.toggle('mxchat-input-near-limit', warn);
1712 - container.classList.toggle('mxchat-input-at-limit', full);
1713 -}
1714 -
1715 -function mxchatBumpInput(inputEl) {
1716 - var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1717 - if (!container) return;
1718 - container.classList.remove('mxchat-input-bump');
1719 - void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1720 - container.classList.add('mxchat-input-bump');
1721 - clearTimeout($(container).data('mxchatBumpTimeout'));
1722 - var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1723 - $(container).data('mxchatBumpTimeout', t);
1724 -}
1725 -
1726 -// Live counter update on every input.
1727 -$(document).on('input', '.chat-input', function() {
1728 - mxchatUpdateCharCounter(this);
1729 -});
1730 -
1731 -// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1732 -// rejected at the cap (maxlength silently swallows it otherwise).
1733 -$(document).on('keydown', '.chat-input', function(e) {
1734 - var max = parseInt(this.getAttribute('maxlength'), 10);
1735 - if (!max || max <= 0 || this.value.length < max) return;
1736 - if (e.ctrlKey || e.metaKey || e.altKey) return;
1737 - // A single printable char with no selection to overwrite WILL be rejected.
1738 - if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1739 - mxchatBumpInput(this);
1740 - }
1741 -});
1742 -
1743 -// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1744 -$(document).on('paste', '.chat-input', function() {
1745 - var el = this;
1746 - var max = parseInt(el.getAttribute('maxlength'), 10);
1747 - if (!max || max <= 0) return;
1748 - setTimeout(function() {
1749 - mxchatUpdateCharCounter(el);
1750 - if (el.value.length >= max) mxchatBumpInput(el);
1751 - }, 0);
1752 -});
1753 -
1754 -// Builds the list of overflow-menu items for a given bot.
1755 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1756 -function mxchatGetHeaderMenuItems(botId) {
1757 - var items = [];
1758 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1759 -
1760 - // The `print_button_*` keys still gate this item for back-compat with
1761 - // existing user options. The action is now a transcript download, not print.
1762 - if (settings.print_button_enabled === 'on') {
1763 - items.push({
1764 - id: 'download-transcript',
1765 - label: settings.print_button_label || 'Download Transcript',
1766 - 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>',
1767 - action: function() {
1768 - mxchatDownloadTranscript(botId);
1769 - }
1770 - });
1771 - }
1772 -
1773 - // "Start new chat" — surfaces the EXISTING per-conversation reset
1774 - // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1775 - // without the site owner disabling chat persistence globally. Default OFF;
1776 - // gated by the reset_chat_enabled option. plan ac2e81.
1777 - if (settings.reset_chat_enabled === 'on') {
1778 - items.push({
1779 - id: 'reset-chat',
1780 - label: settings.reset_chat_label || 'Start new chat',
1781 - 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>',
1782 - action: function() {
1783 - var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1784 - if (window.confirm(confirmMsg)) {
1785 - MxChatInstances.resetChatSession(botId);
1786 - }
1787 - }
1788 - });
1789 - }
1790 -
1791 - return items;
1792 -}
1793 -
1794 -// Builds a clean markdown transcript of the current conversation and triggers
1795 -// a file download. Used by the "Download Transcript" menu item.
1796 -function mxchatDownloadTranscript(botId) {
1797 - var $chatBox = getElement(botId, 'chat-box');
1798 - if (!$chatBox || !$chatBox.length) return;
1799 -
1800 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1801 - var headerTitle = settings.print_header_title || 'Chat transcript';
1802 - var now = new Date();
1803 - var stamp = now.toLocaleString();
1804 -
1805 - var lines = [];
1806 - lines.push('# ' + headerTitle);
1807 - lines.push('');
1808 - lines.push('Exported: ' + stamp);
1809 - lines.push('');
1810 - lines.push('---');
1811 - lines.push('');
1812 -
1813 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1814 - var $msg = $(this);
1815 - // Skip thinking placeholders and any in-flight temporary messages.
1816 - if ($msg.find('.thinking-dots').length) return;
1817 - if ($msg.hasClass('temporary-message')) return;
1818 -
1819 - var sender;
1820 - if ($msg.hasClass('user-message')) sender = 'User';
1821 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1822 - else sender = 'AI Agent';
1823 -
1824 - // Strip interactive UI from the cloned message so we get the conversation text.
1825 - var $clone = $msg.clone();
1826 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1827 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1828 - if (!text) return;
1829 -
1830 - lines.push('**' + sender + '**');
1831 - lines.push('');
1832 - lines.push(text);
1833 - lines.push('');
1834 - });
1835 -
1836 - var content = lines.join('\n');
1837 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1838 - var fname = 'mxchat-transcript-' + iso + '.md';
1839 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1840 - var url = URL.createObjectURL(blob);
1841 - var a = document.createElement('a');
1842 - a.href = url;
1843 - a.download = fname;
1844 - a.style.display = 'none';
1845 - document.body.appendChild(a);
1846 - a.click();
1847 - setTimeout(function() {
1848 - if (a.parentNode) a.parentNode.removeChild(a);
1849 - URL.revokeObjectURL(url);
1850 - }, 100);
1851 -}
1852 -
1853 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1854 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1855 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1856 -function mxchatSyncMenuColors(botId, $wrap) {
1857 - if (!$wrap || !$wrap.length) return;
1858 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1859 - if (!$bot.length) return;
1860 - var cs = window.getComputedStyle($bot[0]);
1861 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1862 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1863 - }
1864 - // Bot text color usually lives on a child div, not .bot-message itself.
1865 - var $textChild = $bot.find('[style*="color"]').first();
1866 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1867 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1868 -}
1869 -
1870 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1871 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1872 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1873 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1874 -// a rebuild before init (never happens, but harmless) just skips it.
1875 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1876 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1877 - var $menu = $wrap.find('.mxchat-header-menu');
1878 - var items = mxchatGetHeaderMenuItems(botId);
1879 -
1880 - $menu.empty();
1881 -
1882 - if (!items.length) {
1883 - $trigger.hide();
1884 - $menu.hide();
1885 - return;
1886 - }
1887 -
1888 - // Clear any inline display:none a previous zero-item render left behind —
1889 - // open/close visibility is governed by the hidden prop + is-open class.
1890 - $trigger.css('display', '');
1891 - $menu.css('display', '');
1892 -
1893 - items.forEach(function(item, idx) {
1894 - var $btn = $('<button>', {
1895 - type: 'button',
1896 - 'class': 'mxchat-menu-item',
1897 - 'role': 'menuitem',
1898 - 'tabindex': '-1',
1899 - 'data-menu-id': item.id,
1900 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1901 - '<span class="mxchat-menu-item-label"></span>'
1902 - });
1903 - $btn.find('.mxchat-menu-item-label').text(item.label);
1904 - $btn.on('click', function(e) {
1905 - e.preventDefault();
1906 - e.stopPropagation();
1907 - if (closeMenuFn) closeMenuFn();
1908 - try { item.action(); } catch (err) { /* no-op */ }
1909 - });
1910 - $menu.append($btn);
1911 - });
1912 -}
1913 -
1914 -// Re-render every menu on the page after a dynamic-settings merge
1915 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1916 -// swapping items under the user mid-interaction yanks focus — and the
1917 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1918 -function mxchatRebuildHeaderMenus() {
1919 - $('.mxchat-header-menu-wrap').each(function() {
1920 - var $wrap = $(this);
1921 - var botId = $wrap.data('bot-id');
1922 - if (!botId) return;
1923 - if (!$wrap.data('mxchatMenuReady')) {
1924 - mxchatInitHeaderMenu(botId);
1925 - return;
1926 - }
1927 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1928 - $wrap.data('mxchatMenuRebuildPending', true);
1929 - return;
1930 - }
1931 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1932 - });
1933 -}
1934 -
1935 -// One-time per-widget init: renders menu items, wires open/close,
1936 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1937 -// trigger. Wiring happens even when there are zero items at init, so a
1938 -// later dynamic-settings rebuild that adds items has a working trigger.
1939 -function mxchatInitHeaderMenu(botId) {
1940 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1941 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1942 -
1943 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1944 - var $menu = $wrap.find('.mxchat-header-menu');
1945 -
1946 - // Initial color sync — covers normal page load.
1947 - mxchatSyncMenuColors(botId, $wrap);
1948 -
1949 - function openMenu() {
1950 - // Re-sync each open in case the active theme changed since init.
1951 - mxchatSyncMenuColors(botId, $wrap);
1952 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1953 - $trigger.attr('aria-expanded', 'true');
1954 - // Focus the first item for keyboard users
1955 - setTimeout(function() {
1956 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1957 - }, 0);
1958 - }
1959 - function closeMenu(returnFocus) {
1960 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1961 - $trigger.attr('aria-expanded', 'false');
1962 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1963 - if (returnFocus) $trigger.trigger('focus');
1964 - // A dynamic-settings rebuild that arrived while the menu was open
1965 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1966 - if ($wrap.data('mxchatMenuRebuildPending')) {
1967 - $wrap.removeData('mxchatMenuRebuildPending');
1968 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1969 - }
1970 - }
1971 -
1972 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1973 - // click-to-collapse handler does not fire.
1974 - $trigger.on('click', function(e) {
1975 - e.preventDefault();
1976 - e.stopPropagation();
1977 - if ($menu.hasClass('is-open')) closeMenu();
1978 - else openMenu();
1979 - });
1980 -
1981 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1982 - $menu.on('click', function(e) {
1983 - e.stopPropagation();
1984 - });
1985 -
1986 - // Outside click closes the menu.
1987 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1988 - if (!$menu.hasClass('is-open')) return;
1989 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
1990 - closeMenu();
1991 - });
1992 -
1993 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
1994 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
1995 - var $items = $menu.find('.mxchat-menu-item');
1996 - var idx = $items.index(this);
1997 - if (e.key === 'Escape') {
1998 - e.preventDefault();
1999 - closeMenu(true);
2000 - } else if (e.key === 'ArrowDown') {
2001 - e.preventDefault();
2002 - var $next = $items.eq((idx + 1) % $items.length);
2003 - $items.attr('tabindex', '-1');
2004 - $next.attr('tabindex', '0').trigger('focus');
2005 - } else if (e.key === 'ArrowUp') {
2006 - e.preventDefault();
2007 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2008 - $items.attr('tabindex', '-1');
2009 - $prev.attr('tabindex', '0').trigger('focus');
2010 - } else if (e.key === 'Enter' || e.key === ' ') {
2011 - e.preventDefault();
2012 - $(this).trigger('click');
2013 - }
2014 - });
2015 - $trigger.on('keydown', function(e) {
2016 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2017 - e.preventDefault();
2018 - closeMenu(true);
2019 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2020 - e.preventDefault();
2021 - openMenu();
2022 - }
2023 - });
2024 -
2025 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2026 - // then do the initial item render.
2027 - $wrap.data('mxchatMenuClose', closeMenu);
2028 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2029 -
2030 - $wrap.data('mxchatMenuReady', true);
2031 -}
2032 -
2033 -// Initialize header menus for every rendered widget on DOM ready.
2034 -$(function() {
2035 - $('.mxchat-header-menu-wrap').each(function() {
2036 - var botId = $(this).data('bot-id');
2037 - if (botId) mxchatInitHeaderMenu(botId);
2038 - });
2039 -
2040 - // Embedded (non-floating) widgets are open from the moment the page
2041 - // renders — refresh dynamic settings at init (plan-32db95). Floating
2042 - // widgets refresh on first launcher open instead.
2043 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2044 - return !$(this).closest('.floating-chatbot').length;
2045 - }).length > 0;
2046 - if (hasEmbeddedWidget) {
2047 - mxchatRefreshDynamicSettings();
2048 - }
2049 -});
2050 -
1272 +
2051 1273 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2052 1274 try {
2053 1275 // Determine styles based on sender type
2054 1276 let messageClass, bgColor, fontColor;
@@ -2086,12 +1308,17 @@
2086 1308 'margin-bottom': '1em'
2087 1309 });
2088 1310 }
2089 1311
2090 - // Process the message content - always run linkify to convert markdown
2091 - // links and format text. linkify() handles existing HTML safely via
2092 - // negative lookaheads that skip URLs already inside <a> tags.
2093 - let fullMessage = linkify(messageText);
1312 + // Process the message content based on sender
1313 + let fullMessage;
1314 + if (sender === "user") {
1315 + // For user messages, apply linkify after sanitization
1316 + fullMessage = linkify(messageText);
1317 + } else {
1318 + // For bot/agent messages, preserve HTML
1319 + fullMessage = messageText;
1320 + }
2094 1321
2095 1322 // Add images if provided
2096 1323 if (images && images.length > 0) {
2097 1324 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -2123,11 +1350,9 @@
2123 1350
2124 1351 messageDiv.html(fullMessage);
2125 1352
2126 1353 if (isTemporary) {
2127 - // In-flight bubble: hold aria-busy so the live region stays quiet
2128 - // until the content is finalized (plan 67f126).
2129 - messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
1354 + messageDiv.addClass('temporary-message');
2130 1355 }
2131 1356
2132 1357 // Append to the correct chatbot instance's chat-box
2133 1358 var $chatBox = getElement(botId, 'chat-box');
@@ -2142,12 +1367,8 @@
2142 1367 if (lastUserMessage.length) {
2143 1368 scrollElementToTop(lastUserMessage, botId);
2144 1369 }
2145 1370 }
2146 -
2147 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2148 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2149 - }
2150 1371 });
2151 1372
2152 1373 if (messageText.id) {
2153 1374 var instance = MxChatInstances.get(botId);
@@ -2232,12 +1453,26 @@
2232 1453 bgColor = botMessageBgColor;
2233 1454 fontColor = botMessageFontColor;
2234 1455 }
2235 1456
2236 - // Always run linkify to convert markdown links and format text.
2237 - // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2238 - // to avoid double-processing URLs that are already inside <a> tags).
2239 - var fullMessage = linkify(responseText);
1457 + // FIXED: Only linkify if response doesn't already contain HTML links or tags
1458 + // This prevents double-processing of URLs that are already formatted as HTML
1459 + var fullMessage;
1460 + if (sender === "user") {
1461 + // Always linkify user messages (they're plain text)
1462 + fullMessage = linkify(responseText);
1463 + } else {
1464 + // For bot/agent messages, check if HTML already exists
1465 + if (responseText.includes('<a href=') || responseText.includes('</a>') ||
1466 + responseText.includes('<img') || responseText.includes('<div') ||
1467 + responseText.includes('<p>') || responseText.includes('<br>')) {
1468 + // Response already has HTML, don't process it
1469 + fullMessage = responseText;
1470 + } else {
1471 + // Plain text response, apply linkify
1472 + fullMessage = linkify(responseText);
1473 + }
1474 + }
2240 1475
2241 1476 if (responseHtml) {
2242 1477 // Only add line breaks if there's actual text content before the HTML
2243 1478 if (fullMessage && fullMessage.trim()) {
@@ -2262,16 +1497,13 @@
2262 1497 }
2263 1498
2264 1499 if (lastMessageDiv.length) {
2265 1500 // Replace content immediately to prevent visual gap between thinking dots and response
2266 - // aria-busy released AFTER the final content is set, so the live region
2267 - // announces the finished message once (plan 67f126).
2268 1501 lastMessageDiv
2269 1502 .html(fullMessage)
2270 1503 .removeClass('bot-message user-message temporary-message')
2271 1504 .addClass(messageClass)
2272 - .attr('dir', 'auto')
2273 - .attr('aria-busy', 'false');
1505 + .attr('dir', 'auto');
2274 1506
2275 1507 // Only apply inline colors if AI theme is not active (let CSS handle it)
2276 1508 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2277 1509 if (!skipColors) {
@@ -2297,12 +1529,8 @@
2297 1529 }
2298 1530
2299 1531 // Re-enable chat input after response is displayed
2300 1532 enableChatInput(botId);
2301 -
2302 - if (sender === "bot" || sender === "agent") {
2303 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2304 - }
2305 1533 } else {
2306 1534 appendMessage(sender, responseText, responseHtml, images, false, botId);
2307 1535 // Re-enable chat input after response is displayed
2308 1536 enableChatInput(botId);
@@ -2331,15 +1559,10 @@
2331 1559 var botMessageFontColor = mxchatChat.bot_message_font_color;
2332 1560 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2333 1561
2334 1562 // Build thinking dots HTML - skip inline colors if AI theme is active
2335 - // The dots are decorative; the sr-only span is what the live region
2336 - // announces for the waiting state (plan 67f126). Server-localized
2337 - // string — safe to inject (esc_html__ output, no user content).
2338 1563 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2339 - var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2340 - var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2341 - '<div class="thinking-dots-container" aria-hidden="true">' +
1564 + var thinkingHtml = '<div class="thinking-dots-container">' +
2342 1565 '<div class="thinking-dots">' +
2343 1566 '<span class="dot"' + dotStyle + '></span>' +
2344 1567 '<span class="dot"' + dotStyle + '></span>' +
2345 1568 '<span class="dot"' + dotStyle + '></span>' +
@@ -2410,63 +1633,37 @@
2410 1633 // Return as a proper link without the brackets
2411 1634 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2412 1635 });
2413 1636
2414 - // Process markdown links: [text](url) and [](url)
2415 - // Uses balanced parenthesis matching to handle URLs containing parens
2416 - // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2417 - processedText = (function(input) {
2418 - var result = '';
2419 - var i = 0;
2420 - while (i < input.length) {
2421 - // Look for [ at current position
2422 - if (input[i] === '[') {
2423 - // Find closing ]
2424 - var closeBracket = input.indexOf(']', i + 1);
2425 - if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2426 - result += input[i];
2427 - i++;
2428 - continue;
2429 - }
2430 - var linkText = input.substring(i + 1, closeBracket);
2431 - // Check if URL starts with http
2432 - var urlStart = closeBracket + 2;
2433 - if (!input.substring(urlStart).match(/^https?:\/\//)) {
2434 - result += input[i];
2435 - i++;
2436 - continue;
2437 - }
2438 - // Find balanced closing paren
2439 - var depth = 1;
2440 - var j = urlStart;
2441 - while (j < input.length && depth > 0) {
2442 - if (input[j] === '(') depth++;
2443 - else if (input[j] === ')') depth--;
2444 - if (depth > 0) j++;
2445 - }
2446 - if (depth !== 0) {
2447 - result += input[i];
2448 - i++;
2449 - continue;
2450 - }
2451 - var url = input.substring(urlStart, j);
2452 - var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2453 - var encodedUrl = safeEncodeUrl(cleanUrl);
2454 - if (!linkText || !linkText.trim()) {
2455 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2456 - } else {
2457 - var safeText = sanitizeUserInput(linkText);
2458 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2459 - }
2460 - i = j + 1; // Skip past the closing )
2461 - } else {
2462 - result += input[i];
2463 - i++;
2464 - }
1637 + // Process proper markdown links with text: [text](url)
1638 + // This MUST have non-empty text in the first brackets
1639 + const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
1640 + processedText = processedText.replace(markdownLinkPattern, (match, text, url) => {
1641 + // Make sure we have actual text (not just whitespace)
1642 + if (!text || !text.trim()) {
1643 + // If no text, treat the URL as the text
1644 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1645 + const safeUrl = safeEncodeUrl(cleanUrl);
1646 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2465 1647 }
2466 - return result;
2467 - })(processedText);
1648 +
1649 + // Clean the URL
1650 + let cleanUrl = url.replace(/[\].,;!?]+$/, '');
1651 + const safeUrl = safeEncodeUrl(cleanUrl);
1652 + const safeText = sanitizeUserInput(text);
1653 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1654 + });
2468 1655
1656 + // Handle empty markdown links: [](url)
1657 + // This is a specific case where there's no text
1658 + const emptyMarkdownPattern = /\[\]\((https?:\/\/[^\s)]+)\)/g;
1659 + processedText = processedText.replace(emptyMarkdownPattern, (match, url) => {
1660 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1661 + const safeUrl = safeEncodeUrl(cleanUrl);
1662 + // Use the URL itself as the link text
1663 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
1664 + });
1665 +
2469 1666 // Process phone numbers: [text](tel:number)
2470 1667 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2471 1668 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2472 1669 const safePhone = safeEncodeUrl(phone);
@@ -2760,14 +1957,13 @@
2760 1957 requestAnimationFrame(smoothScroll);
2761 1958 }
2762 1959 }
2763 1960
2764 - function scrollElementToTop(element, botId, topOffset) {
1961 + function scrollElementToTop(element, botId) {
2765 1962 botId = botId || 'default';
2766 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2767 1963 var chatBox = getElement(botId, 'chat-box');
2768 1964 var elementTop = element.position().top + chatBox.scrollTop();
2769 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1965 + chatBox.animate({ scrollTop: elementTop }, 500);
2770 1966 }
2771 1967
2772 1968 function showChatWidget(botId) {
2773 1969 botId = botId || 'default';
@@ -2994,19 +2190,11 @@
2994 2190 if (onComplete) onComplete();
2995 2191 return;
2996 2192 }
2997 2193
2998 - // Use getChatSession which returns null if no session exists (does NOT create one)
2999 2194 var sessionId = getChatSession(botId);
3000 2195 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3001 2196
3002 - // No session yet — nothing to load. History will load after first message via ensureSession.
3003 - if (!sessionId) {
3004 - instance.chatHistoryLoaded = true;
3005 - if (onComplete) onComplete();
3006 - return;
3007 - }
3008 -
3009 2197 if (chatPersistenceEnabled && sessionId) {
3010 2198 $.ajax({
3011 2199 url: mxchatChat.ajax_url,
3012 2200 type: 'POST',
@@ -3017,10 +2205,10 @@
3017 2205 },
3018 2206 success: function(response) {
3019 2207 // Handle session reset (IP changed while user was away)
3020 2208 if (response.success === false && response.data && response.data.action === 'reset_session') {
3021 - // Silent reset — new session but don't clear UI
3022 - MxChatInstances.silentResetSession(botId);
2209 + // Silently reset session - user will start fresh
2210 + resetChatSession(botId);
3023 2211 instance.chatHistoryLoaded = true; // Prevent retry loop
3024 2212 if (onComplete) onComplete();
3025 2213 return;
3026 2214 }
@@ -3039,20 +2227,8 @@
3039 2227 }
3040 2228
3041 2229 // Only process if there are actual messages
3042 2230 if (response.data.conversation.length > 0) {
3043 - // Restored history must be SILENT to screen readers
3044 - // (plan 67f126): these are DOM additions inside the
3045 - // live region and would otherwise announce as if
3046 - // they just arrived. Lift aria-live for the batch
3047 - // repopulate, restore it after the browser has
3048 - // processed the mutations.
3049 - var mxLiveRegionEl = $chatBox.get(0);
3050 - var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3051 - if (mxLiveRegionEl) {
3052 - mxLiveRegionEl.setAttribute('aria-live', 'off');
3053 - }
3054 -
3055 2231 // IMPORTANT: Clear existing messages before loading history
3056 2232 $chatBox.empty();
3057 2233
3058 2234 $.each(response.data.conversation, function(index, message) {
@@ -3090,23 +2266,9 @@
3090 2266 var content = message.content;
3091 2267 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3092 2268 content = decodeHTMLEntities(content);
3093 2269
3094 - // Skip linkify for messages containing structured HTML
3095 - // (forms, product cards, galleries, etc.) to avoid
3096 - // markdown formatting corrupting HTML attributes
3097 - // (e.g. underscores in name="field_name" becoming <em> tags).
3098 - // One family check instead of a per-card literal list: any
3099 - // element carrying an mxchat- prefixed class is MxChat-generated
3100 - // structured markup and replays raw. The old list drifted every
3101 - // time an add-on minted a new card class — the filtered-search
3102 - // card ("mxchat-filtered-product-card") missed it and replayed
3103 - // through linkify as visible markup.
3104 - if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3105 - content.includes("<form") ||
3106 - content.includes("<input") ||
3107 - content.includes("<select") ||
3108 - content.includes("<textarea")) {
2270 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
3109 2271 messageElement.html(content);
3110 2272 } else {
3111 2273 var formattedContent = linkify(content);
3112 2274 messageElement.html(formattedContent);
@@ -3124,18 +2286,8 @@
3124 2286 // Only append messages and scroll if we have content
3125 2287 $chatBox.append($fragment);
3126 2288 scrollToBottom(botId, true);
3127 2289
3128 - // Re-attach live semantics AFTER the rehydration
3129 - // mutations have been processed with the region off
3130 - // (plan 67f126). Restoring later announces nothing
3131 - // retroactively; new turns announce normally.
3132 - if (mxLiveRegionEl) {
3133 - setTimeout(function() {
3134 - mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3135 - }, 200);
3136 - }
3137 -
3138 2290 // Collapse quick questions if we have conversation history
3139 2291 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3140 2292 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3141 2293 collapseQuickQuestions(botId);
@@ -3230,10 +2382,10 @@
3230 2382 .then(data => {
3231 2383 if (data.success) {
3232 2384 container.style.display = 'none';
3233 2385 nameElement.textContent = '';
3234 - instance.activePdfFile = null;
3235 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2386 + activePdfFile = null;
2387 + appendMessage('bot', 'PDF removed.');
3236 2388 }
3237 2389 })
3238 2390 .catch(error => {
3239 2391 // Error removing PDF - silently continue
@@ -3239,16 +2391,14 @@
3239 2391 // Error removing PDF - silently continue
3240 2392 });
3241 2393 }
3242 2394
3243 - function removeActiveWord(botId) {
3244 - botId = botId || 'default';
3245 - var instance = MxChatInstances.get(botId);
3246 - const container = getElementDOM(botId, 'active-word-container');
3247 - const nameElement = getElementDOM(botId, 'active-word-name');
3248 -
3249 - if (!container || !nameElement || !instance.activeWordFile) return;
3250 -
2395 + function removeActiveWord() {
2396 + const container = document.getElementById('active-word-container');
2397 + const nameElement = document.getElementById('active-word-name');
2398 +
2399 + if (!container || !nameElement || !activeWordFile) return;
2400 +
3251 2401 fetch(mxchatChat.ajax_url, {
3252 2402 method: 'POST',
3253 2403 headers: {
3254 2404 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3254,9 +2404,9 @@
3254 2404 'Content-Type': 'application/x-www-form-urlencoded',
3255 2405 },
3256 2406 body: new URLSearchParams({
3257 2407 'action': 'mxchat_remove_word',
3258 - 'session_id': getChatSession(botId),
2408 + 'session_id': sessionId,
3259 2409 'nonce': mxchatChat.nonce
3260 2410 })
3261 2411 })
3262 2412 .then(response => response.json())
@@ -3263,10 +2413,10 @@
3263 2413 .then(data => {
3264 2414 if (data.success) {
3265 2415 container.style.display = 'none';
3266 2416 nameElement.textContent = '';
3267 - instance.activeWordFile = null;
3268 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2417 + activeWordFile = null;
2418 + appendMessage('bot', 'Word document removed.');
3269 2419 }
3270 2420 })
3271 2421 .catch(error => {
3272 2422 // Error removing Word document - silently continue
@@ -3345,20 +2495,14 @@
3345 2495
3346 2496 function checkPreChatDismissal(botId) {
3347 2497 botId = botId || 'default';
3348 2498 try {
3349 - var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3350 - if (dismissedAt) {
3351 - // Re-show after 24 hours
3352 - var elapsed = Date.now() - parseInt(dismissedAt, 10);
3353 - if (elapsed < 86400000) {
3354 - getElement(botId, 'pre-chat-message').hide();
3355 - return;
3356 - }
3357 - // Expired — clear and show again
3358 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2499 + var dismissed = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
2500 + if (!dismissed) {
2501 + getElement(botId, 'pre-chat-message').fadeIn(250);
2502 + } else {
2503 + getElement(botId, 'pre-chat-message').hide();
3359 2504 }
3360 - getElement(botId, 'pre-chat-message').fadeIn(250);
3361 2505 } catch (e) {
3362 2506 // localStorage unavailable — show the message
3363 2507 getElement(botId, 'pre-chat-message').fadeIn(250);
3364 2508 }
@@ -3367,9 +2511,9 @@
3367 2511 function handlePreChatDismissal(botId) {
3368 2512 botId = botId || 'default';
3369 2513 getElement(botId, 'pre-chat-message').fadeOut(200);
3370 2514 try {
3371 - localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
2515 + localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, '1');
3372 2516 } catch (e) {
3373 2517 // localStorage unavailable — dismissal won't persist
3374 2518 }
3375 2519 }
@@ -3425,38 +2569,11 @@
3425 2569 e.stopPropagation();
3426 2570 var botId = getBotIdFromElement(this);
3427 2571 collapseQuickQuestions(botId);
3428 2572 });
3429 -
3430 -// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3431 -// thumbnail facade — no Google iframe exists until the visitor taps play.
3432 -// Delegated so it also works for embeds restored from chat history.
3433 -$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3434 - e.preventDefault();
3435 - var $wrap = $(this).closest('.mxchat-youtube-embed');
3436 - var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3437 - if (!videoId) {
3438 - return;
3439 - }
3440 - var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3441 - var $iframe = $('<iframe>', {
3442 - src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3443 - title: title,
3444 - allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3445 - allowfullscreen: true,
3446 - frameborder: 0
3447 - }).addClass('mxchat-youtube-iframe');
3448 - $wrap.addClass('mxchat-youtube-playing');
3449 - $(this).replaceWith($iframe);
3450 -});
3451 2573
3452 2574 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3453 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3454 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3455 - if (e.type === 'keydown') {
3456 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3457 - e.preventDefault();
3458 - }
2575 + $(document).on('click', '.floating-chatbot-button', function() {
3459 2576 var botId = getBotIdFromElement(this);
3460 2577 var $chatbot = getElement(botId, 'floating-chatbot');
3461 2578 var $badge = getElement(botId, 'chat-notification-badge');
3462 2579 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3461,26 +2578,14 @@
3461 2578 var $badge = getElement(botId, 'chat-notification-badge');
3462 2579 var $preChat = getElement(botId, 'pre-chat-message');
3463 2580
3464 2581 if ($chatbot.hasClass('hidden')) {
3465 - $chatbot.removeClass('hidden').addClass('visible')
3466 - .attr('aria-modal', 'true').attr('role', 'dialog');
3467 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2582 + $chatbot.removeClass('hidden').addClass('visible');
2583 + $(this).addClass('hidden');
3468 2584 $badge.hide(); // Hide notification when opening chat
3469 2585 disableScroll();
3470 2586 $preChat.fadeOut(250);
3471 2587
3472 - // First open per page load: re-fetch behavior settings in case
3473 - // this page's inline values came from a stale full-page cache
3474 - // (plan-32db95). Idempotent — later opens are a no-op.
3475 - mxchatRefreshDynamicSettings();
3476 -
3477 - // Load chat history for returning visitors (persistence)
3478 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3479 - if (chatPersistenceEnabled) {
3480 - MxChatInstances.ensureSession(botId);
3481 - }
3482 -
3483 2588 // Deferred email check — only on first widget open
3484 2589 var emailBlocker = getElementDOM(botId, 'email-blocker');
3485 2590 var instance = MxChatInstances.get(botId);
3486 2591 if (emailBlocker && !instance.emailCheckDone) {
@@ -3485,64 +2590,31 @@
3485 2590 var instance = MxChatInstances.get(botId);
3486 2591 if (emailBlocker && !instance.emailCheckDone) {
3487 2592 instance.emailCheckDone = true;
3488 2593 resolveEmailState(botId);
3489 - } else if (!emailBlocker) {
3490 - // No email collection — still route through showChatContainerForBot
3491 - // so the loader is shown while chat history loads
3492 - showChatContainerForBot(botId);
3493 2594 }
3494 -
3495 - // Move keyboard focus into the message input after the open transition.
3496 - setTimeout(function() {
3497 - var chatInput = getElementDOM(botId, 'chat-input');
3498 - if (chatInput && !chatInput.disabled) {
3499 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3500 - }
3501 - }, 300);
3502 2595 } else {
3503 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3504 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2596 + $chatbot.removeClass('visible').addClass('hidden');
2597 + $(this).removeClass('hidden');
3505 2598 enableScroll();
3506 2599 checkPreChatDismissal(botId);
3507 2600 }
3508 2601 });
3509 2602
3510 - // Allow clicking anywhere on the title bar to close the chatbot.
3511 - // Returns keyboard focus to the launcher so keyboard users don't get
3512 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3513 - // heuristic-based so mouse-triggered close won't show a focus ring.
2603 + // Allow clicking anywhere on the title bar to close the chatbot
3514 2604 $(document).on('click', '.chatbot-top-bar', function() {
3515 2605 var botId = getBotIdFromElement(this);
3516 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3517 - var $launcher = getElement(botId, 'floating-chatbot-button');
3518 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2606 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2607 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3519 2608 enableScroll();
3520 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3521 2609 });
3522 2610
3523 - // Global Escape-key handler — closes any visible chat widget and
3524 - // returns focus to its launcher. Standard modal-dismissal pattern;
3525 - // pairs with aria-modal="true" set on the widget when it opens.
3526 - $(document).on('keydown', function(e) {
3527 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3528 - var $visible = $('.floating-chatbot.visible');
3529 - if (!$visible.length) return;
3530 - e.preventDefault();
3531 - $visible.each(function() {
3532 - var botId = getBotIdFromElement(this);
3533 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3534 - var $launcher = getElement(botId, 'floating-chatbot-button');
3535 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3536 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3537 - });
3538 - enableScroll();
3539 - });
3540 -
3541 2611 $(document).on('click', '.close-pre-chat-message', function(e) {
3542 2612 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3543 2613 var botId = getBotIdFromElement(this);
3544 - handlePreChatDismissal(botId);
2614 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2615 + $(this).remove();
2616 + });
3545 2617 });
3546 2618
3547 2619
3548 2620 // PDF upload button handlers - use class selector
@@ -3558,20 +2630,17 @@
3558 2630 var wordInput = getElementDOM(botId, 'word-upload');
3559 2631 if (wordInput) wordInput.click();
3560 2632 });
3561 2633
3562 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3563 - $(document).on('change', '.pdf-upload', async function(e) {
3564 - var botId = getBotIdFromElement(this);
3565 - var instance = MxChatInstances.get(botId);
3566 - const file = this.files[0];
3567 - const sessionId = MxChatInstances.ensureSession(botId);
3568 -
2634 + // PDF file input change handler
2635 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2636 + const file = e.target.files[0];
2637 +
3569 2638 if (!file || file.type !== 'application/pdf') {
3570 2639 alert('Please select a valid PDF file.');
3571 2640 return;
3572 2641 }
3573 -
2642 +
3574 2643 if (!sessionId) {
3575 2644 alert('Error: No session ID found');
3576 2645 return;
3577 2646 }
@@ -3579,49 +2648,47 @@
3579 2648 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3580 2649 alert('Error: Ajax configuration missing');
3581 2650 return;
3582 2651 }
3583 -
2652 +
3584 2653 // Disable buttons and show loading state
3585 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3586 - const sendBtn = getElementDOM(botId, 'send-button');
3587 - if (!uploadBtn) return;
2654 + const uploadBtn = document.getElementById('pdf-upload-btn');
2655 + const sendBtn = document.getElementById('send-button');
3588 2656 const originalBtnContent = uploadBtn.innerHTML;
3589 -
2657 +
3590 2658 try {
3591 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3592 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3593 2659 const formData = new FormData();
3594 2660 formData.append('action', 'mxchat_upload_pdf');
3595 2661 formData.append('pdf_file', file);
3596 2662 formData.append('session_id', sessionId);
3597 2663 formData.append('nonce', mxchatChat.nonce);
3598 -
2664 +
3599 2665 uploadBtn.disabled = true;
3600 - if (sendBtn) sendBtn.disabled = true;
2666 + sendBtn.disabled = true;
3601 2667 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3602 2668 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3603 2669 </svg>`;
3604 -
2670 +
3605 2671 const response = await fetch(mxchatChat.ajax_url, {
3606 2672 method: 'POST',
3607 2673 body: formData
3608 2674 });
3609 -
2675 +
3610 2676 const data = await response.json();
3611 -
2677 +
3612 2678 if (data.success) {
3613 2679 // Hide popular questions if they exist
3614 - if (hasQuickQuestions(botId)) {
3615 - collapseQuickQuestions(botId);
2680 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2681 + if (hasQuickQuestions()) {
2682 + collapseQuickQuestions();
3616 2683 }
3617 -
2684 +
3618 2685 // Show the active PDF name
3619 - showActivePdf(data.data.filename, botId);
3620 -
3621 - appendMessage('bot', data.data.message, '', [], false, botId);
3622 - scrollToBottom(botId);
3623 - instance.activePdfFile = data.data.filename;
2686 + showActivePdf(data.data.filename);
2687 +
2688 + appendMessage('bot', data.data.message);
2689 + scrollToBottom();
2690 + activePdfFile = data.data.filename;
3624 2691 } else {
3625 2692 alert('Failed to upload PDF. Please try again.');
3626 2693 }
3627 2694 } catch (error) {
@@ -3627,76 +2694,66 @@
3627 2694 } catch (error) {
3628 2695 alert('Error uploading file. Please try again.');
3629 2696 } finally {
3630 2697 uploadBtn.disabled = false;
3631 - if (sendBtn) sendBtn.disabled = false;
2698 + sendBtn.disabled = false;
3632 2699 uploadBtn.innerHTML = originalBtnContent;
3633 2700 this.value = ''; // Reset file input
3634 2701 }
3635 2702 });
3636 2703
3637 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3638 - $(document).on('change', '.word-upload', async function(e) {
3639 - var botId = getBotIdFromElement(this);
3640 - var instance = MxChatInstances.get(botId);
3641 - const file = this.files[0];
3642 - const sessionId = MxChatInstances.ensureSession(botId);
3643 -
2704 + // Word file input change handler
2705 + addSafeEventListener('word-upload', 'change', async function(e) {
2706 + const file = e.target.files[0];
2707 +
3644 2708 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3645 2709 alert('Please select a valid Word document (.docx).');
3646 2710 return;
3647 2711 }
3648 -
2712 +
3649 2713 if (!sessionId) {
3650 2714 alert('Error: No session ID found');
3651 2715 return;
3652 2716 }
3653 2717
3654 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3655 - alert('Error: Ajax configuration missing');
3656 - return;
3657 - }
3658 -
3659 2718 // Disable buttons and show loading state
3660 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3661 - const sendBtn = getElementDOM(botId, 'send-button');
3662 - if (!uploadBtn) return;
2719 + const uploadBtn = document.getElementById('word-upload-btn');
2720 + const sendBtn = document.getElementById('send-button');
3663 2721 const originalBtnContent = uploadBtn.innerHTML;
3664 -
2722 +
3665 2723 try {
3666 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3667 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3668 2724 const formData = new FormData();
3669 2725 formData.append('action', 'mxchat_upload_word');
3670 2726 formData.append('word_file', file);
3671 2727 formData.append('session_id', sessionId);
3672 2728 formData.append('nonce', mxchatChat.nonce);
3673 -
2729 +
3674 2730 uploadBtn.disabled = true;
3675 - if (sendBtn) sendBtn.disabled = true;
2731 + sendBtn.disabled = true;
3676 2732 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3677 2733 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3678 2734 </svg>`;
3679 -
2735 +
3680 2736 const response = await fetch(mxchatChat.ajax_url, {
3681 2737 method: 'POST',
3682 2738 body: formData
3683 2739 });
3684 -
2740 +
3685 2741 const data = await response.json();
3686 -
2742 +
3687 2743 if (data.success) {
3688 2744 // Hide popular questions if they exist
3689 - if (hasQuickQuestions(botId)) {
3690 - collapseQuickQuestions(botId);
2745 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2746 + if (hasQuickQuestions()) {
2747 + collapseQuickQuestions();
3691 2748 }
3692 -
2749 +
3693 2750 // Show the active Word document name
3694 - showActiveWord(data.data.filename, botId);
3695 -
3696 - appendMessage('bot', data.data.message, '', [], false, botId);
3697 - scrollToBottom(botId);
3698 - instance.activeWordFile = data.data.filename;
2751 + showActiveWord(data.data.filename);
2752 +
2753 + appendMessage('bot', data.data.message);
2754 + scrollToBottom();
2755 + activeWordFile = data.data.filename;
3699 2756 } else {
3700 2757 alert('Failed to upload Word document. Please try again.');
3701 2758 }
3702 2759 } catch (error) {
@@ -3702,25 +2759,25 @@
3702 2759 } catch (error) {
3703 2760 alert('Error uploading file. Please try again.');
3704 2761 } finally {
3705 2762 uploadBtn.disabled = false;
3706 - if (sendBtn) sendBtn.disabled = false;
2763 + sendBtn.disabled = false;
3707 2764 uploadBtn.innerHTML = originalBtnContent;
3708 2765 this.value = ''; // Reset file input
3709 2766 }
3710 2767 });
3711 2768
3712 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3713 - $(document).on('click', '.remove-pdf-btn', function(e) {
2769 + // Remove button click handlers
2770 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3714 2771 e.preventDefault();
3715 2772 e.stopPropagation();
3716 - removeActivePdf(getBotIdFromElement(this));
2773 + removeActivePdf();
3717 2774 });
3718 -
3719 - $(document).on('click', '.remove-word-btn', function(e) {
2775 +
2776 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3720 2777 e.preventDefault();
3721 2778 e.stopPropagation();
3722 - removeActiveWord(getBotIdFromElement(this));
2779 + removeActiveWord();
3723 2780 });
3724 2781
3725 2782 // Window resize handlers
3726 2783 $(window).on('resize orientationchange', function() {
@@ -3758,59 +2815,8 @@
3758 2815 });
3759 2816
3760 2817
3761 2818 // ====================================
3762 -// INIT LOADER & CHAT CONTAINER HELPERS
3763 -// ====================================
3764 -// These must be outside the email collection block so they're always available
3765 -// (used by persistence loading even when email collection is off)
3766 -
3767 -function showInitLoader(botId) {
3768 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3769 - if (loader) loader.style.display = 'flex';
3770 -}
3771 -
3772 -function hideInitLoader(botId) {
3773 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3774 - if (loader) loader.style.display = 'none';
3775 -}
3776 -
3777 -function showEmailFormForBot(botId) {
3778 - hideInitLoader(botId);
3779 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3780 - var chatContainer = getElementDOM(botId, 'chat-container');
3781 - if (emailBlocker) emailBlocker.style.display = 'flex';
3782 - if (chatContainer) chatContainer.style.display = 'none';
3783 -}
3784 -
3785 -function showChatContainerForBot(botId) {
3786 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3787 - var chatContainer = getElementDOM(botId, 'chat-container');
3788 - if (emailBlocker) emailBlocker.style.display = 'none';
3789 -
3790 - var instance = MxChatInstances.get(botId);
3791 - var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3792 -
3793 - // If persistence is on and history hasn't loaded yet, show loader
3794 - // while history loads to prevent flash of empty chat
3795 - if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3796 - if (chatContainer) chatContainer.style.display = 'none';
3797 - showInitLoader(botId);
3798 - loadChatHistory(botId, function() {
3799 - hideInitLoader(botId);
3800 - if (chatContainer) chatContainer.style.display = 'flex';
3801 - scrollToBottom(botId, true);
3802 - });
3803 - } else {
3804 - hideInitLoader(botId);
3805 - if (chatContainer) chatContainer.style.display = 'flex';
3806 - if (typeof loadChatHistory === 'function') {
3807 - loadChatHistory(botId);
3808 - }
3809 - }
3810 -}
3811 -
3812 -// ====================================
3813 2819 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3814 2820 // ====================================
3815 2821 // Only run email collection setup if it's enabled
3816 2822 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
@@ -3846,8 +2852,40 @@
3846 2852 `;
3847 2853 document.head.appendChild(style);
3848 2854 }
3849 2855
2856 + // Helper functions for email collection (multi-instance aware)
2857 + function showEmailFormForBot(botId) {
2858 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2859 + var chatContainer = getElementDOM(botId, 'chat-container');
2860 + if (emailBlocker) emailBlocker.style.display = 'flex';
2861 + if (chatContainer) chatContainer.style.display = 'none';
2862 + }
2863 +
2864 + function showChatContainerForBot(botId) {
2865 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2866 + var chatContainer = getElementDOM(botId, 'chat-container');
2867 + if (emailBlocker) emailBlocker.style.display = 'none';
2868 +
2869 + var instance = MxChatInstances.get(botId);
2870 + var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
2871 +
2872 + // If persistence is on and history hasn't loaded yet, keep container
2873 + // hidden until history loads to prevent flash of empty chat
2874 + if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
2875 + if (chatContainer) chatContainer.style.display = 'none';
2876 + loadChatHistory(botId, function() {
2877 + if (chatContainer) chatContainer.style.display = 'flex';
2878 + scrollToBottom(botId, true);
2879 + });
2880 + } else {
2881 + if (chatContainer) chatContainer.style.display = 'flex';
2882 + if (typeof loadChatHistory === 'function') {
2883 + loadChatHistory(botId);
2884 + }
2885 + }
2886 + }
2887 +
3850 2888 function isValidEmailAddress(email) {
3851 2889 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3852 2890 return emailRegex.test(email.trim()) && email.length <= 254;
3853 2891 }
@@ -3983,16 +3021,15 @@
3983 3021 }
3984 3022 }
3985 3023
3986 3024 function checkSessionAndEmailForBot(botId) {
3987 - const sessionId = MxChatInstances.ensureSession(botId);
3025 + const sessionId = getChatSession(botId);
3988 3026
3989 - // Hide both panels while we check — show loader instead
3027 + // Hide both panels while we check — prevents flash of wrong state
3990 3028 var emailBlocker = getElementDOM(botId, 'email-blocker');
3991 3029 var chatContainer = getElementDOM(botId, 'chat-container');
3992 3030 if (emailBlocker) emailBlocker.style.display = 'none';
3993 3031 if (chatContainer) chatContainer.style.display = 'none';
3994 - showInitLoader(botId);
3995 3032
3996 3033 fetch(mxchatChat.ajax_url, {
3997 3034 method: 'POST',
3998 3035 headers: {
@@ -4039,12 +3076,11 @@
4039 3076 }
4040 3077
4041 3078 var emailInput = getElementDOM(botId, 'user-email');
4042 3079 var nameInput = getElementDOM(botId, 'user-name');
4043 - var consentInput = getElementDOM(botId, 'user-consent');
4044 3080 var userEmail = emailInput ? emailInput.value.trim() : '';
4045 3081 var userName = nameInput ? nameInput.value.trim() : '';
4046 - var sessionId = MxChatInstances.ensureSession(botId);
3082 + var sessionId = getChatSession(botId);
4047 3083
4048 3084 // Validate email
4049 3085 if (!userEmail) {
4050 3086 showEmailError(botId, 'Please enter your email address.');
@@ -4061,15 +3097,8 @@
4061 3097 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4062 3098 return false;
4063 3099 }
4064 3100
4065 - // Consent checkbox (b062c4): backstop behind the native required
4066 - // attribute; the server enforces this independently either way.
4067 - if (consentInput && consentInput.required && !consentInput.checked) {
4068 - showEmailError(botId, 'Please tick the consent box to continue.');
4069 - return false;
4070 - }
4071 -
4072 3101 clearEmailError(botId);
4073 3102 setEmailSubmissionState(botId, true);
4074 3103
4075 3104 // Prepare form data
@@ -4083,14 +3112,8 @@
4083 3112 if (userName) {
4084 3113 formData.append('name', userName);
4085 3114 }
4086 3115
4087 - // Ticked/unticked both travel when the checkbox is rendered, so an
4088 - // optional-consent "no" is recorded as a decision, not an absence.
4089 - if (consentInput) {
4090 - formData.append('consent', consentInput.checked ? '1' : '0');
4091 - }
4092 -
4093 3116 fetch(mxchatChat.ajax_url, {
4094 3117 method: 'POST',
4095 3118 headers: {
4096 3119 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4186,8 +3209,9 @@
4186 3209 $('.mxchat-chatbot-wrapper').each(function() {
4187 3210 var botId = $(this).data('bot-id') || 'default';
4188 3211 var emailBlocker = getElementDOM(botId, 'email-blocker');
4189 3212
3213 + // Only check if email blocker exists for this bot
4190 3214 if (emailBlocker) {
4191 3215 if (isEmbeddedBot(botId)) {
4192 3216 // Embedded bots are always visible — check now
4193 3217 resolveEmailState(botId);
@@ -4192,15 +3216,8 @@
4192 3216 // Embedded bots are always visible — check now
4193 3217 resolveEmailState(botId);
4194 3218 }
4195 3219 // Floating bots: handled in the widget open handler
4196 - } else if (isEmbeddedBot(botId)) {
4197 - // Embedded bot, no email collection — load history with loader
4198 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4199 - if (chatPersistenceEnabled) {
4200 - MxChatInstances.ensureSession(botId);
4201 - showChatContainerForBot(botId);
4202 - }
4203 3220 }
4204 3221 });
4205 3222 }
4206 3223
@@ -4210,17 +3227,11 @@
4210 3227 var $chatbot = getElement(botId, 'floating-chatbot');
4211 3228 if ($chatbot.hasClass('hidden')) {
4212 3229 $chatbot.removeClass('hidden').addClass('visible');
4213 3230 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4214 - handlePreChatDismissal(botId);
3231 + $(this).fadeOut(250); // Hide pre-chat message
4215 3232 disableScroll(); // Disable scroll when chatbot opens
4216 3233
4217 - // Load chat history for returning visitors (persistence)
4218 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4219 - if (chatPersistenceEnabled) {
4220 - MxChatInstances.ensureSession(botId);
4221 - }
4222 -
4223 3234 // Deferred email check — only on first widget open
4224 3235 var emailBlocker = getElementDOM(botId, 'email-blocker');
4225 3236 var instance = MxChatInstances.get(botId);
4226 3237 if (emailBlocker && !instance.emailCheckDone) {
@@ -4225,17 +3236,38 @@
4225 3236 var instance = MxChatInstances.get(botId);
4226 3237 if (emailBlocker && !instance.emailCheckDone) {
4227 3238 instance.emailCheckDone = true;
4228 3239 resolveEmailState(botId);
4229 - } else if (!emailBlocker) {
4230 - showChatContainerForBot(botId);
4231 3240 }
4232 3241 }
4233 3242 });
4234 3243
4235 - // Legacy duplicate close handler removed — handled by single event delegation above
3244 + // Dismiss pre-chat message via close button - handled by event delegation above at line ~2376
3245 + // This is a fallback for legacy support
3246 + $(document).on('click', '.close-pre-chat-message', function() {
3247 + var botId = getBotIdFromElement(this);
3248 + var $preChat = getElement(botId, 'pre-chat-message');
3249 + $preChat.fadeOut(200); // Hide the message
4236 3250
3251 + // Send an AJAX request to set the transient flag for 24 hours
3252 + $.ajax({
3253 + url: mxchatChat.ajax_url,
3254 + type: 'POST',
3255 + data: {
3256 + action: 'mxchat_dismiss_pre_chat_message',
3257 + _ajax_nonce: mxchatChat.nonce
3258 + },
3259 + success: function() {
3260 + // Ensure the message is hidden after dismissal
3261 + $preChat.hide();
3262 + },
3263 + error: function() {
3264 + // Error dismissing pre-chat message - silently continue
3265 + }
3266 + });
3267 + });
4237 3268
3269 +
4238 3270 function hasQuickQuestions(botId) {
4239 3271 botId = botId || 'default';
4240 3272 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4241 3273 if (!questionsContainer) return false;
@@ -4428,312 +3460,6 @@
4428 3460 }, 2000);
4429 3461 });
4430 3462 }
4431 3463 }
4432 -});
4433 -
4434 -// ============================================================================
4435 -// SATISFACTION RATING (v3.2.6)
4436 -// ============================================================================
4437 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4438 -// inactivity following a bot reply. One prompt per session, deduped via
4439 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4440 -// the option (default off) is authoritative.
4441 -jQuery(function($) {
4442 - if (typeof mxchatChat === 'undefined') return;
4443 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4444 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4445 - // old "disabled when exactly false/'off'" check let '' through and the
4446 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4447 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4448 - // (boolean-true localizations) working.
4449 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4450 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4451 - // merges fresh values over mxchatChat (that merge fires on first widget
4452 - // open, after this module has already decided). Re-evaluating would mean
4453 - // restructuring the whole module to late-bind its listeners — not worth it
4454 - // for a prompt that is at worst stale for one page load on a cached page.
4455 - var sre = mxchatChat.satisfaction_rating_enabled;
4456 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4457 -
4458 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4459 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4460 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4461 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4462 - if (idleSeconds < 5) idleSeconds = 5;
4463 - if (idleSeconds > 600) idleSeconds = 600;
4464 - var IDLE_MS = idleSeconds * 1000;
4465 - var MIN_BOT_REPLIES = 2;
4466 - var ratingState = {};
4467 -
4468 - function getState(botId) {
4469 - if (!ratingState[botId]) {
4470 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4471 - }
4472 - return ratingState[botId];
4473 - }
4474 -
4475 - function getSessionId(botId) {
4476 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4477 - return MxChatInstances.getChatSession(botId);
4478 - }
4479 - return null;
4480 - }
4481 -
4482 - function isAlreadyRated(sessionId) {
4483 - if (!sessionId) return false;
4484 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4485 - }
4486 -
4487 - function markRated(sessionId) {
4488 - if (!sessionId) return;
4489 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4490 - }
4491 -
4492 - function esc(s) {
4493 - return String(s == null ? '' : s)
4494 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4495 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4496 - }
4497 -
4498 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4499 - function ratingSkipInlineColors(botId) {
4500 - if (mxchatChat.skip_inline_colors) return true;
4501 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4502 - return botAssignments.hasOwnProperty(botId);
4503 - }
4504 -
4505 - function botBubbleStyleAttr(botId) {
4506 - if (ratingSkipInlineColors(botId)) return '';
4507 - var bg = mxchatChat.bot_message_bg_color;
4508 - var fg = mxchatChat.bot_message_font_color;
4509 - if (!bg && !fg) return '';
4510 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4511 - }
4512 -
4513 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4514 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4515 - // and paints the filled "Send" pill so it fills with the bot font color and
4516 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4517 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4518 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4519 - // unreliably inside a descendant's `background`, so a bubble-level var would
4520 - // silently fall back to the literal (white-block bug all over again). Inline
4521 - // longhand always wins. Same transparent-guard as the menu so we never paint
4522 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4523 - function syncRatingBubbleColors(botId) {
4524 - var $chatBox = getChatBoxByBotId(botId);
4525 - if (!$chatBox || !$chatBox.length) return;
4526 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4527 - if (!bubbleEl) return;
4528 - var cs = window.getComputedStyle(bubbleEl);
4529 - var fg = cs.color;
4530 - var bg = cs.backgroundColor;
4531 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4532 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4533 - // Expose on the bubble too, for any inheriting styles / future use.
4534 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4535 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4536 - // Paint the Send pill directly — the part that actually fixes the bug.
4537 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4538 - if (submitEl) {
4539 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4540 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4541 - }
4542 - }
4543 -
4544 - function copy(key) {
4545 - var c = mxchatChat.satisfaction_rating_copy || {};
4546 - var d = {
4547 - question: 'Was this helpful?',
4548 - helpful: 'Helpful',
4549 - not_helpful: 'Not helpful',
4550 - dismiss: 'Dismiss',
4551 - thanks: 'Thanks! Anything we should improve? (optional)',
4552 - placeholder: 'Tell us what could be better…',
4553 - send: 'Send',
4554 - skip: 'Skip',
4555 - saved: 'Thanks for the feedback.'
4556 - };
4557 - return c[key] || d[key];
4558 - }
4559 -
4560 - function thumbUpSvg() {
4561 - 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>';
4562 - }
4563 - function thumbDownSvg() {
4564 - 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>';
4565 - }
4566 -
4567 - function buildPromptHtml(botId) {
4568 - var styleAttr = botBubbleStyleAttr(botId);
4569 - return ''
4570 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4571 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4572 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4573 - + '<div class="mxchat-rating-actions">'
4574 - + '<span class="mxchat-rating-buttons">'
4575 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4576 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4577 - + '</span>'
4578 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4579 - + '</div>'
4580 - + '</div>'
4581 - + '</div>';
4582 - }
4583 -
4584 - function buildFeedbackHtml(botId, rating) {
4585 - var styleAttr = botBubbleStyleAttr(botId);
4586 - return ''
4587 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4588 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4589 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4590 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4591 - + '<div class="mxchat-rating-feedback-actions">'
4592 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4593 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4594 - + '</div>'
4595 - + '</div>'
4596 - + '</div>';
4597 - }
4598 -
4599 - function buildSavedHtml(botId) {
4600 - var styleAttr = botBubbleStyleAttr(botId);
4601 - return ''
4602 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4603 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4604 - + '</div>';
4605 - }
4606 -
4607 - function getChatBoxByBotId(botId) {
4608 - var $byId = $('#chat-box-' + botId);
4609 - if ($byId.length) return $byId.first();
4610 - return $('.chat-box').first();
4611 - }
4612 -
4613 - function scrollChatBoxToBottom($chatBox) {
4614 - if (!$chatBox || !$chatBox.length) return;
4615 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4616 - }
4617 -
4618 - function showPrompt(botId) {
4619 - var s = getState(botId);
4620 - if (s.promptShown || s.dismissed) return;
4621 - var sessionId = getSessionId(botId);
4622 - if (!sessionId) return;
4623 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4624 - var $chatBox = getChatBoxByBotId(botId);
4625 - if (!$chatBox.length) return;
4626 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4627 - $chatBox.append(buildPromptHtml(botId));
4628 - syncRatingBubbleColors(botId);
4629 - s.promptShown = true;
4630 - scrollChatBoxToBottom($chatBox);
4631 - }
4632 -
4633 - function submitRating(botId, rating, feedback) {
4634 - var sessionId = getSessionId(botId);
4635 - if (!sessionId) return;
4636 - $.post(mxchatChat.ajax_url, {
4637 - action: 'mxchat_save_rating',
4638 - session_id: sessionId,
4639 - bot_id: botId,
4640 - rating: rating,
4641 - feedback: feedback || ''
4642 - });
4643 - markRated(sessionId);
4644 - }
4645 -
4646 - function onBotReply(botId) {
4647 - var s = getState(botId);
4648 - s.botReplies += 1;
4649 - if (s.promptShown || s.dismissed) return;
4650 - var sessionId = getSessionId(botId);
4651 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4652 - if (s.botReplies < MIN_BOT_REPLIES) return;
4653 - if (s.idleTimer) clearTimeout(s.idleTimer);
4654 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4655 - }
4656 -
4657 - function onUserMessage(botId) {
4658 - var s = getState(botId);
4659 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4660 - }
4661 -
4662 - function botIdFromChatBox(el) {
4663 - var id = el && el.id ? el.id : '';
4664 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4665 - }
4666 -
4667 - function setupObserver(chatBox) {
4668 - var botId = botIdFromChatBox(chatBox);
4669 - try {
4670 - var observer = new MutationObserver(function(mutations) {
4671 - mutations.forEach(function(m) {
4672 - for (var i = 0; i < m.addedNodes.length; i++) {
4673 - var node = m.addedNodes[i];
4674 - if (!node || node.nodeType !== 1) continue;
4675 - var $n = $(node);
4676 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4677 - 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)
4678 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4679 - }
4680 - });
4681 - });
4682 - observer.observe(chatBox, { childList: true });
4683 - } catch (e) { /* noop */ }
4684 - }
4685 -
4686 - $('.chat-box').each(function() { setupObserver(this); });
4687 -
4688 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4689 - e.preventDefault();
4690 - var $btn = $(this);
4691 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4692 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4693 - var botId = $prompt.data('bot-id') || 'default';
4694 - var rating = parseInt($btn.attr('data-rating'), 10);
4695 - if (rating !== 1 && rating !== -1) return;
4696 - submitRating(botId, rating, '');
4697 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4698 - syncRatingBubbleColors(botId);
4699 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4700 - });
4701 -
4702 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4703 - e.preventDefault();
4704 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4705 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4706 - var botId = $prompt.data('bot-id') || 'default';
4707 - var s = getState(botId);
4708 - s.dismissed = true;
4709 - markRated(getSessionId(botId));
4710 - ($wrap.length ? $wrap : $prompt).remove();
4711 - });
4712 -
4713 - function closeFeedback($fb) {
4714 - var botId = $fb.data('bot-id') || 'default';
4715 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4716 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4717 - syncRatingBubbleColors(botId);
4718 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4719 - }
4720 -
4721 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4722 - e.preventDefault();
4723 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4724 - });
4725 -
4726 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4727 - e.preventDefault();
4728 - var $fb = $(this).closest('.mxchat-rating-feedback');
4729 - var botId = $fb.data('bot-id') || 'default';
4730 - var rating = parseInt($fb.attr('data-rating'), 10);
4731 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4732 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4733 - if (text !== '') {
4734 - submitRating(botId, rating, text);
4735 - }
4736 - closeFeedback($fb);
4737 - });
4738 3464 });
4739 3465