PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
3.2.25 3.2.24 3.2.23 3.2.22 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 All 156 releases
← All changes | js/chat-script.js +1628 -176 3.2.2 → 3.2.23 View file →
@@ -1,20 +1,163 @@
1 1 jQuery(document).ready(function($) {
2 2
3 - // Nonce refresh is deferred until first user interaction (ensureSession)
4 - // to avoid admin-ajax calls on passive page loads.
5 - var nonceRefreshed = false;
6 - function refreshNonceIfNeeded(callback) {
7 - if (nonceRefreshed || typeof mxchatChat === 'undefined' || !mxchatChat.ajax_url) {
3 + // Nonce refresh — v2 (plan-6a68c9).
4 + //
5 + // The widget no longer relies on a nonce embedded in inline cached HTML.
6 + // Before each chat-send / stream-send / upload, we call the REST endpoint
7 + // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The
8 + // endpoint creates the nonce with action `mxchat_chat_send`; the server-side
9 + // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a
10 + // 30-day backwards-compat window so cached pages still in users' browsers
11 + // (which carry the legacy inline-localized nonce) keep working.
12 + //
13 + // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are
14 + // 24h but we refetch at half-life so a freshly-cached-page user never sees
15 + // a borderline-stale nonce).
16 + var cachedFreshNonce = null;
17 + var cachedFreshNonceFetchedAt = 0;
18 + var NONCE_TTL_MS = 12 * 60 * 60 * 1000;
19 + var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
20 + var nonceRefreshCallbacks = [];
21 +
22 + function getRestNonceUrl() {
23 + if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) {
24 + return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce';
25 + }
26 + // Fallback: derive from current origin if mxchatChat.rest_url isn't set.
27 + return window.location.origin + '/wp-json/mxchat/v1/nonce';
28 + }
29 +
30 + function fetchFreshNonceFromRest() {
31 + return fetch(getRestNonceUrl(), {
32 + credentials: 'same-origin',
33 + headers: { 'Accept': 'application/json' }
34 + }).then(function (resp) {
35 + if (!resp.ok) {
36 + throw new Error('REST nonce fetch failed: ' + resp.status);
37 + }
38 + return resp.json();
39 + }).then(function (data) {
40 + if (data && data.nonce) {
41 + return data.nonce;
42 + }
43 + throw new Error('REST nonce response had no nonce field.');
44 + });
45 + }
46 +
47 + /**
48 + * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
49 + * Tries REST endpoint first (cache-bypass design); falls back to the legacy
50 + * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
51 + * calls share the same in-flight refresh.
52 + */
53 + function withFreshNonce(callback) {
54 + if (typeof mxchatChat === 'undefined') {
8 55 if (callback) callback();
9 56 return;
10 57 }
11 - nonceRefreshed = true;
12 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
13 - if (res && res.success && res.data && res.data.nonce) {
14 - mxchatChat.nonce = res.data.nonce;
58 + var now = Date.now();
59 + if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 + mxchatChat.nonce = cachedFreshNonce;
61 + if (callback) callback();
62 + return;
63 + }
64 + if (callback) nonceRefreshCallbacks.push(callback);
65 + if (nonceRefreshState === 'pending') return;
66 + nonceRefreshState = 'pending';
67 +
68 + var resolved = function (nonce) {
69 + if (nonce) {
70 + cachedFreshNonce = nonce;
71 + cachedFreshNonceFetchedAt = Date.now();
72 + mxchatChat.nonce = nonce;
15 73 }
16 - if (callback) callback();
74 + nonceRefreshState = 'done';
75 + var pending = nonceRefreshCallbacks;
76 + nonceRefreshCallbacks = [];
77 + pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78 + };
79 +
80 + fetchFreshNonceFromRest()
81 + .then(resolved)
82 + .catch(function () {
83 + // Fallback to the legacy admin-ajax refresh path (issued with the
84 + // old action `mxchat_chat_nonce`; the server still accepts both
85 + // during the compat window).
86 + if (mxchatChat.ajax_url) {
87 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88 + .done(function (res) {
89 + if (res && res.success && res.data && res.data.nonce) {
90 + resolved(res.data.nonce);
91 + return;
92 + }
93 + resolved(null);
94 + })
95 + .fail(function () { resolved(null); });
96 + } else {
97 + resolved(null);
98 + }
99 + });
100 + }
101 +
102 + // Backwards-compat alias — every existing caller in this file (and any
103 + // out-of-tree consumer that hit this internal API) keeps working unchanged.
104 + function refreshNonceIfNeeded(callback) {
105 + return withFreshNonce(callback);
106 + }
107 +
108 + // Dynamic-settings refresh (plan-32db95).
109 + //
110 + // Every widget setting ships inline in cached page HTML, so behind a
111 + // full-page cache the site owner can't purge (host cache, CDN, the
112 + // browser itself) a toggled setting looks broken until the cache turns
113 + // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114 + // on the FIRST widget open per page load we ask the nonce endpoint for
115 + // the current behavior-gate settings (?with_settings=1), merge them over
116 + // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117 + // they're server-inline-styled, so a runtime swap would visibly flash.
118 + // On any failure we keep the inline values silently (nonce-fallback
119 + // posture). At most one request per page load, only if a widget opens.
120 + var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121 +
122 + function mxchatRefreshDynamicSettings() {
123 + if (dynamicSettingsState !== 'idle') return;
124 + if (typeof mxchatChat === 'undefined') return;
125 + dynamicSettingsState = 'pending';
126 +
127 + var applied = function (data) {
128 + dynamicSettingsState = 'done';
129 + if (!data) return; // endpoint unavailable — inline values stand.
130 + if (data.nonce) {
131 + // Seed the nonce cache too: saves the first send's REST
132 + // round-trip and keeps us under the endpoint's rate limit.
133 + cachedFreshNonce = data.nonce;
134 + cachedFreshNonceFetchedAt = Date.now();
135 + mxchatChat.nonce = data.nonce;
136 + }
137 + if (data.settings && typeof data.settings === 'object') {
138 + $.extend(mxchatChat, data.settings);
139 + mxchatRebuildHeaderMenus();
140 + }
141 + };
142 +
143 + fetch(getRestNonceUrl() + '?with_settings=1', {
144 + credentials: 'same-origin',
145 + headers: { 'Accept': 'application/json' }
146 + }).then(function (resp) {
147 + if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148 + return resp.json();
149 + }).then(applied).catch(function () {
150 + // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151 + if (mxchatChat.ajax_url) {
152 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153 + .done(function (res) {
154 + applied(res && res.success && res.data ? res.data : null);
155 + })
156 + .fail(function () { applied(null); });
157 + } else {
158 + applied(null);
159 + }
17 160 });
18 161 }
19 162
20 163 // ====================================
@@ -24,8 +167,16 @@
24 167 // Instance registry - tracks all chatbot instances on the page
25 168 const MxChatInstances = {
26 169 instances: {},
27 170
171 + // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The
172 + // events below fire once; a script that loads late checks these instead.
173 + toolbarReady: false,
174 + opened: {},
175 + hasOpened: function(botId) {
176 + return !!this.opened[botId || 'default'];
177 + },
178 +
28 179 // Initialize an instance for a bot
29 180 init: function(botId) {
30 181 if (!this.instances[botId]) {
31 182 // When persistence is OFF, track when this session started
@@ -60,10 +211,10 @@
60 211 return Object.keys(this.instances);
61 212 },
62 213
63 214 // Session management per bot
64 - // Returns existing session ID from cookie or localStorage, or null if none exists.
65 - // Does NOT create a new session — use ensureSession() for that.
215 + // Returns existing session ID from cookie or localStorage (with in-memory fallback),
216 + // or null if none exists. Does NOT create a new session — use ensureSession() for that.
66 217 getChatSession: function(botId) {
67 218 var cookieName = 'mxchat_session_id_' + botId;
68 219 var storageKey = 'mxchat_session_id_' + botId;
69 220 var sessionId = getCookie(cookieName);
@@ -72,8 +223,21 @@
72 223 if (!sessionId) {
73 224 try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
74 225 }
75 226
227 + // Fallback to in-memory instance when cookie AND localStorage are both blocked
228 + // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned
229 + // storage). Without this, ensureSession() can generate and store an ID that
230 + // getChatSession() then can't read back, causing null session_ids on send.
231 + if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) {
232 + sessionId = this.instances[botId].sessionId;
233 + }
234 +
235 + // Guard against stored sentinel values that indicate earlier broken writes.
236 + if (sessionId === 'null' || sessionId === 'undefined') {
237 + sessionId = null;
238 + }
239 +
76 240 // Re-sync cookie from localStorage if cookie was lost
77 241 if (sessionId && !getCookie(cookieName)) {
78 242 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
79 243 }
@@ -128,9 +292,20 @@
128 292 var newSessionId = generateSessionId();
129 293 this.setChatSession(botId, newSessionId);
130 294 var $chatBox = getElement(botId, 'chat-box');
131 295 if ($chatBox.length) {
132 - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
296 + // Keep the greeting, drop everything else. Identify the greeting
297 + // by its marker class, NOT by position (plan a1a79b): after a
298 + // chat-persistence restore the first .bot-message is a real
299 + // reply, so ":not(:first)" left a stale answer sitting at the
300 + // top of an otherwise empty box. The positional fallback only
301 + // runs when the marker is absent — a page served from HTML cache
302 + // that predates this release — and behaves exactly as before.
303 + if ($chatBox.find('.mxchat-intro-message').length) {
304 + $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
305 + } else {
306 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
307 + }
133 308 }
134 309 if (this.instances[botId]) {
135 310 this.instances[botId].chatHistoryLoaded = false;
136 311 this.instances[botId].processedMessageIds = new Set();
@@ -150,8 +325,50 @@
150 325 }
151 326 };
152 327
153 328 // ====================================
329 + // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6)
330 + // ====================================
331 + //
332 + // Document-level CustomEvents an add-on listens for instead of polling the
333 + // DOM or firing requests on page load:
334 + // mxchat:toolbar-ready detail { enabled } once, right after the
335 + // toolbar show/hide pass; items rendered through the
336 + // mxchat_chat_toolbar_items filter can bind now.
337 + // mxchat:opened detail { botId, embedded } once per bot per page
338 + // load, the first time its window is shown — the
339 + // launcher, the pre-chat bubble, an embedded widget
340 + // (at load), or any script that reveals it.
341 + // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId).
342 + function mxchatDispatch(name, detail) {
343 + try {
344 + document.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
345 + } catch (err) { /* CustomEvent unsupported — nothing to notify */ }
346 + }
347 +
348 + function mxchatFireOpened(botId) {
349 + botId = botId || 'default';
350 + if (MxChatInstances.opened[botId]) return;
351 + MxChatInstances.opened[botId] = true;
352 + mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) });
353 + }
354 +
355 + // Catch openers this file does not own (the Trigger add-on, theme buttons,
356 + // custom scripts): a floating window gaining .visible counts as opened.
357 + function mxchatWatchForOpen(botId) {
358 + var el = getElementDOM(botId, 'floating-chatbot');
359 + if (!el || typeof MutationObserver === 'undefined') return;
360 + if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; }
361 + var obs = new MutationObserver(function() {
362 + if (el.classList.contains('visible')) {
363 + mxchatFireOpened(botId);
364 + obs.disconnect();
365 + }
366 + });
367 + obs.observe(el, { attributes: true, attributeFilter: ['class'] });
368 + }
369 +
370 + // ====================================
154 371 // ELEMENT SELECTOR HELPERS
155 372 // ====================================
156 373
157 374 // Check if a specific bot has an AI theme assigned (skip inline colors)
@@ -187,8 +404,20 @@
187 404 var id = $floating.attr('id') || '';
188 405 var match = id.match(/floating-chatbot-(.+)/);
189 406 if (match) return match[1];
190 407 }
408 + // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
409 + // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
410 + // .close-pre-chat-message button, which carries only a class and no id — miss both
411 + // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
412 + // and read the suffix. (closest() includes the element itself, so a click directly on
413 + // #pre-chat-message-{bot_id} resolves here too.)
414 + var $preChat = $(element).closest('[id^="pre-chat-message-"]');
415 + if ($preChat.length) {
416 + var preId = $preChat.attr('id') || '';
417 + var preMatch = preId.match(/^pre-chat-message-(.+)$/);
418 + if (preMatch) return preMatch[1];
419 + }
191 420 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
192 421 var elementId = $(element).attr('id') || '';
193 422 if (elementId) {
194 423 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -228,9 +457,27 @@
228 457 if (parts.length == 2) return parts.pop().split(";").shift();
229 458 }
230 459
231 460 function generateSessionId() {
232 - return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
461 + // Session IDs function as the de-facto bearer token for an anonymous
462 + // chat, so generate them with a CSPRNG when available. Math.random is a
463 + // legacy fallback for ancient/sandboxed environments that lack
464 + // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
465 + // code pattern-matches on it). (plan-0c17b5)
466 + var rand;
467 + try {
468 + if (window.crypto && window.crypto.getRandomValues) {
469 + var buf = new Uint8Array(16); // 128 bits
470 + window.crypto.getRandomValues(buf);
471 + rand = Array.prototype.map.call(buf, function (b) {
472 + return ('0' + b.toString(16)).slice(-2);
473 + }).join('');
474 + }
475 + } catch (e) {}
476 + if (!rand) {
477 + rand = Math.random().toString(36).substr(2, 9); // legacy fallback
478 + }
479 + return 'mxchat_chat_' + rand;
233 480 }
234 481
235 482 // Legacy function - now delegates to instance manager
236 483 function getChatSession(botId) {
@@ -274,23 +521,28 @@
274 521 // ====================================
275 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
276 523 // ====================================
277 524
525 +// Plan f3e515: the page context is computed once per page, not once per message.
526 +// Keyed on URL + title + the content element's text length — cheap to check,
527 +// and enough to notice a navigation or a re-rendered content area.
528 +var pageContextCache = { key: null, value: null };
529 +
278 530 function getPageContext() {
279 531 // Check if contextual awareness is enabled
280 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
281 533 return null;
282 534 }
283 -
535 +
284 536 // Get page URL
285 537 const pageUrl = window.location.href;
286 -
538 +
287 539 // Get page title
288 540 const pageTitle = document.title || '';
289 -
541 +
290 542 // Get main content from the page
291 543 let pageContent = '';
292 -
544 +
293 545 // Try to get content from common content areas
294 546 const contentSelectors = [
295 547 'main',
296 548 '[role="main"]',
@@ -302,9 +554,9 @@
302 554 'article',
303 555 '#content',
304 556 '#main'
305 557 ];
306 -
558 +
307 559 let contentElement = null;
308 560 for (const selector of contentSelectors) {
309 561 contentElement = document.querySelector(selector);
310 562 if (contentElement) {
@@ -310,18 +562,31 @@
310 562 if (contentElement) {
311 563 break;
312 564 }
313 565 }
314 -
566 +
315 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
316 568 if (!contentElement) {
317 569 contentElement = document.body;
318 570 }
319 -
571 +
320 572 if (contentElement) {
321 - // Clone the element to avoid modifying the original
322 - const clone = contentElement.cloneNode(true);
323 -
573 + const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length;
574 + if (pageContextCache.key === cacheKey) {
575 + return pageContextCache.value;
576 + }
577 +
578 + // Copy the element into a document that has no browsing context
579 + // (plan f3e515). A live cloneNode(true) constructed real <video> /
580 + // <audio> / <img> elements: media with a src runs resource selection
581 + // on creation whether or not it is attached, and an autoplay attribute
582 + // made the detached copy PLAY — audibly, once the visible player had
583 + // been unmuted through the property. Every message spawned another
584 + // one. Elements in an inert document never fetch or play, while
585 + // querySelectorAll / textContent / dataset all still work on them.
586 + const inertDocument = document.implementation.createHTMLDocument('');
587 + const clone = inertDocument.importNode(contentElement, true);
588 +
324 589 // Remove unwanted elements
325 590 const unwantedSelectors = [
326 591 'header',
327 592 'footer',
@@ -339,16 +604,25 @@
339 604 '#floating-chatbot',
340 605 '#floating-chatbot-button',
341 606 '.mxchat',
342 607 '[class*="chat"]',
343 - '[id*="chat"]'
608 + '[id*="chat"]',
609 + // Media contributes no text and (outside the inert document) fetches.
610 + 'video',
611 + 'audio',
612 + 'iframe',
613 + 'object',
614 + 'embed',
615 + 'picture',
616 + 'img',
617 + 'source'
344 618 ];
345 -
619 +
346 620 unwantedSelectors.forEach(selector => {
347 621 const elements = clone.querySelectorAll(selector);
348 622 elements.forEach(el => el.remove());
349 623 });
350 -
624 +
351 625 // Extract MxChat context data attributes before getting text content
352 626 const contextData = [];
353 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
354 628 const contextValue = el.dataset.mxchatContext;
@@ -355,34 +629,34 @@
355 629 if (contextValue && contextValue.trim()) {
356 630 contextData.push(contextValue);
357 631 }
358 632 });
359 -
633 +
360 634 // Get text content and clean it up
361 635 pageContent = clone.textContent || clone.innerText || '';
362 -
636 +
363 637 // Add context data to page content if any were found
364 638 if (contextData.length > 0) {
365 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
366 640 }
367 -
641 +
368 642 // Clean up whitespace and limit length
369 643 pageContent = pageContent
370 644 .replace(/\s+/g, ' ')
371 645 .trim()
372 646 .substring(0, 3000); // Limit to 3000 characters to avoid token limits
647 +
648 + // Only return context if we have meaningful content
649 + const result = (!pageContent || pageContent.length < 50) ? null : {
650 + url: pageUrl,
651 + title: pageTitle,
652 + content: pageContent
653 + };
654 + pageContextCache = { key: cacheKey, value: result };
655 + return result;
373 656 }
374 -
375 - // Only return context if we have meaningful content
376 - if (!pageContent || pageContent.length < 50) {
377 - return null;
378 - }
379 -
380 - return {
381 - url: pageUrl,
382 - title: pageTitle,
383 - content: pageContent
384 - };
657 +
658 + return null;
385 659 }
386 660
387 661 // Track originating page when chat starts
388 662 function trackOriginatingPage() {
@@ -433,8 +707,26 @@
433 707 sendButton.style.pointerEvents = 'none';
434 708 }
435 709 }
436 710
711 +// Whether the input may grab focus after a completed reply (plan 03799f).
712 +// On coarse-pointer devices focusing a text input summons the on-screen
713 +// keyboard over the answer the visitor is trying to read, so 'auto' (the
714 +// default) focuses only on fine-pointer devices. The site-wide
715 +// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
716 +// NOT used on widget open (:~3424) — that focus is a deliberate act and is
717 +// what makes the widget keyboard-accessible.
718 +function mxchatShouldAutofocusAfterReply() {
719 + var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
720 + if (pref === 'on') return true;
721 + if (pref === 'off') return false;
722 + try {
723 + return !window.matchMedia('(pointer: coarse)').matches;
724 + } catch (err) {
725 + return true;
726 + }
727 +}
728 +
437 729 function enableChatInput(botId) {
438 730 botId = botId || 'default';
439 731 var chatInput = getElementDOM(botId, 'chat-input');
440 732 var sendButton = getElementDOM(botId, 'send-button');
@@ -440,9 +732,11 @@
440 732 var sendButton = getElementDOM(botId, 'send-button');
441 733 if (chatInput) {
442 734 chatInput.disabled = false;
443 735 chatInput.style.opacity = '1';
444 - chatInput.focus();
736 + if (mxchatShouldAutofocusAfterReply()) {
737 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 + }
445 739 }
446 740 if (sendButton) {
447 741 sendButton.disabled = false;
448 742 sendButton.style.opacity = '1';
@@ -447,10 +741,102 @@
447 741 sendButton.disabled = false;
448 742 sendButton.style.opacity = '1';
449 743 sendButton.style.pointerEvents = 'auto';
450 744 }
745 + // Every completion path re-enables input, so this is the single restore
746 + // point for the streaming Stop affordance (no-op when not in stop mode).
747 + mxchatRestoreSendButton(botId);
451 748 }
452 749
750 +// --- Streaming Stop control -------------------------------------------------
751 +// One live stream handle per bot instance, so Stop on one widget never aborts
752 +// another bot on the same page.
753 +var mxchatActiveStreams = {};
754 +// Original send-button markup, captured once per bot the first time the Stop
755 +// state is shown (never captured while already in stop mode, so a rapid
756 +// stop-then-resend can't save the stop glyph as the "original").
757 +var mxchatSendMarkup = {};
758 +
759 +function mxchatShowStopButton(botId) {
760 + var btn = getElementDOM(botId, 'send-button');
761 + if (!btn) return;
762 + if (!btn.classList.contains('mxchat-stop-mode')) {
763 + mxchatSendMarkup[botId] = {
764 + html: btn.innerHTML,
765 + label: btn.getAttribute('aria-label')
766 + };
767 + }
768 +
769 + // Mirror the send icon's rendered size + color so the stop glyph looks
770 + // native, including custom send images/colors and theme overrides.
771 + var child = btn.querySelector('svg, img');
772 + var size = 25;
773 + var color = '';
774 + if (child) {
775 + var rect = child.getBoundingClientRect();
776 + if (rect.width) {
777 + size = Math.round(Math.min(rect.width, rect.height));
778 + }
779 + var cs = window.getComputedStyle(child);
780 + color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
781 + }
782 + var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
783 + 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>';
784 + // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
785 + // start a stream synchronously while the originating click is still
786 + // bubbling up to our delegated handler. Without this guard, that handler
787 + // reads the just-added stop-mode class as a user Stop press and aborts the
788 + // brand-new stream — the user's message renders but no reply ever fires
789 + // (plan-4bba64 silent message loss). The flag only spans the current event
790 + // dispatch: cleared on the next macrotask, long before a real Stop click.
791 + btn.__mxchatStopJustShown = true;
792 + setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
793 + btn.classList.add('mxchat-stop-mode');
794 + btn.setAttribute('aria-label', stopLabel);
795 + btn.setAttribute('title', stopLabel);
796 + // disableChatInput() ran when the turn was sent; the Stop control itself
797 + // must stay clickable while the textarea remains disabled.
798 + btn.disabled = false;
799 + btn.style.opacity = '1';
800 + btn.style.pointerEvents = 'auto';
801 +}
802 +
803 +function mxchatRestoreSendButton(botId) {
804 + var btn = getElementDOM(botId, 'send-button');
805 + var saved = mxchatSendMarkup[botId];
806 + if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
807 + btn.innerHTML = saved.html;
808 + btn.classList.remove('mxchat-stop-mode');
809 + btn.removeAttribute('title');
810 + if (saved.label) {
811 + btn.setAttribute('aria-label', saved.label);
812 + }
813 +}
814 +
815 +function mxchatStopStreaming(botId) {
816 + var entry = mxchatActiveStreams[botId];
817 + if (!entry || !entry.controller) return;
818 + entry.aborted = true;
819 + try { entry.controller.abort(); } catch (e) {}
820 +}
821 +
822 +// Returns true when a stream rejection came from an intentional Stop click:
823 +// keep the partial text as the turn's answer — no error UI, no fallback resend.
824 +function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
825 + var entry = mxchatActiveStreams[botId];
826 + if (!entry || !entry.aborted) return false;
827 + delete mxchatActiveStreams[botId];
828 + if (!accumulatedContent) {
829 + // Stopped before the first chunk: drop the thinking bubble, no orphan message.
830 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
831 + }
832 + enableChatInput(botId); // also restores the send icon
833 + if (callback) {
834 + callback(accumulatedContent || '');
835 + }
836 + return true;
837 +}
838 +
453 839 // Update your existing sendMessage function
454 840 function sendMessage(botId) {
455 841 botId = botId || 'default';
456 842 MxChatInstances.ensureSession(botId);
@@ -461,9 +847,12 @@
461 847 if (typeof customMxChatFilter === 'function') {
462 848 message = customMxChatFilter(message, "prompt");
463 849 }
464 850
465 - if (message) {
851 + // Whitespace-only is empty: nothing is sent and nothing is disabled. When
852 + // there IS a message the original string (spaces and all) is what goes out,
853 + // so the transcript is unchanged (plan b67fd5).
854 + if (message && String(message).trim() !== '') {
466 855 // Don't disable input in live agent mode - let users chat freely
467 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
468 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
469 858 if (!isAgentMode) {
@@ -471,8 +860,9 @@
471 860 }
472 861
473 862 appendMessage("user", message, '', [], false, botId);
474 863 $chatInput.val('');
864 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
475 865 $chatInput.css('height', 'auto');
476 866
477 867 if (hasQuickQuestions(botId)) {
478 868 collapseQuickQuestions(botId);
@@ -479,14 +869,16 @@
479 869 }
480 870 appendThinkingMessage(botId);
481 871 scrollToBottom(botId);
482 872
483 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
873 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
484 874
485 875 // Check if streaming is enabled AND supported for this model
486 876 if (shouldUseStreaming(currentModel)) {
487 877 callMxChatStream(message, function(response) {
488 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
878 + // Content is final: releasing aria-busy lets the live region
879 + // announce the completed reply once (plan 67f126).
880 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
489 881 }, botId);
490 882 } else {
491 883 callMxChat(message, function(response) {
492 884 replaceLastMessage("bot", response, '', [], botId);
@@ -504,8 +896,14 @@
504 896 if (typeof customMxChatFilter === 'function') {
505 897 message = customMxChatFilter(message, "prompt");
506 898 }
507 899
900 + // Nothing to send means nothing to disable (plan b67fd5): a caller handing
901 + // over an empty string must not lock the box the way Enter-on-empty did.
902 + if (!message || String(message).trim() === '') {
903 + return;
904 + }
905 +
508 906 // Don't disable input in live agent mode - let users chat freely
509 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
510 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
511 909 if (!isAgentMode) {
@@ -519,14 +917,15 @@
519 917 }
520 918 appendThinkingMessage(botId);
521 919 scrollToBottom(botId);
522 920
523 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
921 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
524 922
525 923 // Check if streaming is enabled AND supported for this model
526 924 if (shouldUseStreaming(currentModel)) {
527 925 callMxChatStream(message, function(response) {
528 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
926 + // Final content — release aria-busy so the reply announces once (67f126).
927 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
529 928 }, botId);
530 929 } else {
531 930 callMxChat(message, function(response) {
532 931 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -590,8 +989,15 @@
590 989
591 990 function callMxChat(message, callback, botId) {
592 991 botId = botId || getMxChatBotId();
593 992
993 + // Streaming fallbacks land here: drop any leftover stream handle and
994 + // return the button to its send state (no-op for plain non-stream turns).
995 + if (mxchatActiveStreams[botId]) {
996 + delete mxchatActiveStreams[botId];
997 + }
998 + mxchatRestoreSendButton(botId);
999 +
594 1000 // Store the message in case we need to retry after session reset
595 1001 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
596 1002
597 1003 // Get page context if contextual awareness is enabled
@@ -599,13 +1005,28 @@
599 1005
600 1006 // Get instance for session start timestamp (used when persistence is OFF)
601 1007 var instance = MxChatInstances.get(botId);
602 1008
1009 + // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
1010 + // and returns the guaranteed-present session id from the in-memory instance even when
1011 + // cookie/localStorage writes are silently blocked by the browser.
1012 + var sessionId = MxChatInstances.ensureSession(botId);
1013 + if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
1014 + // Last-resort generation to ensure we never POST a null marker.
1015 + sessionId = generateSessionId();
1016 + MxChatInstances.setChatSession(botId, sessionId);
1017 + }
1018 +
1019 + // Wait for the page-cache nonce refresh to complete before firing the
1020 + // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
1021 + // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
1022 + // callback guarantees we read the fresh value. See plan-c5457f.
1023 + refreshNonceIfNeeded(function() {
603 1024 // Prepare AJAX data
604 1025 const ajaxData = {
605 1026 action: 'mxchat_handle_chat_request',
606 1027 message: message,
607 - session_id: getChatSession(botId),
1028 + session_id: sessionId,
608 1029 nonce: mxchatChat.nonce,
609 1030 current_page_url: window.location.href,
610 1031 current_page_title: document.title,
611 1032 bot_id: botId,
@@ -611,14 +1032,14 @@
611 1032 bot_id: botId,
612 1033 // Pass session start timestamp so AI context matches what user sees
613 1034 session_start_timestamp: instance.sessionStartTimestamp || 0
614 1035 };
615 -
1036 +
616 1037 // Add page context if available
617 1038 if (pageContext) {
618 1039 ajaxData.page_context = JSON.stringify(pageContext);
619 1040 }
620 -
1041 +
621 1042 // CHECK FOR VISION FLAGS AND ADD THEM
622 1043 if (window.mxchatVisionProcessed) {
623 1044 ajaxData.vision_processed = true;
624 1045 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -627,9 +1048,9 @@
627 1048 window.mxchatVisionProcessed = false;
628 1049 window.mxchatOriginalMessage = null;
629 1050 window.mxchatVisionImagesCount = 0;
630 1051 }
631 -
1052 +
632 1053 $.ajax({
633 1054 url: mxchatChat.ajax_url,
634 1055 type: 'POST',
635 1056 dataType: 'json',
@@ -674,12 +1095,13 @@
674 1095 // Re-send the original message with the new session (user message is already displayed)
675 1096 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
676 1097 if (originalMessage) {
677 1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
678 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1099 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
679 1100 if (shouldUseStreaming(currentModel)) {
680 1101 callMxChatStream(originalMessage, function(response) {
681 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1102 + // Final content — release aria-busy so the reply announces once (67f126).
1103 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
682 1104 }, botId);
683 1105 } else {
684 1106 callMxChat(originalMessage, function(response) {
685 1107 replaceLastMessage("bot", response, '', [], botId);
@@ -818,8 +1240,9 @@
818 1240
819 1241 replaceLastMessage("bot", errorMessage, '', [], botId);
820 1242 }
821 1243 });
1244 + }); // refreshNonceIfNeeded
822 1245 }
823 1246
824 1247 function callMxChatStream(message, callback, botId) {
825 1248 botId = botId || getMxChatBotId();
@@ -826,9 +1249,9 @@
826 1249
827 1250 // Store the message in case we need to retry after session reset
828 1251 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
829 1252
830 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1253 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
831 1254 if (!isStreamingSupported(currentModel)) {
832 1255 callMxChat(message, callback, botId);
833 1256 return;
834 1257 }
@@ -838,12 +1261,25 @@
838 1261
839 1262 // Get instance for session start timestamp (used when persistence is OFF)
840 1263 var instance = MxChatInstances.get(botId);
841 1264
1265 + // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1266 + // non-string value via String(), so passing `null` would POST the literal string "null"
1267 + // and land in the transcripts table as a ghost session. ensureSession() always returns
1268 + // a real string even when cookies/localStorage are blocked.
1269 + var streamSessionId = MxChatInstances.ensureSession(botId);
1270 + if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1271 + streamSessionId = generateSessionId();
1272 + MxChatInstances.setChatSession(botId, streamSessionId);
1273 + }
1274 +
1275 + // Wait for the page-cache nonce refresh before constructing formData (which
1276 + // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1277 + refreshNonceIfNeeded(function() {
842 1278 const formData = new FormData();
843 1279 formData.append('action', 'mxchat_stream_chat');
844 1280 formData.append('message', message);
845 - formData.append('session_id', getChatSession(botId));
1281 + formData.append('session_id', streamSessionId);
846 1282 formData.append('nonce', mxchatChat.nonce);
847 1283 formData.append('current_page_url', window.location.href);
848 1284 formData.append('current_page_title', document.title);
849 1285 formData.append('bot_id', botId);
@@ -868,13 +1304,25 @@
868 1304
869 1305 let accumulatedContent = '';
870 1306 let testingDataReceived = false;
871 1307 let streamingStarted = false;
1308 + // Server-pushed html to append as its OWN bot bubble once the stream
1309 + // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1310 + // deferred to [DONE] so the embed always lands BELOW the streamed text.
1311 + let pendingAppendHtml = '';
872 1312
1313 + // Abortable stream: a fresh controller per turn, keyed by bot instance.
1314 + // The Stop control (send button swapped in place) aborts both the read
1315 + // loop and the underlying request.
1316 + var streamControl = { controller: new AbortController(), aborted: false };
1317 + mxchatActiveStreams[botId] = streamControl;
1318 + mxchatShowStopButton(botId);
1319 +
873 1320 fetch(mxchatChat.ajax_url, {
874 1321 method: 'POST',
875 1322 body: formData,
876 - credentials: 'same-origin'
1323 + credentials: 'same-origin',
1324 + signal: streamControl.controller.signal
877 1325 })
878 1326 .then(response => {
879 1327 // Store the response for potential fallback handling
880 1328 const responseClone = response.clone();
@@ -943,8 +1391,16 @@
943 1391
944 1392 // Re-enable chat input when stream ends with content
945 1393 enableChatInput(botId);
946 1394
1395 + // Scroll the user's last message to the top now that the
1396 + // bot's full reply has rendered (gives max reading room).
1397 + var $chatBoxDone = getElement(botId, 'chat-box');
1398 + var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1399 + if ($lastUserMsgDone.length) {
1400 + scrollElementToTop($lastUserMsgDone, botId);
1401 + }
1402 +
947 1403 if (callback) {
948 1404 callback(accumulatedContent);
949 1405 }
950 1406 return;
@@ -967,8 +1423,25 @@
967 1423
968 1424 // Re-enable chat input after streaming completes
969 1425 enableChatInput(botId);
970 1426
1427 + // Render any server-pushed appendix html (e.g. the
1428 + // YouTube embed) as its own bot bubble below the
1429 + // streamed text — mirrors how it is saved in the
1430 + // transcript, so history replays identically.
1431 + if (pendingAppendHtml) {
1432 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1433 + pendingAppendHtml = '';
1434 + }
1435 +
1436 + // Scroll the user's last message to the top now
1437 + // that the bot's full reply has rendered.
1438 + var $chatBoxStreamDone = getElement(botId, 'chat-box');
1439 + var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1440 + if ($lastUserMsgStreamDone.length) {
1441 + scrollElementToTop($lastUserMsgStreamDone, botId);
1442 + }
1443 +
971 1444 if (callback) {
972 1445 callback(accumulatedContent);
973 1446 }
974 1447 return;
@@ -994,8 +1467,21 @@
994 1467 streamingStarted = true;
995 1468 accumulatedContent += json.content;
996 1469 updateStreamingMessage(accumulatedContent, botId);
997 1470 }
1471 + // Stash appendix html (e.g. video embed) for [DONE]
1472 + else if (json.append_html) {
1473 + pendingAppendHtml = json.append_html;
1474 + }
1475 + // Server-side final pass changed the assembled text
1476 + // (ffef6f: dead-link stripping) — swap the rendered
1477 + // bubble for the validated version. Arrives at most
1478 + // once, just before [DONE].
1479 + else if (json.replace_content) {
1480 + streamingStarted = true;
1481 + accumulatedContent = json.replace_content;
1482 + updateStreamingMessage(accumulatedContent, botId);
1483 + }
998 1484 // Handle complete response in stream (fallback response)
999 1485 else if (json.text || json.message || json.html) {
1000 1486 handleNonStreamResponse(json, callback, botId);
1001 1487 return;
@@ -1025,8 +1511,9 @@
1025 1511 }
1026 1512
1027 1513 processStream();
1028 1514 }).catch(streamError => {
1515 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1029 1516 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1030 1517 callMxChat(message, callback, botId);
1031 1518 });
1032 1519 }
@@ -1033,8 +1520,9 @@
1033 1520
1034 1521 processStream();
1035 1522 })
1036 1523 .catch(error => {
1524 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1037 1525 // Check if we have server error data with chat mode
1038 1526 if (error && error.isServerError && error.data) {
1039 1527 // Check for chat mode in error data
1040 1528 if (error.data.chat_mode) {
@@ -1047,8 +1535,9 @@
1047 1535 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1048 1536 callMxChat(message, callback, botId);
1049 1537 }
1050 1538 });
1539 + }); // refreshNonceIfNeeded
1051 1540 }
1052 1541
1053 1542 // Helper function to handle non-streaming responses
1054 1543 function handleNonStreamResponse(data, callback, botId) {
@@ -1094,9 +1583,9 @@
1094 1583 // Re-send the original message with the new session (user message is already displayed)
1095 1584 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1096 1585 if (originalMessage) {
1097 1586 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1098 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1587 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1099 1588 if (shouldUseStreaming(currentModel)) {
1100 1589 callMxChatStream(originalMessage, callback, botId);
1101 1590 } else {
1102 1591 callMxChat(originalMessage, callback, botId);
@@ -1229,8 +1718,15 @@
1229 1718 var $chatBox = getElement(botId, 'chat-box');
1230 1719 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1231 1720
1232 1721 if (tempMessage.length) {
1722 + // aria-busy=true for the whole stream: the bubble is rewritten on
1723 + // every chunk, and without busy a polite live region announces those
1724 + // rewrites continuously. Flipped false once the reply is final, so
1725 + // assistive tech announces the completed message ONCE (plan 67f126).
1726 + if (tempMessage.attr('aria-busy') !== 'true') {
1727 + tempMessage.attr('aria-busy', 'true');
1728 + }
1233 1729 // Update existing message
1234 1730 tempMessage.html(formattedContent);
1235 1731 } else {
1236 1732 // Create new temporary message if it doesn't exist
@@ -1257,29 +1753,395 @@
1257 1753 // Update the event handlers to use the correct function names (using event delegation)
1258 1754 // Use class-based selectors for multi-instance support
1259 1755 $(document).on('click', '.send-button', function() {
1260 1756 var botId = getBotIdFromElement(this);
1261 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1262 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1263 - disableChatInput(botId);
1757 + // While a response is streaming the button is a Stop control.
1758 + if (this.classList.contains('mxchat-stop-mode')) {
1759 + // Same click that just started this stream (an add-on's direct handler
1760 + // ran before this delegated one) — not a Stop press. See
1761 + // mxchatShowStopButton for the full story (plan-4bba64).
1762 + if (this.__mxchatStopJustShown) return;
1763 + mxchatStopStreaming(botId);
1764 + return;
1264 1765 }
1766 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1265 1767 sendMessage(botId);
1266 1768 });
1267 1769
1268 -// Override enter key handler (using event delegation)
1770 +// Enter sends, Shift+Enter inserts a newline (using event delegation).
1771 +// sendMessage() owns the disable: it greys the box only when a real message is
1772 +// about to go out. Disabling here first left the widget locked for good after
1773 +// Enter on an empty box - the send returned early and no reply path ever
1774 +// re-enabled it (plan b67fd5).
1269 1775 $(document).on('keypress', '.chat-input', function(e) {
1270 1776 if (e.which == 13 && !e.shiftKey) {
1271 1777 e.preventDefault();
1272 - var botId = getBotIdFromElement(this);
1273 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1274 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1275 - disableChatInput(botId);
1778 + sendMessage(getBotIdFromElement(this));
1779 + }
1780 +});
1781 +
1782 +// Chat input character counter + soft limit feedback (plan 7091a2).
1783 +// Language-neutral: numbers + color only, no translatable strings. The counter
1784 +// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1785 +// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1786 +// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1787 +function mxchatUpdateCharCounter(inputEl) {
1788 + if (!inputEl || !inputEl.closest) return;
1789 + var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1790 + var container = inputEl.closest('.input-container');
1791 + if (!container || !max || max <= 0) return;
1792 + var counter = container.querySelector('.mxchat-char-counter');
1793 + if (!counter) return;
1794 + var len = inputEl.value.length;
1795 + var ratio = len / max;
1796 + var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1797 + var cur = counter.querySelector('.mxchat-char-counter-current');
1798 + if (cur) cur.textContent = len;
1799 + var warn = ratio >= nearThreshold && len < max;
1800 + var full = len >= max;
1801 + counter.classList.toggle('is-visible', ratio >= nearThreshold);
1802 + counter.classList.toggle('is-warn', warn);
1803 + counter.classList.toggle('is-full', full);
1804 + container.classList.toggle('mxchat-input-near-limit', warn);
1805 + container.classList.toggle('mxchat-input-at-limit', full);
1806 +}
1807 +
1808 +function mxchatBumpInput(inputEl) {
1809 + var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1810 + if (!container) return;
1811 + container.classList.remove('mxchat-input-bump');
1812 + void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1813 + container.classList.add('mxchat-input-bump');
1814 + clearTimeout($(container).data('mxchatBumpTimeout'));
1815 + var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1816 + $(container).data('mxchatBumpTimeout', t);
1817 +}
1818 +
1819 +// Live counter update on every input.
1820 +$(document).on('input', '.chat-input', function() {
1821 + mxchatUpdateCharCounter(this);
1822 +});
1823 +
1824 +// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1825 +// rejected at the cap (maxlength silently swallows it otherwise).
1826 +$(document).on('keydown', '.chat-input', function(e) {
1827 + var max = parseInt(this.getAttribute('maxlength'), 10);
1828 + if (!max || max <= 0 || this.value.length < max) return;
1829 + if (e.ctrlKey || e.metaKey || e.altKey) return;
1830 + // A single printable char with no selection to overwrite WILL be rejected.
1831 + if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1832 + mxchatBumpInput(this);
1833 + }
1834 +});
1835 +
1836 +// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1837 +$(document).on('paste', '.chat-input', function() {
1838 + var el = this;
1839 + var max = parseInt(el.getAttribute('maxlength'), 10);
1840 + if (!max || max <= 0) return;
1841 + setTimeout(function() {
1842 + mxchatUpdateCharCounter(el);
1843 + if (el.value.length >= max) mxchatBumpInput(el);
1844 + }, 0);
1845 +});
1846 +
1847 +// Builds the list of overflow-menu items for a given bot.
1848 +// Adding a future item is one push to this array — do NOT hardcode "only download."
1849 +function mxchatGetHeaderMenuItems(botId) {
1850 + var items = [];
1851 + var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1852 +
1853 + // The `print_button_*` keys still gate this item for back-compat with
1854 + // existing user options. The action is now a transcript download, not print.
1855 + if (settings.print_button_enabled === 'on') {
1856 + items.push({
1857 + id: 'download-transcript',
1858 + label: settings.print_button_label || 'Download Transcript',
1859 + 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>',
1860 + action: function() {
1861 + mxchatDownloadTranscript(botId);
1862 + }
1863 + });
1864 + }
1865 +
1866 + // "Start new chat" — surfaces the EXISTING per-conversation reset
1867 + // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1868 + // without the site owner disabling chat persistence globally. Default OFF;
1869 + // gated by the reset_chat_enabled option. plan ac2e81.
1870 + if (settings.reset_chat_enabled === 'on') {
1871 + items.push({
1872 + id: 'reset-chat',
1873 + label: settings.reset_chat_label || 'Start new chat',
1874 + 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>',
1875 + action: function() {
1876 + var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1877 + if (window.confirm(confirmMsg)) {
1878 + MxChatInstances.resetChatSession(botId);
1879 + }
1880 + }
1881 + });
1882 + }
1883 +
1884 + return items;
1885 +}
1886 +
1887 +// Builds a clean markdown transcript of the current conversation and triggers
1888 +// a file download. Used by the "Download Transcript" menu item.
1889 +function mxchatDownloadTranscript(botId) {
1890 + var $chatBox = getElement(botId, 'chat-box');
1891 + if (!$chatBox || !$chatBox.length) return;
1892 +
1893 + var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1894 + var headerTitle = settings.print_header_title || 'Chat transcript';
1895 + var now = new Date();
1896 + var stamp = now.toLocaleString();
1897 +
1898 + var lines = [];
1899 + lines.push('# ' + headerTitle);
1900 + lines.push('');
1901 + lines.push('Exported: ' + stamp);
1902 + lines.push('');
1903 + lines.push('---');
1904 + lines.push('');
1905 +
1906 + $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1907 + var $msg = $(this);
1908 + // Skip thinking placeholders and any in-flight temporary messages.
1909 + if ($msg.find('.thinking-dots').length) return;
1910 + if ($msg.hasClass('temporary-message')) return;
1911 +
1912 + var sender;
1913 + if ($msg.hasClass('user-message')) sender = 'User';
1914 + else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1915 + else sender = 'AI Agent';
1916 +
1917 + // Strip interactive UI from the cloned message so we get the conversation text.
1918 + var $clone = $msg.clone();
1919 + $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1920 + var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1921 + if (!text) return;
1922 +
1923 + lines.push('**' + sender + '**');
1924 + lines.push('');
1925 + lines.push(text);
1926 + lines.push('');
1927 + });
1928 +
1929 + var content = lines.join('\n');
1930 + var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1931 + var fname = 'mxchat-transcript-' + iso + '.md';
1932 + var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1933 + var url = URL.createObjectURL(blob);
1934 + var a = document.createElement('a');
1935 + a.href = url;
1936 + a.download = fname;
1937 + a.style.display = 'none';
1938 + document.body.appendChild(a);
1939 + a.click();
1940 + setTimeout(function() {
1941 + if (a.parentNode) a.parentNode.removeChild(a);
1942 + URL.revokeObjectURL(url);
1943 + }, 100);
1944 +}
1945 +
1946 +// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1947 +// on the menu wrap, so the dropdown matches whatever paints the bubble —
1948 +// saved options, AI theme CSS, or the mxchat-theme add-on.
1949 +function mxchatSyncMenuColors(botId, $wrap) {
1950 + if (!$wrap || !$wrap.length) return;
1951 + var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1952 + if (!$bot.length) return;
1953 + var cs = window.getComputedStyle($bot[0]);
1954 + if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1955 + $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1956 + }
1957 + // Bot text color usually lives on a child div, not .bot-message itself.
1958 + var $textChild = $bot.find('[style*="color"]').first();
1959 + var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1960 + if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1961 +}
1962 +
1963 +// Renders (or re-renders) the item list for one menu wrap. Split out of
1964 +// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1965 +// rebuild items + trigger visibility WITHOUT re-binding the one-time
1966 +// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1967 +// a rebuild before init (never happens, but harmless) just skips it.
1968 +function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1969 + var $trigger = $wrap.find('.mxchat-menu-trigger');
1970 + var $menu = $wrap.find('.mxchat-header-menu');
1971 + var items = mxchatGetHeaderMenuItems(botId);
1972 +
1973 + $menu.empty();
1974 +
1975 + if (!items.length) {
1976 + $trigger.hide();
1977 + $menu.hide();
1978 + return;
1979 + }
1980 +
1981 + // Clear any inline display:none a previous zero-item render left behind —
1982 + // open/close visibility is governed by the hidden prop + is-open class.
1983 + $trigger.css('display', '');
1984 + $menu.css('display', '');
1985 +
1986 + items.forEach(function(item, idx) {
1987 + var $btn = $('<button>', {
1988 + type: 'button',
1989 + 'class': 'mxchat-menu-item',
1990 + 'role': 'menuitem',
1991 + 'tabindex': '-1',
1992 + 'data-menu-id': item.id,
1993 + html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1994 + '<span class="mxchat-menu-item-label"></span>'
1995 + });
1996 + $btn.find('.mxchat-menu-item-label').text(item.label);
1997 + $btn.on('click', function(e) {
1998 + e.preventDefault();
1999 + e.stopPropagation();
2000 + if (closeMenuFn) closeMenuFn();
2001 + try { item.action(); } catch (err) { /* no-op */ }
2002 + });
2003 + $menu.append($btn);
2004 + });
2005 +}
2006 +
2007 +// Re-render every menu on the page after a dynamic-settings merge
2008 +// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
2009 +// swapping items under the user mid-interaction yanks focus — and the
2010 +// rebuild runs when it closes instead (closeMenu checks the pending flag).
2011 +function mxchatRebuildHeaderMenus() {
2012 + $('.mxchat-header-menu-wrap').each(function() {
2013 + var $wrap = $(this);
2014 + var botId = $wrap.data('bot-id');
2015 + if (!botId) return;
2016 + if (!$wrap.data('mxchatMenuReady')) {
2017 + mxchatInitHeaderMenu(botId);
2018 + return;
1276 2019 }
1277 - sendMessage(botId);
2020 + if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
2021 + $wrap.data('mxchatMenuRebuildPending', true);
2022 + return;
2023 + }
2024 + mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
2025 + });
2026 +}
2027 +
2028 +// One-time per-widget init: renders menu items, wires open/close,
2029 +// outside-click, Escape, and arrow-key navigation. If no items, hides the
2030 +// trigger. Wiring happens even when there are zero items at init, so a
2031 +// later dynamic-settings rebuild that adds items has a working trigger.
2032 +function mxchatInitHeaderMenu(botId) {
2033 + var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
2034 + if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
2035 +
2036 + var $trigger = $wrap.find('.mxchat-menu-trigger');
2037 + var $menu = $wrap.find('.mxchat-header-menu');
2038 +
2039 + // Initial color sync — covers normal page load.
2040 + mxchatSyncMenuColors(botId, $wrap);
2041 +
2042 + function openMenu() {
2043 + // Re-sync each open in case the active theme changed since init.
2044 + mxchatSyncMenuColors(botId, $wrap);
2045 + $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
2046 + $trigger.attr('aria-expanded', 'true');
2047 + // Focus the first item for keyboard users
2048 + setTimeout(function() {
2049 + $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
2050 + }, 0);
1278 2051 }
2052 + function closeMenu(returnFocus) {
2053 + $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
2054 + $trigger.attr('aria-expanded', 'false');
2055 + $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
2056 + if (returnFocus) $trigger.trigger('focus');
2057 + // A dynamic-settings rebuild that arrived while the menu was open
2058 + // was deferred (mxchatRebuildHeaderMenus) — run it now.
2059 + if ($wrap.data('mxchatMenuRebuildPending')) {
2060 + $wrap.removeData('mxchatMenuRebuildPending');
2061 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2062 + }
2063 + }
2064 +
2065 + // Toggle on trigger click — stop propagation so the .chatbot-top-bar
2066 + // click-to-collapse handler does not fire.
2067 + $trigger.on('click', function(e) {
2068 + e.preventDefault();
2069 + e.stopPropagation();
2070 + if ($menu.hasClass('is-open')) closeMenu();
2071 + else openMenu();
2072 + });
2073 +
2074 + // Don't let clicks inside the menu bubble to the top-bar collapse handler.
2075 + $menu.on('click', function(e) {
2076 + e.stopPropagation();
2077 + });
2078 +
2079 + // Outside click closes the menu.
2080 + $(document).on('click.mxchatMenu-' + botId, function(e) {
2081 + if (!$menu.hasClass('is-open')) return;
2082 + if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
2083 + closeMenu();
2084 + });
2085 +
2086 + // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
2087 + $menu.on('keydown', '.mxchat-menu-item', function(e) {
2088 + var $items = $menu.find('.mxchat-menu-item');
2089 + var idx = $items.index(this);
2090 + if (e.key === 'Escape') {
2091 + e.preventDefault();
2092 + closeMenu(true);
2093 + } else if (e.key === 'ArrowDown') {
2094 + e.preventDefault();
2095 + var $next = $items.eq((idx + 1) % $items.length);
2096 + $items.attr('tabindex', '-1');
2097 + $next.attr('tabindex', '0').trigger('focus');
2098 + } else if (e.key === 'ArrowUp') {
2099 + e.preventDefault();
2100 + var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2101 + $items.attr('tabindex', '-1');
2102 + $prev.attr('tabindex', '0').trigger('focus');
2103 + } else if (e.key === 'Enter' || e.key === ' ') {
2104 + e.preventDefault();
2105 + $(this).trigger('click');
2106 + }
2107 + });
2108 + $trigger.on('keydown', function(e) {
2109 + if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2110 + e.preventDefault();
2111 + closeMenu(true);
2112 + } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2113 + e.preventDefault();
2114 + openMenu();
2115 + }
2116 + });
2117 +
2118 + // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2119 + // then do the initial item render.
2120 + $wrap.data('mxchatMenuClose', closeMenu);
2121 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2122 +
2123 + $wrap.data('mxchatMenuReady', true);
2124 +}
2125 +
2126 +// Initialize header menus for every rendered widget on DOM ready.
2127 +$(function() {
2128 + $('.mxchat-header-menu-wrap').each(function() {
2129 + var botId = $(this).data('bot-id');
2130 + if (botId) mxchatInitHeaderMenu(botId);
2131 + });
2132 +
2133 + // Embedded (non-floating) widgets are open from the moment the page
2134 + // renders — refresh dynamic settings at init (plan-32db95). Floating
2135 + // widgets refresh on first launcher open instead.
2136 + var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2137 + return !$(this).closest('.floating-chatbot').length;
2138 + }).length > 0;
2139 + if (hasEmbeddedWidget) {
2140 + mxchatRefreshDynamicSettings();
2141 + }
1279 2142 });
1280 2143
1281 -
1282 2144 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1283 2145 try {
1284 2146 // Determine styles based on sender type
1285 2147 let messageClass, bgColor, fontColor;
@@ -1354,9 +2216,11 @@
1354 2216
1355 2217 messageDiv.html(fullMessage);
1356 2218
1357 2219 if (isTemporary) {
1358 - messageDiv.addClass('temporary-message');
2220 + // In-flight bubble: hold aria-busy so the live region stays quiet
2221 + // until the content is finalized (plan 67f126).
2222 + messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
1359 2223 }
1360 2224
1361 2225 // Append to the correct chatbot instance's chat-box
1362 2226 var $chatBox = getElement(botId, 'chat-box');
@@ -1371,8 +2235,12 @@
1371 2235 if (lastUserMessage.length) {
1372 2236 scrollElementToTop(lastUserMessage, botId);
1373 2237 }
1374 2238 }
2239 +
2240 + if ((sender === "bot" || sender === "agent") && !isTemporary) {
2241 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2242 + }
1375 2243 });
1376 2244
1377 2245 if (messageText.id) {
1378 2246 var instance = MxChatInstances.get(botId);
@@ -1474,13 +2342,16 @@
1474 2342
1475 2343 if (images.length > 0) {
1476 2344 fullMessage += '<div class="image-gallery" dir="auto">';
1477 2345 images.forEach(img => {
2346 + const safeTitle = sanitizeUserInput(img.title);
2347 + const safeUrl = escapeAttr(img.image_url);
2348 + const safeThumbnail = escapeAttr(img.thumbnail_url);
1478 2349 fullMessage += `
1479 2350 <div style="margin-bottom: 10px;">
1480 - <strong>${img.title}</strong><br>
1481 - <a href="${img.image_url}" target="_blank">
1482 - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" />
2351 + <strong>${safeTitle}</strong><br>
2352 + <a href="${safeUrl}" target="_blank">
2353 + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
1483 2354 </a>
1484 2355 </div>`;
1485 2356 });
1486 2357 fullMessage += '</div>';
@@ -1487,13 +2358,16 @@
1487 2358 }
1488 2359
1489 2360 if (lastMessageDiv.length) {
1490 2361 // Replace content immediately to prevent visual gap between thinking dots and response
2362 + // aria-busy released AFTER the final content is set, so the live region
2363 + // announces the finished message once (plan 67f126).
1491 2364 lastMessageDiv
1492 2365 .html(fullMessage)
1493 2366 .removeClass('bot-message user-message temporary-message')
1494 2367 .addClass(messageClass)
1495 - .attr('dir', 'auto');
2368 + .attr('dir', 'auto')
2369 + .attr('aria-busy', 'false');
1496 2370
1497 2371 // Only apply inline colors if AI theme is not active (let CSS handle it)
1498 2372 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1499 2373 if (!skipColors) {
@@ -1519,8 +2393,12 @@
1519 2393 }
1520 2394
1521 2395 // Re-enable chat input after response is displayed
1522 2396 enableChatInput(botId);
2397 +
2398 + if (sender === "bot" || sender === "agent") {
2399 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2400 + }
1523 2401 } else {
1524 2402 appendMessage(sender, responseText, responseHtml, images, false, botId);
1525 2403 // Re-enable chat input after response is displayed
1526 2404 enableChatInput(botId);
@@ -1549,10 +2427,15 @@
1549 2427 var botMessageFontColor = mxchatChat.bot_message_font_color;
1550 2428 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1551 2429
1552 2430 // Build thinking dots HTML - skip inline colors if AI theme is active
2431 + // The dots are decorative; the sr-only span is what the live region
2432 + // announces for the waiting state (plan 67f126). Server-localized
2433 + // string — safe to inject (esc_html__ output, no user content).
1553 2434 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1554 - var thinkingHtml = '<div class="thinking-dots-container">' +
2435 + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2436 + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2437 + '<div class="thinking-dots-container" aria-hidden="true">' +
1555 2438 '<div class="thinking-dots">' +
1556 2439 '<span class="dot"' + dotStyle + '></span>' +
1557 2440 '<span class="dot"' + dotStyle + '></span>' +
1558 2441 '<span class="dot"' + dotStyle + '></span>' +
@@ -1587,27 +2470,42 @@
1587 2470 // Check for % followed by exactly 2 hex digits
1588 2471 return /%[0-9a-fA-F]{2}/.test(url);
1589 2472 }
1590 2473
1591 - // Helper function to safely encode URLs only if needed
2474 + // Helper function to safely encode URLs only if needed, then make the
2475 + // result attribute-safe. "Don't double-encode an already-encoded URL"
2476 + // used to mean "return it untouched" — so a literal quote riding next
2477 + // to a %20 reached href="…" intact and closed the attribute
2478 + // (plan 2db182). Every href this function feeds now goes through
2479 + // escapeAttr(), whether or not the URL was already percent-encoded.
1592 2480 function safeEncodeUrl(url) {
1593 - // If URL already contains encoded characters, return as-is
1594 - if (isUrlEncoded(url)) {
1595 - return url;
1596 - }
1597 - // Otherwise, encode it
1598 - return encodeURI(url);
2481 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2482 + return escapeAttr(encoded);
1599 2483 }
1600 2484
1601 - // Process markdown headers FIRST
1602 - let processedText = formatMarkdownHeaders(inputText);
1603 -
2485 + // Take backslash escapes and code (fenced + inline) out of the text FIRST
2486 + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2487 + // backtick could not protect an underscore and \_ was not an escape at
2488 + // all; the header rule also ran over fenced code. Each escape becomes a
2489 + // placeholder carrying its literal character, each code span a
2490 + // placeholder carrying its rendered HTML; both are restored below, after
2491 + // the styling rules have run, so nothing downstream changes.
2492 + const literals = protectLiterals(inputText);
2493 + let processedText = literals.text;
2494 +
2495 + // Process markdown headers
2496 + processedText = formatMarkdownHeaders(processedText);
2497 +
1604 2498 // Process text styling (bold, italic, strikethrough)
1605 2499 processedText = formatTextStyling(processedText);
1606 -
1607 - // Process code blocks BEFORE processing links
2500 +
2501 + // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2502 + // rendered inside protectLiterals) — still BEFORE processing links
1608 2503 processedText = formatCodeBlocks(processedText);
1609 2504
2505 + // Put the rendered code and the escaped characters back
2506 + processedText = literals.restore(processedText);
2507 +
1610 2508 // Process markdown tables BEFORE converting newlines to paragraphs
1611 2509 processedText = formatMarkdownTables(processedText);
1612 2510
1613 2511 // NOW convert to paragraphs
@@ -1619,10 +2517,13 @@
1619 2517 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
1620 2518 // Clean the URL of any trailing punctuation
1621 2519 let cleanUrl = url.replace(/[.,;!?]+$/, '');
1622 2520 const safeUrl = safeEncodeUrl(cleanUrl);
2521 + // The bracket body is the only link text that can carry angle
2522 + // brackets (the standalone matcher excludes them) — keep it text.
2523 + const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1623 2524 // Return as a proper link without the brackets
1624 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2525 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1625 2526 });
1626 2527
1627 2528 // Process markdown links: [text](url) and [](url)
1628 2529 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -1726,8 +2627,30 @@
1726 2627
1727 2628 return processedText;
1728 2629 }
1729 2630
2631 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2632 + // A single alternation keeps the two honest with each other: an escaped
2633 + // backtick is consumed as an escape before it can open a code span, and a
2634 + // code span is consumed whole so the escapes inside it stay verbatim.
2635 + // Placeholders are private-use characters — no underscore, asterisk,
2636 + // backtick, hash, pipe or bracket for a later rule to match.
2637 + function protectLiterals(text) {
2638 + const store = [];
2639 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2640 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2641 + return '\uE000' + (store.length - 1) + '\uE001';
2642 + });
2643 + return {
2644 + text: out,
2645 + restore: function(t) {
2646 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2647 + return store[Number(i)];
2648 + });
2649 + }
2650 + };
2651 + }
2652 +
1730 2653 function formatMarkdownHeaders(text) {
1731 2654 // Handle h1 to h6 headers
1732 2655 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
1733 2656 const level = hashes.length;
@@ -1769,11 +2692,24 @@
1769 2692 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
1770 2693 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
1771 2694 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
1772 2695
1773 - // Handle underscores for italic - Safari-compatible (no lookbehind)
1774 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
1775 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2696 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2697 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2698 + // text or a non-word character, the closer must precede the end or a
2699 + // non-word character, and the content may not start or end with a
2700 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2701 + // while "use _this_ word" still italicises. A token whose content is
2702 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2703 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2704 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2705 + // adjacent to another underscore or a word character.
2706 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2707 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2708 + return match;
2709 + }
2710 + return before + '<em>' + content + '</em>';
2711 + });
1776 2712
1777 2713 // Handle strikethrough (~~text~~)
1778 2714 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
1779 2715
@@ -1907,8 +2843,24 @@
1907 2843 div.textContent = text;
1908 2844 return div.innerHTML;
1909 2845 }
1910 2846
2847 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2848 + // Percent-encodes the characters that can end or extend an attribute.
2849 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2850 + // never changes where a legitimate link resolves — encodeURI() already
2851 + // produces most of these, this just guarantees them for URLs that skip
2852 + // encodeURI() because they were percent-encoded on arrival.
2853 + function escapeAttr(url) {
2854 + return String(url)
2855 + .replace(/"/g, '%22')
2856 + .replace(/'/g, '%27')
2857 + .replace(/</g, '%3C')
2858 + .replace(/>/g, '%3E')
2859 + .replace(/`/g, '%60')
2860 + .replace(/ /g, '%20');
2861 + }
2862 +
1911 2863 function escapeHtml(unsafe) {
1912 2864 // Skip escaping if it's already escaped or contains HTML code block markup
1913 2865 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
1914 2866 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -1973,13 +2925,14 @@
1973 2925 requestAnimationFrame(smoothScroll);
1974 2926 }
1975 2927 }
1976 2928
1977 - function scrollElementToTop(element, botId) {
2929 + function scrollElementToTop(element, botId, topOffset) {
1978 2930 botId = botId || 'default';
2931 + topOffset = (typeof topOffset === 'number') ? topOffset : 2;
1979 2932 var chatBox = getElement(botId, 'chat-box');
1980 2933 var elementTop = element.position().top + chatBox.scrollTop();
1981 - chatBox.animate({ scrollTop: elementTop }, 500);
2934 + chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1982 2935 }
1983 2936
1984 2937 function showChatWidget(botId) {
1985 2938 botId = botId || 'default';
@@ -2251,9 +3204,30 @@
2251 3204 }
2252 3205
2253 3206 // Only process if there are actual messages
2254 3207 if (response.data.conversation.length > 0) {
2255 - // IMPORTANT: Clear existing messages before loading history
3208 + // Restored history must be SILENT to screen readers
3209 + // (plan 67f126): these are DOM additions inside the
3210 + // live region and would otherwise announce as if
3211 + // they just arrived. Lift aria-live for the batch
3212 + // repopulate, restore it after the browser has
3213 + // processed the mutations.
3214 + var mxLiveRegionEl = $chatBox.get(0);
3215 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3216 + if (mxLiveRegionEl) {
3217 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3218 + }
3219 +
3220 + // IMPORTANT: Clear existing messages before loading history.
3221 + // Detach the greeting first and put it back below —
3222 + // it is server-rendered and never stored in the
3223 + // transcript, so the old unconditional .empty()
3224 + // deleted it for the rest of the page life (plan
3225 + // a1a79b). Detach rather than rebuild: intro_message
3226 + // is not localized to JS, and this node already
3227 + // carries the per-bot inline colors and any
3228 + // {visitor_name} substitution already applied to it.
3229 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2256 3230 $chatBox.empty();
2257 3231
2258 3232 $.each(response.data.conversation, function(index, message) {
2259 3233 // Skip agent messages if persistence is off
@@ -2290,15 +3264,28 @@
2290 3264 var content = message.content;
2291 3265 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2292 3266 content = decodeHTMLEntities(content);
2293 3267
3268 + // A visitor's own rows get the same treatment as the
3269 + // live turn (appendMessage escapes user text before
3270 + // linkify). Without this a replayed user message came
3271 + // back as live markup after a refresh (plan 2db182).
3272 + // Bot/agent rows are trusted HTML and stay as they were.
3273 + if (message.role === 'user') {
3274 + content = sanitizeUserInput(content);
3275 + }
3276 +
2294 3277 // Skip linkify for messages containing structured HTML
2295 3278 // (forms, product cards, galleries, etc.) to avoid
2296 3279 // markdown formatting corrupting HTML attributes
2297 - // (e.g. underscores in name="field_name" becoming <em> tags)
2298 - if (content.includes("mxchat-product-card") ||
2299 - content.includes("mxchat-image-gallery") ||
2300 - content.includes("mxchat-featured-products") ||
3280 + // (e.g. underscores in name="field_name" becoming <em> tags).
3281 + // One family check instead of a per-card literal list: any
3282 + // element carrying an mxchat- prefixed class is MxChat-generated
3283 + // structured markup and replays raw. The old list drifted every
3284 + // time an add-on minted a new card class — the filtered-search
3285 + // card ("mxchat-filtered-product-card") missed it and replayed
3286 + // through linkify as visible markup.
3287 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
2301 3288 content.includes("<form") ||
2302 3289 content.includes("<input") ||
2303 3290 content.includes("<select") ||
2304 3291 content.includes("<textarea")) {
@@ -2316,12 +3303,29 @@
2316 3303 instance.processedMessageIds.add(message.id);
2317 3304 }
2318 3305 });
2319 3306
2320 - // Only append messages and scroll if we have content
3307 + // Only append messages and scroll if we have content.
3308 + // Greeting goes back FIRST, above the restored
3309 + // history: "Hello — [earlier conversation]" is the
3310 + // natural reading and matches the order a fresh
3311 + // visitor sees (plan a1a79b).
3312 + if ($mxIntro && $mxIntro.length) {
3313 + $chatBox.append($mxIntro);
3314 + }
2321 3315 $chatBox.append($fragment);
2322 3316 scrollToBottom(botId, true);
2323 3317
3318 + // Re-attach live semantics AFTER the rehydration
3319 + // mutations have been processed with the region off
3320 + // (plan 67f126). Restoring later announces nothing
3321 + // retroactively; new turns announce normally.
3322 + if (mxLiveRegionEl) {
3323 + setTimeout(function() {
3324 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3325 + }, 200);
3326 + }
3327 +
2324 3328 // Collapse quick questions if we have conversation history
2325 3329 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2326 3330 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2327 3331 collapseQuickQuestions(botId);
@@ -2416,10 +3420,10 @@
2416 3420 .then(data => {
2417 3421 if (data.success) {
2418 3422 container.style.display = 'none';
2419 3423 nameElement.textContent = '';
2420 - activePdfFile = null;
2421 - appendMessage('bot', 'PDF removed.');
3424 + instance.activePdfFile = null;
3425 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2422 3426 }
2423 3427 })
2424 3428 .catch(error => {
2425 3429 // Error removing PDF - silently continue
@@ -2425,14 +3429,16 @@
2425 3429 // Error removing PDF - silently continue
2426 3430 });
2427 3431 }
2428 3432
2429 - function removeActiveWord() {
2430 - const container = document.getElementById('active-word-container');
2431 - const nameElement = document.getElementById('active-word-name');
2432 -
2433 - if (!container || !nameElement || !activeWordFile) return;
2434 -
3433 + function removeActiveWord(botId) {
3434 + botId = botId || 'default';
3435 + var instance = MxChatInstances.get(botId);
3436 + const container = getElementDOM(botId, 'active-word-container');
3437 + const nameElement = getElementDOM(botId, 'active-word-name');
3438 +
3439 + if (!container || !nameElement || !instance.activeWordFile) return;
3440 +
2435 3441 fetch(mxchatChat.ajax_url, {
2436 3442 method: 'POST',
2437 3443 headers: {
2438 3444 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2438,9 +3444,9 @@
2438 3444 'Content-Type': 'application/x-www-form-urlencoded',
2439 3445 },
2440 3446 body: new URLSearchParams({
2441 3447 'action': 'mxchat_remove_word',
2442 - 'session_id': sessionId,
3448 + 'session_id': getChatSession(botId),
2443 3449 'nonce': mxchatChat.nonce
2444 3450 })
2445 3451 })
2446 3452 .then(response => response.json())
@@ -2447,10 +3453,10 @@
2447 3453 .then(data => {
2448 3454 if (data.success) {
2449 3455 container.style.display = 'none';
2450 3456 nameElement.textContent = '';
2451 - activeWordFile = null;
2452 - appendMessage('bot', 'Word document removed.');
3457 + instance.activeWordFile = null;
3458 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2453 3459 }
2454 3460 })
2455 3461 .catch(error => {
2456 3462 // Error removing Word document - silently continue
@@ -2609,11 +3615,38 @@
2609 3615 e.stopPropagation();
2610 3616 var botId = getBotIdFromElement(this);
2611 3617 collapseQuickQuestions(botId);
2612 3618 });
3619 +
3620 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3621 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3622 +// Delegated so it also works for embeds restored from chat history.
3623 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3624 + e.preventDefault();
3625 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3626 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3627 + if (!videoId) {
3628 + return;
3629 + }
3630 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3631 + var $iframe = $('<iframe>', {
3632 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3633 + title: title,
3634 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3635 + allowfullscreen: true,
3636 + frameborder: 0
3637 + }).addClass('mxchat-youtube-iframe');
3638 + $wrap.addClass('mxchat-youtube-playing');
3639 + $(this).replaceWith($iframe);
3640 +});
2613 3641
2614 3642 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2615 - $(document).on('click', '.floating-chatbot-button', function() {
3643 + // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3644 + $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3645 + if (e.type === 'keydown') {
3646 + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3647 + e.preventDefault();
3648 + }
2616 3649 var botId = getBotIdFromElement(this);
2617 3650 var $chatbot = getElement(botId, 'floating-chatbot');
2618 3651 var $badge = getElement(botId, 'chat-notification-badge');
2619 3652 var $preChat = getElement(botId, 'pre-chat-message');
@@ -2618,14 +3651,21 @@
2618 3651 var $badge = getElement(botId, 'chat-notification-badge');
2619 3652 var $preChat = getElement(botId, 'pre-chat-message');
2620 3653
2621 3654 if ($chatbot.hasClass('hidden')) {
2622 - $chatbot.removeClass('hidden').addClass('visible');
2623 - $(this).addClass('hidden');
3655 + $chatbot.removeClass('hidden').addClass('visible')
3656 + .attr('aria-modal', 'true').attr('role', 'dialog');
3657 + $(this).addClass('hidden').attr('aria-expanded', 'true');
2624 3658 $badge.hide(); // Hide notification when opening chat
2625 3659 disableScroll();
2626 3660 $preChat.fadeOut(250);
3661 + mxchatFireOpened(botId);
2627 3662
3663 + // First open per page load: re-fetch behavior settings in case
3664 + // this page's inline values came from a stale full-page cache
3665 + // (plan-32db95). Idempotent — later opens are a no-op.
3666 + mxchatRefreshDynamicSettings();
3667 +
2628 3668 // Load chat history for returning visitors (persistence)
2629 3669 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
2630 3670 if (chatPersistenceEnabled) {
2631 3671 MxChatInstances.ensureSession(botId);
@@ -2641,24 +3681,55 @@
2641 3681 // No email collection — still route through showChatContainerForBot
2642 3682 // so the loader is shown while chat history loads
2643 3683 showChatContainerForBot(botId);
2644 3684 }
3685 +
3686 + // Move keyboard focus into the message input after the open transition.
3687 + setTimeout(function() {
3688 + var chatInput = getElementDOM(botId, 'chat-input');
3689 + if (chatInput && !chatInput.disabled) {
3690 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3691 + }
3692 + }, 300);
2645 3693 } else {
2646 - $chatbot.removeClass('visible').addClass('hidden');
2647 - $(this).removeClass('hidden');
3694 + $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3695 + $(this).removeClass('hidden').attr('aria-expanded', 'false');
2648 3696 enableScroll();
2649 3697 checkPreChatDismissal(botId);
2650 3698 }
2651 3699 });
2652 3700
2653 - // Allow clicking anywhere on the title bar to close the chatbot
3701 + // Allow clicking anywhere on the title bar to close the chatbot.
3702 + // Returns keyboard focus to the launcher so keyboard users don't get
3703 + // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3704 + // heuristic-based so mouse-triggered close won't show a focus ring.
2654 3705 $(document).on('click', '.chatbot-top-bar', function() {
2655 3706 var botId = getBotIdFromElement(this);
2656 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2657 - getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3707 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3708 + var $launcher = getElement(botId, 'floating-chatbot-button');
3709 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2658 3710 enableScroll();
3711 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
2659 3712 });
2660 3713
3714 + // Global Escape-key handler — closes any visible chat widget and
3715 + // returns focus to its launcher. Standard modal-dismissal pattern;
3716 + // pairs with aria-modal="true" set on the widget when it opens.
3717 + $(document).on('keydown', function(e) {
3718 + if (e.key !== 'Escape' && e.key !== 'Esc') return;
3719 + var $visible = $('.floating-chatbot.visible');
3720 + if (!$visible.length) return;
3721 + e.preventDefault();
3722 + $visible.each(function() {
3723 + var botId = getBotIdFromElement(this);
3724 + $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3725 + var $launcher = getElement(botId, 'floating-chatbot-button');
3726 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3727 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3728 + });
3729 + enableScroll();
3730 + });
3731 +
2661 3732 $(document).on('click', '.close-pre-chat-message', function(e) {
2662 3733 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
2663 3734 var botId = getBotIdFromElement(this);
2664 3735 handlePreChatDismissal(botId);
@@ -2678,17 +3749,20 @@
2678 3749 var wordInput = getElementDOM(botId, 'word-upload');
2679 3750 if (wordInput) wordInput.click();
2680 3751 });
2681 3752
2682 - // PDF file input change handler
2683 - addSafeEventListener('pdf-upload', 'change', async function(e) {
2684 - const file = e.target.files[0];
2685 -
3753 + // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3754 + $(document).on('change', '.pdf-upload', async function(e) {
3755 + var botId = getBotIdFromElement(this);
3756 + var instance = MxChatInstances.get(botId);
3757 + const file = this.files[0];
3758 + const sessionId = MxChatInstances.ensureSession(botId);
3759 +
2686 3760 if (!file || file.type !== 'application/pdf') {
2687 3761 alert('Please select a valid PDF file.');
2688 3762 return;
2689 3763 }
2690 -
3764 +
2691 3765 if (!sessionId) {
2692 3766 alert('Error: No session ID found');
2693 3767 return;
2694 3768 }
@@ -2696,47 +3770,49 @@
2696 3770 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
2697 3771 alert('Error: Ajax configuration missing');
2698 3772 return;
2699 3773 }
2700 -
3774 +
2701 3775 // Disable buttons and show loading state
2702 - const uploadBtn = document.getElementById('pdf-upload-btn');
2703 - const sendBtn = document.getElementById('send-button');
3776 + const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3777 + const sendBtn = getElementDOM(botId, 'send-button');
3778 + if (!uploadBtn) return;
2704 3779 const originalBtnContent = uploadBtn.innerHTML;
2705 -
3780 +
2706 3781 try {
3782 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3783 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2707 3784 const formData = new FormData();
2708 3785 formData.append('action', 'mxchat_upload_pdf');
2709 3786 formData.append('pdf_file', file);
2710 3787 formData.append('session_id', sessionId);
2711 3788 formData.append('nonce', mxchatChat.nonce);
2712 -
3789 +
2713 3790 uploadBtn.disabled = true;
2714 - sendBtn.disabled = true;
3791 + if (sendBtn) sendBtn.disabled = true;
2715 3792 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
2716 3793 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
2717 3794 </svg>`;
2718 -
3795 +
2719 3796 const response = await fetch(mxchatChat.ajax_url, {
2720 3797 method: 'POST',
2721 3798 body: formData
2722 3799 });
2723 -
3800 +
2724 3801 const data = await response.json();
2725 -
3802 +
2726 3803 if (data.success) {
2727 3804 // Hide popular questions if they exist
2728 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2729 - if (hasQuickQuestions()) {
2730 - collapseQuickQuestions();
3805 + if (hasQuickQuestions(botId)) {
3806 + collapseQuickQuestions(botId);
2731 3807 }
2732 -
3808 +
2733 3809 // Show the active PDF name
2734 - showActivePdf(data.data.filename);
2735 -
2736 - appendMessage('bot', data.data.message);
2737 - scrollToBottom();
2738 - activePdfFile = data.data.filename;
3810 + showActivePdf(data.data.filename, botId);
3811 +
3812 + appendMessage('bot', data.data.message, '', [], false, botId);
3813 + scrollToBottom(botId);
3814 + instance.activePdfFile = data.data.filename;
2739 3815 } else {
2740 3816 alert('Failed to upload PDF. Please try again.');
2741 3817 }
2742 3818 } catch (error) {
@@ -2742,66 +3818,76 @@
2742 3818 } catch (error) {
2743 3819 alert('Error uploading file. Please try again.');
2744 3820 } finally {
2745 3821 uploadBtn.disabled = false;
2746 - sendBtn.disabled = false;
3822 + if (sendBtn) sendBtn.disabled = false;
2747 3823 uploadBtn.innerHTML = originalBtnContent;
2748 3824 this.value = ''; // Reset file input
2749 3825 }
2750 3826 });
2751 3827
2752 - // Word file input change handler
2753 - addSafeEventListener('word-upload', 'change', async function(e) {
2754 - const file = e.target.files[0];
2755 -
3828 + // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3829 + $(document).on('change', '.word-upload', async function(e) {
3830 + var botId = getBotIdFromElement(this);
3831 + var instance = MxChatInstances.get(botId);
3832 + const file = this.files[0];
3833 + const sessionId = MxChatInstances.ensureSession(botId);
3834 +
2756 3835 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
2757 3836 alert('Please select a valid Word document (.docx).');
2758 3837 return;
2759 3838 }
2760 -
3839 +
2761 3840 if (!sessionId) {
2762 3841 alert('Error: No session ID found');
2763 3842 return;
2764 3843 }
2765 3844
3845 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3846 + alert('Error: Ajax configuration missing');
3847 + return;
3848 + }
3849 +
2766 3850 // Disable buttons and show loading state
2767 - const uploadBtn = document.getElementById('word-upload-btn');
2768 - const sendBtn = document.getElementById('send-button');
3851 + const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3852 + const sendBtn = getElementDOM(botId, 'send-button');
3853 + if (!uploadBtn) return;
2769 3854 const originalBtnContent = uploadBtn.innerHTML;
2770 -
3855 +
2771 3856 try {
3857 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3858 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2772 3859 const formData = new FormData();
2773 3860 formData.append('action', 'mxchat_upload_word');
2774 3861 formData.append('word_file', file);
2775 3862 formData.append('session_id', sessionId);
2776 3863 formData.append('nonce', mxchatChat.nonce);
2777 -
3864 +
2778 3865 uploadBtn.disabled = true;
2779 - sendBtn.disabled = true;
3866 + if (sendBtn) sendBtn.disabled = true;
2780 3867 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
2781 3868 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
2782 3869 </svg>`;
2783 -
3870 +
2784 3871 const response = await fetch(mxchatChat.ajax_url, {
2785 3872 method: 'POST',
2786 3873 body: formData
2787 3874 });
2788 -
3875 +
2789 3876 const data = await response.json();
2790 -
3877 +
2791 3878 if (data.success) {
2792 3879 // Hide popular questions if they exist
2793 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2794 - if (hasQuickQuestions()) {
2795 - collapseQuickQuestions();
3880 + if (hasQuickQuestions(botId)) {
3881 + collapseQuickQuestions(botId);
2796 3882 }
2797 -
3883 +
2798 3884 // Show the active Word document name
2799 - showActiveWord(data.data.filename);
2800 -
2801 - appendMessage('bot', data.data.message);
2802 - scrollToBottom();
2803 - activeWordFile = data.data.filename;
3885 + showActiveWord(data.data.filename, botId);
3886 +
3887 + appendMessage('bot', data.data.message, '', [], false, botId);
3888 + scrollToBottom(botId);
3889 + instance.activeWordFile = data.data.filename;
2804 3890 } else {
2805 3891 alert('Failed to upload Word document. Please try again.');
2806 3892 }
2807 3893 } catch (error) {
@@ -2807,25 +3893,25 @@
2807 3893 } catch (error) {
2808 3894 alert('Error uploading file. Please try again.');
2809 3895 } finally {
2810 3896 uploadBtn.disabled = false;
2811 - sendBtn.disabled = false;
3897 + if (sendBtn) sendBtn.disabled = false;
2812 3898 uploadBtn.innerHTML = originalBtnContent;
2813 3899 this.value = ''; // Reset file input
2814 3900 }
2815 3901 });
2816 3902
2817 - // Remove button click handlers
2818 - document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3903 + // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3904 + $(document).on('click', '.remove-pdf-btn', function(e) {
2819 3905 e.preventDefault();
2820 3906 e.stopPropagation();
2821 - removeActivePdf();
3907 + removeActivePdf(getBotIdFromElement(this));
2822 3908 });
2823 -
2824 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3909 +
3910 + $(document).on('click', '.remove-word-btn', function(e) {
2825 3911 e.preventDefault();
2826 3912 e.stopPropagation();
2827 - removeActiveWord();
3913 + removeActiveWord(getBotIdFromElement(this));
2828 3914 });
2829 3915
2830 3916 // Window resize handlers
2831 3917 $(window).on('resize orientationchange', function() {
@@ -2842,9 +3928,12 @@
2842 3928 $('.chat-toolbar').show();
2843 3929 } else {
2844 3930 $('.chat-toolbar').hide();
2845 3931 }
2846 -
3932 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3933 + MxChatInstances.toolbarReady = true;
3934 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3935 +
2847 3936 // Apply toolbar icon colors
2848 3937 const toolbarElements = [
2849 3938 '#mxchat-chatbot .toolbar-btn svg',
2850 3939 '#mxchat-chatbot .active-pdf-name',
@@ -2969,10 +4058,13 @@
2969 4058 function replaceVisitorNamePlaceholder(botId, visitorName) {
2970 4059 var chatBox = getElementDOM(botId, 'chat-box');
2971 4060 if (!chatBox) return;
2972 4061
2973 - // Find the first bot message (intro message)
2974 - var introMessage = chatBox.querySelector('.bot-message');
4062 + // Find the greeting by its marker, not by position (plan a1a79b) —
4063 + // after a persistence restore the first .bot-message is a restored
4064 + // reply, and {visitor_name} was being substituted into that instead.
4065 + // Positional fallback for HTML cached before this release only.
4066 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
2975 4067 if (!introMessage) return;
2976 4068
2977 4069 var messageContent = introMessage.querySelector('div[dir="auto"]');
2978 4070 if (!messageContent) return;
@@ -3116,14 +4208,25 @@
3116 4208 return response.json();
3117 4209 })
3118 4210 .then((data) => {
3119 4211 if (data.success) {
3120 - if (data.data.logged_in || data.data.email) {
4212 + // A success response means the server has already decided the
4213 + // required info is on file. With email optional (c0cfaf) that
4214 + // info can be a name and no address, so keying on data.email
4215 + // alone re-prompted a name-only visitor on every page load.
4216 + if (data.data.logged_in || data.data.email || data.data.name) {
3121 4217 showChatContainerForBot(botId);
3122 4218 } else {
3123 4219 showEmailFormForBot(botId);
3124 4220 }
3125 4221 } else {
4222 + // Pre-fill for a signed-in visitor being shown the form
4223 + // (c0cfaf). It arrives on this nocache'd response rather than
4224 + // in the page HTML, which is cacheable. Never overwrite what
4225 + // the visitor has already typed.
4226 + if (data.data) {
4227 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4228 + }
3126 4229 showEmailFormForBot(botId);
3127 4230 }
3128 4231 })
3129 4232 .catch((error) => {
@@ -3130,8 +4233,19 @@
3130 4233 showEmailFormForBot(botId);
3131 4234 });
3132 4235 }
3133 4236
4237 + function prefillEmailForm(botId, name, email) {
4238 + var nameInput = getElementDOM(botId, 'user-name');
4239 + var emailInput = getElementDOM(botId, 'user-email');
4240 + if (nameInput && name && !nameInput.value) {
4241 + nameInput.value = name;
4242 + }
4243 + if (emailInput && email && !emailInput.value) {
4244 + emailInput.value = email;
4245 + }
4246 + }
4247 +
3134 4248 // Event delegation for email form submission
3135 4249 $(document).on('submit', '.email-collection-form', function(e) {
3136 4250 e.preventDefault();
3137 4251 e.stopPropagation();
@@ -3144,23 +4258,34 @@
3144 4258 }
3145 4259
3146 4260 var emailInput = getElementDOM(botId, 'user-email');
3147 4261 var nameInput = getElementDOM(botId, 'user-name');
4262 + var consentInput = getElementDOM(botId, 'user-consent');
3148 4263 var userEmail = emailInput ? emailInput.value.trim() : '';
3149 4264 var userName = nameInput ? nameInput.value.trim() : '';
3150 4265 var sessionId = MxChatInstances.ensureSession(botId);
3151 4266
3152 - // Validate email
3153 - if (!userEmail) {
4267 + // Validate email. The address is optional when the owner turned
4268 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4269 + // still has to be a valid address. The server enforces both rules
4270 + // independently.
4271 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4272 +
4273 + if (emailRequired && !userEmail) {
3154 4274 showEmailError(botId, 'Please enter your email address.');
3155 4275 return false;
3156 4276 }
3157 4277
3158 - if (!isValidEmailAddress(userEmail)) {
4278 + if (userEmail && !isValidEmailAddress(userEmail)) {
3159 4279 showEmailError(botId, 'Please enter a valid email address.');
3160 4280 return false;
3161 4281 }
3162 4282
4283 + if (!emailRequired && !userEmail && !userName) {
4284 + showEmailError(botId, 'Please enter your name or email address.');
4285 + return false;
4286 + }
4287 +
3163 4288 // Validate name if field exists and has content
3164 4289 if (nameInput && userName && !isValidNameInput(userName)) {
3165 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3166 4291 return false;
@@ -3165,8 +4290,15 @@
3165 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3166 4291 return false;
3167 4292 }
3168 4293
4294 + // Consent checkbox (b062c4): backstop behind the native required
4295 + // attribute; the server enforces this independently either way.
4296 + if (consentInput && consentInput.required && !consentInput.checked) {
4297 + showEmailError(botId, 'Please tick the consent box to continue.');
4298 + return false;
4299 + }
4300 +
3169 4301 clearEmailError(botId);
3170 4302 setEmailSubmissionState(botId, true);
3171 4303
3172 4304 // Prepare form data
@@ -3180,8 +4312,14 @@
3180 4312 if (userName) {
3181 4313 formData.append('name', userName);
3182 4314 }
3183 4315
4316 + // Ticked/unticked both travel when the checkbox is rendered, so an
4317 + // optional-consent "no" is recorded as a decision, not an absence.
4318 + if (consentInput) {
4319 + formData.append('consent', consentInput.checked ? '1' : '0');
4320 + }
4321 +
3184 4322 fetch(mxchatChat.ajax_url, {
3185 4323 method: 'POST',
3186 4324 headers: {
3187 4325 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3303,8 +4441,9 @@
3303 4441 $chatbot.removeClass('hidden').addClass('visible');
3304 4442 getElement(botId, 'floating-chatbot-button').addClass('hidden');
3305 4443 handlePreChatDismissal(botId);
3306 4444 disableScroll(); // Disable scroll when chatbot opens
4445 + mxchatFireOpened(botId);
3307 4446
3308 4447 // Load chat history for returning visitors (persistence)
3309 4448 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3310 4449 if (chatPersistenceEnabled) {
@@ -3470,8 +4609,15 @@
3470 4609 // Initialize chat visibility for all instances
3471 4610 $('.mxchat-chatbot-wrapper').each(function() {
3472 4611 var botId = $(this).data('bot-id') || 'default';
3473 4612 initializeChatVisibility(botId);
4613 + // An embedded widget is on screen from the first paint; a floating one
4614 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4615 + if (isEmbeddedBot(botId)) {
4616 + mxchatFireOpened(botId);
4617 + } else {
4618 + mxchatWatchForOpen(botId);
4619 + }
3474 4620 });
3475 4621
3476 4622 // Make functions globally available for add-ons
3477 4623 window.hasQuickQuestions = hasQuickQuestions;
@@ -3519,6 +4665,312 @@
3519 4665 }, 2000);
3520 4666 });
3521 4667 }
3522 4668 }
4669 +});
4670 +
4671 +// ============================================================================
4672 +// SATISFACTION RATING (v3.2.6)
4673 +// ============================================================================
4674 +// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4675 +// inactivity following a bot reply. One prompt per session, deduped via
4676 +// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4677 +// the option (default off) is authoritative.
4678 +jQuery(function($) {
4679 + if (typeof mxchatChat === 'undefined') return;
4680 + // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4681 + // '' and true as '1', so this must be an explicit-enable allowlist — the
4682 + // old "disabled when exactly false/'off'" check let '' through and the
4683 + // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4684 + // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4685 + // (boolean-true localizations) working.
4686 + // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4687 + // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4688 + // merges fresh values over mxchatChat (that merge fires on first widget
4689 + // open, after this module has already decided). Re-evaluating would mean
4690 + // restructuring the whole module to late-bind its listeners — not worth it
4691 + // for a prompt that is at worst stale for one page load on a cached page.
4692 + var sre = mxchatChat.satisfaction_rating_enabled;
4693 + if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4694 +
4695 + // wp_localize_script stringifies ints, so accept both number and numeric string.
4696 + var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4697 + var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4698 + if (!isFinite(idleSeconds)) idleSeconds = 60;
4699 + if (idleSeconds < 5) idleSeconds = 5;
4700 + if (idleSeconds > 600) idleSeconds = 600;
4701 + var IDLE_MS = idleSeconds * 1000;
4702 + var MIN_BOT_REPLIES = 2;
4703 + var ratingState = {};
4704 +
4705 + function getState(botId) {
4706 + if (!ratingState[botId]) {
4707 + ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4708 + }
4709 + return ratingState[botId];
4710 + }
4711 +
4712 + function getSessionId(botId) {
4713 + if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4714 + return MxChatInstances.getChatSession(botId);
4715 + }
4716 + return null;
4717 + }
4718 +
4719 + function isAlreadyRated(sessionId) {
4720 + if (!sessionId) return false;
4721 + try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4722 + }
4723 +
4724 + function markRated(sessionId) {
4725 + if (!sessionId) return;
4726 + try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4727 + }
4728 +
4729 + function esc(s) {
4730 + return String(s == null ? '' : s)
4731 + .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4732 + .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4733 + }
4734 +
4735 + // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4736 + function ratingSkipInlineColors(botId) {
4737 + if (mxchatChat.skip_inline_colors) return true;
4738 + var botAssignments = mxchatChat.bot_theme_assignments || {};
4739 + return botAssignments.hasOwnProperty(botId);
4740 + }
4741 +
4742 + function botBubbleStyleAttr(botId) {
4743 + if (ratingSkipInlineColors(botId)) return '';
4744 + var bg = mxchatChat.bot_message_bg_color;
4745 + var fg = mxchatChat.bot_message_font_color;
4746 + if (!bg && !fg) return '';
4747 + return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4748 + }
4749 +
4750 + // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4751 + // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4752 + // and paints the filled "Send" pill so it fills with the bot font color and
4753 + // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4754 + // We paint the submit button DIRECTLY (inline longhand) rather than relying
4755 + // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4756 + // unreliably inside a descendant's `background`, so a bubble-level var would
4757 + // silently fall back to the literal (white-block bug all over again). Inline
4758 + // longhand always wins. Same transparent-guard as the menu so we never paint
4759 + // a see-through value — in that case the CSS literal fallbacks keep it legible.
4760 + function syncRatingBubbleColors(botId) {
4761 + var $chatBox = getChatBoxByBotId(botId);
4762 + if (!$chatBox || !$chatBox.length) return;
4763 + var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4764 + if (!bubbleEl) return;
4765 + var cs = window.getComputedStyle(bubbleEl);
4766 + var fg = cs.color;
4767 + var bg = cs.backgroundColor;
4768 + var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4769 + var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4770 + // Expose on the bubble too, for any inheriting styles / future use.
4771 + if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4772 + if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4773 + // Paint the Send pill directly — the part that actually fixes the bug.
4774 + var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4775 + if (submitEl) {
4776 + if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4777 + if (hasBg) submitEl.style.color = bg; // label = bubble background
4778 + }
4779 + }
4780 +
4781 + function copy(key) {
4782 + var c = mxchatChat.satisfaction_rating_copy || {};
4783 + var d = {
4784 + question: 'Was this helpful?',
4785 + helpful: 'Helpful',
4786 + not_helpful: 'Not helpful',
4787 + dismiss: 'Dismiss',
4788 + thanks: 'Thanks! Anything we should improve? (optional)',
4789 + placeholder: 'Tell us what could be better…',
4790 + send: 'Send',
4791 + skip: 'Skip',
4792 + saved: 'Thanks for the feedback.'
4793 + };
4794 + return c[key] || d[key];
4795 + }
4796 +
4797 + function thumbUpSvg() {
4798 + 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>';
4799 + }
4800 + function thumbDownSvg() {
4801 + 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>';
4802 + }
4803 +
4804 + function buildPromptHtml(botId) {
4805 + var styleAttr = botBubbleStyleAttr(botId);
4806 + return ''
4807 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4808 + + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4809 + + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4810 + + '<div class="mxchat-rating-actions">'
4811 + + '<span class="mxchat-rating-buttons">'
4812 + + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4813 + + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4814 + + '</span>'
4815 + + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4816 + + '</div>'
4817 + + '</div>'
4818 + + '</div>';
4819 + }
4820 +
4821 + function buildFeedbackHtml(botId, rating) {
4822 + var styleAttr = botBubbleStyleAttr(botId);
4823 + return ''
4824 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4825 + + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4826 + + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4827 + + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4828 + + '<div class="mxchat-rating-feedback-actions">'
4829 + + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4830 + + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4831 + + '</div>'
4832 + + '</div>'
4833 + + '</div>';
4834 + }
4835 +
4836 + function buildSavedHtml(botId) {
4837 + var styleAttr = botBubbleStyleAttr(botId);
4838 + return ''
4839 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4840 + + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4841 + + '</div>';
4842 + }
4843 +
4844 + function getChatBoxByBotId(botId) {
4845 + var $byId = $('#chat-box-' + botId);
4846 + if ($byId.length) return $byId.first();
4847 + return $('.chat-box').first();
4848 + }
4849 +
4850 + function scrollChatBoxToBottom($chatBox) {
4851 + if (!$chatBox || !$chatBox.length) return;
4852 + $chatBox.scrollTop($chatBox[0].scrollHeight);
4853 + }
4854 +
4855 + function showPrompt(botId) {
4856 + var s = getState(botId);
4857 + if (s.promptShown || s.dismissed) return;
4858 + var sessionId = getSessionId(botId);
4859 + if (!sessionId) return;
4860 + if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4861 + var $chatBox = getChatBoxByBotId(botId);
4862 + if (!$chatBox.length) return;
4863 + if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4864 + $chatBox.append(buildPromptHtml(botId));
4865 + syncRatingBubbleColors(botId);
4866 + s.promptShown = true;
4867 + scrollChatBoxToBottom($chatBox);
4868 + }
4869 +
4870 + function submitRating(botId, rating, feedback) {
4871 + var sessionId = getSessionId(botId);
4872 + if (!sessionId) return;
4873 + $.post(mxchatChat.ajax_url, {
4874 + action: 'mxchat_save_rating',
4875 + session_id: sessionId,
4876 + bot_id: botId,
4877 + rating: rating,
4878 + feedback: feedback || ''
4879 + });
4880 + markRated(sessionId);
4881 + }
4882 +
4883 + function onBotReply(botId) {
4884 + var s = getState(botId);
4885 + s.botReplies += 1;
4886 + if (s.promptShown || s.dismissed) return;
4887 + var sessionId = getSessionId(botId);
4888 + if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4889 + if (s.botReplies < MIN_BOT_REPLIES) return;
4890 + if (s.idleTimer) clearTimeout(s.idleTimer);
4891 + s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4892 + }
4893 +
4894 + function onUserMessage(botId) {
4895 + var s = getState(botId);
4896 + if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4897 + }
4898 +
4899 + function botIdFromChatBox(el) {
4900 + var id = el && el.id ? el.id : '';
4901 + return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4902 + }
4903 +
4904 + function setupObserver(chatBox) {
4905 + var botId = botIdFromChatBox(chatBox);
4906 + try {
4907 + var observer = new MutationObserver(function(mutations) {
4908 + mutations.forEach(function(m) {
4909 + for (var i = 0; i < m.addedNodes.length; i++) {
4910 + var node = m.addedNodes[i];
4911 + if (!node || node.nodeType !== 1) continue;
4912 + var $n = $(node);
4913 + if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4914 + 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)
4915 + else if ($n.hasClass('user-message')) onUserMessage(botId);
4916 + }
4917 + });
4918 + });
4919 + observer.observe(chatBox, { childList: true });
4920 + } catch (e) { /* noop */ }
4921 + }
4922 +
4923 + $('.chat-box').each(function() { setupObserver(this); });
4924 +
4925 + $(document).on('click', '.mxchat-rating-btn', function(e) {
4926 + e.preventDefault();
4927 + var $btn = $(this);
4928 + var $prompt = $btn.closest('.mxchat-rating-prompt');
4929 + var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4930 + var botId = $prompt.data('bot-id') || 'default';
4931 + var rating = parseInt($btn.attr('data-rating'), 10);
4932 + if (rating !== 1 && rating !== -1) return;
4933 + submitRating(botId, rating, '');
4934 + ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4935 + syncRatingBubbleColors(botId);
4936 + scrollChatBoxToBottom(getChatBoxByBotId(botId));
4937 + });
4938 +
4939 + $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4940 + e.preventDefault();
4941 + var $prompt = $(this).closest('.mxchat-rating-prompt');
4942 + var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4943 + var botId = $prompt.data('bot-id') || 'default';
4944 + var s = getState(botId);
4945 + s.dismissed = true;
4946 + markRated(getSessionId(botId));
4947 + ($wrap.length ? $wrap : $prompt).remove();
4948 + });
4949 +
4950 + function closeFeedback($fb) {
4951 + var botId = $fb.data('bot-id') || 'default';
4952 + var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4953 + ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4954 + syncRatingBubbleColors(botId);
4955 + scrollChatBoxToBottom(getChatBoxByBotId(botId));
4956 + }
4957 +
4958 + $(document).on('click', '.mxchat-rating-skip', function(e) {
4959 + e.preventDefault();
4960 + closeFeedback($(this).closest('.mxchat-rating-feedback'));
4961 + });
4962 +
4963 + $(document).on('click', '.mxchat-rating-submit', function(e) {
4964 + e.preventDefault();
4965 + var $fb = $(this).closest('.mxchat-rating-feedback');
4966 + var botId = $fb.data('bot-id') || 'default';
4967 + var rating = parseInt($fb.attr('data-rating'), 10);
4968 + if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4969 + var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4970 + if (text !== '') {
4971 + submitRating(botId, rating, text);
4972 + }
4973 + closeFeedback($fb);
4974 + });
3523 4975 });
3524 4976