PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.0.9
MxChat – AI Chatbot & Content Generation for WordPress v3.0.9
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 +297 -1794 3.2.213.0.9 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,43 +64,18 @@
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) {
288 - // Keep the greeting, drop everything else. Identify the greeting
289 - // by its marker class, NOT by position (plan a1a79b): after a
290 - // chat-persistence restore the first .bot-message is a real
291 - // reply, so ":not(:first)" left a stale answer sitting at the
292 - // top of an otherwise empty box. The positional fallback only
293 - // runs when the marker is absent — a page served from HTML cache
294 - // that predates this release — and behaves exactly as before.
295 - if ($chatBox.find('.mxchat-intro-message').length) {
296 - $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
297 - } else {
298 - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
299 - }
72 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
300 73 }
301 74 if (this.instances[botId]) {
302 75 this.instances[botId].chatHistoryLoaded = false;
303 76 this.instances[botId].processedMessageIds = new Set();
304 77 }
305 - },
306 -
307 - // Silent reset — new session ID without clearing the chat UI
308 - // Used when IP changes mid-conversation so the user doesn't see messages vanish
309 - silentResetSession: function(botId) {
310 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
311 - var newSessionId = generateSessionId();
312 - this.setChatSession(botId, newSessionId);
313 - if (this.instances[botId]) {
314 - this.instances[botId].sessionId = newSessionId;
315 - }
316 - return newSessionId;
317 78 }
318 79 };
319 80
320 81 // ====================================
@@ -354,20 +115,8 @@
354 115 var id = $floating.attr('id') || '';
355 116 var match = id.match(/floating-chatbot-(.+)/);
356 117 if (match) return match[1];
357 118 }
358 - // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
359 - // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
360 - // .close-pre-chat-message button, which carries only a class and no id — miss both
361 - // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
362 - // and read the suffix. (closest() includes the element itself, so a click directly on
363 - // #pre-chat-message-{bot_id} resolves here too.)
364 - var $preChat = $(element).closest('[id^="pre-chat-message-"]');
365 - if ($preChat.length) {
366 - var preId = $preChat.attr('id') || '';
367 - var preMatch = preId.match(/^pre-chat-message-(.+)$/);
368 - if (preMatch) return preMatch[1];
369 - }
370 119 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
371 120 var elementId = $(element).attr('id') || '';
372 121 if (elementId) {
373 122 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -407,27 +156,9 @@
407 156 if (parts.length == 2) return parts.pop().split(";").shift();
408 157 }
409 158
410 159 function generateSessionId() {
411 - // Session IDs function as the de-facto bearer token for an anonymous
412 - // chat, so generate them with a CSPRNG when available. Math.random is a
413 - // legacy fallback for ancient/sandboxed environments that lack
414 - // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
415 - // code pattern-matches on it). (plan-0c17b5)
416 - var rand;
417 - try {
418 - if (window.crypto && window.crypto.getRandomValues) {
419 - var buf = new Uint8Array(16); // 128 bits
420 - window.crypto.getRandomValues(buf);
421 - rand = Array.prototype.map.call(buf, function (b) {
422 - return ('0' + b.toString(16)).slice(-2);
423 - }).join('');
424 - }
425 - } catch (e) {}
426 - if (!rand) {
427 - rand = Math.random().toString(36).substr(2, 9); // legacy fallback
428 - }
429 - return 'mxchat_chat_' + rand;
160 + return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
430 161 }
431 162
432 163 // Legacy function - now delegates to instance manager
433 164 function getChatSession(botId) {
@@ -630,26 +361,8 @@
630 361 sendButton.style.pointerEvents = 'none';
631 362 }
632 363 }
633 364
634 -// Whether the input may grab focus after a completed reply (plan 03799f).
635 -// On coarse-pointer devices focusing a text input summons the on-screen
636 -// keyboard over the answer the visitor is trying to read, so 'auto' (the
637 -// default) focuses only on fine-pointer devices. The site-wide
638 -// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
639 -// NOT used on widget open (:~3424) — that focus is a deliberate act and is
640 -// what makes the widget keyboard-accessible.
641 -function mxchatShouldAutofocusAfterReply() {
642 - var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
643 - if (pref === 'on') return true;
644 - if (pref === 'off') return false;
645 - try {
646 - return !window.matchMedia('(pointer: coarse)').matches;
647 - } catch (err) {
648 - return true;
649 - }
650 -}
651 -
652 365 function enableChatInput(botId) {
653 366 botId = botId || 'default';
654 367 var chatInput = getElementDOM(botId, 'chat-input');
655 368 var sendButton = getElementDOM(botId, 'send-button');
@@ -655,11 +368,9 @@
655 368 var sendButton = getElementDOM(botId, 'send-button');
656 369 if (chatInput) {
657 370 chatInput.disabled = false;
658 371 chatInput.style.opacity = '1';
659 - if (mxchatShouldAutofocusAfterReply()) {
660 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 - }
372 + chatInput.focus();
662 373 }
663 374 if (sendButton) {
664 375 sendButton.disabled = false;
665 376 sendButton.style.opacity = '1';
@@ -664,106 +375,13 @@
664 375 sendButton.disabled = false;
665 376 sendButton.style.opacity = '1';
666 377 sendButton.style.pointerEvents = 'auto';
667 378 }
668 - // Every completion path re-enables input, so this is the single restore
669 - // point for the streaming Stop affordance (no-op when not in stop mode).
670 - mxchatRestoreSendButton(botId);
671 379 }
672 380
673 -// --- Streaming Stop control -------------------------------------------------
674 -// One live stream handle per bot instance, so Stop on one widget never aborts
675 -// another bot on the same page.
676 -var mxchatActiveStreams = {};
677 -// Original send-button markup, captured once per bot the first time the Stop
678 -// state is shown (never captured while already in stop mode, so a rapid
679 -// stop-then-resend can't save the stop glyph as the "original").
680 -var mxchatSendMarkup = {};
681 -
682 -function mxchatShowStopButton(botId) {
683 - var btn = getElementDOM(botId, 'send-button');
684 - if (!btn) return;
685 - if (!btn.classList.contains('mxchat-stop-mode')) {
686 - mxchatSendMarkup[botId] = {
687 - html: btn.innerHTML,
688 - label: btn.getAttribute('aria-label')
689 - };
690 - }
691 -
692 - // Mirror the send icon's rendered size + color so the stop glyph looks
693 - // native, including custom send images/colors and theme overrides.
694 - var child = btn.querySelector('svg, img');
695 - var size = 25;
696 - var color = '';
697 - if (child) {
698 - var rect = child.getBoundingClientRect();
699 - if (rect.width) {
700 - size = Math.round(Math.min(rect.width, rect.height));
701 - }
702 - var cs = window.getComputedStyle(child);
703 - color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
704 - }
705 - var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
706 - btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" style="width:' + size + 'px;height:' + size + 'px;' + (color ? 'fill:' + color + ';' : '') + '"><rect x="5" y="5" width="14" height="14" rx="3"></rect></svg>';
707 - // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
708 - // start a stream synchronously while the originating click is still
709 - // bubbling up to our delegated handler. Without this guard, that handler
710 - // reads the just-added stop-mode class as a user Stop press and aborts the
711 - // brand-new stream — the user's message renders but no reply ever fires
712 - // (plan-4bba64 silent message loss). The flag only spans the current event
713 - // dispatch: cleared on the next macrotask, long before a real Stop click.
714 - btn.__mxchatStopJustShown = true;
715 - setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
716 - btn.classList.add('mxchat-stop-mode');
717 - btn.setAttribute('aria-label', stopLabel);
718 - btn.setAttribute('title', stopLabel);
719 - // disableChatInput() ran when the turn was sent; the Stop control itself
720 - // must stay clickable while the textarea remains disabled.
721 - btn.disabled = false;
722 - btn.style.opacity = '1';
723 - btn.style.pointerEvents = 'auto';
724 -}
725 -
726 -function mxchatRestoreSendButton(botId) {
727 - var btn = getElementDOM(botId, 'send-button');
728 - var saved = mxchatSendMarkup[botId];
729 - if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
730 - btn.innerHTML = saved.html;
731 - btn.classList.remove('mxchat-stop-mode');
732 - btn.removeAttribute('title');
733 - if (saved.label) {
734 - btn.setAttribute('aria-label', saved.label);
735 - }
736 -}
737 -
738 -function mxchatStopStreaming(botId) {
739 - var entry = mxchatActiveStreams[botId];
740 - if (!entry || !entry.controller) return;
741 - entry.aborted = true;
742 - try { entry.controller.abort(); } catch (e) {}
743 -}
744 -
745 -// Returns true when a stream rejection came from an intentional Stop click:
746 -// keep the partial text as the turn's answer — no error UI, no fallback resend.
747 -function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
748 - var entry = mxchatActiveStreams[botId];
749 - if (!entry || !entry.aborted) return false;
750 - delete mxchatActiveStreams[botId];
751 - if (!accumulatedContent) {
752 - // Stopped before the first chunk: drop the thinking bubble, no orphan message.
753 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
754 - }
755 - enableChatInput(botId); // also restores the send icon
756 - if (callback) {
757 - callback(accumulatedContent || '');
758 - }
759 - return true;
760 -}
761 -
762 381 // Update your existing sendMessage function
763 382 function sendMessage(botId) {
764 383 botId = botId || 'default';
765 - MxChatInstances.ensureSession(botId);
766 384 var $chatInput = getElement(botId, 'chat-input');
767 385 var message = $chatInput.val();
768 386
769 387 // ADD PROMPT HOOK HERE
@@ -771,18 +389,13 @@
771 389 message = customMxChatFilter(message, "prompt");
772 390 }
773 391
774 392 if (message) {
775 - // Don't disable input in live agent mode - let users chat freely
776 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
777 - var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
778 - if (!isAgentMode) {
779 - disableChatInput(botId);
780 - }
393 + // Disable input while waiting for response
394 + disableChatInput(botId);
781 395
782 396 appendMessage("user", message, '', [], false, botId);
783 397 $chatInput.val('');
784 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
785 398 $chatInput.css('height', 'auto');
786 399
787 400 if (hasQuickQuestions(botId)) {
788 401 collapseQuickQuestions(botId);
@@ -789,16 +402,14 @@
789 402 }
790 403 appendThinkingMessage(botId);
791 404 scrollToBottom(botId);
792 405
793 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
406 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
794 407
795 408 // Check if streaming is enabled AND supported for this model
796 409 if (shouldUseStreaming(currentModel)) {
797 410 callMxChatStream(message, function(response) {
798 - // Content is final: releasing aria-busy lets the live region
799 - // announce the completed reply once (plan 67f126).
800 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
411 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
801 412 }, botId);
802 413 } else {
803 414 callMxChat(message, function(response) {
804 415 replaceLastMessage("bot", response, '', [], botId);
@@ -809,9 +420,8 @@
809 420
810 421 // Update your existing sendMessageToChatbot function
811 422 function sendMessageToChatbot(message, botId) {
812 423 botId = botId || 'default';
813 - MxChatInstances.ensureSession(botId);
814 424
815 425 // ADD PROMPT HOOK HERE
816 426 if (typeof customMxChatFilter === 'function') {
817 427 message = customMxChatFilter(message, "prompt");
@@ -816,14 +426,10 @@
816 426 if (typeof customMxChatFilter === 'function') {
817 427 message = customMxChatFilter(message, "prompt");
818 428 }
819 429
820 - // Don't disable input in live agent mode - let users chat freely
821 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
822 - var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
823 - if (!isAgentMode) {
824 - disableChatInput(botId);
825 - }
430 + // Disable input while waiting for response
431 + disableChatInput(botId);
826 432
827 433 var sessionId = getChatSession(botId);
828 434
829 435 if (hasQuickQuestions(botId)) {
@@ -831,15 +437,14 @@
831 437 }
832 438 appendThinkingMessage(botId);
833 439 scrollToBottom(botId);
834 440
835 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
441 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
836 442
837 443 // Check if streaming is enabled AND supported for this model
838 444 if (shouldUseStreaming(currentModel)) {
839 445 callMxChatStream(message, function(response) {
840 - // Final content — release aria-busy so the reply announces once (67f126).
841 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
446 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
842 447 }, botId);
843 448 } else {
844 449 callMxChat(message, function(response) {
845 450 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -903,15 +508,8 @@
903 508
904 509 function callMxChat(message, callback, botId) {
905 510 botId = botId || getMxChatBotId();
906 511
907 - // Streaming fallbacks land here: drop any leftover stream handle and
908 - // return the button to its send state (no-op for plain non-stream turns).
909 - if (mxchatActiveStreams[botId]) {
910 - delete mxchatActiveStreams[botId];
911 - }
912 - mxchatRestoreSendButton(botId);
913 -
914 512 // Store the message in case we need to retry after session reset
915 513 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
916 514
917 515 // Get page context if contextual awareness is enabled
@@ -919,28 +517,13 @@
919 517
920 518 // Get instance for session start timestamp (used when persistence is OFF)
921 519 var instance = MxChatInstances.get(botId);
922 520
923 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
924 - // and returns the guaranteed-present session id from the in-memory instance even when
925 - // cookie/localStorage writes are silently blocked by the browser.
926 - var sessionId = MxChatInstances.ensureSession(botId);
927 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
928 - // Last-resort generation to ensure we never POST a null marker.
929 - sessionId = generateSessionId();
930 - MxChatInstances.setChatSession(botId, sessionId);
931 - }
932 -
933 - // Wait for the page-cache nonce refresh to complete before firing the
934 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
935 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
936 - // callback guarantees we read the fresh value. See plan-c5457f.
937 - refreshNonceIfNeeded(function() {
938 521 // Prepare AJAX data
939 522 const ajaxData = {
940 523 action: 'mxchat_handle_chat_request',
941 524 message: message,
942 - session_id: sessionId,
525 + session_id: getChatSession(botId),
943 526 nonce: mxchatChat.nonce,
944 527 current_page_url: window.location.href,
945 528 current_page_title: document.title,
946 529 bot_id: botId,
@@ -946,14 +529,14 @@
946 529 bot_id: botId,
947 530 // Pass session start timestamp so AI context matches what user sees
948 531 session_start_timestamp: instance.sessionStartTimestamp || 0
949 532 };
950 -
533 +
951 534 // Add page context if available
952 535 if (pageContext) {
953 536 ajaxData.page_context = JSON.stringify(pageContext);
954 537 }
955 -
538 +
956 539 // CHECK FOR VISION FLAGS AND ADD THEM
957 540 if (window.mxchatVisionProcessed) {
958 541 ajaxData.vision_processed = true;
959 542 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -962,9 +545,9 @@
962 545 window.mxchatVisionProcessed = false;
963 546 window.mxchatOriginalMessage = null;
964 547 window.mxchatVisionImagesCount = 0;
965 548 }
966 -
549 +
967 550 $.ajax({
968 551 url: mxchatChat.ajax_url,
969 552 type: 'POST',
970 553 dataType: 'json',
@@ -1002,20 +585,26 @@
1002 585 errorMessage = "An error occurred. Please try again or contact support.";
1003 586 }
1004 587
1005 588 // Handle session reset action (IP changed, session expired, etc.)
1006 - // Silent reset — keep chat UI intact, just get a new session and retry
1007 589 if (response.data && response.data.action === 'reset_session') {
1008 - MxChatInstances.silentResetSession(botId);
1009 - // 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
1010 595 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1011 596 if (originalMessage) {
1012 597 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1013 - 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';
1014 604 if (shouldUseStreaming(currentModel)) {
1015 605 callMxChatStream(originalMessage, function(response) {
1016 - // Final content — release aria-busy so the reply announces once (67f126).
1017 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
606 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1018 607 }, botId);
1019 608 } else {
1020 609 callMxChat(originalMessage, function(response) {
1021 610 replaceLastMessage("bot", response, '', [], botId);
@@ -1069,11 +658,9 @@
1069 658 }
1070 659
1071 660 // Check for live agent response
1072 661 if (response.success && response.data && response.data.status === 'waiting_for_agent') {
1073 - removeThinkingDots(botId);
1074 662 updateChatModeIndicator('agent', botId);
1075 - enableChatInput(botId);
1076 663 return;
1077 664 }
1078 665
1079 666 // Handle the message and show notification if chat is hidden
@@ -1106,13 +693,9 @@
1106 693 $badge.show();
1107 694 }
1108 695 }
1109 696 } else {
1110 - var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1111 - if (response.vectorstore_error) {
1112 - emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1113 - }
1114 - replaceLastMessage("bot", emptyMsg, '', [], botId);
697 + replaceLastMessage("bot", "I received an empty response. Please try again or contact support if this persists.", '', [], botId);
1115 698 }
1116 699
1117 700 if (response.message_id) {
1118 701 var instance = MxChatInstances.get(botId);
@@ -1154,9 +737,8 @@
1154 737
1155 738 replaceLastMessage("bot", errorMessage, '', [], botId);
1156 739 }
1157 740 });
1158 - }); // refreshNonceIfNeeded
1159 741 }
1160 742
1161 743 function callMxChatStream(message, callback, botId) {
1162 744 botId = botId || getMxChatBotId();
@@ -1163,9 +745,9 @@
1163 745
1164 746 // Store the message in case we need to retry after session reset
1165 747 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1166 748
1167 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
749 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1168 750 if (!isStreamingSupported(currentModel)) {
1169 751 callMxChat(message, callback, botId);
1170 752 return;
1171 753 }
@@ -1175,25 +757,12 @@
1175 757
1176 758 // Get instance for session start timestamp (used when persistence is OFF)
1177 759 var instance = MxChatInstances.get(botId);
1178 760
1179 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1180 - // non-string value via String(), so passing `null` would POST the literal string "null"
1181 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1182 - // a real string even when cookies/localStorage are blocked.
1183 - var streamSessionId = MxChatInstances.ensureSession(botId);
1184 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1185 - streamSessionId = generateSessionId();
1186 - MxChatInstances.setChatSession(botId, streamSessionId);
1187 - }
1188 -
1189 - // Wait for the page-cache nonce refresh before constructing formData (which
1190 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1191 - refreshNonceIfNeeded(function() {
1192 761 const formData = new FormData();
1193 762 formData.append('action', 'mxchat_stream_chat');
1194 763 formData.append('message', message);
1195 - formData.append('session_id', streamSessionId);
764 + formData.append('session_id', getChatSession(botId));
1196 765 formData.append('nonce', mxchatChat.nonce);
1197 766 formData.append('current_page_url', window.location.href);
1198 767 formData.append('current_page_title', document.title);
1199 768 formData.append('bot_id', botId);
@@ -1218,25 +787,13 @@
1218 787
1219 788 let accumulatedContent = '';
1220 789 let testingDataReceived = false;
1221 790 let streamingStarted = false;
1222 - // Server-pushed html to append as its OWN bot bubble once the stream
1223 - // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1224 - // deferred to [DONE] so the embed always lands BELOW the streamed text.
1225 - let pendingAppendHtml = '';
1226 791
1227 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1228 - // The Stop control (send button swapped in place) aborts both the read
1229 - // loop and the underlying request.
1230 - var streamControl = { controller: new AbortController(), aborted: false };
1231 - mxchatActiveStreams[botId] = streamControl;
1232 - mxchatShowStopButton(botId);
1233 -
1234 792 fetch(mxchatChat.ajax_url, {
1235 793 method: 'POST',
1236 794 body: formData,
1237 - credentials: 'same-origin',
1238 - signal: streamControl.controller.signal
795 + credentials: 'same-origin'
1239 796 })
1240 797 .then(response => {
1241 798 // Store the response for potential fallback handling
1242 799 const responseClone = response.clone();
@@ -1305,16 +862,8 @@
1305 862
1306 863 // Re-enable chat input when stream ends with content
1307 864 enableChatInput(botId);
1308 865
1309 - // Scroll the user's last message to the top now that the
1310 - // bot's full reply has rendered (gives max reading room).
1311 - var $chatBoxDone = getElement(botId, 'chat-box');
1312 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1313 - if ($lastUserMsgDone.length) {
1314 - scrollElementToTop($lastUserMsgDone, botId);
1315 - }
1316 -
1317 866 if (callback) {
1318 867 callback(accumulatedContent);
1319 868 }
1320 869 return;
@@ -1337,25 +886,8 @@
1337 886
1338 887 // Re-enable chat input after streaming completes
1339 888 enableChatInput(botId);
1340 889
1341 - // Render any server-pushed appendix html (e.g. the
1342 - // YouTube embed) as its own bot bubble below the
1343 - // streamed text — mirrors how it is saved in the
1344 - // transcript, so history replays identically.
1345 - if (pendingAppendHtml) {
1346 - appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1347 - pendingAppendHtml = '';
1348 - }
1349 -
1350 - // Scroll the user's last message to the top now
1351 - // that the bot's full reply has rendered.
1352 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1353 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1354 - if ($lastUserMsgStreamDone.length) {
1355 - scrollElementToTop($lastUserMsgStreamDone, botId);
1356 - }
1357 -
1358 890 if (callback) {
1359 891 callback(accumulatedContent);
1360 892 }
1361 893 return;
@@ -1381,21 +913,8 @@
1381 913 streamingStarted = true;
1382 914 accumulatedContent += json.content;
1383 915 updateStreamingMessage(accumulatedContent, botId);
1384 916 }
1385 - // Stash appendix html (e.g. video embed) for [DONE]
1386 - else if (json.append_html) {
1387 - pendingAppendHtml = json.append_html;
1388 - }
1389 - // Server-side final pass changed the assembled text
1390 - // (ffef6f: dead-link stripping) — swap the rendered
1391 - // bubble for the validated version. Arrives at most
1392 - // once, just before [DONE].
1393 - else if (json.replace_content) {
1394 - streamingStarted = true;
1395 - accumulatedContent = json.replace_content;
1396 - updateStreamingMessage(accumulatedContent, botId);
1397 - }
1398 917 // Handle complete response in stream (fallback response)
1399 918 else if (json.text || json.message || json.html) {
1400 919 handleNonStreamResponse(json, callback, botId);
1401 920 return;
@@ -1425,9 +944,8 @@
1425 944 }
1426 945
1427 946 processStream();
1428 947 }).catch(streamError => {
1429 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1430 948 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1431 949 callMxChat(message, callback, botId);
1432 950 });
1433 951 }
@@ -1434,9 +952,8 @@
1434 952
1435 953 processStream();
1436 954 })
1437 955 .catch(error => {
1438 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1439 956 // Check if we have server error data with chat mode
1440 957 if (error && error.isServerError && error.data) {
1441 958 // Check for chat mode in error data
1442 959 if (error.data.chat_mode) {
@@ -1449,9 +966,8 @@
1449 966 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1450 967 callMxChat(message, callback, botId);
1451 968 }
1452 969 });
1453 - }); // refreshNonceIfNeeded
1454 970 }
1455 971
1456 972 // Helper function to handle non-streaming responses
1457 973 function handleNonStreamResponse(data, callback, botId) {
@@ -1490,16 +1006,21 @@
1490 1006 errorMessage = "An error occurred. Please try again or contact support.";
1491 1007 }
1492 1008
1493 1009 // Handle session reset action (IP changed, session expired, etc.)
1494 - // Silent reset — keep chat UI intact, just get a new session and retry
1495 1010 if (data.data && data.data.action === 'reset_session') {
1496 - MxChatInstances.silentResetSession(botId);
1497 - // 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
1498 1014 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1499 1015 if (originalMessage) {
1500 1016 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1501 - 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';
1502 1023 if (shouldUseStreaming(currentModel)) {
1503 1024 callMxChatStream(originalMessage, callback, botId);
1504 1025 } else {
1505 1026 callMxChat(originalMessage, callback, botId);
@@ -1521,22 +1042,8 @@
1521 1042 }
1522 1043 return; // Exit early for errors
1523 1044 }
1524 1045
1525 - // Check for live agent response
1526 - if (data.success && data.data && data.data.status === 'waiting_for_agent') {
1527 - removeThinkingDots(botId);
1528 - // Also remove any leftover bot-message that lost its temporary-message class
1529 - var $chatBox = getElement(botId, 'chat-box');
1530 - $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
1531 - updateChatModeIndicator('agent', botId);
1532 - enableChatInput(botId);
1533 - if (callback) {
1534 - callback('');
1535 - }
1536 - return;
1537 - }
1538 -
1539 1046 // Handle different response formats
1540 1047 if (data.text || data.html || data.message) {
1541 1048
1542 1049 // Apply response hooks
@@ -1581,15 +1088,19 @@
1581 1088 }
1582 1089
1583 1090 // Enhanced updateChatModeIndicator function for immediate DOM updates
1584 1091 function updateChatModeIndicator(mode, botId) {
1092 + console.log('[MxChat] updateChatModeIndicator called with mode:', mode, 'botId:', botId);
1585 1093 botId = botId || 'default';
1586 1094 const indicator = getElementDOM(botId, 'chat-mode-indicator');
1095 + console.log('[MxChat] chat-mode-indicator element found:', !!indicator);
1587 1096 if (indicator) {
1588 1097 const oldText = indicator.textContent;
1098 + console.log('[MxChat] Current indicator text:', oldText, '-> changing to mode:', mode);
1589 1099
1590 1100 if (mode === 'agent') {
1591 1101 indicator.textContent = 'Live Agent';
1102 + console.log('[MxChat] Mode is agent, calling startPolling...');
1592 1103 startPolling(botId);
1593 1104 } else {
1594 1105 // Everything else is AI mode
1595 1106 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
@@ -1632,15 +1143,8 @@
1632 1143 var $chatBox = getElement(botId, 'chat-box');
1633 1144 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1634 1145
1635 1146 if (tempMessage.length) {
1636 - // aria-busy=true for the whole stream: the bubble is rewritten on
1637 - // every chunk, and without busy a polite live region announces those
1638 - // rewrites continuously. Flipped false once the reply is final, so
1639 - // assistive tech announces the completed message ONCE (plan 67f126).
1640 - if (tempMessage.attr('aria-busy') !== 'true') {
1641 - tempMessage.attr('aria-busy', 'true');
1642 - }
1643 1147 // Update existing message
1644 1148 tempMessage.html(formattedContent);
1645 1149 } else {
1646 1150 // Create new temporary message if it doesn't exist
@@ -1667,21 +1171,9 @@
1667 1171 // Update the event handlers to use the correct function names (using event delegation)
1668 1172 // Use class-based selectors for multi-instance support
1669 1173 $(document).on('click', '.send-button', function() {
1670 1174 var botId = getBotIdFromElement(this);
1671 - // While a response is streaming the button is a Stop control.
1672 - if (this.classList.contains('mxchat-stop-mode')) {
1673 - // Same click that just started this stream (an add-on's direct handler
1674 - // ran before this delegated one) — not a Stop press. See
1675 - // mxchatShowStopButton for the full story (plan-4bba64).
1676 - if (this.__mxchatStopJustShown) return;
1677 - mxchatStopStreaming(botId);
1678 - return;
1679 - }
1680 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1681 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1682 - disableChatInput(botId);
1683 - }
1175 + disableChatInput(botId);
1684 1176 sendMessage(botId);
1685 1177 });
1686 1178
1687 1179 // Override enter key handler (using event delegation)
@@ -1688,378 +1180,14 @@
1688 1180 $(document).on('keypress', '.chat-input', function(e) {
1689 1181 if (e.which == 13 && !e.shiftKey) {
1690 1182 e.preventDefault();
1691 1183 var botId = getBotIdFromElement(this);
1692 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1693 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1694 - disableChatInput(botId);
1695 - }
1184 + disableChatInput(botId);
1696 1185 sendMessage(botId);
1697 1186 }
1698 1187 });
1699 1188
1700 -// Chat input character counter + soft limit feedback (plan 7091a2).
1701 -// Language-neutral: numbers + color only, no translatable strings. The counter
1702 -// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1703 -// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1704 -// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1705 -function mxchatUpdateCharCounter(inputEl) {
1706 - if (!inputEl || !inputEl.closest) return;
1707 - var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1708 - var container = inputEl.closest('.input-container');
1709 - if (!container || !max || max <= 0) return;
1710 - var counter = container.querySelector('.mxchat-char-counter');
1711 - if (!counter) return;
1712 - var len = inputEl.value.length;
1713 - var ratio = len / max;
1714 - var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1715 - var cur = counter.querySelector('.mxchat-char-counter-current');
1716 - if (cur) cur.textContent = len;
1717 - var warn = ratio >= nearThreshold && len < max;
1718 - var full = len >= max;
1719 - counter.classList.toggle('is-visible', ratio >= nearThreshold);
1720 - counter.classList.toggle('is-warn', warn);
1721 - counter.classList.toggle('is-full', full);
1722 - container.classList.toggle('mxchat-input-near-limit', warn);
1723 - container.classList.toggle('mxchat-input-at-limit', full);
1724 -}
1725 -
1726 -function mxchatBumpInput(inputEl) {
1727 - var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1728 - if (!container) return;
1729 - container.classList.remove('mxchat-input-bump');
1730 - void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1731 - container.classList.add('mxchat-input-bump');
1732 - clearTimeout($(container).data('mxchatBumpTimeout'));
1733 - var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1734 - $(container).data('mxchatBumpTimeout', t);
1735 -}
1736 -
1737 -// Live counter update on every input.
1738 -$(document).on('input', '.chat-input', function() {
1739 - mxchatUpdateCharCounter(this);
1740 -});
1741 -
1742 -// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1743 -// rejected at the cap (maxlength silently swallows it otherwise).
1744 -$(document).on('keydown', '.chat-input', function(e) {
1745 - var max = parseInt(this.getAttribute('maxlength'), 10);
1746 - if (!max || max <= 0 || this.value.length < max) return;
1747 - if (e.ctrlKey || e.metaKey || e.altKey) return;
1748 - // A single printable char with no selection to overwrite WILL be rejected.
1749 - if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1750 - mxchatBumpInput(this);
1751 - }
1752 -});
1753 -
1754 -// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1755 -$(document).on('paste', '.chat-input', function() {
1756 - var el = this;
1757 - var max = parseInt(el.getAttribute('maxlength'), 10);
1758 - if (!max || max <= 0) return;
1759 - setTimeout(function() {
1760 - mxchatUpdateCharCounter(el);
1761 - if (el.value.length >= max) mxchatBumpInput(el);
1762 - }, 0);
1763 -});
1764 -
1765 -// Builds the list of overflow-menu items for a given bot.
1766 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1767 -function mxchatGetHeaderMenuItems(botId) {
1768 - var items = [];
1769 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1770 -
1771 - // The `print_button_*` keys still gate this item for back-compat with
1772 - // existing user options. The action is now a transcript download, not print.
1773 - if (settings.print_button_enabled === 'on') {
1774 - items.push({
1775 - id: 'download-transcript',
1776 - label: settings.print_button_label || 'Download Transcript',
1777 - icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1778 - action: function() {
1779 - mxchatDownloadTranscript(botId);
1780 - }
1781 - });
1782 - }
1783 -
1784 - // "Start new chat" — surfaces the EXISTING per-conversation reset
1785 - // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1786 - // without the site owner disabling chat persistence globally. Default OFF;
1787 - // gated by the reset_chat_enabled option. plan ac2e81.
1788 - if (settings.reset_chat_enabled === 'on') {
1789 - items.push({
1790 - id: 'reset-chat',
1791 - label: settings.reset_chat_label || 'Start new chat',
1792 - icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>',
1793 - action: function() {
1794 - var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1795 - if (window.confirm(confirmMsg)) {
1796 - MxChatInstances.resetChatSession(botId);
1797 - }
1798 - }
1799 - });
1800 - }
1801 -
1802 - return items;
1803 -}
1804 -
1805 -// Builds a clean markdown transcript of the current conversation and triggers
1806 -// a file download. Used by the "Download Transcript" menu item.
1807 -function mxchatDownloadTranscript(botId) {
1808 - var $chatBox = getElement(botId, 'chat-box');
1809 - if (!$chatBox || !$chatBox.length) return;
1810 -
1811 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1812 - var headerTitle = settings.print_header_title || 'Chat transcript';
1813 - var now = new Date();
1814 - var stamp = now.toLocaleString();
1815 -
1816 - var lines = [];
1817 - lines.push('# ' + headerTitle);
1818 - lines.push('');
1819 - lines.push('Exported: ' + stamp);
1820 - lines.push('');
1821 - lines.push('---');
1822 - lines.push('');
1823 -
1824 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1825 - var $msg = $(this);
1826 - // Skip thinking placeholders and any in-flight temporary messages.
1827 - if ($msg.find('.thinking-dots').length) return;
1828 - if ($msg.hasClass('temporary-message')) return;
1829 -
1830 - var sender;
1831 - if ($msg.hasClass('user-message')) sender = 'User';
1832 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1833 - else sender = 'AI Agent';
1834 -
1835 - // Strip interactive UI from the cloned message so we get the conversation text.
1836 - var $clone = $msg.clone();
1837 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1838 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1839 - if (!text) return;
1840 -
1841 - lines.push('**' + sender + '**');
1842 - lines.push('');
1843 - lines.push(text);
1844 - lines.push('');
1845 - });
1846 -
1847 - var content = lines.join('\n');
1848 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1849 - var fname = 'mxchat-transcript-' + iso + '.md';
1850 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1851 - var url = URL.createObjectURL(blob);
1852 - var a = document.createElement('a');
1853 - a.href = url;
1854 - a.download = fname;
1855 - a.style.display = 'none';
1856 - document.body.appendChild(a);
1857 - a.click();
1858 - setTimeout(function() {
1859 - if (a.parentNode) a.parentNode.removeChild(a);
1860 - URL.revokeObjectURL(url);
1861 - }, 100);
1862 -}
1863 -
1864 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1865 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1866 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1867 -function mxchatSyncMenuColors(botId, $wrap) {
1868 - if (!$wrap || !$wrap.length) return;
1869 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1870 - if (!$bot.length) return;
1871 - var cs = window.getComputedStyle($bot[0]);
1872 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1873 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1874 - }
1875 - // Bot text color usually lives on a child div, not .bot-message itself.
1876 - var $textChild = $bot.find('[style*="color"]').first();
1877 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1878 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1879 -}
1880 -
1881 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1882 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1883 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1884 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1885 -// a rebuild before init (never happens, but harmless) just skips it.
1886 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1887 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1888 - var $menu = $wrap.find('.mxchat-header-menu');
1889 - var items = mxchatGetHeaderMenuItems(botId);
1890 -
1891 - $menu.empty();
1892 -
1893 - if (!items.length) {
1894 - $trigger.hide();
1895 - $menu.hide();
1896 - return;
1897 - }
1898 -
1899 - // Clear any inline display:none a previous zero-item render left behind —
1900 - // open/close visibility is governed by the hidden prop + is-open class.
1901 - $trigger.css('display', '');
1902 - $menu.css('display', '');
1903 -
1904 - items.forEach(function(item, idx) {
1905 - var $btn = $('<button>', {
1906 - type: 'button',
1907 - 'class': 'mxchat-menu-item',
1908 - 'role': 'menuitem',
1909 - 'tabindex': '-1',
1910 - 'data-menu-id': item.id,
1911 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1912 - '<span class="mxchat-menu-item-label"></span>'
1913 - });
1914 - $btn.find('.mxchat-menu-item-label').text(item.label);
1915 - $btn.on('click', function(e) {
1916 - e.preventDefault();
1917 - e.stopPropagation();
1918 - if (closeMenuFn) closeMenuFn();
1919 - try { item.action(); } catch (err) { /* no-op */ }
1920 - });
1921 - $menu.append($btn);
1922 - });
1923 -}
1924 -
1925 -// Re-render every menu on the page after a dynamic-settings merge
1926 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1927 -// swapping items under the user mid-interaction yanks focus — and the
1928 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1929 -function mxchatRebuildHeaderMenus() {
1930 - $('.mxchat-header-menu-wrap').each(function() {
1931 - var $wrap = $(this);
1932 - var botId = $wrap.data('bot-id');
1933 - if (!botId) return;
1934 - if (!$wrap.data('mxchatMenuReady')) {
1935 - mxchatInitHeaderMenu(botId);
1936 - return;
1937 - }
1938 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1939 - $wrap.data('mxchatMenuRebuildPending', true);
1940 - return;
1941 - }
1942 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1943 - });
1944 -}
1945 -
1946 -// One-time per-widget init: renders menu items, wires open/close,
1947 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1948 -// trigger. Wiring happens even when there are zero items at init, so a
1949 -// later dynamic-settings rebuild that adds items has a working trigger.
1950 -function mxchatInitHeaderMenu(botId) {
1951 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1952 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1953 -
1954 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1955 - var $menu = $wrap.find('.mxchat-header-menu');
1956 -
1957 - // Initial color sync — covers normal page load.
1958 - mxchatSyncMenuColors(botId, $wrap);
1959 -
1960 - function openMenu() {
1961 - // Re-sync each open in case the active theme changed since init.
1962 - mxchatSyncMenuColors(botId, $wrap);
1963 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1964 - $trigger.attr('aria-expanded', 'true');
1965 - // Focus the first item for keyboard users
1966 - setTimeout(function() {
1967 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1968 - }, 0);
1969 - }
1970 - function closeMenu(returnFocus) {
1971 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1972 - $trigger.attr('aria-expanded', 'false');
1973 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1974 - if (returnFocus) $trigger.trigger('focus');
1975 - // A dynamic-settings rebuild that arrived while the menu was open
1976 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1977 - if ($wrap.data('mxchatMenuRebuildPending')) {
1978 - $wrap.removeData('mxchatMenuRebuildPending');
1979 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1980 - }
1981 - }
1982 -
1983 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1984 - // click-to-collapse handler does not fire.
1985 - $trigger.on('click', function(e) {
1986 - e.preventDefault();
1987 - e.stopPropagation();
1988 - if ($menu.hasClass('is-open')) closeMenu();
1989 - else openMenu();
1990 - });
1991 -
1992 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1993 - $menu.on('click', function(e) {
1994 - e.stopPropagation();
1995 - });
1996 -
1997 - // Outside click closes the menu.
1998 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1999 - if (!$menu.hasClass('is-open')) return;
2000 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
2001 - closeMenu();
2002 - });
2003 -
2004 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
2005 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
2006 - var $items = $menu.find('.mxchat-menu-item');
2007 - var idx = $items.index(this);
2008 - if (e.key === 'Escape') {
2009 - e.preventDefault();
2010 - closeMenu(true);
2011 - } else if (e.key === 'ArrowDown') {
2012 - e.preventDefault();
2013 - var $next = $items.eq((idx + 1) % $items.length);
2014 - $items.attr('tabindex', '-1');
2015 - $next.attr('tabindex', '0').trigger('focus');
2016 - } else if (e.key === 'ArrowUp') {
2017 - e.preventDefault();
2018 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2019 - $items.attr('tabindex', '-1');
2020 - $prev.attr('tabindex', '0').trigger('focus');
2021 - } else if (e.key === 'Enter' || e.key === ' ') {
2022 - e.preventDefault();
2023 - $(this).trigger('click');
2024 - }
2025 - });
2026 - $trigger.on('keydown', function(e) {
2027 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2028 - e.preventDefault();
2029 - closeMenu(true);
2030 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2031 - e.preventDefault();
2032 - openMenu();
2033 - }
2034 - });
2035 -
2036 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2037 - // then do the initial item render.
2038 - $wrap.data('mxchatMenuClose', closeMenu);
2039 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2040 -
2041 - $wrap.data('mxchatMenuReady', true);
2042 -}
2043 -
2044 -// Initialize header menus for every rendered widget on DOM ready.
2045 -$(function() {
2046 - $('.mxchat-header-menu-wrap').each(function() {
2047 - var botId = $(this).data('bot-id');
2048 - if (botId) mxchatInitHeaderMenu(botId);
2049 - });
2050 -
2051 - // Embedded (non-floating) widgets are open from the moment the page
2052 - // renders — refresh dynamic settings at init (plan-32db95). Floating
2053 - // widgets refresh on first launcher open instead.
2054 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2055 - return !$(this).closest('.floating-chatbot').length;
2056 - }).length > 0;
2057 - if (hasEmbeddedWidget) {
2058 - mxchatRefreshDynamicSettings();
2059 - }
2060 -});
2061 -
1189 +
2062 1190 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2063 1191 try {
2064 1192 // Determine styles based on sender type
2065 1193 let messageClass, bgColor, fontColor;
@@ -2097,12 +1225,17 @@
2097 1225 'margin-bottom': '1em'
2098 1226 });
2099 1227 }
2100 1228
2101 - // Process the message content - always run linkify to convert markdown
2102 - // links and format text. linkify() handles existing HTML safely via
2103 - // negative lookaheads that skip URLs already inside <a> tags.
2104 - 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 + }
2105 1238
2106 1239 // Add images if provided
2107 1240 if (images && images.length > 0) {
2108 1241 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -2134,11 +1267,9 @@
2134 1267
2135 1268 messageDiv.html(fullMessage);
2136 1269
2137 1270 if (isTemporary) {
2138 - // In-flight bubble: hold aria-busy so the live region stays quiet
2139 - // until the content is finalized (plan 67f126).
2140 - messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
1271 + messageDiv.addClass('temporary-message');
2141 1272 }
2142 1273
2143 1274 // Append to the correct chatbot instance's chat-box
2144 1275 var $chatBox = getElement(botId, 'chat-box');
@@ -2153,12 +1284,8 @@
2153 1284 if (lastUserMessage.length) {
2154 1285 scrollElementToTop(lastUserMessage, botId);
2155 1286 }
2156 1287 }
2157 -
2158 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2159 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2160 - }
2161 1288 });
2162 1289
2163 1290 if (messageText.id) {
2164 1291 var instance = MxChatInstances.get(botId);
@@ -2243,12 +1370,26 @@
2243 1370 bgColor = botMessageBgColor;
2244 1371 fontColor = botMessageFontColor;
2245 1372 }
2246 1373
2247 - // Always run linkify to convert markdown links and format text.
2248 - // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2249 - // to avoid double-processing URLs that are already inside <a> tags).
2250 - 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 + }
2251 1392
2252 1393 if (responseHtml) {
2253 1394 // Only add line breaks if there's actual text content before the HTML
2254 1395 if (fullMessage && fullMessage.trim()) {
@@ -2273,16 +1414,13 @@
2273 1414 }
2274 1415
2275 1416 if (lastMessageDiv.length) {
2276 1417 // Replace content immediately to prevent visual gap between thinking dots and response
2277 - // aria-busy released AFTER the final content is set, so the live region
2278 - // announces the finished message once (plan 67f126).
2279 1418 lastMessageDiv
2280 1419 .html(fullMessage)
2281 1420 .removeClass('bot-message user-message temporary-message')
2282 1421 .addClass(messageClass)
2283 - .attr('dir', 'auto')
2284 - .attr('aria-busy', 'false');
1422 + .attr('dir', 'auto');
2285 1423
2286 1424 // Only apply inline colors if AI theme is not active (let CSS handle it)
2287 1425 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2288 1426 if (!skipColors) {
@@ -2308,12 +1446,8 @@
2308 1446 }
2309 1447
2310 1448 // Re-enable chat input after response is displayed
2311 1449 enableChatInput(botId);
2312 -
2313 - if (sender === "bot" || sender === "agent") {
2314 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2315 - }
2316 1450 } else {
2317 1451 appendMessage(sender, responseText, responseHtml, images, false, botId);
2318 1452 // Re-enable chat input after response is displayed
2319 1453 enableChatInput(botId);
@@ -2322,15 +1456,8 @@
2322 1456
2323 1457
2324 1458 function appendThinkingMessage(botId) {
2325 1459 botId = botId || 'default';
2326 -
2327 - // Don't show thinking dots in live agent mode - message is just forwarded to a human
2328 - var indicator = getElementDOM(botId, 'chat-mode-indicator');
2329 - if (indicator && indicator.textContent === 'Live Agent') {
2330 - return;
2331 - }
2332 -
2333 1460 var $chatBox = getElement(botId, 'chat-box');
2334 1461
2335 1462 // Remove any existing thinking dots in this bot's chat first
2336 1463 $chatBox.find('.thinking-dots').remove();
@@ -2342,15 +1469,10 @@
2342 1469 var botMessageFontColor = mxchatChat.bot_message_font_color;
2343 1470 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2344 1471
2345 1472 // Build thinking dots HTML - skip inline colors if AI theme is active
2346 - // The dots are decorative; the sr-only span is what the live region
2347 - // announces for the waiting state (plan 67f126). Server-localized
2348 - // string — safe to inject (esc_html__ output, no user content).
2349 1473 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2350 - var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2351 - var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2352 - '<div class="thinking-dots-container" aria-hidden="true">' +
1474 + var thinkingHtml = '<div class="thinking-dots-container">' +
2353 1475 '<div class="thinking-dots">' +
2354 1476 '<span class="dot"' + dotStyle + '></span>' +
2355 1477 '<span class="dot"' + dotStyle + '></span>' +
2356 1478 '<span class="dot"' + dotStyle + '></span>' +
@@ -2357,9 +1479,9 @@
2357 1479 '</div>' +
2358 1480 '</div>';
2359 1481
2360 1482 // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active
2361 - var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"';
1483 + var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + ';"';
2362 1484 $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>');
2363 1485 scrollToBottom(botId);
2364 1486 }
2365 1487
@@ -2365,11 +1487,9 @@
2365 1487
2366 1488 function removeThinkingDots(botId) {
2367 1489 botId = botId || 'default';
2368 1490 var $chatBox = getElement(botId, 'chat-box');
2369 - // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots
2370 1491 $chatBox.find('.thinking-dots').closest('.temporary-message').remove();
2371 - $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
2372 1492 }
2373 1493
2374 1494 // ====================================
2375 1495 // TEXT FORMATTING & PROCESSING
@@ -2403,12 +1523,9 @@
2403 1523 processedText = formatTextStyling(processedText);
2404 1524
2405 1525 // Process code blocks BEFORE processing links
2406 1526 processedText = formatCodeBlocks(processedText);
2407 -
2408 - // Process markdown tables BEFORE converting newlines to paragraphs
2409 - processedText = formatMarkdownTables(processedText);
2410 -
1527 +
2411 1528 // NOW convert to paragraphs
2412 1529 processedText = convertNewlinesToBreaks(processedText);
2413 1530
2414 1531 // IMPORTANT: Handle citation-style brackets FIRST [URL]
@@ -2421,63 +1538,37 @@
2421 1538 // Return as a proper link without the brackets
2422 1539 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2423 1540 });
2424 1541
2425 - // Process markdown links: [text](url) and [](url)
2426 - // Uses balanced parenthesis matching to handle URLs containing parens
2427 - // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2428 - processedText = (function(input) {
2429 - var result = '';
2430 - var i = 0;
2431 - while (i < input.length) {
2432 - // Look for [ at current position
2433 - if (input[i] === '[') {
2434 - // Find closing ]
2435 - var closeBracket = input.indexOf(']', i + 1);
2436 - if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2437 - result += input[i];
2438 - i++;
2439 - continue;
2440 - }
2441 - var linkText = input.substring(i + 1, closeBracket);
2442 - // Check if URL starts with http
2443 - var urlStart = closeBracket + 2;
2444 - if (!input.substring(urlStart).match(/^https?:\/\//)) {
2445 - result += input[i];
2446 - i++;
2447 - continue;
2448 - }
2449 - // Find balanced closing paren
2450 - var depth = 1;
2451 - var j = urlStart;
2452 - while (j < input.length && depth > 0) {
2453 - if (input[j] === '(') depth++;
2454 - else if (input[j] === ')') depth--;
2455 - if (depth > 0) j++;
2456 - }
2457 - if (depth !== 0) {
2458 - result += input[i];
2459 - i++;
2460 - continue;
2461 - }
2462 - var url = input.substring(urlStart, j);
2463 - var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2464 - var encodedUrl = safeEncodeUrl(cleanUrl);
2465 - if (!linkText || !linkText.trim()) {
2466 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2467 - } else {
2468 - var safeText = sanitizeUserInput(linkText);
2469 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2470 - }
2471 - i = j + 1; // Skip past the closing )
2472 - } else {
2473 - result += input[i];
2474 - i++;
2475 - }
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>`;
2476 1552 }
2477 - return result;
2478 - })(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 + });
2479 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 +
2480 1571 // Process phone numbers: [text](tel:number)
2481 1572 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2482 1573 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2483 1574 const safePhone = safeEncodeUrl(phone);
@@ -2629,78 +1720,9 @@
2629 1720 });
2630 1721
2631 1722 return text;
2632 1723 }
2633 -
2634 - function formatMarkdownTables(text) {
2635 - var lines = text.split('\n');
2636 - var result = [];
2637 - var i = 0;
2638 -
2639 - while (i < lines.length) {
2640 - // Check for a table: current line has pipes AND next line is a separator row
2641 - if (i + 1 < lines.length &&
2642 - lines[i].indexOf('|') !== -1 &&
2643 - /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {
2644 -
2645 - var tableLines = [];
2646 - var headerLine = lines[i];
2647 - var separatorLine = lines[i + 1];
2648 - tableLines.push(headerLine);
2649 - tableLines.push(separatorLine);
2650 -
2651 - // Collect remaining table rows
2652 - var j = i + 2;
2653 - while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
2654 - tableLines.push(lines[j]);
2655 - j++;
2656 - }
2657 -
2658 - // Parse alignment from separator row
2659 - var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
2660 - var alignments = sepCells.map(function(cell) {
2661 - var trimmed = cell.trim();
2662 - if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
2663 - if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
2664 - return 'left';
2665 - });
2666 -
2667 - // Build HTML table
2668 - var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';
2669 -
2670 - // Header row
2671 - var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
2672 - html += '<thead><tr>';
2673 - headerCells.forEach(function(cell, idx) {
2674 - var align = alignments[idx] || 'left';
2675 - html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
2676 - });
2677 - html += '</tr></thead>';
2678 -
2679 - // Body rows
2680 - html += '<tbody>';
2681 - for (var r = 2; r < tableLines.length; r++) {
2682 - var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
2683 - html += '<tr>';
2684 - rowCells.forEach(function(cell, idx) {
2685 - var align = alignments[idx] || 'left';
2686 - html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
2687 - });
2688 - html += '</tr>';
2689 - }
2690 - html += '</tbody></table></div>';
2691 -
2692 - result.push(html);
2693 - i = j;
2694 - } else {
2695 - result.push(lines[i]);
2696 - i++;
2697 - }
2698 - }
2699 -
2700 - return result.join('\n');
2701 - }
2702 -
1724 +
2703 1725 function sanitizeUserInput(text) {
2704 1726 const div = document.createElement('div');
2705 1727 div.textContent = text;
2706 1728 return div.innerHTML;
@@ -2771,14 +1793,13 @@
2771 1793 requestAnimationFrame(smoothScroll);
2772 1794 }
2773 1795 }
2774 1796
2775 - function scrollElementToTop(element, botId, topOffset) {
1797 + function scrollElementToTop(element, botId) {
2776 1798 botId = botId || 'default';
2777 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2778 1799 var chatBox = getElement(botId, 'chat-box');
2779 1800 var elementTop = element.position().top + chatBox.scrollTop();
2780 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1801 + chatBox.animate({ scrollTop: elementTop }, 500);
2781 1802 }
2782 1803
2783 1804 function showChatWidget(botId) {
2784 1805 botId = botId || 'default';
@@ -2922,12 +1943,15 @@
2922 1943 // LIVE AGENT FUNCTIONALITY
2923 1944 // ====================================
2924 1945
2925 1946 function startPolling(botId) {
1947 + console.log('[MxChat] startPolling called for botId:', botId);
2926 1948 botId = botId || 'default';
2927 1949 var instance = MxChatInstances.get(botId);
2928 1950 // Clear any existing interval first
2929 1951 stopPolling(botId);
1952 + // Start new polling interval
1953 + console.log('[MxChat] Starting polling interval (5s) for botId:', botId);
2930 1954 instance.pollingInterval = setInterval(function() {
2931 1955 checkForAgentMessages(botId);
2932 1956 }, 5000);
2933 1957 }
@@ -2932,17 +1956,20 @@
2932 1956 }, 5000);
2933 1957 }
2934 1958
2935 1959 function stopPolling(botId) {
1960 + console.log('[MxChat] stopPolling called for botId:', botId);
2936 1961 botId = botId || 'default';
2937 1962 var instance = MxChatInstances.get(botId);
2938 1963 if (instance.pollingInterval) {
2939 1964 clearInterval(instance.pollingInterval);
2940 1965 instance.pollingInterval = null;
1966 + console.log('[MxChat] Polling stopped for botId:', botId);
2941 1967 }
2942 1968 }
2943 1969
2944 1970 function checkForAgentMessages(botId) {
1971 + console.log('[MxChat] checkForAgentMessages called for botId:', botId);
2945 1972 botId = botId || 'default';
2946 1973 var instance = MxChatInstances.get(botId);
2947 1974 const sessionId = getChatSession(botId);
2948 1975 $.ajax({
@@ -2968,12 +1995,8 @@
2968 1995 instance.processedMessageIds.add(message.id);
2969 1996 }
2970 1997 });
2971 1998
2972 - if (hasNewMessage) {
2973 - enableChatInput(botId);
2974 - }
2975 -
2976 1999 var $floatingChatbot = getElement(botId, 'floating-chatbot');
2977 2000 if (hasNewMessage && $floatingChatbot.hasClass('hidden')) {
2978 2001 showNotification(botId);
2979 2002 }
@@ -2979,13 +2002,8 @@
2979 2002 }
2980 2003
2981 2004 scrollToBottom(botId, true);
2982 2005 }
2983 -
2984 - // Handle chat mode transitions (e.g. agent ended chat via !endchat)
2985 - if (response.success && response.data?.chat_mode) {
2986 - updateChatModeIndicator(response.data.chat_mode, botId);
2987 - }
2988 2006 },
2989 2007 error: function (xhr, status, error) {
2990 2008 // Polling error - silently continue
2991 2009 }
@@ -2995,29 +2013,20 @@
2995 2013 // ====================================
2996 2014 // CHAT HISTORY & PERSISTENCE
2997 2015 // ====================================
2998 2016
2999 -function loadChatHistory(botId, onComplete) {
2017 +function loadChatHistory(botId) {
3000 2018 botId = botId || 'default';
3001 2019 var instance = MxChatInstances.get(botId);
3002 2020
3003 2021 // Prevent duplicate loading
3004 2022 if (instance.chatHistoryLoaded) {
3005 - if (onComplete) onComplete();
3006 2023 return;
3007 2024 }
3008 2025
3009 - // Use getChatSession which returns null if no session exists (does NOT create one)
3010 2026 var sessionId = getChatSession(botId);
3011 2027 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3012 2028
3013 - // No session yet — nothing to load. History will load after first message via ensureSession.
3014 - if (!sessionId) {
3015 - instance.chatHistoryLoaded = true;
3016 - if (onComplete) onComplete();
3017 - return;
3018 - }
3019 -
3020 2029 if (chatPersistenceEnabled && sessionId) {
3021 2030 $.ajax({
3022 2031 url: mxchatChat.ajax_url,
3023 2032 type: 'POST',
@@ -3028,12 +2037,11 @@
3028 2037 },
3029 2038 success: function(response) {
3030 2039 // Handle session reset (IP changed while user was away)
3031 2040 if (response.success === false && response.data && response.data.action === 'reset_session') {
3032 - // Silent reset — new session but don't clear UI
3033 - MxChatInstances.silentResetSession(botId);
2041 + // Silently reset session - user will start fresh
2042 + resetChatSession(botId);
3034 2043 instance.chatHistoryLoaded = true; // Prevent retry loop
3035 - if (onComplete) onComplete();
3036 2044 return;
3037 2045 }
3038 2046
3039 2047 // Check if the response indicates success
@@ -3050,30 +2058,9 @@
3050 2058 }
3051 2059
3052 2060 // Only process if there are actual messages
3053 2061 if (response.data.conversation.length > 0) {
3054 - // Restored history must be SILENT to screen readers
3055 - // (plan 67f126): these are DOM additions inside the
3056 - // live region and would otherwise announce as if
3057 - // they just arrived. Lift aria-live for the batch
3058 - // repopulate, restore it after the browser has
3059 - // processed the mutations.
3060 - var mxLiveRegionEl = $chatBox.get(0);
3061 - var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3062 - if (mxLiveRegionEl) {
3063 - mxLiveRegionEl.setAttribute('aria-live', 'off');
3064 - }
3065 -
3066 - // IMPORTANT: Clear existing messages before loading history.
3067 - // Detach the greeting first and put it back below —
3068 - // it is server-rendered and never stored in the
3069 - // transcript, so the old unconditional .empty()
3070 - // deleted it for the rest of the page life (plan
3071 - // a1a79b). Detach rather than rebuild: intro_message
3072 - // is not localized to JS, and this node already
3073 - // carries the per-bot inline colors and any
3074 - // {visitor_name} substitution already applied to it.
3075 - var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2062 + // IMPORTANT: Clear existing messages before loading history
3076 2063 $chatBox.empty();
3077 2064
3078 2065 $.each(response.data.conversation, function(index, message) {
3079 2066 // Skip agent messages if persistence is off
@@ -3110,23 +2097,9 @@
3110 2097 var content = message.content;
3111 2098 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3112 2099 content = decodeHTMLEntities(content);
3113 2100
3114 - // Skip linkify for messages containing structured HTML
3115 - // (forms, product cards, galleries, etc.) to avoid
3116 - // markdown formatting corrupting HTML attributes
3117 - // (e.g. underscores in name="field_name" becoming <em> tags).
3118 - // One family check instead of a per-card literal list: any
3119 - // element carrying an mxchat- prefixed class is MxChat-generated
3120 - // structured markup and replays raw. The old list drifted every
3121 - // time an add-on minted a new card class — the filtered-search
3122 - // card ("mxchat-filtered-product-card") missed it and replayed
3123 - // through linkify as visible markup.
3124 - if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3125 - content.includes("<form") ||
3126 - content.includes("<input") ||
3127 - content.includes("<select") ||
3128 - content.includes("<textarea")) {
2101 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
3129 2102 messageElement.html(content);
3130 2103 } else {
3131 2104 var formattedContent = linkify(content);
3132 2105 messageElement.html(formattedContent);
@@ -3140,29 +2113,12 @@
3140 2113 instance.processedMessageIds.add(message.id);
3141 2114 }
3142 2115 });
3143 2116
3144 - // Only append messages and scroll if we have content.
3145 - // Greeting goes back FIRST, above the restored
3146 - // history: "Hello — [earlier conversation]" is the
3147 - // natural reading and matches the order a fresh
3148 - // visitor sees (plan a1a79b).
3149 - if ($mxIntro && $mxIntro.length) {
3150 - $chatBox.append($mxIntro);
3151 - }
2117 + // Only append messages and scroll if we have content
3152 2118 $chatBox.append($fragment);
3153 2119 scrollToBottom(botId, true);
3154 2120
3155 - // Re-attach live semantics AFTER the rehydration
3156 - // mutations have been processed with the region off
3157 - // (plan 67f126). Restoring later announces nothing
3158 - // retroactively; new turns announce normally.
3159 - if (mxLiveRegionEl) {
3160 - setTimeout(function() {
3161 - mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3162 - }, 200);
3163 - }
3164 -
3165 2121 // Collapse quick questions if we have conversation history
3166 2122 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3167 2123 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3168 2124 collapseQuickQuestions(botId);
@@ -3183,17 +2139,13 @@
3183 2139 instance.chatHistoryLoaded = true;
3184 2140 }
3185 2141 }
3186 2142 }
3187 - if (onComplete) onComplete();
3188 2143 },
3189 2144 error: function(xhr, status, error) {
3190 2145 // Error loading chat history - silently continue
3191 - if (onComplete) onComplete();
3192 2146 }
3193 2147 });
3194 - } else {
3195 - if (onComplete) onComplete();
3196 2148 }
3197 2149 }
3198 2150
3199 2151
@@ -3257,10 +2209,10 @@
3257 2209 .then(data => {
3258 2210 if (data.success) {
3259 2211 container.style.display = 'none';
3260 2212 nameElement.textContent = '';
3261 - instance.activePdfFile = null;
3262 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2213 + activePdfFile = null;
2214 + appendMessage('bot', 'PDF removed.');
3263 2215 }
3264 2216 })
3265 2217 .catch(error => {
3266 2218 // Error removing PDF - silently continue
@@ -3266,16 +2218,14 @@
3266 2218 // Error removing PDF - silently continue
3267 2219 });
3268 2220 }
3269 2221
3270 - function removeActiveWord(botId) {
3271 - botId = botId || 'default';
3272 - var instance = MxChatInstances.get(botId);
3273 - const container = getElementDOM(botId, 'active-word-container');
3274 - const nameElement = getElementDOM(botId, 'active-word-name');
3275 -
3276 - if (!container || !nameElement || !instance.activeWordFile) return;
3277 -
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 +
3278 2228 fetch(mxchatChat.ajax_url, {
3279 2229 method: 'POST',
3280 2230 headers: {
3281 2231 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3281,9 +2231,9 @@
3281 2231 'Content-Type': 'application/x-www-form-urlencoded',
3282 2232 },
3283 2233 body: new URLSearchParams({
3284 2234 'action': 'mxchat_remove_word',
3285 - 'session_id': getChatSession(botId),
2235 + 'session_id': sessionId,
3286 2236 'nonce': mxchatChat.nonce
3287 2237 })
3288 2238 })
3289 2239 .then(response => response.json())
@@ -3290,10 +2240,10 @@
3290 2240 .then(data => {
3291 2241 if (data.success) {
3292 2242 container.style.display = 'none';
3293 2243 nameElement.textContent = '';
3294 - instance.activeWordFile = null;
3295 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2244 + activeWordFile = null;
2245 + appendMessage('bot', 'Word document removed.');
3296 2246 }
3297 2247 })
3298 2248 .catch(error => {
3299 2249 // Error removing Word document - silently continue
@@ -3371,35 +2321,45 @@
3371 2321 // ====================================
3372 2322
3373 2323 function checkPreChatDismissal(botId) {
3374 2324 botId = botId || 'default';
3375 - try {
3376 - var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3377 - if (dismissedAt) {
3378 - // Re-show after 24 hours
3379 - var elapsed = Date.now() - parseInt(dismissedAt, 10);
3380 - 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 {
3381 2336 getElement(botId, 'pre-chat-message').hide();
3382 - return;
3383 2337 }
3384 - // Expired — clear and show again
3385 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2338 + },
2339 + error: function() {
2340 + // Error checking pre-chat dismissal - silently continue
3386 2341 }
3387 - getElement(botId, 'pre-chat-message').fadeIn(250);
3388 - } catch (e) {
3389 - // localStorage unavailable — show the message
3390 - getElement(botId, 'pre-chat-message').fadeIn(250);
3391 - }
2342 + });
3392 2343 }
3393 2344
3394 2345 function handlePreChatDismissal(botId) {
3395 2346 botId = botId || 'default';
3396 2347 getElement(botId, 'pre-chat-message').fadeOut(200);
3397 - try {
3398 - localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3399 - } catch (e) {
3400 - // localStorage unavailable — dismissal won't persist
3401 - }
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 + });
3402 2362 }
3403 2363
3404 2364
3405 2365 // ====================================
@@ -3452,38 +2412,11 @@
3452 2412 e.stopPropagation();
3453 2413 var botId = getBotIdFromElement(this);
3454 2414 collapseQuickQuestions(botId);
3455 2415 });
3456 -
3457 -// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3458 -// thumbnail facade — no Google iframe exists until the visitor taps play.
3459 -// Delegated so it also works for embeds restored from chat history.
3460 -$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3461 - e.preventDefault();
3462 - var $wrap = $(this).closest('.mxchat-youtube-embed');
3463 - var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3464 - if (!videoId) {
3465 - return;
3466 - }
3467 - var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3468 - var $iframe = $('<iframe>', {
3469 - src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3470 - title: title,
3471 - allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3472 - allowfullscreen: true,
3473 - frameborder: 0
3474 - }).addClass('mxchat-youtube-iframe');
3475 - $wrap.addClass('mxchat-youtube-playing');
3476 - $(this).replaceWith($iframe);
3477 -});
3478 2416
3479 2417 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3480 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3481 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3482 - if (e.type === 'keydown') {
3483 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3484 - e.preventDefault();
3485 - }
2418 + $(document).on('click', '.floating-chatbot-button', function() {
3486 2419 var botId = getBotIdFromElement(this);
3487 2420 var $chatbot = getElement(botId, 'floating-chatbot');
3488 2421 var $badge = getElement(botId, 'chat-notification-badge');
3489 2422 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3488,88 +2421,35 @@
3488 2421 var $badge = getElement(botId, 'chat-notification-badge');
3489 2422 var $preChat = getElement(botId, 'pre-chat-message');
3490 2423
3491 2424 if ($chatbot.hasClass('hidden')) {
3492 - $chatbot.removeClass('hidden').addClass('visible')
3493 - .attr('aria-modal', 'true').attr('role', 'dialog');
3494 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2425 + $chatbot.removeClass('hidden').addClass('visible');
2426 + $(this).addClass('hidden');
3495 2427 $badge.hide(); // Hide notification when opening chat
3496 2428 disableScroll();
3497 2429 $preChat.fadeOut(250);
3498 -
3499 - // First open per page load: re-fetch behavior settings in case
3500 - // this page's inline values came from a stale full-page cache
3501 - // (plan-32db95). Idempotent — later opens are a no-op.
3502 - mxchatRefreshDynamicSettings();
3503 -
3504 - // Load chat history for returning visitors (persistence)
3505 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3506 - if (chatPersistenceEnabled) {
3507 - MxChatInstances.ensureSession(botId);
3508 - }
3509 -
3510 - // Deferred email check — only on first widget open
3511 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3512 - var instance = MxChatInstances.get(botId);
3513 - if (emailBlocker && !instance.emailCheckDone) {
3514 - instance.emailCheckDone = true;
3515 - resolveEmailState(botId);
3516 - } else if (!emailBlocker) {
3517 - // No email collection — still route through showChatContainerForBot
3518 - // so the loader is shown while chat history loads
3519 - showChatContainerForBot(botId);
3520 - }
3521 -
3522 - // Move keyboard focus into the message input after the open transition.
3523 - setTimeout(function() {
3524 - var chatInput = getElementDOM(botId, 'chat-input');
3525 - if (chatInput && !chatInput.disabled) {
3526 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3527 - }
3528 - }, 300);
3529 2430 } else {
3530 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3531 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2431 + $chatbot.removeClass('visible').addClass('hidden');
2432 + $(this).removeClass('hidden');
3532 2433 enableScroll();
3533 2434 checkPreChatDismissal(botId);
3534 2435 }
3535 2436 });
3536 2437
3537 - // Allow clicking anywhere on the title bar to close the chatbot.
3538 - // Returns keyboard focus to the launcher so keyboard users don't get
3539 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3540 - // heuristic-based so mouse-triggered close won't show a focus ring.
2438 + // Allow clicking anywhere on the title bar to close the chatbot
3541 2439 $(document).on('click', '.chatbot-top-bar', function() {
3542 2440 var botId = getBotIdFromElement(this);
3543 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3544 - var $launcher = getElement(botId, 'floating-chatbot-button');
3545 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2441 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2442 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3546 2443 enableScroll();
3547 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3548 2444 });
3549 2445
3550 - // Global Escape-key handler — closes any visible chat widget and
3551 - // returns focus to its launcher. Standard modal-dismissal pattern;
3552 - // pairs with aria-modal="true" set on the widget when it opens.
3553 - $(document).on('keydown', function(e) {
3554 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3555 - var $visible = $('.floating-chatbot.visible');
3556 - if (!$visible.length) return;
3557 - e.preventDefault();
3558 - $visible.each(function() {
3559 - var botId = getBotIdFromElement(this);
3560 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3561 - var $launcher = getElement(botId, 'floating-chatbot-button');
3562 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3563 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3564 - });
3565 - enableScroll();
3566 - });
3567 -
3568 2446 $(document).on('click', '.close-pre-chat-message', function(e) {
3569 2447 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3570 2448 var botId = getBotIdFromElement(this);
3571 - handlePreChatDismissal(botId);
2449 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2450 + $(this).remove();
2451 + });
3572 2452 });
3573 2453
3574 2454
3575 2455 // PDF upload button handlers - use class selector
@@ -3585,20 +2465,17 @@
3585 2465 var wordInput = getElementDOM(botId, 'word-upload');
3586 2466 if (wordInput) wordInput.click();
3587 2467 });
3588 2468
3589 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3590 - $(document).on('change', '.pdf-upload', async function(e) {
3591 - var botId = getBotIdFromElement(this);
3592 - var instance = MxChatInstances.get(botId);
3593 - const file = this.files[0];
3594 - const sessionId = MxChatInstances.ensureSession(botId);
3595 -
2469 + // PDF file input change handler
2470 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2471 + const file = e.target.files[0];
2472 +
3596 2473 if (!file || file.type !== 'application/pdf') {
3597 2474 alert('Please select a valid PDF file.');
3598 2475 return;
3599 2476 }
3600 -
2477 +
3601 2478 if (!sessionId) {
3602 2479 alert('Error: No session ID found');
3603 2480 return;
3604 2481 }
@@ -3606,49 +2483,47 @@
3606 2483 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3607 2484 alert('Error: Ajax configuration missing');
3608 2485 return;
3609 2486 }
3610 -
2487 +
3611 2488 // Disable buttons and show loading state
3612 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3613 - const sendBtn = getElementDOM(botId, 'send-button');
3614 - if (!uploadBtn) return;
2489 + const uploadBtn = document.getElementById('pdf-upload-btn');
2490 + const sendBtn = document.getElementById('send-button');
3615 2491 const originalBtnContent = uploadBtn.innerHTML;
3616 -
2492 +
3617 2493 try {
3618 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3619 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3620 2494 const formData = new FormData();
3621 2495 formData.append('action', 'mxchat_upload_pdf');
3622 2496 formData.append('pdf_file', file);
3623 2497 formData.append('session_id', sessionId);
3624 2498 formData.append('nonce', mxchatChat.nonce);
3625 -
2499 +
3626 2500 uploadBtn.disabled = true;
3627 - if (sendBtn) sendBtn.disabled = true;
2501 + sendBtn.disabled = true;
3628 2502 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3629 2503 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3630 2504 </svg>`;
3631 -
2505 +
3632 2506 const response = await fetch(mxchatChat.ajax_url, {
3633 2507 method: 'POST',
3634 2508 body: formData
3635 2509 });
3636 -
2510 +
3637 2511 const data = await response.json();
3638 -
2512 +
3639 2513 if (data.success) {
3640 2514 // Hide popular questions if they exist
3641 - if (hasQuickQuestions(botId)) {
3642 - collapseQuickQuestions(botId);
2515 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2516 + if (hasQuickQuestions()) {
2517 + collapseQuickQuestions();
3643 2518 }
3644 -
2519 +
3645 2520 // Show the active PDF name
3646 - showActivePdf(data.data.filename, botId);
3647 -
3648 - appendMessage('bot', data.data.message, '', [], false, botId);
3649 - scrollToBottom(botId);
3650 - instance.activePdfFile = data.data.filename;
2521 + showActivePdf(data.data.filename);
2522 +
2523 + appendMessage('bot', data.data.message);
2524 + scrollToBottom();
2525 + activePdfFile = data.data.filename;
3651 2526 } else {
3652 2527 alert('Failed to upload PDF. Please try again.');
3653 2528 }
3654 2529 } catch (error) {
@@ -3654,76 +2529,66 @@
3654 2529 } catch (error) {
3655 2530 alert('Error uploading file. Please try again.');
3656 2531 } finally {
3657 2532 uploadBtn.disabled = false;
3658 - if (sendBtn) sendBtn.disabled = false;
2533 + sendBtn.disabled = false;
3659 2534 uploadBtn.innerHTML = originalBtnContent;
3660 2535 this.value = ''; // Reset file input
3661 2536 }
3662 2537 });
3663 2538
3664 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3665 - $(document).on('change', '.word-upload', async function(e) {
3666 - var botId = getBotIdFromElement(this);
3667 - var instance = MxChatInstances.get(botId);
3668 - const file = this.files[0];
3669 - const sessionId = MxChatInstances.ensureSession(botId);
3670 -
2539 + // Word file input change handler
2540 + addSafeEventListener('word-upload', 'change', async function(e) {
2541 + const file = e.target.files[0];
2542 +
3671 2543 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3672 2544 alert('Please select a valid Word document (.docx).');
3673 2545 return;
3674 2546 }
3675 -
2547 +
3676 2548 if (!sessionId) {
3677 2549 alert('Error: No session ID found');
3678 2550 return;
3679 2551 }
3680 2552
3681 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3682 - alert('Error: Ajax configuration missing');
3683 - return;
3684 - }
3685 -
3686 2553 // Disable buttons and show loading state
3687 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3688 - const sendBtn = getElementDOM(botId, 'send-button');
3689 - if (!uploadBtn) return;
2554 + const uploadBtn = document.getElementById('word-upload-btn');
2555 + const sendBtn = document.getElementById('send-button');
3690 2556 const originalBtnContent = uploadBtn.innerHTML;
3691 -
2557 +
3692 2558 try {
3693 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3694 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3695 2559 const formData = new FormData();
3696 2560 formData.append('action', 'mxchat_upload_word');
3697 2561 formData.append('word_file', file);
3698 2562 formData.append('session_id', sessionId);
3699 2563 formData.append('nonce', mxchatChat.nonce);
3700 -
2564 +
3701 2565 uploadBtn.disabled = true;
3702 - if (sendBtn) sendBtn.disabled = true;
2566 + sendBtn.disabled = true;
3703 2567 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3704 2568 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3705 2569 </svg>`;
3706 -
2570 +
3707 2571 const response = await fetch(mxchatChat.ajax_url, {
3708 2572 method: 'POST',
3709 2573 body: formData
3710 2574 });
3711 -
2575 +
3712 2576 const data = await response.json();
3713 -
2577 +
3714 2578 if (data.success) {
3715 2579 // Hide popular questions if they exist
3716 - if (hasQuickQuestions(botId)) {
3717 - collapseQuickQuestions(botId);
2580 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2581 + if (hasQuickQuestions()) {
2582 + collapseQuickQuestions();
3718 2583 }
3719 -
2584 +
3720 2585 // Show the active Word document name
3721 - showActiveWord(data.data.filename, botId);
3722 -
3723 - appendMessage('bot', data.data.message, '', [], false, botId);
3724 - scrollToBottom(botId);
3725 - instance.activeWordFile = data.data.filename;
2586 + showActiveWord(data.data.filename);
2587 +
2588 + appendMessage('bot', data.data.message);
2589 + scrollToBottom();
2590 + activeWordFile = data.data.filename;
3726 2591 } else {
3727 2592 alert('Failed to upload Word document. Please try again.');
3728 2593 }
3729 2594 } catch (error) {
@@ -3729,25 +2594,25 @@
3729 2594 } catch (error) {
3730 2595 alert('Error uploading file. Please try again.');
3731 2596 } finally {
3732 2597 uploadBtn.disabled = false;
3733 - if (sendBtn) sendBtn.disabled = false;
2598 + sendBtn.disabled = false;
3734 2599 uploadBtn.innerHTML = originalBtnContent;
3735 2600 this.value = ''; // Reset file input
3736 2601 }
3737 2602 });
3738 2603
3739 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3740 - $(document).on('click', '.remove-pdf-btn', function(e) {
2604 + // Remove button click handlers
2605 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3741 2606 e.preventDefault();
3742 2607 e.stopPropagation();
3743 - removeActivePdf(getBotIdFromElement(this));
2608 + removeActivePdf();
3744 2609 });
3745 -
3746 - $(document).on('click', '.remove-word-btn', function(e) {
2610 +
2611 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3747 2612 e.preventDefault();
3748 2613 e.stopPropagation();
3749 - removeActiveWord(getBotIdFromElement(this));
2614 + removeActiveWord();
3750 2615 });
3751 2616
3752 2617 // Window resize handlers
3753 2618 $(window).on('resize orientationchange', function() {
@@ -3785,59 +2650,8 @@
3785 2650 });
3786 2651
3787 2652
3788 2653 // ====================================
3789 -// INIT LOADER & CHAT CONTAINER HELPERS
3790 -// ====================================
3791 -// These must be outside the email collection block so they're always available
3792 -// (used by persistence loading even when email collection is off)
3793 -
3794 -function showInitLoader(botId) {
3795 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3796 - if (loader) loader.style.display = 'flex';
3797 -}
3798 -
3799 -function hideInitLoader(botId) {
3800 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3801 - if (loader) loader.style.display = 'none';
3802 -}
3803 -
3804 -function showEmailFormForBot(botId) {
3805 - hideInitLoader(botId);
3806 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3807 - var chatContainer = getElementDOM(botId, 'chat-container');
3808 - if (emailBlocker) emailBlocker.style.display = 'flex';
3809 - if (chatContainer) chatContainer.style.display = 'none';
3810 -}
3811 -
3812 -function showChatContainerForBot(botId) {
3813 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3814 - var chatContainer = getElementDOM(botId, 'chat-container');
3815 - if (emailBlocker) emailBlocker.style.display = 'none';
3816 -
3817 - var instance = MxChatInstances.get(botId);
3818 - var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3819 -
3820 - // If persistence is on and history hasn't loaded yet, show loader
3821 - // while history loads to prevent flash of empty chat
3822 - if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3823 - if (chatContainer) chatContainer.style.display = 'none';
3824 - showInitLoader(botId);
3825 - loadChatHistory(botId, function() {
3826 - hideInitLoader(botId);
3827 - if (chatContainer) chatContainer.style.display = 'flex';
3828 - scrollToBottom(botId, true);
3829 - });
3830 - } else {
3831 - hideInitLoader(botId);
3832 - if (chatContainer) chatContainer.style.display = 'flex';
3833 - if (typeof loadChatHistory === 'function') {
3834 - loadChatHistory(botId);
3835 - }
3836 - }
3837 -}
3838 -
3839 -// ====================================
3840 2654 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3841 2655 // ====================================
3842 2656 // Only run email collection setup if it's enabled
3843 2657 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
@@ -3873,8 +2687,28 @@
3873 2687 `;
3874 2688 document.head.appendChild(style);
3875 2689 }
3876 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 +
3877 2711 function isValidEmailAddress(email) {
3878 2712 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3879 2713 return emailRegex.test(email.trim()) && email.length <= 254;
3880 2714 }
@@ -3891,13 +2725,10 @@
3891 2725 function replaceVisitorNamePlaceholder(botId, visitorName) {
3892 2726 var chatBox = getElementDOM(botId, 'chat-box');
3893 2727 if (!chatBox) return;
3894 2728
3895 - // Find the greeting by its marker, not by position (plan a1a79b) —
3896 - // after a persistence restore the first .bot-message is a restored
3897 - // reply, and {visitor_name} was being substituted into that instead.
3898 - // Positional fallback for HTML cached before this release only.
3899 - var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
2729 + // Find the first bot message (intro message)
2730 + var introMessage = chatBox.querySelector('.bot-message');
3900 2731 if (!introMessage) return;
3901 2732
3902 2733 var messageContent = introMessage.querySelector('div[dir="auto"]');
3903 2734 if (!messageContent) return;
@@ -3999,31 +2830,11 @@
3999 2830 existingErrors.forEach(error => error.remove());
4000 2831 }
4001 2832 }
4002 2833
4003 - // Resolve email state using server-side data when available, AJAX fallback otherwise
4004 - function resolveEmailState(botId) {
4005 - if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
4006 - if (mxchatChat.initial_email_state.show_email_form) {
4007 - showEmailFormForBot(botId);
4008 - } else {
4009 - showChatContainerForBot(botId);
4010 - }
4011 - } else {
4012 - checkSessionAndEmailForBot(botId);
4013 - }
4014 - }
4015 -
4016 2834 function checkSessionAndEmailForBot(botId) {
4017 - const sessionId = MxChatInstances.ensureSession(botId);
2835 + const sessionId = getChatSession(botId);
4018 2836
4019 - // Hide both panels while we check — show loader instead
4020 - var emailBlocker = getElementDOM(botId, 'email-blocker');
4021 - var chatContainer = getElementDOM(botId, 'chat-container');
4022 - if (emailBlocker) emailBlocker.style.display = 'none';
4023 - if (chatContainer) chatContainer.style.display = 'none';
4024 - showInitLoader(botId);
4025 -
4026 2837 fetch(mxchatChat.ajax_url, {
4027 2838 method: 'POST',
4028 2839 headers: {
4029 2840 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4069,12 +2880,11 @@
4069 2880 }
4070 2881
4071 2882 var emailInput = getElementDOM(botId, 'user-email');
4072 2883 var nameInput = getElementDOM(botId, 'user-name');
4073 - var consentInput = getElementDOM(botId, 'user-consent');
4074 2884 var userEmail = emailInput ? emailInput.value.trim() : '';
4075 2885 var userName = nameInput ? nameInput.value.trim() : '';
4076 - var sessionId = MxChatInstances.ensureSession(botId);
2886 + var sessionId = getChatSession(botId);
4077 2887
4078 2888 // Validate email
4079 2889 if (!userEmail) {
4080 2890 showEmailError(botId, 'Please enter your email address.');
@@ -4091,15 +2901,8 @@
4091 2901 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4092 2902 return false;
4093 2903 }
4094 2904
4095 - // Consent checkbox (b062c4): backstop behind the native required
4096 - // attribute; the server enforces this independently either way.
4097 - if (consentInput && consentInput.required && !consentInput.checked) {
4098 - showEmailError(botId, 'Please tick the consent box to continue.');
4099 - return false;
4100 - }
4101 -
4102 2905 clearEmailError(botId);
4103 2906 setEmailSubmissionState(botId, true);
4104 2907
4105 2908 // Prepare form data
@@ -4113,14 +2916,8 @@
4113 2916 if (userName) {
4114 2917 formData.append('name', userName);
4115 2918 }
4116 2919
4117 - // Ticked/unticked both travel when the checkbox is rendered, so an
4118 - // optional-consent "no" is recorded as a decision, not an absence.
4119 - if (consentInput) {
4120 - formData.append('consent', consentInput.checked ? '1' : '0');
4121 - }
4122 -
4123 2920 fetch(mxchatChat.ajax_url, {
4124 2921 method: 'POST',
4125 2922 headers: {
4126 2923 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4210,27 +3007,25 @@
4210 3007 }
4211 3008 });
4212 3009
4213 3010 // Initialize email check for all bot instances
4214 - // For floating bots: defer until widget is opened (zero passive AJAX)
4215 - // For embedded bots: check immediately since the form is visible
4216 3011 $('.mxchat-chatbot-wrapper').each(function() {
4217 3012 var botId = $(this).data('bot-id') || 'default';
4218 3013 var emailBlocker = getElementDOM(botId, 'email-blocker');
4219 3014
3015 + // Only check if email blocker exists for this bot
4220 3016 if (emailBlocker) {
4221 - if (isEmbeddedBot(botId)) {
4222 - // Embedded bots are always visible — check now
4223 - 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);
4224 3027 }
4225 - // Floating bots: handled in the widget open handler
4226 - } else if (isEmbeddedBot(botId)) {
4227 - // Embedded bot, no email collection — load history with loader
4228 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4229 - if (chatPersistenceEnabled) {
4230 - MxChatInstances.ensureSession(botId);
4231 - showChatContainerForBot(botId);
4232 - }
4233 3028 }
4234 3029 });
4235 3030 }
4236 3031
@@ -4240,32 +3035,39 @@
4240 3035 var $chatbot = getElement(botId, 'floating-chatbot');
4241 3036 if ($chatbot.hasClass('hidden')) {
4242 3037 $chatbot.removeClass('hidden').addClass('visible');
4243 3038 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4244 - handlePreChatDismissal(botId);
3039 + $(this).fadeOut(250); // Hide pre-chat message
4245 3040 disableScroll(); // Disable scroll when chatbot opens
3041 + }
3042 + });
4246 3043
4247 - // Load chat history for returning visitors (persistence)
4248 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4249 - if (chatPersistenceEnabled) {
4250 - MxChatInstances.ensureSession(botId);
4251 - }
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
4252 3050
4253 - // Deferred email check — only on first widget open
4254 - var emailBlocker = getElementDOM(botId, 'email-blocker');
4255 - var instance = MxChatInstances.get(botId);
4256 - if (emailBlocker && !instance.emailCheckDone) {
4257 - instance.emailCheckDone = true;
4258 - resolveEmailState(botId);
4259 - } else if (!emailBlocker) {
4260 - 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
4261 3065 }
4262 - }
3066 + });
4263 3067 });
4264 3068
4265 - // Legacy duplicate close handler removed — handled by single event delegation above
4266 3069
4267 -
4268 3070 function hasQuickQuestions(botId) {
4269 3071 botId = botId || 'default';
4270 3072 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4271 3073 if (!questionsContainer) return false;
@@ -4401,11 +3203,18 @@
4401 3203 });
4402 3204
4403 3205 // Initialize when document is ready
4404 3206 setFullHeight();
3207 + trackOriginatingPage();
4405 3208
4406 - // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4407 - // 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 + }
4408 3217
4409 3218 // Initialize chat visibility for all instances
4410 3219 $('.mxchat-chatbot-wrapper').each(function() {
4411 3220 var botId = $(this).data('bot-id') || 'default';
@@ -4458,312 +3267,6 @@
4458 3267 }, 2000);
4459 3268 });
4460 3269 }
4461 3270 }
4462 -});
4463 -
4464 -// ============================================================================
4465 -// SATISFACTION RATING (v3.2.6)
4466 -// ============================================================================
4467 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4468 -// inactivity following a bot reply. One prompt per session, deduped via
4469 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4470 -// the option (default off) is authoritative.
4471 -jQuery(function($) {
4472 - if (typeof mxchatChat === 'undefined') return;
4473 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4474 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4475 - // old "disabled when exactly false/'off'" check let '' through and the
4476 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4477 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4478 - // (boolean-true localizations) working.
4479 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4480 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4481 - // merges fresh values over mxchatChat (that merge fires on first widget
4482 - // open, after this module has already decided). Re-evaluating would mean
4483 - // restructuring the whole module to late-bind its listeners — not worth it
4484 - // for a prompt that is at worst stale for one page load on a cached page.
4485 - var sre = mxchatChat.satisfaction_rating_enabled;
4486 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4487 -
4488 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4489 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4490 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4491 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4492 - if (idleSeconds < 5) idleSeconds = 5;
4493 - if (idleSeconds > 600) idleSeconds = 600;
4494 - var IDLE_MS = idleSeconds * 1000;
4495 - var MIN_BOT_REPLIES = 2;
4496 - var ratingState = {};
4497 -
4498 - function getState(botId) {
4499 - if (!ratingState[botId]) {
4500 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4501 - }
4502 - return ratingState[botId];
4503 - }
4504 -
4505 - function getSessionId(botId) {
4506 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4507 - return MxChatInstances.getChatSession(botId);
4508 - }
4509 - return null;
4510 - }
4511 -
4512 - function isAlreadyRated(sessionId) {
4513 - if (!sessionId) return false;
4514 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4515 - }
4516 -
4517 - function markRated(sessionId) {
4518 - if (!sessionId) return;
4519 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4520 - }
4521 -
4522 - function esc(s) {
4523 - return String(s == null ? '' : s)
4524 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4525 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4526 - }
4527 -
4528 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4529 - function ratingSkipInlineColors(botId) {
4530 - if (mxchatChat.skip_inline_colors) return true;
4531 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4532 - return botAssignments.hasOwnProperty(botId);
4533 - }
4534 -
4535 - function botBubbleStyleAttr(botId) {
4536 - if (ratingSkipInlineColors(botId)) return '';
4537 - var bg = mxchatChat.bot_message_bg_color;
4538 - var fg = mxchatChat.bot_message_font_color;
4539 - if (!bg && !fg) return '';
4540 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4541 - }
4542 -
4543 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4544 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4545 - // and paints the filled "Send" pill so it fills with the bot font color and
4546 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4547 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4548 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4549 - // unreliably inside a descendant's `background`, so a bubble-level var would
4550 - // silently fall back to the literal (white-block bug all over again). Inline
4551 - // longhand always wins. Same transparent-guard as the menu so we never paint
4552 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4553 - function syncRatingBubbleColors(botId) {
4554 - var $chatBox = getChatBoxByBotId(botId);
4555 - if (!$chatBox || !$chatBox.length) return;
4556 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4557 - if (!bubbleEl) return;
4558 - var cs = window.getComputedStyle(bubbleEl);
4559 - var fg = cs.color;
4560 - var bg = cs.backgroundColor;
4561 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4562 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4563 - // Expose on the bubble too, for any inheriting styles / future use.
4564 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4565 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4566 - // Paint the Send pill directly — the part that actually fixes the bug.
4567 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4568 - if (submitEl) {
4569 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4570 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4571 - }
4572 - }
4573 -
4574 - function copy(key) {
4575 - var c = mxchatChat.satisfaction_rating_copy || {};
4576 - var d = {
4577 - question: 'Was this helpful?',
4578 - helpful: 'Helpful',
4579 - not_helpful: 'Not helpful',
4580 - dismiss: 'Dismiss',
4581 - thanks: 'Thanks! Anything we should improve? (optional)',
4582 - placeholder: 'Tell us what could be better…',
4583 - send: 'Send',
4584 - skip: 'Skip',
4585 - saved: 'Thanks for the feedback.'
4586 - };
4587 - return c[key] || d[key];
4588 - }
4589 -
4590 - function thumbUpSvg() {
4591 - return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
4592 - }
4593 - function thumbDownSvg() {
4594 - return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
4595 - }
4596 -
4597 - function buildPromptHtml(botId) {
4598 - var styleAttr = botBubbleStyleAttr(botId);
4599 - return ''
4600 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4601 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4602 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4603 - + '<div class="mxchat-rating-actions">'
4604 - + '<span class="mxchat-rating-buttons">'
4605 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4606 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4607 - + '</span>'
4608 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4609 - + '</div>'
4610 - + '</div>'
4611 - + '</div>';
4612 - }
4613 -
4614 - function buildFeedbackHtml(botId, rating) {
4615 - var styleAttr = botBubbleStyleAttr(botId);
4616 - return ''
4617 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4618 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4619 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4620 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4621 - + '<div class="mxchat-rating-feedback-actions">'
4622 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4623 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4624 - + '</div>'
4625 - + '</div>'
4626 - + '</div>';
4627 - }
4628 -
4629 - function buildSavedHtml(botId) {
4630 - var styleAttr = botBubbleStyleAttr(botId);
4631 - return ''
4632 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4633 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4634 - + '</div>';
4635 - }
4636 -
4637 - function getChatBoxByBotId(botId) {
4638 - var $byId = $('#chat-box-' + botId);
4639 - if ($byId.length) return $byId.first();
4640 - return $('.chat-box').first();
4641 - }
4642 -
4643 - function scrollChatBoxToBottom($chatBox) {
4644 - if (!$chatBox || !$chatBox.length) return;
4645 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4646 - }
4647 -
4648 - function showPrompt(botId) {
4649 - var s = getState(botId);
4650 - if (s.promptShown || s.dismissed) return;
4651 - var sessionId = getSessionId(botId);
4652 - if (!sessionId) return;
4653 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4654 - var $chatBox = getChatBoxByBotId(botId);
4655 - if (!$chatBox.length) return;
4656 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4657 - $chatBox.append(buildPromptHtml(botId));
4658 - syncRatingBubbleColors(botId);
4659 - s.promptShown = true;
4660 - scrollChatBoxToBottom($chatBox);
4661 - }
4662 -
4663 - function submitRating(botId, rating, feedback) {
4664 - var sessionId = getSessionId(botId);
4665 - if (!sessionId) return;
4666 - $.post(mxchatChat.ajax_url, {
4667 - action: 'mxchat_save_rating',
4668 - session_id: sessionId,
4669 - bot_id: botId,
4670 - rating: rating,
4671 - feedback: feedback || ''
4672 - });
4673 - markRated(sessionId);
4674 - }
4675 -
4676 - function onBotReply(botId) {
4677 - var s = getState(botId);
4678 - s.botReplies += 1;
4679 - if (s.promptShown || s.dismissed) return;
4680 - var sessionId = getSessionId(botId);
4681 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4682 - if (s.botReplies < MIN_BOT_REPLIES) return;
4683 - if (s.idleTimer) clearTimeout(s.idleTimer);
4684 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4685 - }
4686 -
4687 - function onUserMessage(botId) {
4688 - var s = getState(botId);
4689 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4690 - }
4691 -
4692 - function botIdFromChatBox(el) {
4693 - var id = el && el.id ? el.id : '';
4694 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4695 - }
4696 -
4697 - function setupObserver(chatBox) {
4698 - var botId = botIdFromChatBox(chatBox);
4699 - try {
4700 - var observer = new MutationObserver(function(mutations) {
4701 - mutations.forEach(function(m) {
4702 - for (var i = 0; i < m.addedNodes.length; i++) {
4703 - var node = m.addedNodes[i];
4704 - if (!node || node.nodeType !== 1) continue;
4705 - var $n = $(node);
4706 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4707 - if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
4708 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4709 - }
4710 - });
4711 - });
4712 - observer.observe(chatBox, { childList: true });
4713 - } catch (e) { /* noop */ }
4714 - }
4715 -
4716 - $('.chat-box').each(function() { setupObserver(this); });
4717 -
4718 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4719 - e.preventDefault();
4720 - var $btn = $(this);
4721 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4722 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4723 - var botId = $prompt.data('bot-id') || 'default';
4724 - var rating = parseInt($btn.attr('data-rating'), 10);
4725 - if (rating !== 1 && rating !== -1) return;
4726 - submitRating(botId, rating, '');
4727 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4728 - syncRatingBubbleColors(botId);
4729 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4730 - });
4731 -
4732 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4733 - e.preventDefault();
4734 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4735 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4736 - var botId = $prompt.data('bot-id') || 'default';
4737 - var s = getState(botId);
4738 - s.dismissed = true;
4739 - markRated(getSessionId(botId));
4740 - ($wrap.length ? $wrap : $prompt).remove();
4741 - });
4742 -
4743 - function closeFeedback($fb) {
4744 - var botId = $fb.data('bot-id') || 'default';
4745 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4746 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4747 - syncRatingBubbleColors(botId);
4748 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4749 - }
4750 -
4751 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4752 - e.preventDefault();
4753 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4754 - });
4755 -
4756 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4757 - e.preventDefault();
4758 - var $fb = $(this).closest('.mxchat-rating-feedback');
4759 - var botId = $fb.data('bot-id') || 'default';
4760 - var rating = parseInt($fb.attr('data-rating'), 10);
4761 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4762 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4763 - if (text !== '') {
4764 - submitRating(botId, rating, text);
4765 - }
4766 - closeFeedback($fb);
4767 - });
4768 3271 });
4769 3272