PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.1
MxChat – AI Chatbot & Content Generation for WordPress v3.1.1
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 +285 -1735 3.2.213.1.1 View file →
@@ -1,166 +1,15 @@
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);
3 + // Refresh nonce on load — fixes stale nonces from page-cache plugins
4 + if (typeof mxchatChat !== 'undefined' && mxchatChat.ajax_url) {
5 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
6 + if (res && res.success && res.data && res.data.nonce) {
7 + mxchatChat.nonce = res.data.nonce;
37 8 }
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 9 });
45 10 }
46 11
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 12 // ====================================
164 13 // MULTI-INSTANCE MANAGEMENT SYSTEM
165 14 // ====================================
166 15
@@ -176,9 +25,9 @@
176 25 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
177 26
178 27 this.instances[botId] = {
179 28 botId: botId,
180 - sessionId: null,
29 + sessionId: this.getChatSession(botId),
181 30 lastSeenMessageId: '',
182 31 notificationCheckInterval: null,
183 32 pollingInterval: null,
184 33 processedMessageIds: new Set(),
@@ -203,77 +52,23 @@
203 52 return Object.keys(this.instances);
204 53 },
205 54
206 55 // 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 56 getChatSession: function(botId) {
210 57 var cookieName = 'mxchat_session_id_' + botId;
211 - var storageKey = 'mxchat_session_id_' + botId;
212 58 var sessionId = getCookie(cookieName);
213 59
214 - // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
215 60 if (!sessionId) {
216 - try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
61 + sessionId = generateSessionId();
62 + this.setChatSession(botId, sessionId);
217 63 }
218 64
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;
65 + return sessionId;
238 66 },
239 67
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 68 setChatSession: function(botId, sessionId) {
272 69 var cookieName = 'mxchat_session_id_' + botId;
273 - var storageKey = 'mxchat_session_id_' + botId;
274 70 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
275 - try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
276 71 if (this.instances[botId]) {
277 72 this.instances[botId].sessionId = sessionId;
278 73 }
279 74 },
@@ -278,43 +73,18 @@
278 73 }
279 74 },
280 75
281 76 resetChatSession: function(botId) {
282 - // Clear old session from localStorage before setting new one
283 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
284 77 var newSessionId = generateSessionId();
285 78 this.setChatSession(botId, newSessionId);
286 79 var $chatBox = getElement(botId, 'chat-box');
287 80 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 - }
81 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
300 82 }
301 83 if (this.instances[botId]) {
302 84 this.instances[botId].chatHistoryLoaded = false;
303 85 this.instances[botId].processedMessageIds = new Set();
304 86 }
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 87 }
318 88 };
319 89
320 90 // ====================================
@@ -354,20 +124,8 @@
354 124 var id = $floating.attr('id') || '';
355 125 var match = id.match(/floating-chatbot-(.+)/);
356 126 if (match) return match[1];
357 127 }
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 128 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
371 129 var elementId = $(element).attr('id') || '';
372 130 if (elementId) {
373 131 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -407,27 +165,9 @@
407 165 if (parts.length == 2) return parts.pop().split(";").shift();
408 166 }
409 167
410 168 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;
169 + return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
430 170 }
431 171
432 172 // Legacy function - now delegates to instance manager
433 173 function getChatSession(botId) {
@@ -630,26 +370,8 @@
630 370 sendButton.style.pointerEvents = 'none';
631 371 }
632 372 }
633 373
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 374 function enableChatInput(botId) {
653 375 botId = botId || 'default';
654 376 var chatInput = getElementDOM(botId, 'chat-input');
655 377 var sendButton = getElementDOM(botId, 'send-button');
@@ -655,11 +377,9 @@
655 377 var sendButton = getElementDOM(botId, 'send-button');
656 378 if (chatInput) {
657 379 chatInput.disabled = false;
658 380 chatInput.style.opacity = '1';
659 - if (mxchatShouldAutofocusAfterReply()) {
660 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 - }
381 + chatInput.focus();
662 382 }
663 383 if (sendButton) {
664 384 sendButton.disabled = false;
665 385 sendButton.style.opacity = '1';
@@ -664,106 +384,13 @@
664 384 sendButton.disabled = false;
665 385 sendButton.style.opacity = '1';
666 386 sendButton.style.pointerEvents = 'auto';
667 387 }
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 388 }
672 389
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 390 // Update your existing sendMessage function
763 391 function sendMessage(botId) {
764 392 botId = botId || 'default';
765 - MxChatInstances.ensureSession(botId);
766 393 var $chatInput = getElement(botId, 'chat-input');
767 394 var message = $chatInput.val();
768 395
769 396 // ADD PROMPT HOOK HERE
@@ -780,9 +407,8 @@
780 407 }
781 408
782 409 appendMessage("user", message, '', [], false, botId);
783 410 $chatInput.val('');
784 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
785 411 $chatInput.css('height', 'auto');
786 412
787 413 if (hasQuickQuestions(botId)) {
788 414 collapseQuickQuestions(botId);
@@ -789,16 +415,14 @@
789 415 }
790 416 appendThinkingMessage(botId);
791 417 scrollToBottom(botId);
792 418
793 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
419 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
794 420
795 421 // Check if streaming is enabled AND supported for this model
796 422 if (shouldUseStreaming(currentModel)) {
797 423 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');
424 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
801 425 }, botId);
802 426 } else {
803 427 callMxChat(message, function(response) {
804 428 replaceLastMessage("bot", response, '', [], botId);
@@ -809,9 +433,8 @@
809 433
810 434 // Update your existing sendMessageToChatbot function
811 435 function sendMessageToChatbot(message, botId) {
812 436 botId = botId || 'default';
813 - MxChatInstances.ensureSession(botId);
814 437
815 438 // ADD PROMPT HOOK HERE
816 439 if (typeof customMxChatFilter === 'function') {
817 440 message = customMxChatFilter(message, "prompt");
@@ -831,15 +454,14 @@
831 454 }
832 455 appendThinkingMessage(botId);
833 456 scrollToBottom(botId);
834 457
835 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
458 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
836 459
837 460 // Check if streaming is enabled AND supported for this model
838 461 if (shouldUseStreaming(currentModel)) {
839 462 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');
463 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
842 464 }, botId);
843 465 } else {
844 466 callMxChat(message, function(response) {
845 467 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -903,15 +525,8 @@
903 525
904 526 function callMxChat(message, callback, botId) {
905 527 botId = botId || getMxChatBotId();
906 528
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 529 // Store the message in case we need to retry after session reset
915 530 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
916 531
917 532 // Get page context if contextual awareness is enabled
@@ -919,28 +534,13 @@
919 534
920 535 // Get instance for session start timestamp (used when persistence is OFF)
921 536 var instance = MxChatInstances.get(botId);
922 537
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 538 // Prepare AJAX data
939 539 const ajaxData = {
940 540 action: 'mxchat_handle_chat_request',
941 541 message: message,
942 - session_id: sessionId,
542 + session_id: getChatSession(botId),
943 543 nonce: mxchatChat.nonce,
944 544 current_page_url: window.location.href,
945 545 current_page_title: document.title,
946 546 bot_id: botId,
@@ -946,14 +546,14 @@
946 546 bot_id: botId,
947 547 // Pass session start timestamp so AI context matches what user sees
948 548 session_start_timestamp: instance.sessionStartTimestamp || 0
949 549 };
950 -
550 +
951 551 // Add page context if available
952 552 if (pageContext) {
953 553 ajaxData.page_context = JSON.stringify(pageContext);
954 554 }
955 -
555 +
956 556 // CHECK FOR VISION FLAGS AND ADD THEM
957 557 if (window.mxchatVisionProcessed) {
958 558 ajaxData.vision_processed = true;
959 559 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -962,9 +562,9 @@
962 562 window.mxchatVisionProcessed = false;
963 563 window.mxchatOriginalMessage = null;
964 564 window.mxchatVisionImagesCount = 0;
965 565 }
966 -
566 +
967 567 $.ajax({
968 568 url: mxchatChat.ajax_url,
969 569 type: 'POST',
970 570 dataType: 'json',
@@ -1002,20 +602,26 @@
1002 602 errorMessage = "An error occurred. Please try again or contact support.";
1003 603 }
1004 604
1005 605 // Handle session reset action (IP changed, session expired, etc.)
1006 - // Silent reset — keep chat UI intact, just get a new session and retry
1007 606 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)
607 + // Clear the old session and generate a new one
608 + resetChatSession(botId);
609 + // Remove the temporary loading message
610 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
611 + // Re-send the original message with the new session
1010 612 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1011 613 if (originalMessage) {
1012 614 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1013 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
615 + // Re-add the user message and thinking indicator
616 + appendMessage("user", originalMessage, '', [], false, botId);
617 + appendThinkingMessage(botId);
618 + scrollToBottom(botId);
619 + // Determine whether to use streaming
620 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1014 621 if (shouldUseStreaming(currentModel)) {
1015 622 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');
623 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1018 624 }, botId);
1019 625 } else {
1020 626 callMxChat(originalMessage, function(response) {
1021 627 replaceLastMessage("bot", response, '', [], botId);
@@ -1106,13 +712,9 @@
1106 712 $badge.show();
1107 713 }
1108 714 }
1109 715 } 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);
716 + replaceLastMessage("bot", "I received an empty response. Please try again or contact support if this persists.", '', [], botId);
1115 717 }
1116 718
1117 719 if (response.message_id) {
1118 720 var instance = MxChatInstances.get(botId);
@@ -1154,9 +756,8 @@
1154 756
1155 757 replaceLastMessage("bot", errorMessage, '', [], botId);
1156 758 }
1157 759 });
1158 - }); // refreshNonceIfNeeded
1159 760 }
1160 761
1161 762 function callMxChatStream(message, callback, botId) {
1162 763 botId = botId || getMxChatBotId();
@@ -1163,9 +764,9 @@
1163 764
1164 765 // Store the message in case we need to retry after session reset
1165 766 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1166 767
1167 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
768 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1168 769 if (!isStreamingSupported(currentModel)) {
1169 770 callMxChat(message, callback, botId);
1170 771 return;
1171 772 }
@@ -1175,25 +776,12 @@
1175 776
1176 777 // Get instance for session start timestamp (used when persistence is OFF)
1177 778 var instance = MxChatInstances.get(botId);
1178 779
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 780 const formData = new FormData();
1193 781 formData.append('action', 'mxchat_stream_chat');
1194 782 formData.append('message', message);
1195 - formData.append('session_id', streamSessionId);
783 + formData.append('session_id', getChatSession(botId));
1196 784 formData.append('nonce', mxchatChat.nonce);
1197 785 formData.append('current_page_url', window.location.href);
1198 786 formData.append('current_page_title', document.title);
1199 787 formData.append('bot_id', botId);
@@ -1218,25 +806,13 @@
1218 806
1219 807 let accumulatedContent = '';
1220 808 let testingDataReceived = false;
1221 809 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 810
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 811 fetch(mxchatChat.ajax_url, {
1235 812 method: 'POST',
1236 813 body: formData,
1237 - credentials: 'same-origin',
1238 - signal: streamControl.controller.signal
814 + credentials: 'same-origin'
1239 815 })
1240 816 .then(response => {
1241 817 // Store the response for potential fallback handling
1242 818 const responseClone = response.clone();
@@ -1305,16 +881,8 @@
1305 881
1306 882 // Re-enable chat input when stream ends with content
1307 883 enableChatInput(botId);
1308 884
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 885 if (callback) {
1318 886 callback(accumulatedContent);
1319 887 }
1320 888 return;
@@ -1337,25 +905,8 @@
1337 905
1338 906 // Re-enable chat input after streaming completes
1339 907 enableChatInput(botId);
1340 908
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 909 if (callback) {
1359 910 callback(accumulatedContent);
1360 911 }
1361 912 return;
@@ -1381,21 +932,8 @@
1381 932 streamingStarted = true;
1382 933 accumulatedContent += json.content;
1383 934 updateStreamingMessage(accumulatedContent, botId);
1384 935 }
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 936 // Handle complete response in stream (fallback response)
1399 937 else if (json.text || json.message || json.html) {
1400 938 handleNonStreamResponse(json, callback, botId);
1401 939 return;
@@ -1425,9 +963,8 @@
1425 963 }
1426 964
1427 965 processStream();
1428 966 }).catch(streamError => {
1429 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1430 967 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1431 968 callMxChat(message, callback, botId);
1432 969 });
1433 970 }
@@ -1434,9 +971,8 @@
1434 971
1435 972 processStream();
1436 973 })
1437 974 .catch(error => {
1438 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1439 975 // Check if we have server error data with chat mode
1440 976 if (error && error.isServerError && error.data) {
1441 977 // Check for chat mode in error data
1442 978 if (error.data.chat_mode) {
@@ -1449,9 +985,8 @@
1449 985 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1450 986 callMxChat(message, callback, botId);
1451 987 }
1452 988 });
1453 - }); // refreshNonceIfNeeded
1454 989 }
1455 990
1456 991 // Helper function to handle non-streaming responses
1457 992 function handleNonStreamResponse(data, callback, botId) {
@@ -1490,16 +1025,21 @@
1490 1025 errorMessage = "An error occurred. Please try again or contact support.";
1491 1026 }
1492 1027
1493 1028 // Handle session reset action (IP changed, session expired, etc.)
1494 - // Silent reset — keep chat UI intact, just get a new session and retry
1495 1029 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)
1030 + // Clear the old session and generate a new one
1031 + resetChatSession(botId);
1032 + // Re-send the original message with the new session
1498 1033 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1499 1034 if (originalMessage) {
1500 1035 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1501 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1036 + // Re-add the user message and thinking indicator
1037 + appendMessage("user", originalMessage, '', [], false, botId);
1038 + appendThinkingMessage(botId);
1039 + scrollToBottom(botId);
1040 + // Determine whether to use streaming
1041 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1502 1042 if (shouldUseStreaming(currentModel)) {
1503 1043 callMxChatStream(originalMessage, callback, botId);
1504 1044 } else {
1505 1045 callMxChat(originalMessage, callback, botId);
@@ -1632,15 +1172,8 @@
1632 1172 var $chatBox = getElement(botId, 'chat-box');
1633 1173 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1634 1174
1635 1175 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 1176 // Update existing message
1644 1177 tempMessage.html(formattedContent);
1645 1178 } else {
1646 1179 // Create new temporary message if it doesn't exist
@@ -1667,17 +1200,8 @@
1667 1200 // Update the event handlers to use the correct function names (using event delegation)
1668 1201 // Use class-based selectors for multi-instance support
1669 1202 $(document).on('click', '.send-button', function() {
1670 1203 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 1204 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1681 1205 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1682 1206 disableChatInput(botId);
1683 1207 }
@@ -1696,370 +1220,9 @@
1696 1220 sendMessage(botId);
1697 1221 }
1698 1222 });
1699 1223
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 -
1224 +
2062 1225 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2063 1226 try {
2064 1227 // Determine styles based on sender type
2065 1228 let messageClass, bgColor, fontColor;
@@ -2097,12 +1260,17 @@
2097 1260 'margin-bottom': '1em'
2098 1261 });
2099 1262 }
2100 1263
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);
1264 + // Process the message content based on sender
1265 + let fullMessage;
1266 + if (sender === "user") {
1267 + // For user messages, apply linkify after sanitization
1268 + fullMessage = linkify(messageText);
1269 + } else {
1270 + // For bot/agent messages, preserve HTML
1271 + fullMessage = messageText;
1272 + }
2105 1273
2106 1274 // Add images if provided
2107 1275 if (images && images.length > 0) {
2108 1276 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -2134,11 +1302,9 @@
2134 1302
2135 1303 messageDiv.html(fullMessage);
2136 1304
2137 1305 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');
1306 + messageDiv.addClass('temporary-message');
2141 1307 }
2142 1308
2143 1309 // Append to the correct chatbot instance's chat-box
2144 1310 var $chatBox = getElement(botId, 'chat-box');
@@ -2153,12 +1319,8 @@
2153 1319 if (lastUserMessage.length) {
2154 1320 scrollElementToTop(lastUserMessage, botId);
2155 1321 }
2156 1322 }
2157 -
2158 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2159 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2160 - }
2161 1323 });
2162 1324
2163 1325 if (messageText.id) {
2164 1326 var instance = MxChatInstances.get(botId);
@@ -2243,12 +1405,26 @@
2243 1405 bgColor = botMessageBgColor;
2244 1406 fontColor = botMessageFontColor;
2245 1407 }
2246 1408
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);
1409 + // FIXED: Only linkify if response doesn't already contain HTML links or tags
1410 + // This prevents double-processing of URLs that are already formatted as HTML
1411 + var fullMessage;
1412 + if (sender === "user") {
1413 + // Always linkify user messages (they're plain text)
1414 + fullMessage = linkify(responseText);
1415 + } else {
1416 + // For bot/agent messages, check if HTML already exists
1417 + if (responseText.includes('<a href=') || responseText.includes('</a>') ||
1418 + responseText.includes('<img') || responseText.includes('<div') ||
1419 + responseText.includes('<p>') || responseText.includes('<br>')) {
1420 + // Response already has HTML, don't process it
1421 + fullMessage = responseText;
1422 + } else {
1423 + // Plain text response, apply linkify
1424 + fullMessage = linkify(responseText);
1425 + }
1426 + }
2251 1427
2252 1428 if (responseHtml) {
2253 1429 // Only add line breaks if there's actual text content before the HTML
2254 1430 if (fullMessage && fullMessage.trim()) {
@@ -2273,16 +1449,13 @@
2273 1449 }
2274 1450
2275 1451 if (lastMessageDiv.length) {
2276 1452 // 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 1453 lastMessageDiv
2280 1454 .html(fullMessage)
2281 1455 .removeClass('bot-message user-message temporary-message')
2282 1456 .addClass(messageClass)
2283 - .attr('dir', 'auto')
2284 - .attr('aria-busy', 'false');
1457 + .attr('dir', 'auto');
2285 1458
2286 1459 // Only apply inline colors if AI theme is not active (let CSS handle it)
2287 1460 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2288 1461 if (!skipColors) {
@@ -2308,12 +1481,8 @@
2308 1481 }
2309 1482
2310 1483 // Re-enable chat input after response is displayed
2311 1484 enableChatInput(botId);
2312 -
2313 - if (sender === "bot" || sender === "agent") {
2314 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2315 - }
2316 1485 } else {
2317 1486 appendMessage(sender, responseText, responseHtml, images, false, botId);
2318 1487 // Re-enable chat input after response is displayed
2319 1488 enableChatInput(botId);
@@ -2342,15 +1511,10 @@
2342 1511 var botMessageFontColor = mxchatChat.bot_message_font_color;
2343 1512 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2344 1513
2345 1514 // 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 1515 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">' +
1516 + var thinkingHtml = '<div class="thinking-dots-container">' +
2353 1517 '<div class="thinking-dots">' +
2354 1518 '<span class="dot"' + dotStyle + '></span>' +
2355 1519 '<span class="dot"' + dotStyle + '></span>' +
2356 1520 '<span class="dot"' + dotStyle + '></span>' +
@@ -2403,12 +1567,9 @@
2403 1567 processedText = formatTextStyling(processedText);
2404 1568
2405 1569 // Process code blocks BEFORE processing links
2406 1570 processedText = formatCodeBlocks(processedText);
2407 -
2408 - // Process markdown tables BEFORE converting newlines to paragraphs
2409 - processedText = formatMarkdownTables(processedText);
2410 -
1571 +
2411 1572 // NOW convert to paragraphs
2412 1573 processedText = convertNewlinesToBreaks(processedText);
2413 1574
2414 1575 // IMPORTANT: Handle citation-style brackets FIRST [URL]
@@ -2421,63 +1582,37 @@
2421 1582 // Return as a proper link without the brackets
2422 1583 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2423 1584 });
2424 1585
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 - }
1586 + // Process proper markdown links with text: [text](url)
1587 + // This MUST have non-empty text in the first brackets
1588 + const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
1589 + processedText = processedText.replace(markdownLinkPattern, (match, text, url) => {
1590 + // Make sure we have actual text (not just whitespace)
1591 + if (!text || !text.trim()) {
1592 + // If no text, treat the URL as the text
1593 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1594 + const safeUrl = safeEncodeUrl(cleanUrl);
1595 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2476 1596 }
2477 - return result;
2478 - })(processedText);
1597 +
1598 + // Clean the URL
1599 + let cleanUrl = url.replace(/[\].,;!?]+$/, '');
1600 + const safeUrl = safeEncodeUrl(cleanUrl);
1601 + const safeText = sanitizeUserInput(text);
1602 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1603 + });
2479 1604
1605 + // Handle empty markdown links: [](url)
1606 + // This is a specific case where there's no text
1607 + const emptyMarkdownPattern = /\[\]\((https?:\/\/[^\s)]+)\)/g;
1608 + processedText = processedText.replace(emptyMarkdownPattern, (match, url) => {
1609 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1610 + const safeUrl = safeEncodeUrl(cleanUrl);
1611 + // Use the URL itself as the link text
1612 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
1613 + });
1614 +
2480 1615 // Process phone numbers: [text](tel:number)
2481 1616 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2482 1617 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2483 1618 const safePhone = safeEncodeUrl(phone);
@@ -2629,78 +1764,9 @@
2629 1764 });
2630 1765
2631 1766 return text;
2632 1767 }
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 -
1768 +
2703 1769 function sanitizeUserInput(text) {
2704 1770 const div = document.createElement('div');
2705 1771 div.textContent = text;
2706 1772 return div.innerHTML;
@@ -2771,14 +1837,13 @@
2771 1837 requestAnimationFrame(smoothScroll);
2772 1838 }
2773 1839 }
2774 1840
2775 - function scrollElementToTop(element, botId, topOffset) {
1841 + function scrollElementToTop(element, botId) {
2776 1842 botId = botId || 'default';
2777 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2778 1843 var chatBox = getElement(botId, 'chat-box');
2779 1844 var elementTop = element.position().top + chatBox.scrollTop();
2780 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1845 + chatBox.animate({ scrollTop: elementTop }, 500);
2781 1846 }
2782 1847
2783 1848 function showChatWidget(botId) {
2784 1849 botId = botId || 'default';
@@ -2995,29 +2060,20 @@
2995 2060 // ====================================
2996 2061 // CHAT HISTORY & PERSISTENCE
2997 2062 // ====================================
2998 2063
2999 -function loadChatHistory(botId, onComplete) {
2064 +function loadChatHistory(botId) {
3000 2065 botId = botId || 'default';
3001 2066 var instance = MxChatInstances.get(botId);
3002 2067
3003 2068 // Prevent duplicate loading
3004 2069 if (instance.chatHistoryLoaded) {
3005 - if (onComplete) onComplete();
3006 2070 return;
3007 2071 }
3008 2072
3009 - // Use getChatSession which returns null if no session exists (does NOT create one)
3010 2073 var sessionId = getChatSession(botId);
3011 2074 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3012 2075
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 2076 if (chatPersistenceEnabled && sessionId) {
3021 2077 $.ajax({
3022 2078 url: mxchatChat.ajax_url,
3023 2079 type: 'POST',
@@ -3028,12 +2084,11 @@
3028 2084 },
3029 2085 success: function(response) {
3030 2086 // Handle session reset (IP changed while user was away)
3031 2087 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);
2088 + // Silently reset session - user will start fresh
2089 + resetChatSession(botId);
3034 2090 instance.chatHistoryLoaded = true; // Prevent retry loop
3035 - if (onComplete) onComplete();
3036 2091 return;
3037 2092 }
3038 2093
3039 2094 // Check if the response indicates success
@@ -3050,30 +2105,9 @@
3050 2105 }
3051 2106
3052 2107 // Only process if there are actual messages
3053 2108 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();
2109 + // IMPORTANT: Clear existing messages before loading history
3076 2110 $chatBox.empty();
3077 2111
3078 2112 $.each(response.data.conversation, function(index, message) {
3079 2113 // Skip agent messages if persistence is off
@@ -3110,23 +2144,9 @@
3110 2144 var content = message.content;
3111 2145 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3112 2146 content = decodeHTMLEntities(content);
3113 2147
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")) {
2148 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
3129 2149 messageElement.html(content);
3130 2150 } else {
3131 2151 var formattedContent = linkify(content);
3132 2152 messageElement.html(formattedContent);
@@ -3140,29 +2160,12 @@
3140 2160 instance.processedMessageIds.add(message.id);
3141 2161 }
3142 2162 });
3143 2163
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 - }
2164 + // Only append messages and scroll if we have content
3152 2165 $chatBox.append($fragment);
3153 2166 scrollToBottom(botId, true);
3154 2167
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 2168 // Collapse quick questions if we have conversation history
3166 2169 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3167 2170 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3168 2171 collapseQuickQuestions(botId);
@@ -3183,17 +2186,13 @@
3183 2186 instance.chatHistoryLoaded = true;
3184 2187 }
3185 2188 }
3186 2189 }
3187 - if (onComplete) onComplete();
3188 2190 },
3189 2191 error: function(xhr, status, error) {
3190 2192 // Error loading chat history - silently continue
3191 - if (onComplete) onComplete();
3192 2193 }
3193 2194 });
3194 - } else {
3195 - if (onComplete) onComplete();
3196 2195 }
3197 2196 }
3198 2197
3199 2198
@@ -3257,10 +2256,10 @@
3257 2256 .then(data => {
3258 2257 if (data.success) {
3259 2258 container.style.display = 'none';
3260 2259 nameElement.textContent = '';
3261 - instance.activePdfFile = null;
3262 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2260 + activePdfFile = null;
2261 + appendMessage('bot', 'PDF removed.');
3263 2262 }
3264 2263 })
3265 2264 .catch(error => {
3266 2265 // Error removing PDF - silently continue
@@ -3266,16 +2265,14 @@
3266 2265 // Error removing PDF - silently continue
3267 2266 });
3268 2267 }
3269 2268
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 -
2269 + function removeActiveWord() {
2270 + const container = document.getElementById('active-word-container');
2271 + const nameElement = document.getElementById('active-word-name');
2272 +
2273 + if (!container || !nameElement || !activeWordFile) return;
2274 +
3278 2275 fetch(mxchatChat.ajax_url, {
3279 2276 method: 'POST',
3280 2277 headers: {
3281 2278 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3281,9 +2278,9 @@
3281 2278 'Content-Type': 'application/x-www-form-urlencoded',
3282 2279 },
3283 2280 body: new URLSearchParams({
3284 2281 'action': 'mxchat_remove_word',
3285 - 'session_id': getChatSession(botId),
2282 + 'session_id': sessionId,
3286 2283 'nonce': mxchatChat.nonce
3287 2284 })
3288 2285 })
3289 2286 .then(response => response.json())
@@ -3290,10 +2287,10 @@
3290 2287 .then(data => {
3291 2288 if (data.success) {
3292 2289 container.style.display = 'none';
3293 2290 nameElement.textContent = '';
3294 - instance.activeWordFile = null;
3295 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2291 + activeWordFile = null;
2292 + appendMessage('bot', 'Word document removed.');
3296 2293 }
3297 2294 })
3298 2295 .catch(error => {
3299 2296 // Error removing Word document - silently continue
@@ -3371,35 +2368,45 @@
3371 2368 // ====================================
3372 2369
3373 2370 function checkPreChatDismissal(botId) {
3374 2371 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) {
2372 + $.ajax({
2373 + url: mxchatChat.ajax_url,
2374 + type: 'POST',
2375 + data: {
2376 + action: 'mxchat_check_pre_chat_message_status',
2377 + _ajax_nonce: mxchatChat.nonce
2378 + },
2379 + success: function(response) {
2380 + if (response.success && !response.data.dismissed) {
2381 + getElement(botId, 'pre-chat-message').fadeIn(250);
2382 + } else {
3381 2383 getElement(botId, 'pre-chat-message').hide();
3382 - return;
3383 2384 }
3384 - // Expired — clear and show again
3385 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2385 + },
2386 + error: function() {
2387 + // Error checking pre-chat dismissal - silently continue
3386 2388 }
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 - }
2389 + });
3392 2390 }
3393 2391
3394 2392 function handlePreChatDismissal(botId) {
3395 2393 botId = botId || 'default';
3396 2394 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 - }
2395 + $.ajax({
2396 + url: mxchatChat.ajax_url,
2397 + type: 'POST',
2398 + data: {
2399 + action: 'mxchat_dismiss_pre_chat_message',
2400 + _ajax_nonce: mxchatChat.nonce
2401 + },
2402 + success: function() {
2403 + $('#pre-chat-message').hide();
2404 + },
2405 + error: function() {
2406 + // Error dismissing pre-chat message - silently continue
2407 + }
2408 + });
3402 2409 }
3403 2410
3404 2411
3405 2412 // ====================================
@@ -3452,38 +2459,11 @@
3452 2459 e.stopPropagation();
3453 2460 var botId = getBotIdFromElement(this);
3454 2461 collapseQuickQuestions(botId);
3455 2462 });
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 2463
3479 2464 // 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 - }
2465 + $(document).on('click', '.floating-chatbot-button', function() {
3486 2466 var botId = getBotIdFromElement(this);
3487 2467 var $chatbot = getElement(botId, 'floating-chatbot');
3488 2468 var $badge = getElement(botId, 'chat-notification-badge');
3489 2469 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3488,88 +2468,35 @@
3488 2468 var $badge = getElement(botId, 'chat-notification-badge');
3489 2469 var $preChat = getElement(botId, 'pre-chat-message');
3490 2470
3491 2471 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');
2472 + $chatbot.removeClass('hidden').addClass('visible');
2473 + $(this).addClass('hidden');
3495 2474 $badge.hide(); // Hide notification when opening chat
3496 2475 disableScroll();
3497 2476 $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 2477 } else {
3530 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3531 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2478 + $chatbot.removeClass('visible').addClass('hidden');
2479 + $(this).removeClass('hidden');
3532 2480 enableScroll();
3533 2481 checkPreChatDismissal(botId);
3534 2482 }
3535 2483 });
3536 2484
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.
2485 + // Allow clicking anywhere on the title bar to close the chatbot
3541 2486 $(document).on('click', '.chatbot-top-bar', function() {
3542 2487 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');
2488 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2489 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3546 2490 enableScroll();
3547 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3548 2491 });
3549 2492
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 2493 $(document).on('click', '.close-pre-chat-message', function(e) {
3569 2494 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3570 2495 var botId = getBotIdFromElement(this);
3571 - handlePreChatDismissal(botId);
2496 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2497 + $(this).remove();
2498 + });
3572 2499 });
3573 2500
3574 2501
3575 2502 // PDF upload button handlers - use class selector
@@ -3585,20 +2512,17 @@
3585 2512 var wordInput = getElementDOM(botId, 'word-upload');
3586 2513 if (wordInput) wordInput.click();
3587 2514 });
3588 2515
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 -
2516 + // PDF file input change handler
2517 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2518 + const file = e.target.files[0];
2519 +
3596 2520 if (!file || file.type !== 'application/pdf') {
3597 2521 alert('Please select a valid PDF file.');
3598 2522 return;
3599 2523 }
3600 -
2524 +
3601 2525 if (!sessionId) {
3602 2526 alert('Error: No session ID found');
3603 2527 return;
3604 2528 }
@@ -3606,49 +2530,47 @@
3606 2530 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3607 2531 alert('Error: Ajax configuration missing');
3608 2532 return;
3609 2533 }
3610 -
2534 +
3611 2535 // 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;
2536 + const uploadBtn = document.getElementById('pdf-upload-btn');
2537 + const sendBtn = document.getElementById('send-button');
3615 2538 const originalBtnContent = uploadBtn.innerHTML;
3616 -
2539 +
3617 2540 try {
3618 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3619 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3620 2541 const formData = new FormData();
3621 2542 formData.append('action', 'mxchat_upload_pdf');
3622 2543 formData.append('pdf_file', file);
3623 2544 formData.append('session_id', sessionId);
3624 2545 formData.append('nonce', mxchatChat.nonce);
3625 -
2546 +
3626 2547 uploadBtn.disabled = true;
3627 - if (sendBtn) sendBtn.disabled = true;
2548 + sendBtn.disabled = true;
3628 2549 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3629 2550 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3630 2551 </svg>`;
3631 -
2552 +
3632 2553 const response = await fetch(mxchatChat.ajax_url, {
3633 2554 method: 'POST',
3634 2555 body: formData
3635 2556 });
3636 -
2557 +
3637 2558 const data = await response.json();
3638 -
2559 +
3639 2560 if (data.success) {
3640 2561 // Hide popular questions if they exist
3641 - if (hasQuickQuestions(botId)) {
3642 - collapseQuickQuestions(botId);
2562 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2563 + if (hasQuickQuestions()) {
2564 + collapseQuickQuestions();
3643 2565 }
3644 -
2566 +
3645 2567 // 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;
2568 + showActivePdf(data.data.filename);
2569 +
2570 + appendMessage('bot', data.data.message);
2571 + scrollToBottom();
2572 + activePdfFile = data.data.filename;
3651 2573 } else {
3652 2574 alert('Failed to upload PDF. Please try again.');
3653 2575 }
3654 2576 } catch (error) {
@@ -3654,76 +2576,66 @@
3654 2576 } catch (error) {
3655 2577 alert('Error uploading file. Please try again.');
3656 2578 } finally {
3657 2579 uploadBtn.disabled = false;
3658 - if (sendBtn) sendBtn.disabled = false;
2580 + sendBtn.disabled = false;
3659 2581 uploadBtn.innerHTML = originalBtnContent;
3660 2582 this.value = ''; // Reset file input
3661 2583 }
3662 2584 });
3663 2585
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 -
2586 + // Word file input change handler
2587 + addSafeEventListener('word-upload', 'change', async function(e) {
2588 + const file = e.target.files[0];
2589 +
3671 2590 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3672 2591 alert('Please select a valid Word document (.docx).');
3673 2592 return;
3674 2593 }
3675 -
2594 +
3676 2595 if (!sessionId) {
3677 2596 alert('Error: No session ID found');
3678 2597 return;
3679 2598 }
3680 2599
3681 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3682 - alert('Error: Ajax configuration missing');
3683 - return;
3684 - }
3685 -
3686 2600 // 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;
2601 + const uploadBtn = document.getElementById('word-upload-btn');
2602 + const sendBtn = document.getElementById('send-button');
3690 2603 const originalBtnContent = uploadBtn.innerHTML;
3691 -
2604 +
3692 2605 try {
3693 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3694 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3695 2606 const formData = new FormData();
3696 2607 formData.append('action', 'mxchat_upload_word');
3697 2608 formData.append('word_file', file);
3698 2609 formData.append('session_id', sessionId);
3699 2610 formData.append('nonce', mxchatChat.nonce);
3700 -
2611 +
3701 2612 uploadBtn.disabled = true;
3702 - if (sendBtn) sendBtn.disabled = true;
2613 + sendBtn.disabled = true;
3703 2614 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3704 2615 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3705 2616 </svg>`;
3706 -
2617 +
3707 2618 const response = await fetch(mxchatChat.ajax_url, {
3708 2619 method: 'POST',
3709 2620 body: formData
3710 2621 });
3711 -
2622 +
3712 2623 const data = await response.json();
3713 -
2624 +
3714 2625 if (data.success) {
3715 2626 // Hide popular questions if they exist
3716 - if (hasQuickQuestions(botId)) {
3717 - collapseQuickQuestions(botId);
2627 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2628 + if (hasQuickQuestions()) {
2629 + collapseQuickQuestions();
3718 2630 }
3719 -
2631 +
3720 2632 // 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;
2633 + showActiveWord(data.data.filename);
2634 +
2635 + appendMessage('bot', data.data.message);
2636 + scrollToBottom();
2637 + activeWordFile = data.data.filename;
3726 2638 } else {
3727 2639 alert('Failed to upload Word document. Please try again.');
3728 2640 }
3729 2641 } catch (error) {
@@ -3729,25 +2641,25 @@
3729 2641 } catch (error) {
3730 2642 alert('Error uploading file. Please try again.');
3731 2643 } finally {
3732 2644 uploadBtn.disabled = false;
3733 - if (sendBtn) sendBtn.disabled = false;
2645 + sendBtn.disabled = false;
3734 2646 uploadBtn.innerHTML = originalBtnContent;
3735 2647 this.value = ''; // Reset file input
3736 2648 }
3737 2649 });
3738 2650
3739 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3740 - $(document).on('click', '.remove-pdf-btn', function(e) {
2651 + // Remove button click handlers
2652 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3741 2653 e.preventDefault();
3742 2654 e.stopPropagation();
3743 - removeActivePdf(getBotIdFromElement(this));
2655 + removeActivePdf();
3744 2656 });
3745 -
3746 - $(document).on('click', '.remove-word-btn', function(e) {
2657 +
2658 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3747 2659 e.preventDefault();
3748 2660 e.stopPropagation();
3749 - removeActiveWord(getBotIdFromElement(this));
2661 + removeActiveWord();
3750 2662 });
3751 2663
3752 2664 // Window resize handlers
3753 2665 $(window).on('resize orientationchange', function() {
@@ -3785,59 +2697,8 @@
3785 2697 });
3786 2698
3787 2699
3788 2700 // ====================================
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 2701 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3841 2702 // ====================================
3842 2703 // Only run email collection setup if it's enabled
3843 2704 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
@@ -3873,8 +2734,28 @@
3873 2734 `;
3874 2735 document.head.appendChild(style);
3875 2736 }
3876 2737
2738 + // Helper functions for email collection (multi-instance aware)
2739 + function showEmailFormForBot(botId) {
2740 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2741 + var chatContainer = getElementDOM(botId, 'chat-container');
2742 + if (emailBlocker) emailBlocker.style.display = 'flex';
2743 + if (chatContainer) chatContainer.style.display = 'none';
2744 + }
2745 +
2746 + function showChatContainerForBot(botId) {
2747 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2748 + var chatContainer = getElementDOM(botId, 'chat-container');
2749 + if (emailBlocker) emailBlocker.style.display = 'none';
2750 + if (chatContainer) chatContainer.style.display = 'flex';
2751 +
2752 + // Load chat history for this bot
2753 + if (typeof loadChatHistory === 'function') {
2754 + loadChatHistory(botId);
2755 + }
2756 + }
2757 +
3877 2758 function isValidEmailAddress(email) {
3878 2759 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3879 2760 return emailRegex.test(email.trim()) && email.length <= 254;
3880 2761 }
@@ -3891,13 +2772,10 @@
3891 2772 function replaceVisitorNamePlaceholder(botId, visitorName) {
3892 2773 var chatBox = getElementDOM(botId, 'chat-box');
3893 2774 if (!chatBox) return;
3894 2775
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');
2776 + // Find the first bot message (intro message)
2777 + var introMessage = chatBox.querySelector('.bot-message');
3900 2778 if (!introMessage) return;
3901 2779
3902 2780 var messageContent = introMessage.querySelector('div[dir="auto"]');
3903 2781 if (!messageContent) return;
@@ -3999,31 +2877,11 @@
3999 2877 existingErrors.forEach(error => error.remove());
4000 2878 }
4001 2879 }
4002 2880
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 2881 function checkSessionAndEmailForBot(botId) {
4017 - const sessionId = MxChatInstances.ensureSession(botId);
2882 + const sessionId = getChatSession(botId);
4018 2883
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 2884 fetch(mxchatChat.ajax_url, {
4027 2885 method: 'POST',
4028 2886 headers: {
4029 2887 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4069,12 +2927,11 @@
4069 2927 }
4070 2928
4071 2929 var emailInput = getElementDOM(botId, 'user-email');
4072 2930 var nameInput = getElementDOM(botId, 'user-name');
4073 - var consentInput = getElementDOM(botId, 'user-consent');
4074 2931 var userEmail = emailInput ? emailInput.value.trim() : '';
4075 2932 var userName = nameInput ? nameInput.value.trim() : '';
4076 - var sessionId = MxChatInstances.ensureSession(botId);
2933 + var sessionId = getChatSession(botId);
4077 2934
4078 2935 // Validate email
4079 2936 if (!userEmail) {
4080 2937 showEmailError(botId, 'Please enter your email address.');
@@ -4091,15 +2948,8 @@
4091 2948 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4092 2949 return false;
4093 2950 }
4094 2951
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 2952 clearEmailError(botId);
4103 2953 setEmailSubmissionState(botId, true);
4104 2954
4105 2955 // Prepare form data
@@ -4113,14 +2963,8 @@
4113 2963 if (userName) {
4114 2964 formData.append('name', userName);
4115 2965 }
4116 2966
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 2967 fetch(mxchatChat.ajax_url, {
4124 2968 method: 'POST',
4125 2969 headers: {
4126 2970 'Content-Type': 'application/x-www-form-urlencoded',
@@ -4210,27 +3054,25 @@
4210 3054 }
4211 3055 });
4212 3056
4213 3057 // 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 3058 $('.mxchat-chatbot-wrapper').each(function() {
4217 3059 var botId = $(this).data('bot-id') || 'default';
4218 3060 var emailBlocker = getElementDOM(botId, 'email-blocker');
4219 3061
3062 + // Only check if email blocker exists for this bot
4220 3063 if (emailBlocker) {
4221 - if (isEmbeddedBot(botId)) {
4222 - // Embedded bots are always visible — check now
4223 - resolveEmailState(botId);
3064 + if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
3065 + if (mxchatChat.initial_email_state.show_email_form) {
3066 + showEmailFormForBot(botId);
3067 + } else {
3068 + showChatContainerForBot(botId);
3069 + }
3070 + } else {
3071 + setTimeout(function() {
3072 + checkSessionAndEmailForBot(botId);
3073 + }, 100);
4224 3074 }
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 3075 }
4234 3076 });
4235 3077 }
4236 3078
@@ -4240,32 +3082,39 @@
4240 3082 var $chatbot = getElement(botId, 'floating-chatbot');
4241 3083 if ($chatbot.hasClass('hidden')) {
4242 3084 $chatbot.removeClass('hidden').addClass('visible');
4243 3085 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4244 - handlePreChatDismissal(botId);
3086 + $(this).fadeOut(250); // Hide pre-chat message
4245 3087 disableScroll(); // Disable scroll when chatbot opens
3088 + }
3089 + });
4246 3090
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 - }
3091 + // Dismiss pre-chat message via close button - handled by event delegation above at line ~2376
3092 + // This is a fallback for legacy support
3093 + $(document).on('click', '.close-pre-chat-message', function() {
3094 + var botId = getBotIdFromElement(this);
3095 + var $preChat = getElement(botId, 'pre-chat-message');
3096 + $preChat.fadeOut(200); // Hide the message
4252 3097
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);
3098 + // Send an AJAX request to set the transient flag for 24 hours
3099 + $.ajax({
3100 + url: mxchatChat.ajax_url,
3101 + type: 'POST',
3102 + data: {
3103 + action: 'mxchat_dismiss_pre_chat_message',
3104 + _ajax_nonce: mxchatChat.nonce
3105 + },
3106 + success: function() {
3107 + // Ensure the message is hidden after dismissal
3108 + $preChat.hide();
3109 + },
3110 + error: function() {
3111 + // Error dismissing pre-chat message - silently continue
4261 3112 }
4262 - }
3113 + });
4263 3114 });
4264 3115
4265 - // Legacy duplicate close handler removed — handled by single event delegation above
4266 3116
4267 -
4268 3117 function hasQuickQuestions(botId) {
4269 3118 botId = botId || 'default';
4270 3119 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4271 3120 if (!questionsContainer) return false;
@@ -4401,11 +3250,18 @@
4401 3250 });
4402 3251
4403 3252 // Initialize when document is ready
4404 3253 setFullHeight();
3254 + trackOriginatingPage();
4405 3255
4406 - // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4407 - // until the user's first interaction via MxChatInstances.ensureSession()
3256 + // Only load chat history if email collection is disabled
3257 + if (mxchatChat.email_collection_enabled !== 'on') {
3258 + // Load history for all instances
3259 + $('.mxchat-chatbot-wrapper').each(function() {
3260 + var botId = $(this).data('bot-id') || 'default';
3261 + loadChatHistory(botId);
3262 + });
3263 + }
4408 3264
4409 3265 // Initialize chat visibility for all instances
4410 3266 $('.mxchat-chatbot-wrapper').each(function() {
4411 3267 var botId = $(this).data('bot-id') || 'default';
@@ -4458,312 +3314,6 @@
4458 3314 }, 2000);
4459 3315 });
4460 3316 }
4461 3317 }
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 3318 });
4769 3319