PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.0.8
MxChat – AI Chatbot & Content Generation for WordPress v3.0.8
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 +292 -1759 3.2.203.0.8 View file →
@@ -1,166 +1,6 @@
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') {
55 - if (callback) callback();
56 - return;
57 - }
58 - var now = Date.now();
59 - if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 - mxchatChat.nonce = cachedFreshNonce;
61 - if (callback) callback();
62 - return;
63 - }
64 - if (callback) nonceRefreshCallbacks.push(callback);
65 - if (nonceRefreshState === 'pending') return;
66 - nonceRefreshState = 'pending';
67 -
68 - var resolved = function (nonce) {
69 - if (nonce) {
70 - cachedFreshNonce = nonce;
71 - cachedFreshNonceFetchedAt = Date.now();
72 - mxchatChat.nonce = nonce;
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 - });
161 - }
162 -
163 3 // ====================================
164 4 // MULTI-INSTANCE MANAGEMENT SYSTEM
165 5 // ====================================
166 6
@@ -176,9 +16,9 @@
176 16 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
177 17
178 18 this.instances[botId] = {
179 19 botId: botId,
180 - sessionId: null,
20 + sessionId: this.getChatSession(botId),
181 21 lastSeenMessageId: '',
182 22 notificationCheckInterval: null,
183 23 pollingInterval: null,
184 24 processedMessageIds: new Set(),
@@ -203,77 +43,23 @@
203 43 return Object.keys(this.instances);
204 44 },
205 45
206 46 // 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 47 getChatSession: function(botId) {
210 48 var cookieName = 'mxchat_session_id_' + botId;
211 - var storageKey = 'mxchat_session_id_' + botId;
212 49 var sessionId = getCookie(cookieName);
213 50
214 - // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
215 51 if (!sessionId) {
216 - try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
52 + sessionId = generateSessionId();
53 + this.setChatSession(botId, sessionId);
217 54 }
218 55
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;
56 + return sessionId;
238 57 },
239 58
240 - // Lazy session initializer — called on first user interaction
241 - ensureSession: function(botId) {
242 - botId = botId || 'default';
243 - var instance = this.instances[botId] || this.init(botId);
244 -
245 - if (instance.sessionId) {
246 - return instance.sessionId;
247 - }
248 -
249 - // Check for existing session from cookie or localStorage
250 - var existingSession = this.getChatSession(botId);
251 -
252 - if (existingSession) {
253 - instance.sessionId = existingSession;
254 - } else {
255 - // Brand new session
256 - var newId = generateSessionId();
257 - this.setChatSession(botId, newId);
258 - instance.sessionId = newId;
259 - }
260 -
261 - // Now that we have a session, do the deferred work
262 - refreshNonceIfNeeded();
263 - trackOriginatingPage();
264 -
265 - // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
266 - // so we do NOT call it here to avoid a race condition.
267 -
268 - return instance.sessionId;
269 - },
270 -
271 59 setChatSession: function(botId, sessionId) {
272 60 var cookieName = 'mxchat_session_id_' + botId;
273 - var storageKey = 'mxchat_session_id_' + botId;
274 61 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
275 - try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
276 62 if (this.instances[botId]) {
277 63 this.instances[botId].sessionId = sessionId;
278 64 }
279 65 },
@@ -278,10 +64,8 @@
278 64 }
279 65 },
280 66
281 67 resetChatSession: function(botId) {
282 - // Clear old session from localStorage before setting new one
283 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
284 68 var newSessionId = generateSessionId();
285 69 this.setChatSession(botId, newSessionId);
286 70 var $chatBox = getElement(botId, 'chat-box');
287 71 if ($chatBox.length) {
@@ -290,20 +74,8 @@
290 74 if (this.instances[botId]) {
291 75 this.instances[botId].chatHistoryLoaded = false;
292 76 this.instances[botId].processedMessageIds = new Set();
293 77 }
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 78 }
307 79 };
308 80
309 81 // ====================================
@@ -343,20 +115,8 @@
343 115 var id = $floating.attr('id') || '';
344 116 var match = id.match(/floating-chatbot-(.+)/);
345 117 if (match) return match[1];
346 118 }
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 119 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
360 120 var elementId = $(element).attr('id') || '';
361 121 if (elementId) {
362 122 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -396,27 +156,9 @@
396 156 if (parts.length == 2) return parts.pop().split(";").shift();
397 157 }
398 158
399 159 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;
160 + return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
419 161 }
420 162
421 163 // Legacy function - now delegates to instance manager
422 164 function getChatSession(botId) {
@@ -619,26 +361,8 @@
619 361 sendButton.style.pointerEvents = 'none';
620 362 }
621 363 }
622 364
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 365 function enableChatInput(botId) {
642 366 botId = botId || 'default';
643 367 var chatInput = getElementDOM(botId, 'chat-input');
644 368 var sendButton = getElementDOM(botId, 'send-button');
@@ -644,11 +368,9 @@
644 368 var sendButton = getElementDOM(botId, 'send-button');
645 369 if (chatInput) {
646 370 chatInput.disabled = false;
647 371 chatInput.style.opacity = '1';
648 - if (mxchatShouldAutofocusAfterReply()) {
649 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
650 - }
372 + chatInput.focus();
651 373 }
652 374 if (sendButton) {
653 375 sendButton.disabled = false;
654 376 sendButton.style.opacity = '1';
@@ -653,106 +375,13 @@
653 375 sendButton.disabled = false;
654 376 sendButton.style.opacity = '1';
655 377 sendButton.style.pointerEvents = 'auto';
656 378 }
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 379 }
661 380
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 381 // Update your existing sendMessage function
752 382 function sendMessage(botId) {
753 383 botId = botId || 'default';
754 - MxChatInstances.ensureSession(botId);
755 384 var $chatInput = getElement(botId, 'chat-input');
756 385 var message = $chatInput.val();
757 386
758 387 // ADD PROMPT HOOK HERE
@@ -760,18 +389,13 @@
760 389 message = customMxChatFilter(message, "prompt");
761 390 }
762 391
763 392 if (message) {
764 - // Don't disable input in live agent mode - let users chat freely
765 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
766 - var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
767 - if (!isAgentMode) {
768 - disableChatInput(botId);
769 - }
393 + // Disable input while waiting for response
394 + disableChatInput(botId);
770 395
771 396 appendMessage("user", message, '', [], false, botId);
772 397 $chatInput.val('');
773 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
774 398 $chatInput.css('height', 'auto');
775 399
776 400 if (hasQuickQuestions(botId)) {
777 401 collapseQuickQuestions(botId);
@@ -778,16 +402,14 @@
778 402 }
779 403 appendThinkingMessage(botId);
780 404 scrollToBottom(botId);
781 405
782 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
406 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
783 407
784 408 // Check if streaming is enabled AND supported for this model
785 409 if (shouldUseStreaming(currentModel)) {
786 410 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');
411 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
790 412 }, botId);
791 413 } else {
792 414 callMxChat(message, function(response) {
793 415 replaceLastMessage("bot", response, '', [], botId);
@@ -798,9 +420,8 @@
798 420
799 421 // Update your existing sendMessageToChatbot function
800 422 function sendMessageToChatbot(message, botId) {
801 423 botId = botId || 'default';
802 - MxChatInstances.ensureSession(botId);
803 424
804 425 // ADD PROMPT HOOK HERE
805 426 if (typeof customMxChatFilter === 'function') {
806 427 message = customMxChatFilter(message, "prompt");
@@ -805,14 +426,10 @@
805 426 if (typeof customMxChatFilter === 'function') {
806 427 message = customMxChatFilter(message, "prompt");
807 428 }
808 429
809 - // Don't disable input in live agent mode - let users chat freely
810 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
811 - var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
812 - if (!isAgentMode) {
813 - disableChatInput(botId);
814 - }
430 + // Disable input while waiting for response
431 + disableChatInput(botId);
815 432
816 433 var sessionId = getChatSession(botId);
817 434
818 435 if (hasQuickQuestions(botId)) {
@@ -820,15 +437,14 @@
820 437 }
821 438 appendThinkingMessage(botId);
822 439 scrollToBottom(botId);
823 440
824 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
441 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
825 442
826 443 // Check if streaming is enabled AND supported for this model
827 444 if (shouldUseStreaming(currentModel)) {
828 445 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');
446 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
831 447 }, botId);
832 448 } else {
833 449 callMxChat(message, function(response) {
834 450 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -892,15 +508,8 @@
892 508
893 509 function callMxChat(message, callback, botId) {
894 510 botId = botId || getMxChatBotId();
895 511
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 512 // Store the message in case we need to retry after session reset
904 513 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
905 514
906 515 // Get page context if contextual awareness is enabled
@@ -908,28 +517,13 @@
908 517
909 518 // Get instance for session start timestamp (used when persistence is OFF)
910 519 var instance = MxChatInstances.get(botId);
911 520
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 521 // Prepare AJAX data
928 522 const ajaxData = {
929 523 action: 'mxchat_handle_chat_request',
930 524 message: message,
931 - session_id: sessionId,
525 + session_id: getChatSession(botId),
932 526 nonce: mxchatChat.nonce,
933 527 current_page_url: window.location.href,
934 528 current_page_title: document.title,
935 529 bot_id: botId,
@@ -935,14 +529,14 @@
935 529 bot_id: botId,
936 530 // Pass session start timestamp so AI context matches what user sees
937 531 session_start_timestamp: instance.sessionStartTimestamp || 0
938 532 };
939 -
533 +
940 534 // Add page context if available
941 535 if (pageContext) {
942 536 ajaxData.page_context = JSON.stringify(pageContext);
943 537 }
944 -
538 +
945 539 // CHECK FOR VISION FLAGS AND ADD THEM
946 540 if (window.mxchatVisionProcessed) {
947 541 ajaxData.vision_processed = true;
948 542 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -951,9 +545,9 @@
951 545 window.mxchatVisionProcessed = false;
952 546 window.mxchatOriginalMessage = null;
953 547 window.mxchatVisionImagesCount = 0;
954 548 }
955 -
549 +
956 550 $.ajax({
957 551 url: mxchatChat.ajax_url,
958 552 type: 'POST',
959 553 dataType: 'json',
@@ -991,20 +585,26 @@
991 585 errorMessage = "An error occurred. Please try again or contact support.";
992 586 }
993 587
994 588 // Handle session reset action (IP changed, session expired, etc.)
995 - // Silent reset — keep chat UI intact, just get a new session and retry
996 589 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)
590 + // Clear the old session and generate a new one
591 + resetChatSession(botId);
592 + // Remove the temporary loading message
593 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
594 + // Re-send the original message with the new session
999 595 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1000 596 if (originalMessage) {
1001 597 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1002 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
598 + // Re-add the user message and thinking indicator
599 + appendMessage("user", originalMessage, '', [], false, botId);
600 + appendThinkingMessage(botId);
601 + scrollToBottom(botId);
602 + // Determine whether to use streaming
603 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1003 604 if (shouldUseStreaming(currentModel)) {
1004 605 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');
606 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1007 607 }, botId);
1008 608 } else {
1009 609 callMxChat(originalMessage, function(response) {
1010 610 replaceLastMessage("bot", response, '', [], botId);
@@ -1058,11 +658,9 @@
1058 658 }
1059 659
1060 660 // Check for live agent response
1061 661 if (response.success && response.data && response.data.status === 'waiting_for_agent') {
1062 - removeThinkingDots(botId);
1063 662 updateChatModeIndicator('agent', botId);
1064 - enableChatInput(botId);
1065 663 return;
1066 664 }
1067 665
1068 666 // Handle the message and show notification if chat is hidden
@@ -1095,13 +693,9 @@
1095 693 $badge.show();
1096 694 }
1097 695 }
1098 696 } else {
1099 - var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1100 - if (response.vectorstore_error) {
1101 - emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1102 - }
1103 - replaceLastMessage("bot", emptyMsg, '', [], botId);
697 + replaceLastMessage("bot", "I received an empty response. Please try again or contact support if this persists.", '', [], botId);
1104 698 }
1105 699
1106 700 if (response.message_id) {
1107 701 var instance = MxChatInstances.get(botId);
@@ -1143,9 +737,8 @@
1143 737
1144 738 replaceLastMessage("bot", errorMessage, '', [], botId);
1145 739 }
1146 740 });
1147 - }); // refreshNonceIfNeeded
1148 741 }
1149 742
1150 743 function callMxChatStream(message, callback, botId) {
1151 744 botId = botId || getMxChatBotId();
@@ -1152,9 +745,9 @@
1152 745
1153 746 // Store the message in case we need to retry after session reset
1154 747 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1155 748
1156 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
749 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1157 750 if (!isStreamingSupported(currentModel)) {
1158 751 callMxChat(message, callback, botId);
1159 752 return;
1160 753 }
@@ -1164,25 +757,12 @@
1164 757
1165 758 // Get instance for session start timestamp (used when persistence is OFF)
1166 759 var instance = MxChatInstances.get(botId);
1167 760
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 761 const formData = new FormData();
1182 762 formData.append('action', 'mxchat_stream_chat');
1183 763 formData.append('message', message);
1184 - formData.append('session_id', streamSessionId);
764 + formData.append('session_id', getChatSession(botId));
1185 765 formData.append('nonce', mxchatChat.nonce);
1186 766 formData.append('current_page_url', window.location.href);
1187 767 formData.append('current_page_title', document.title);
1188 768 formData.append('bot_id', botId);
@@ -1207,25 +787,13 @@
1207 787
1208 788 let accumulatedContent = '';
1209 789 let testingDataReceived = false;
1210 790 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 791
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 792 fetch(mxchatChat.ajax_url, {
1224 793 method: 'POST',
1225 794 body: formData,
1226 - credentials: 'same-origin',
1227 - signal: streamControl.controller.signal
795 + credentials: 'same-origin'
1228 796 })
1229 797 .then(response => {
1230 798 // Store the response for potential fallback handling
1231 799 const responseClone = response.clone();
@@ -1294,16 +862,8 @@
1294 862
1295 863 // Re-enable chat input when stream ends with content
1296 864 enableChatInput(botId);
1297 865
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 866 if (callback) {
1307 867 callback(accumulatedContent);
1308 868 }
1309 869 return;
@@ -1326,25 +886,8 @@
1326 886
1327 887 // Re-enable chat input after streaming completes
1328 888 enableChatInput(botId);
1329 889
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 890 if (callback) {
1348 891 callback(accumulatedContent);
1349 892 }
1350 893 return;
@@ -1370,21 +913,8 @@
1370 913 streamingStarted = true;
1371 914 accumulatedContent += json.content;
1372 915 updateStreamingMessage(accumulatedContent, botId);
1373 916 }
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 917 // Handle complete response in stream (fallback response)
1388 918 else if (json.text || json.message || json.html) {
1389 919 handleNonStreamResponse(json, callback, botId);
1390 920 return;
@@ -1414,9 +944,8 @@
1414 944 }
1415 945
1416 946 processStream();
1417 947 }).catch(streamError => {
1418 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1419 948 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1420 949 callMxChat(message, callback, botId);
1421 950 });
1422 951 }
@@ -1423,9 +952,8 @@
1423 952
1424 953 processStream();
1425 954 })
1426 955 .catch(error => {
1427 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1428 956 // Check if we have server error data with chat mode
1429 957 if (error && error.isServerError && error.data) {
1430 958 // Check for chat mode in error data
1431 959 if (error.data.chat_mode) {
@@ -1438,9 +966,8 @@
1438 966 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1439 967 callMxChat(message, callback, botId);
1440 968 }
1441 969 });
1442 - }); // refreshNonceIfNeeded
1443 970 }
1444 971
1445 972 // Helper function to handle non-streaming responses
1446 973 function handleNonStreamResponse(data, callback, botId) {
@@ -1479,16 +1006,21 @@
1479 1006 errorMessage = "An error occurred. Please try again or contact support.";
1480 1007 }
1481 1008
1482 1009 // Handle session reset action (IP changed, session expired, etc.)
1483 - // Silent reset — keep chat UI intact, just get a new session and retry
1484 1010 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)
1011 + // Clear the old session and generate a new one
1012 + resetChatSession(botId);
1013 + // Re-send the original message with the new session
1487 1014 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1488 1015 if (originalMessage) {
1489 1016 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1490 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1017 + // Re-add the user message and thinking indicator
1018 + appendMessage("user", originalMessage, '', [], false, botId);
1019 + appendThinkingMessage(botId);
1020 + scrollToBottom(botId);
1021 + // Determine whether to use streaming
1022 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1491 1023 if (shouldUseStreaming(currentModel)) {
1492 1024 callMxChatStream(originalMessage, callback, botId);
1493 1025 } else {
1494 1026 callMxChat(originalMessage, callback, botId);
@@ -1510,22 +1042,8 @@
1510 1042 }
1511 1043 return; // Exit early for errors
1512 1044 }
1513 1045
1514 - // Check for live agent response
1515 - if (data.success && data.data && data.data.status === 'waiting_for_agent') {
1516 - removeThinkingDots(botId);
1517 - // Also remove any leftover bot-message that lost its temporary-message class
1518 - var $chatBox = getElement(botId, 'chat-box');
1519 - $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
1520 - updateChatModeIndicator('agent', botId);
1521 - enableChatInput(botId);
1522 - if (callback) {
1523 - callback('');
1524 - }
1525 - return;
1526 - }
1527 -
1528 1046 // Handle different response formats
1529 1047 if (data.text || data.html || data.message) {
1530 1048
1531 1049 // Apply response hooks
@@ -1570,15 +1088,19 @@
1570 1088 }
1571 1089
1572 1090 // Enhanced updateChatModeIndicator function for immediate DOM updates
1573 1091 function updateChatModeIndicator(mode, botId) {
1092 + console.log('[MxChat] updateChatModeIndicator called with mode:', mode, 'botId:', botId);
1574 1093 botId = botId || 'default';
1575 1094 const indicator = getElementDOM(botId, 'chat-mode-indicator');
1095 + console.log('[MxChat] chat-mode-indicator element found:', !!indicator);
1576 1096 if (indicator) {
1577 1097 const oldText = indicator.textContent;
1098 + console.log('[MxChat] Current indicator text:', oldText, '-> changing to mode:', mode);
1578 1099
1579 1100 if (mode === 'agent') {
1580 1101 indicator.textContent = 'Live Agent';
1102 + console.log('[MxChat] Mode is agent, calling startPolling...');
1581 1103 startPolling(botId);
1582 1104 } else {
1583 1105 // Everything else is AI mode
1584 1106 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
@@ -1621,15 +1143,8 @@
1621 1143 var $chatBox = getElement(botId, 'chat-box');
1622 1144 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1623 1145
1624 1146 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 1147 // Update existing message
1633 1148 tempMessage.html(formattedContent);
1634 1149 } else {
1635 1150 // Create new temporary message if it doesn't exist
@@ -1656,21 +1171,9 @@
1656 1171 // Update the event handlers to use the correct function names (using event delegation)
1657 1172 // Use class-based selectors for multi-instance support
1658 1173 $(document).on('click', '.send-button', function() {
1659 1174 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 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1670 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1671 - disableChatInput(botId);
1672 - }
1175 + disableChatInput(botId);
1673 1176 sendMessage(botId);
1674 1177 });
1675 1178
1676 1179 // Override enter key handler (using event delegation)
@@ -1677,378 +1180,14 @@
1677 1180 $(document).on('keypress', '.chat-input', function(e) {
1678 1181 if (e.which == 13 && !e.shiftKey) {
1679 1182 e.preventDefault();
1680 1183 var botId = getBotIdFromElement(this);
1681 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1682 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1683 - disableChatInput(botId);
1684 - }
1184 + disableChatInput(botId);
1685 1185 sendMessage(botId);
1686 1186 }
1687 1187 });
1688 1188
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 -
1189 +
2051 1190 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2052 1191 try {
2053 1192 // Determine styles based on sender type
2054 1193 let messageClass, bgColor, fontColor;
@@ -2086,12 +1225,17 @@
2086 1225 'margin-bottom': '1em'
2087 1226 });
2088 1227 }
2089 1228
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);
1229 + // Process the message content based on sender
1230 + let fullMessage;
1231 + if (sender === "user") {
1232 + // For user messages, apply linkify after sanitization
1233 + fullMessage = linkify(messageText);
1234 + } else {
1235 + // For bot/agent messages, preserve HTML
1236 + fullMessage = messageText;
1237 + }
2094 1238
2095 1239 // Add images if provided
2096 1240 if (images && images.length > 0) {
2097 1241 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -2123,11 +1267,9 @@
2123 1267
2124 1268 messageDiv.html(fullMessage);
2125 1269
2126 1270 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');
1271 + messageDiv.addClass('temporary-message');
2130 1272 }
2131 1273
2132 1274 // Append to the correct chatbot instance's chat-box
2133 1275 var $chatBox = getElement(botId, 'chat-box');
@@ -2142,12 +1284,8 @@
2142 1284 if (lastUserMessage.length) {
2143 1285 scrollElementToTop(lastUserMessage, botId);
2144 1286 }
2145 1287 }
2146 -
2147 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2148 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2149 - }
2150 1288 });
2151 1289
2152 1290 if (messageText.id) {
2153 1291 var instance = MxChatInstances.get(botId);
@@ -2232,12 +1370,26 @@
2232 1370 bgColor = botMessageBgColor;
2233 1371 fontColor = botMessageFontColor;
2234 1372 }
2235 1373
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);
1374 + // FIXED: Only linkify if response doesn't already contain HTML links or tags
1375 + // This prevents double-processing of URLs that are already formatted as HTML
1376 + var fullMessage;
1377 + if (sender === "user") {
1378 + // Always linkify user messages (they're plain text)
1379 + fullMessage = linkify(responseText);
1380 + } else {
1381 + // For bot/agent messages, check if HTML already exists
1382 + if (responseText.includes('<a href=') || responseText.includes('</a>') ||
1383 + responseText.includes('<img') || responseText.includes('<div') ||
1384 + responseText.includes('<p>') || responseText.includes('<br>')) {
1385 + // Response already has HTML, don't process it
1386 + fullMessage = responseText;
1387 + } else {
1388 + // Plain text response, apply linkify
1389 + fullMessage = linkify(responseText);
1390 + }
1391 + }
2240 1392
2241 1393 if (responseHtml) {
2242 1394 // Only add line breaks if there's actual text content before the HTML
2243 1395 if (fullMessage && fullMessage.trim()) {
@@ -2262,16 +1414,13 @@
2262 1414 }
2263 1415
2264 1416 if (lastMessageDiv.length) {
2265 1417 // 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 1418 lastMessageDiv
2269 1419 .html(fullMessage)
2270 1420 .removeClass('bot-message user-message temporary-message')
2271 1421 .addClass(messageClass)
2272 - .attr('dir', 'auto')
2273 - .attr('aria-busy', 'false');
1422 + .attr('dir', 'auto');
2274 1423
2275 1424 // Only apply inline colors if AI theme is not active (let CSS handle it)
2276 1425 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2277 1426 if (!skipColors) {
@@ -2297,12 +1446,8 @@
2297 1446 }
2298 1447
2299 1448 // Re-enable chat input after response is displayed
2300 1449 enableChatInput(botId);
2301 -
2302 - if (sender === "bot" || sender === "agent") {
2303 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2304 - }
2305 1450 } else {
2306 1451 appendMessage(sender, responseText, responseHtml, images, false, botId);
2307 1452 // Re-enable chat input after response is displayed
2308 1453 enableChatInput(botId);
@@ -2311,15 +1456,8 @@
2311 1456
2312 1457
2313 1458 function appendThinkingMessage(botId) {
2314 1459 botId = botId || 'default';
2315 -
2316 - // Don't show thinking dots in live agent mode - message is just forwarded to a human
2317 - var indicator = getElementDOM(botId, 'chat-mode-indicator');
2318 - if (indicator && indicator.textContent === 'Live Agent') {
2319 - return;
2320 - }
2321 -
2322 1460 var $chatBox = getElement(botId, 'chat-box');
2323 1461
2324 1462 // Remove any existing thinking dots in this bot's chat first
2325 1463 $chatBox.find('.thinking-dots').remove();
@@ -2331,15 +1469,10 @@
2331 1469 var botMessageFontColor = mxchatChat.bot_message_font_color;
2332 1470 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2333 1471
2334 1472 // 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 1473 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">' +
1474 + var thinkingHtml = '<div class="thinking-dots-container">' +
2342 1475 '<div class="thinking-dots">' +
2343 1476 '<span class="dot"' + dotStyle + '></span>' +
2344 1477 '<span class="dot"' + dotStyle + '></span>' +
2345 1478 '<span class="dot"' + dotStyle + '></span>' +
@@ -2346,9 +1479,9 @@
2346 1479 '</div>' +
2347 1480 '</div>';
2348 1481
2349 1482 // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active
2350 - var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"';
1483 + var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + ';"';
2351 1484 $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>');
2352 1485 scrollToBottom(botId);
2353 1486 }
2354 1487
@@ -2354,11 +1487,9 @@
2354 1487
2355 1488 function removeThinkingDots(botId) {
2356 1489 botId = botId || 'default';
2357 1490 var $chatBox = getElement(botId, 'chat-box');
2358 - // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots
2359 1491 $chatBox.find('.thinking-dots').closest('.temporary-message').remove();
2360 - $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
2361 1492 }
2362 1493
2363 1494 // ====================================
2364 1495 // TEXT FORMATTING & PROCESSING
@@ -2392,12 +1523,9 @@
2392 1523 processedText = formatTextStyling(processedText);
2393 1524
2394 1525 // Process code blocks BEFORE processing links
2395 1526 processedText = formatCodeBlocks(processedText);
2396 -
2397 - // Process markdown tables BEFORE converting newlines to paragraphs
2398 - processedText = formatMarkdownTables(processedText);
2399 -
1527 +
2400 1528 // NOW convert to paragraphs
2401 1529 processedText = convertNewlinesToBreaks(processedText);
2402 1530
2403 1531 // IMPORTANT: Handle citation-style brackets FIRST [URL]
@@ -2410,63 +1538,37 @@
2410 1538 // Return as a proper link without the brackets
2411 1539 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2412 1540 });
2413 1541
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 - }
1542 + // Process proper markdown links with text: [text](url)
1543 + // This MUST have non-empty text in the first brackets
1544 + const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
1545 + processedText = processedText.replace(markdownLinkPattern, (match, text, url) => {
1546 + // Make sure we have actual text (not just whitespace)
1547 + if (!text || !text.trim()) {
1548 + // If no text, treat the URL as the text
1549 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1550 + const safeUrl = safeEncodeUrl(cleanUrl);
1551 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2465 1552 }
2466 - return result;
2467 - })(processedText);
1553 +
1554 + // Clean the URL
1555 + let cleanUrl = url.replace(/[\].,;!?]+$/, '');
1556 + const safeUrl = safeEncodeUrl(cleanUrl);
1557 + const safeText = sanitizeUserInput(text);
1558 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1559 + });
2468 1560
1561 + // Handle empty markdown links: [](url)
1562 + // This is a specific case where there's no text
1563 + const emptyMarkdownPattern = /\[\]\((https?:\/\/[^\s)]+)\)/g;
1564 + processedText = processedText.replace(emptyMarkdownPattern, (match, url) => {
1565 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1566 + const safeUrl = safeEncodeUrl(cleanUrl);
1567 + // Use the URL itself as the link text
1568 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
1569 + });
1570 +
2469 1571 // Process phone numbers: [text](tel:number)
2470 1572 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2471 1573 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2472 1574 const safePhone = safeEncodeUrl(phone);
@@ -2618,78 +1720,9 @@
2618 1720 });
2619 1721
2620 1722 return text;
2621 1723 }
2622 -
2623 - function formatMarkdownTables(text) {
2624 - var lines = text.split('\n');
2625 - var result = [];
2626 - var i = 0;
2627 -
2628 - while (i < lines.length) {
2629 - // Check for a table: current line has pipes AND next line is a separator row
2630 - if (i + 1 < lines.length &&
2631 - lines[i].indexOf('|') !== -1 &&
2632 - /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {
2633 -
2634 - var tableLines = [];
2635 - var headerLine = lines[i];
2636 - var separatorLine = lines[i + 1];
2637 - tableLines.push(headerLine);
2638 - tableLines.push(separatorLine);
2639 -
2640 - // Collect remaining table rows
2641 - var j = i + 2;
2642 - while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
2643 - tableLines.push(lines[j]);
2644 - j++;
2645 - }
2646 -
2647 - // Parse alignment from separator row
2648 - var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
2649 - var alignments = sepCells.map(function(cell) {
2650 - var trimmed = cell.trim();
2651 - if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
2652 - if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
2653 - return 'left';
2654 - });
2655 -
2656 - // Build HTML table
2657 - var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';
2658 -
2659 - // Header row
2660 - var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
2661 - html += '<thead><tr>';
2662 - headerCells.forEach(function(cell, idx) {
2663 - var align = alignments[idx] || 'left';
2664 - html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
2665 - });
2666 - html += '</tr></thead>';
2667 -
2668 - // Body rows
2669 - html += '<tbody>';
2670 - for (var r = 2; r < tableLines.length; r++) {
2671 - var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
2672 - html += '<tr>';
2673 - rowCells.forEach(function(cell, idx) {
2674 - var align = alignments[idx] || 'left';
2675 - html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
2676 - });
2677 - html += '</tr>';
2678 - }
2679 - html += '</tbody></table></div>';
2680 -
2681 - result.push(html);
2682 - i = j;
2683 - } else {
2684 - result.push(lines[i]);
2685 - i++;
2686 - }
2687 - }
2688 -
2689 - return result.join('\n');
2690 - }
2691 -
1724 +
2692 1725 function sanitizeUserInput(text) {
2693 1726 const div = document.createElement('div');
2694 1727 div.textContent = text;
2695 1728 return div.innerHTML;
@@ -2760,14 +1793,13 @@
2760 1793 requestAnimationFrame(smoothScroll);
2761 1794 }
2762 1795 }
2763 1796
2764 - function scrollElementToTop(element, botId, topOffset) {
1797 + function scrollElementToTop(element, botId) {
2765 1798 botId = botId || 'default';
2766 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2767 1799 var chatBox = getElement(botId, 'chat-box');
2768 1800 var elementTop = element.position().top + chatBox.scrollTop();
2769 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1801 + chatBox.animate({ scrollTop: elementTop }, 500);
2770 1802 }
2771 1803
2772 1804 function showChatWidget(botId) {
2773 1805 botId = botId || 'default';
@@ -2911,12 +1943,15 @@
2911 1943 // LIVE AGENT FUNCTIONALITY
2912 1944 // ====================================
2913 1945
2914 1946 function startPolling(botId) {
1947 + console.log('[MxChat] startPolling called for botId:', botId);
2915 1948 botId = botId || 'default';
2916 1949 var instance = MxChatInstances.get(botId);
2917 1950 // Clear any existing interval first
2918 1951 stopPolling(botId);
1952 + // Start new polling interval
1953 + console.log('[MxChat] Starting polling interval (5s) for botId:', botId);
2919 1954 instance.pollingInterval = setInterval(function() {
2920 1955 checkForAgentMessages(botId);
2921 1956 }, 5000);
2922 1957 }
@@ -2921,17 +1956,20 @@
2921 1956 }, 5000);
2922 1957 }
2923 1958
2924 1959 function stopPolling(botId) {
1960 + console.log('[MxChat] stopPolling called for botId:', botId);
2925 1961 botId = botId || 'default';
2926 1962 var instance = MxChatInstances.get(botId);
2927 1963 if (instance.pollingInterval) {
2928 1964 clearInterval(instance.pollingInterval);
2929 1965 instance.pollingInterval = null;
1966 + console.log('[MxChat] Polling stopped for botId:', botId);
2930 1967 }
2931 1968 }
2932 1969
2933 1970 function checkForAgentMessages(botId) {
1971 + console.log('[MxChat] checkForAgentMessages called for botId:', botId);
2934 1972 botId = botId || 'default';
2935 1973 var instance = MxChatInstances.get(botId);
2936 1974 const sessionId = getChatSession(botId);
2937 1975 $.ajax({
@@ -2957,12 +1995,8 @@
2957 1995 instance.processedMessageIds.add(message.id);
2958 1996 }
2959 1997 });
2960 1998
2961 - if (hasNewMessage) {
2962 - enableChatInput(botId);
2963 - }
2964 -
2965 1999 var $floatingChatbot = getElement(botId, 'floating-chatbot');
2966 2000 if (hasNewMessage && $floatingChatbot.hasClass('hidden')) {
2967 2001 showNotification(botId);
2968 2002 }
@@ -2968,13 +2002,8 @@
2968 2002 }
2969 2003
2970 2004 scrollToBottom(botId, true);
2971 2005 }
2972 -
2973 - // Handle chat mode transitions (e.g. agent ended chat via !endchat)
2974 - if (response.success && response.data?.chat_mode) {
2975 - updateChatModeIndicator(response.data.chat_mode, botId);
2976 - }
2977 2006 },
2978 2007 error: function (xhr, status, error) {
2979 2008 // Polling error - silently continue
2980 2009 }
@@ -2984,29 +2013,20 @@
2984 2013 // ====================================
2985 2014 // CHAT HISTORY & PERSISTENCE
2986 2015 // ====================================
2987 2016
2988 -function loadChatHistory(botId, onComplete) {
2017 +function loadChatHistory(botId) {
2989 2018 botId = botId || 'default';
2990 2019 var instance = MxChatInstances.get(botId);
2991 2020
2992 2021 // Prevent duplicate loading
2993 2022 if (instance.chatHistoryLoaded) {
2994 - if (onComplete) onComplete();
2995 2023 return;
2996 2024 }
2997 2025
2998 - // Use getChatSession which returns null if no session exists (does NOT create one)
2999 2026 var sessionId = getChatSession(botId);
3000 2027 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3001 2028
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 2029 if (chatPersistenceEnabled && sessionId) {
3010 2030 $.ajax({
3011 2031 url: mxchatChat.ajax_url,
3012 2032 type: 'POST',
@@ -3017,12 +2037,11 @@
3017 2037 },
3018 2038 success: function(response) {
3019 2039 // Handle session reset (IP changed while user was away)
3020 2040 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);
2041 + // Silently reset session - user will start fresh
2042 + resetChatSession(botId);
3023 2043 instance.chatHistoryLoaded = true; // Prevent retry loop
3024 - if (onComplete) onComplete();
3025 2044 return;
3026 2045 }
3027 2046
3028 2047 // Check if the response indicates success
@@ -3039,20 +2058,8 @@
3039 2058 }
3040 2059
3041 2060 // Only process if there are actual messages
3042 2061 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 2062 // IMPORTANT: Clear existing messages before loading history
3056 2063 $chatBox.empty();
3057 2064
3058 2065 $.each(response.data.conversation, function(index, message) {
@@ -3090,23 +2097,9 @@
3090 2097 var content = message.content;
3091 2098 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3092 2099 content = decodeHTMLEntities(content);
3093 2100
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")) {
2101 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
3109 2102 messageElement.html(content);
3110 2103 } else {
3111 2104 var formattedContent = linkify(content);
3112 2105 messageElement.html(formattedContent);
@@ -3124,18 +2117,8 @@
3124 2117 // Only append messages and scroll if we have content
3125 2118 $chatBox.append($fragment);
3126 2119 scrollToBottom(botId, true);
3127 2120
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 2121 // Collapse quick questions if we have conversation history
3139 2122 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3140 2123 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3141 2124 collapseQuickQuestions(botId);
@@ -3156,17 +2139,13 @@
3156 2139 instance.chatHistoryLoaded = true;
3157 2140 }
3158 2141 }
3159 2142 }
3160 - if (onComplete) onComplete();
3161 2143 },
3162 2144 error: function(xhr, status, error) {
3163 2145 // Error loading chat history - silently continue
3164 - if (onComplete) onComplete();
3165 2146 }
3166 2147 });
3167 - } else {
3168 - if (onComplete) onComplete();
3169 2148 }
3170 2149 }
3171 2150
3172 2151
@@ -3230,10 +2209,10 @@
3230 2209 .then(data => {
3231 2210 if (data.success) {
3232 2211 container.style.display = 'none';
3233 2212 nameElement.textContent = '';
3234 - instance.activePdfFile = null;
3235 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2213 + activePdfFile = null;
2214 + appendMessage('bot', 'PDF removed.');
3236 2215 }
3237 2216 })
3238 2217 .catch(error => {
3239 2218 // Error removing PDF - silently continue
@@ -3239,16 +2218,14 @@
3239 2218 // Error removing PDF - silently continue
3240 2219 });
3241 2220 }
3242 2221
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 -
2222 + function removeActiveWord() {
2223 + const container = document.getElementById('active-word-container');
2224 + const nameElement = document.getElementById('active-word-name');
2225 +
2226 + if (!container || !nameElement || !activeWordFile) return;
2227 +
3251 2228 fetch(mxchatChat.ajax_url, {
3252 2229 method: 'POST',
3253 2230 headers: {
3254 2231 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3254,9 +2231,9 @@
3254 2231 'Content-Type': 'application/x-www-form-urlencoded',
3255 2232 },
3256 2233 body: new URLSearchParams({
3257 2234 'action': 'mxchat_remove_word',
3258 - 'session_id': getChatSession(botId),
2235 + 'session_id': sessionId,
3259 2236 'nonce': mxchatChat.nonce
3260 2237 })
3261 2238 })
3262 2239 .then(response => response.json())
@@ -3263,10 +2240,10 @@
3263 2240 .then(data => {
3264 2241 if (data.success) {
3265 2242 container.style.display = 'none';
3266 2243 nameElement.textContent = '';
3267 - instance.activeWordFile = null;
3268 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2244 + activeWordFile = null;
2245 + appendMessage('bot', 'Word document removed.');
3269 2246 }
3270 2247 })
3271 2248 .catch(error => {
3272 2249 // Error removing Word document - silently continue
@@ -3344,35 +2321,45 @@
3344 2321 // ====================================
3345 2322
3346 2323 function checkPreChatDismissal(botId) {
3347 2324 botId = botId || 'default';
3348 - 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) {
2325 + $.ajax({
2326 + url: mxchatChat.ajax_url,
2327 + type: 'POST',
2328 + data: {
2329 + action: 'mxchat_check_pre_chat_message_status',
2330 + _ajax_nonce: mxchatChat.nonce
2331 + },
2332 + success: function(response) {
2333 + if (response.success && !response.data.dismissed) {
2334 + getElement(botId, 'pre-chat-message').fadeIn(250);
2335 + } else {
3354 2336 getElement(botId, 'pre-chat-message').hide();
3355 - return;
3356 2337 }
3357 - // Expired — clear and show again
3358 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2338 + },
2339 + error: function() {
2340 + // Error checking pre-chat dismissal - silently continue
3359 2341 }
3360 - getElement(botId, 'pre-chat-message').fadeIn(250);
3361 - } catch (e) {
3362 - // localStorage unavailable — show the message
3363 - getElement(botId, 'pre-chat-message').fadeIn(250);
3364 - }
2342 + });
3365 2343 }
3366 2344
3367 2345 function handlePreChatDismissal(botId) {
3368 2346 botId = botId || 'default';
3369 2347 getElement(botId, 'pre-chat-message').fadeOut(200);
3370 - try {
3371 - localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3372 - } catch (e) {
3373 - // localStorage unavailable — dismissal won't persist
3374 - }
2348 + $.ajax({
2349 + url: mxchatChat.ajax_url,
2350 + type: 'POST',
2351 + data: {
2352 + action: 'mxchat_dismiss_pre_chat_message',
2353 + _ajax_nonce: mxchatChat.nonce
2354 + },
2355 + success: function() {
2356 + $('#pre-chat-message').hide();
2357 + },
2358 + error: function() {
2359 + // Error dismissing pre-chat message - silently continue
2360 + }
2361 + });
3375 2362 }
3376 2363
3377 2364
3378 2365 // ====================================
@@ -3425,38 +2412,11 @@
3425 2412 e.stopPropagation();
3426 2413 var botId = getBotIdFromElement(this);
3427 2414 collapseQuickQuestions(botId);
3428 2415 });
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 2416
3452 2417 // 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 - }
2418 + $(document).on('click', '.floating-chatbot-button', function() {
3459 2419 var botId = getBotIdFromElement(this);
3460 2420 var $chatbot = getElement(botId, 'floating-chatbot');
3461 2421 var $badge = getElement(botId, 'chat-notification-badge');
3462 2422 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3461,88 +2421,35 @@
3461 2421 var $badge = getElement(botId, 'chat-notification-badge');
3462 2422 var $preChat = getElement(botId, 'pre-chat-message');
3463 2423
3464 2424 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');
2425 + $chatbot.removeClass('hidden').addClass('visible');
2426 + $(this).addClass('hidden');
3468 2427 $badge.hide(); // Hide notification when opening chat
3469 2428 disableScroll();
3470 2429 $preChat.fadeOut(250);
3471 -
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 - // Deferred email check — only on first widget open
3484 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3485 - var instance = MxChatInstances.get(botId);
3486 - if (emailBlocker && !instance.emailCheckDone) {
3487 - instance.emailCheckDone = true;
3488 - 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 - }
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 2430 } else {
3503 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3504 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2431 + $chatbot.removeClass('visible').addClass('hidden');
2432 + $(this).removeClass('hidden');
3505 2433 enableScroll();
3506 2434 checkPreChatDismissal(botId);
3507 2435 }
3508 2436 });
3509 2437
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.
2438 + // Allow clicking anywhere on the title bar to close the chatbot
3514 2439 $(document).on('click', '.chatbot-top-bar', function() {
3515 2440 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');
2441 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2442 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3519 2443 enableScroll();
3520 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3521 2444 });
3522 2445
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 2446 $(document).on('click', '.close-pre-chat-message', function(e) {
3542 2447 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3543 2448 var botId = getBotIdFromElement(this);
3544 - handlePreChatDismissal(botId);
2449 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2450 + $(this).remove();
2451 + });
3545 2452 });
3546 2453
3547 2454
3548 2455 // PDF upload button handlers - use class selector
@@ -3558,20 +2465,17 @@
3558 2465 var wordInput = getElementDOM(botId, 'word-upload');
3559 2466 if (wordInput) wordInput.click();
3560 2467 });
3561 2468
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 -
2469 + // PDF file input change handler
2470 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2471 + const file = e.target.files[0];
2472 +
3569 2473 if (!file || file.type !== 'application/pdf') {
3570 2474 alert('Please select a valid PDF file.');
3571 2475 return;
3572 2476 }
3573 -
2477 +
3574 2478 if (!sessionId) {
3575 2479 alert('Error: No session ID found');
3576 2480 return;
3577 2481 }
@@ -3579,49 +2483,47 @@
3579 2483 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3580 2484 alert('Error: Ajax configuration missing');
3581 2485 return;
3582 2486 }
3583 -
2487 +
3584 2488 // 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;
2489 + const uploadBtn = document.getElementById('pdf-upload-btn');
2490 + const sendBtn = document.getElementById('send-button');
3588 2491 const originalBtnContent = uploadBtn.innerHTML;
3589 -
2492 +
3590 2493 try {
3591 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3592 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3593 2494 const formData = new FormData();
3594 2495 formData.append('action', 'mxchat_upload_pdf');
3595 2496 formData.append('pdf_file', file);
3596 2497 formData.append('session_id', sessionId);
3597 2498 formData.append('nonce', mxchatChat.nonce);
3598 -
2499 +
3599 2500 uploadBtn.disabled = true;
3600 - if (sendBtn) sendBtn.disabled = true;
2501 + sendBtn.disabled = true;
3601 2502 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3602 2503 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3603 2504 </svg>`;
3604 -
2505 +
3605 2506 const response = await fetch(mxchatChat.ajax_url, {
3606 2507 method: 'POST',
3607 2508 body: formData
3608 2509 });
3609 -
2510 +
3610 2511 const data = await response.json();
3611 -
2512 +
3612 2513 if (data.success) {
3613 2514 // Hide popular questions if they exist
3614 - if (hasQuickQuestions(botId)) {
3615 - collapseQuickQuestions(botId);
2515 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2516 + if (hasQuickQuestions()) {
2517 + collapseQuickQuestions();
3616 2518 }
3617 -
2519 +
3618 2520 // 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;
2521 + showActivePdf(data.data.filename);
2522 +
2523 + appendMessage('bot', data.data.message);
2524 + scrollToBottom();
2525 + activePdfFile = data.data.filename;
3624 2526 } else {
3625 2527 alert('Failed to upload PDF. Please try again.');
3626 2528 }
3627 2529 } catch (error) {
@@ -3627,76 +2529,66 @@
3627 2529 } catch (error) {
3628 2530 alert('Error uploading file. Please try again.');
3629 2531 } finally {
3630 2532 uploadBtn.disabled = false;
3631 - if (sendBtn) sendBtn.disabled = false;
2533 + sendBtn.disabled = false;
3632 2534 uploadBtn.innerHTML = originalBtnContent;
3633 2535 this.value = ''; // Reset file input
3634 2536 }
3635 2537 });
3636 2538
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 -
2539 + // Word file input change handler
2540 + addSafeEventListener('word-upload', 'change', async function(e) {
2541 + const file = e.target.files[0];
2542 +
3644 2543 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3645 2544 alert('Please select a valid Word document (.docx).');
3646 2545 return;
3647 2546 }
3648 -
2547 +
3649 2548 if (!sessionId) {
3650 2549 alert('Error: No session ID found');
3651 2550 return;
3652 2551 }
3653 2552
3654 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3655 - alert('Error: Ajax configuration missing');
3656 - return;
3657 - }
3658 -
3659 2553 // 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;
2554 + const uploadBtn = document.getElementById('word-upload-btn');
2555 + const sendBtn = document.getElementById('send-button');
3663 2556 const originalBtnContent = uploadBtn.innerHTML;
3664 -
2557 +
3665 2558 try {
3666 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3667 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3668 2559 const formData = new FormData();
3669 2560 formData.append('action', 'mxchat_upload_word');
3670 2561 formData.append('word_file', file);
3671 2562 formData.append('session_id', sessionId);
3672 2563 formData.append('nonce', mxchatChat.nonce);
3673 -
2564 +
3674 2565 uploadBtn.disabled = true;
3675 - if (sendBtn) sendBtn.disabled = true;
2566 + sendBtn.disabled = true;
3676 2567 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3677 2568 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3678 2569 </svg>`;
3679 -
2570 +
3680 2571 const response = await fetch(mxchatChat.ajax_url, {
3681 2572 method: 'POST',
3682 2573 body: formData
3683 2574 });
3684 -
2575 +
3685 2576 const data = await response.json();
3686 -
2577 +
3687 2578 if (data.success) {
3688 2579 // Hide popular questions if they exist
3689 - if (hasQuickQuestions(botId)) {
3690 - collapseQuickQuestions(botId);
2580 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2581 + if (hasQuickQuestions()) {
2582 + collapseQuickQuestions();
3691 2583 }
3692 -
2584 +
3693 2585 // 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;
2586 + showActiveWord(data.data.filename);
2587 +
2588 + appendMessage('bot', data.data.message);
2589 + scrollToBottom();
2590 + activeWordFile = data.data.filename;
3699 2591 } else {
3700 2592 alert('Failed to upload Word document. Please try again.');
3701 2593 }
3702 2594 } catch (error) {
@@ -3702,25 +2594,25 @@
3702 2594 } catch (error) {
3703 2595 alert('Error uploading file. Please try again.');
3704 2596 } finally {
3705 2597 uploadBtn.disabled = false;
3706 - if (sendBtn) sendBtn.disabled = false;
2598 + sendBtn.disabled = false;
3707 2599 uploadBtn.innerHTML = originalBtnContent;
3708 2600 this.value = ''; // Reset file input
3709 2601 }
3710 2602 });
3711 2603
3712 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3713 - $(document).on('click', '.remove-pdf-btn', function(e) {
2604 + // Remove button click handlers
2605 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3714 2606 e.preventDefault();
3715 2607 e.stopPropagation();
3716 - removeActivePdf(getBotIdFromElement(this));
2608 + removeActivePdf();
3717 2609 });
3718 -
3719 - $(document).on('click', '.remove-word-btn', function(e) {
2610 +
2611 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3720 2612 e.preventDefault();
3721 2613 e.stopPropagation();
3722 - removeActiveWord(getBotIdFromElement(this));
2614 + removeActiveWord();
3723 2615 });
3724 2616
3725 2617 // Window resize handlers
3726 2618 $(window).on('resize orientationchange', function() {
@@ -3758,59 +2650,8 @@
3758 2650 });
3759 2651
3760 2652
3761 2653 // ====================================
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 2654 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3814 2655 // ====================================
3815 2656 // Only run email collection setup if it's enabled
3816 2657 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
@@ -3846,8 +2687,28 @@
3846 2687 `;
3847 2688 document.head.appendChild(style);
3848 2689 }
3849 2690
2691 + // Helper functions for email collection (multi-instance aware)
2692 + function showEmailFormForBot(botId) {
2693 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2694 + var chatContainer = getElementDOM(botId, 'chat-container');
2695 + if (emailBlocker) emailBlocker.style.display = 'flex';
2696 + if (chatContainer) chatContainer.style.display = 'none';
2697 + }
2698 +
2699 + function showChatContainerForBot(botId) {
2700 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2701 + var chatContainer = getElementDOM(botId, 'chat-container');
2702 + if (emailBlocker) emailBlocker.style.display = 'none';
2703 + if (chatContainer) chatContainer.style.display = 'flex';
2704 +
2705 + // Load chat history for this bot
2706 + if (typeof loadChatHistory === 'function') {
2707 + loadChatHistory(botId);
2708 + }
2709 + }
2710 +
3850 2711 function isValidEmailAddress(email) {
3851 2712 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3852 2713 return emailRegex.test(email.trim()) && email.length <= 254;
3853 2714 }
@@ -3969,31 +2830,11 @@
3969 2830 existingErrors.forEach(error => error.remove());
3970 2831 }
3971 2832 }
3972 2833
3973 - // Resolve email state using server-side data when available, AJAX fallback otherwise
3974 - function resolveEmailState(botId) {
3975 - if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
3976 - if (mxchatChat.initial_email_state.show_email_form) {
3977 - showEmailFormForBot(botId);
3978 - } else {
3979 - showChatContainerForBot(botId);
3980 - }
3981 - } else {
3982 - checkSessionAndEmailForBot(botId);
3983 - }
3984 - }
3985 -
3986 2834 function checkSessionAndEmailForBot(botId) {
3987 - const sessionId = MxChatInstances.ensureSession(botId);
2835 + const sessionId = getChatSession(botId);
3988 2836
3989 - // Hide both panels while we check — show loader instead
3990 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3991 - var chatContainer = getElementDOM(botId, 'chat-container');
3992 - if (emailBlocker) emailBlocker.style.display = 'none';
3993 - if (chatContainer) chatContainer.style.display = 'none';
3994 - showInitLoader(botId);
3995 -
3996 2837 fetch(mxchatChat.ajax_url, {
3997 2838 method: 'POST',
3998 2839 headers: {
3999 2840 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4039,12 +2880,11 @@
4039 2880 }
4040 2881
4041 2882 var emailInput = getElementDOM(botId, 'user-email');
4042 2883 var nameInput = getElementDOM(botId, 'user-name');
4043 - var consentInput = getElementDOM(botId, 'user-consent');
4044 2884 var userEmail = emailInput ? emailInput.value.trim() : '';
4045 2885 var userName = nameInput ? nameInput.value.trim() : '';
4046 - var sessionId = MxChatInstances.ensureSession(botId);
2886 + var sessionId = getChatSession(botId);
4047 2887
4048 2888 // Validate email
4049 2889 if (!userEmail) {
4050 2890 showEmailError(botId, 'Please enter your email address.');
@@ -4061,15 +2901,8 @@
4061 2901 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4062 2902 return false;
4063 2903 }
4064 2904
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 2905 clearEmailError(botId);
4073 2906 setEmailSubmissionState(botId, true);
4074 2907
4075 2908 // Prepare form data
@@ -4083,14 +2916,8 @@
4083 2916 if (userName) {
4084 2917 formData.append('name', userName);
4085 2918 }
4086 2919
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 2920 fetch(mxchatChat.ajax_url, {
4094 2921 method: 'POST',
4095 2922 headers: {
4096 2923 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4180,27 +3007,25 @@
4180 3007 }
4181 3008 });
4182 3009
4183 3010 // Initialize email check for all bot instances
4184 - // For floating bots: defer until widget is opened (zero passive AJAX)
4185 - // For embedded bots: check immediately since the form is visible
4186 3011 $('.mxchat-chatbot-wrapper').each(function() {
4187 3012 var botId = $(this).data('bot-id') || 'default';
4188 3013 var emailBlocker = getElementDOM(botId, 'email-blocker');
4189 3014
3015 + // Only check if email blocker exists for this bot
4190 3016 if (emailBlocker) {
4191 - if (isEmbeddedBot(botId)) {
4192 - // Embedded bots are always visible — check now
4193 - resolveEmailState(botId);
3017 + if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
3018 + if (mxchatChat.initial_email_state.show_email_form) {
3019 + showEmailFormForBot(botId);
3020 + } else {
3021 + showChatContainerForBot(botId);
3022 + }
3023 + } else {
3024 + setTimeout(function() {
3025 + checkSessionAndEmailForBot(botId);
3026 + }, 100);
4194 3027 }
4195 - // 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 3028 }
4204 3029 });
4205 3030 }
4206 3031
@@ -4210,32 +3035,39 @@
4210 3035 var $chatbot = getElement(botId, 'floating-chatbot');
4211 3036 if ($chatbot.hasClass('hidden')) {
4212 3037 $chatbot.removeClass('hidden').addClass('visible');
4213 3038 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4214 - handlePreChatDismissal(botId);
3039 + $(this).fadeOut(250); // Hide pre-chat message
4215 3040 disableScroll(); // Disable scroll when chatbot opens
3041 + }
3042 + });
4216 3043
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 - }
3044 + // Dismiss pre-chat message via close button - handled by event delegation above at line ~2376
3045 + // This is a fallback for legacy support
3046 + $(document).on('click', '.close-pre-chat-message', function() {
3047 + var botId = getBotIdFromElement(this);
3048 + var $preChat = getElement(botId, 'pre-chat-message');
3049 + $preChat.fadeOut(200); // Hide the message
4222 3050
4223 - // Deferred email check — only on first widget open
4224 - var emailBlocker = getElementDOM(botId, 'email-blocker');
4225 - var instance = MxChatInstances.get(botId);
4226 - if (emailBlocker && !instance.emailCheckDone) {
4227 - instance.emailCheckDone = true;
4228 - resolveEmailState(botId);
4229 - } else if (!emailBlocker) {
4230 - showChatContainerForBot(botId);
3051 + // Send an AJAX request to set the transient flag for 24 hours
3052 + $.ajax({
3053 + url: mxchatChat.ajax_url,
3054 + type: 'POST',
3055 + data: {
3056 + action: 'mxchat_dismiss_pre_chat_message',
3057 + _ajax_nonce: mxchatChat.nonce
3058 + },
3059 + success: function() {
3060 + // Ensure the message is hidden after dismissal
3061 + $preChat.hide();
3062 + },
3063 + error: function() {
3064 + // Error dismissing pre-chat message - silently continue
4231 3065 }
4232 - }
3066 + });
4233 3067 });
4234 3068
4235 - // Legacy duplicate close handler removed — handled by single event delegation above
4236 3069
4237 -
4238 3070 function hasQuickQuestions(botId) {
4239 3071 botId = botId || 'default';
4240 3072 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4241 3073 if (!questionsContainer) return false;
@@ -4371,11 +3203,18 @@
4371 3203 });
4372 3204
4373 3205 // Initialize when document is ready
4374 3206 setFullHeight();
3207 + trackOriginatingPage();
4375 3208
4376 - // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4377 - // until the user's first interaction via MxChatInstances.ensureSession()
3209 + // Only load chat history if email collection is disabled
3210 + if (mxchatChat.email_collection_enabled !== 'on') {
3211 + // Load history for all instances
3212 + $('.mxchat-chatbot-wrapper').each(function() {
3213 + var botId = $(this).data('bot-id') || 'default';
3214 + loadChatHistory(botId);
3215 + });
3216 + }
4378 3217
4379 3218 // Initialize chat visibility for all instances
4380 3219 $('.mxchat-chatbot-wrapper').each(function() {
4381 3220 var botId = $(this).data('bot-id') || 'default';
@@ -4428,312 +3267,6 @@
4428 3267 }, 2000);
4429 3268 });
4430 3269 }
4431 3270 }
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 3271 });
4739 3272