PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
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 +1167 -248 3.2.6 → 3.2.25 View file →
@@ -1,41 +1,166 @@
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. The state machine below
5 - // queues callbacks so a chat-send that fires while the refresh AJAX is still
6 - // in flight waits for the fresh nonce instead of racing it with the stale
7 - // cached value (which would 403 as "Access denied" on the first message).
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;
8 19 var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
9 20 var nonceRefreshCallbacks = [];
10 - function refreshNonceIfNeeded(callback) {
11 - if (typeof mxchatChat === 'undefined' || !mxchatChat.ajax_url) {
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') {
12 55 if (callback) callback();
13 56 return;
14 57 }
15 - if (nonceRefreshState === 'done') {
58 + var now = Date.now();
59 + if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 + mxchatChat.nonce = cachedFreshNonce;
16 61 if (callback) callback();
17 62 return;
18 63 }
19 64 if (callback) nonceRefreshCallbacks.push(callback);
20 - if (nonceRefreshState === 'pending') {
21 - return;
22 - }
65 + if (nonceRefreshState === 'pending') return;
23 66 nonceRefreshState = 'pending';
24 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
25 - .done(function(res) {
26 - if (res && res.success && res.data && res.data.nonce) {
27 - mxchatChat.nonce = res.data.nonce;
67 +
68 + var resolved = function (nonce) {
69 + if (nonce) {
70 + cachedFreshNonce = nonce;
71 + cachedFreshNonceFetchedAt = Date.now();
72 + mxchatChat.nonce = nonce;
73 + }
74 + nonceRefreshState = 'done';
75 + var pending = nonceRefreshCallbacks;
76 + nonceRefreshCallbacks = [];
77 + pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78 + };
79 +
80 + fetchFreshNonceFromRest()
81 + .then(resolved)
82 + .catch(function () {
83 + // Fallback to the legacy admin-ajax refresh path (issued with the
84 + // old action `mxchat_chat_nonce`; the server still accepts both
85 + // during the compat window).
86 + if (mxchatChat.ajax_url) {
87 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88 + .done(function (res) {
89 + if (res && res.success && res.data && res.data.nonce) {
90 + resolved(res.data.nonce);
91 + return;
92 + }
93 + resolved(null);
94 + })
95 + .fail(function () { resolved(null); });
96 + } else {
97 + resolved(null);
28 98 }
29 - })
30 - .always(function() {
31 - nonceRefreshState = 'done';
32 - var pending = nonceRefreshCallbacks;
33 - nonceRefreshCallbacks = [];
34 - pending.forEach(function(cb) { try { cb(); } catch (e) {} });
35 99 });
36 100 }
37 101
102 + // Backwards-compat alias — every existing caller in this file (and any
103 + // out-of-tree consumer that hit this internal API) keeps working unchanged.
104 + function refreshNonceIfNeeded(callback) {
105 + return withFreshNonce(callback);
106 + }
107 +
108 + // Dynamic-settings refresh (plan-32db95).
109 + //
110 + // Every widget setting ships inline in cached page HTML, so behind a
111 + // full-page cache the site owner can't purge (host cache, CDN, the
112 + // browser itself) a toggled setting looks broken until the cache turns
113 + // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114 + // on the FIRST widget open per page load we ask the nonce endpoint for
115 + // the current behavior-gate settings (?with_settings=1), merge them over
116 + // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117 + // they're server-inline-styled, so a runtime swap would visibly flash.
118 + // On any failure we keep the inline values silently (nonce-fallback
119 + // posture). At most one request per page load, only if a widget opens.
120 + var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121 +
122 + function mxchatRefreshDynamicSettings() {
123 + if (dynamicSettingsState !== 'idle') return;
124 + if (typeof mxchatChat === 'undefined') return;
125 + dynamicSettingsState = 'pending';
126 +
127 + var applied = function (data) {
128 + dynamicSettingsState = 'done';
129 + if (!data) return; // endpoint unavailable — inline values stand.
130 + if (data.nonce) {
131 + // Seed the nonce cache too: saves the first send's REST
132 + // round-trip and keeps us under the endpoint's rate limit.
133 + cachedFreshNonce = data.nonce;
134 + cachedFreshNonceFetchedAt = Date.now();
135 + mxchatChat.nonce = data.nonce;
136 + }
137 + if (data.settings && typeof data.settings === 'object') {
138 + $.extend(mxchatChat, data.settings);
139 + mxchatRebuildHeaderMenus();
140 + }
141 + };
142 +
143 + fetch(getRestNonceUrl() + '?with_settings=1', {
144 + credentials: 'same-origin',
145 + headers: { 'Accept': 'application/json' }
146 + }).then(function (resp) {
147 + if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148 + return resp.json();
149 + }).then(applied).catch(function () {
150 + // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151 + if (mxchatChat.ajax_url) {
152 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153 + .done(function (res) {
154 + applied(res && res.success && res.data ? res.data : null);
155 + })
156 + .fail(function () { applied(null); });
157 + } else {
158 + applied(null);
159 + }
160 + });
161 + }
162 +
38 163 // ====================================
39 164 // MULTI-INSTANCE MANAGEMENT SYSTEM
40 165 // ====================================
41 166
@@ -42,8 +167,16 @@
42 167 // Instance registry - tracks all chatbot instances on the page
43 168 const MxChatInstances = {
44 169 instances: {},
45 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 +
46 179 // Initialize an instance for a bot
47 180 init: function(botId) {
48 181 if (!this.instances[botId]) {
49 182 // When persistence is OFF, track when this session started
@@ -159,9 +292,20 @@
159 292 var newSessionId = generateSessionId();
160 293 this.setChatSession(botId, newSessionId);
161 294 var $chatBox = getElement(botId, 'chat-box');
162 295 if ($chatBox.length) {
163 - $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 + }
164 308 }
165 309 if (this.instances[botId]) {
166 310 this.instances[botId].chatHistoryLoaded = false;
167 311 this.instances[botId].processedMessageIds = new Set();
@@ -181,8 +325,50 @@
181 325 }
182 326 };
183 327
184 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 + // ====================================
185 371 // ELEMENT SELECTOR HELPERS
186 372 // ====================================
187 373
188 374 // Check if a specific bot has an AI theme assigned (skip inline colors)
@@ -218,8 +404,20 @@
218 404 var id = $floating.attr('id') || '';
219 405 var match = id.match(/floating-chatbot-(.+)/);
220 406 if (match) return match[1];
221 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 + }
222 420 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
223 421 var elementId = $(element).attr('id') || '';
224 422 if (elementId) {
225 423 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -259,9 +457,27 @@
259 457 if (parts.length == 2) return parts.pop().split(";").shift();
260 458 }
261 459
262 460 function generateSessionId() {
263 - 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;
264 480 }
265 481
266 482 // Legacy function - now delegates to instance manager
267 483 function getChatSession(botId) {
@@ -305,23 +521,28 @@
305 521 // ====================================
306 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
307 523 // ====================================
308 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 +
309 530 function getPageContext() {
310 531 // Check if contextual awareness is enabled
311 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
312 533 return null;
313 534 }
314 -
535 +
315 536 // Get page URL
316 537 const pageUrl = window.location.href;
317 -
538 +
318 539 // Get page title
319 540 const pageTitle = document.title || '';
320 -
541 +
321 542 // Get main content from the page
322 543 let pageContent = '';
323 -
544 +
324 545 // Try to get content from common content areas
325 546 const contentSelectors = [
326 547 'main',
327 548 '[role="main"]',
@@ -333,9 +554,9 @@
333 554 'article',
334 555 '#content',
335 556 '#main'
336 557 ];
337 -
558 +
338 559 let contentElement = null;
339 560 for (const selector of contentSelectors) {
340 561 contentElement = document.querySelector(selector);
341 562 if (contentElement) {
@@ -341,18 +562,31 @@
341 562 if (contentElement) {
342 563 break;
343 564 }
344 565 }
345 -
566 +
346 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
347 568 if (!contentElement) {
348 569 contentElement = document.body;
349 570 }
350 -
571 +
351 572 if (contentElement) {
352 - // Clone the element to avoid modifying the original
353 - const clone = contentElement.cloneNode(true);
354 -
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 +
355 589 // Remove unwanted elements
356 590 const unwantedSelectors = [
357 591 'header',
358 592 'footer',
@@ -370,16 +604,25 @@
370 604 '#floating-chatbot',
371 605 '#floating-chatbot-button',
372 606 '.mxchat',
373 607 '[class*="chat"]',
374 - '[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'
375 618 ];
376 -
619 +
377 620 unwantedSelectors.forEach(selector => {
378 621 const elements = clone.querySelectorAll(selector);
379 622 elements.forEach(el => el.remove());
380 623 });
381 -
624 +
382 625 // Extract MxChat context data attributes before getting text content
383 626 const contextData = [];
384 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
385 628 const contextValue = el.dataset.mxchatContext;
@@ -386,34 +629,34 @@
386 629 if (contextValue && contextValue.trim()) {
387 630 contextData.push(contextValue);
388 631 }
389 632 });
390 -
633 +
391 634 // Get text content and clean it up
392 635 pageContent = clone.textContent || clone.innerText || '';
393 -
636 +
394 637 // Add context data to page content if any were found
395 638 if (contextData.length > 0) {
396 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
397 640 }
398 -
641 +
399 642 // Clean up whitespace and limit length
400 643 pageContent = pageContent
401 644 .replace(/\s+/g, ' ')
402 645 .trim()
403 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;
404 656 }
405 -
406 - // Only return context if we have meaningful content
407 - if (!pageContent || pageContent.length < 50) {
408 - return null;
409 - }
410 -
411 - return {
412 - url: pageUrl,
413 - title: pageTitle,
414 - content: pageContent
415 - };
657 +
658 + return null;
416 659 }
417 660
418 661 // Track originating page when chat starts
419 662 function trackOriginatingPage() {
@@ -464,8 +707,26 @@
464 707 sendButton.style.pointerEvents = 'none';
465 708 }
466 709 }
467 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 +
468 729 function enableChatInput(botId) {
469 730 botId = botId || 'default';
470 731 var chatInput = getElementDOM(botId, 'chat-input');
471 732 var sendButton = getElementDOM(botId, 'send-button');
@@ -471,9 +732,11 @@
471 732 var sendButton = getElementDOM(botId, 'send-button');
472 733 if (chatInput) {
473 734 chatInput.disabled = false;
474 735 chatInput.style.opacity = '1';
475 - chatInput.focus();
736 + if (mxchatShouldAutofocusAfterReply()) {
737 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 + }
476 739 }
477 740 if (sendButton) {
478 741 sendButton.disabled = false;
479 742 sendButton.style.opacity = '1';
@@ -478,10 +741,102 @@
478 741 sendButton.disabled = false;
479 742 sendButton.style.opacity = '1';
480 743 sendButton.style.pointerEvents = 'auto';
481 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);
482 748 }
483 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 +
484 839 // Update your existing sendMessage function
485 840 function sendMessage(botId) {
486 841 botId = botId || 'default';
487 842 MxChatInstances.ensureSession(botId);
@@ -492,9 +847,12 @@
492 847 if (typeof customMxChatFilter === 'function') {
493 848 message = customMxChatFilter(message, "prompt");
494 849 }
495 850
496 - 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() !== '') {
497 855 // Don't disable input in live agent mode - let users chat freely
498 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
499 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
500 858 if (!isAgentMode) {
@@ -502,8 +860,9 @@
502 860 }
503 861
504 862 appendMessage("user", message, '', [], false, botId);
505 863 $chatInput.val('');
864 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
506 865 $chatInput.css('height', 'auto');
507 866
508 867 if (hasQuickQuestions(botId)) {
509 868 collapseQuickQuestions(botId);
@@ -510,14 +869,16 @@
510 869 }
511 870 appendThinkingMessage(botId);
512 871 scrollToBottom(botId);
513 872
514 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
873 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
515 874
516 875 // Check if streaming is enabled AND supported for this model
517 876 if (shouldUseStreaming(currentModel)) {
518 877 callMxChatStream(message, function(response) {
519 - 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');
520 881 }, botId);
521 882 } else {
522 883 callMxChat(message, function(response) {
523 884 replaceLastMessage("bot", response, '', [], botId);
@@ -535,8 +896,14 @@
535 896 if (typeof customMxChatFilter === 'function') {
536 897 message = customMxChatFilter(message, "prompt");
537 898 }
538 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 +
539 906 // Don't disable input in live agent mode - let users chat freely
540 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
541 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
542 909 if (!isAgentMode) {
@@ -550,14 +917,15 @@
550 917 }
551 918 appendThinkingMessage(botId);
552 919 scrollToBottom(botId);
553 920
554 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
921 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
555 922
556 923 // Check if streaming is enabled AND supported for this model
557 924 if (shouldUseStreaming(currentModel)) {
558 925 callMxChatStream(message, function(response) {
559 - 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');
560 928 }, botId);
561 929 } else {
562 930 callMxChat(message, function(response) {
563 931 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -621,8 +989,15 @@
621 989
622 990 function callMxChat(message, callback, botId) {
623 991 botId = botId || getMxChatBotId();
624 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 +
625 1000 // Store the message in case we need to retry after session reset
626 1001 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
627 1002
628 1003 // Get page context if contextual awareness is enabled
@@ -720,12 +1095,13 @@
720 1095 // Re-send the original message with the new session (user message is already displayed)
721 1096 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
722 1097 if (originalMessage) {
723 1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
724 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1099 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
725 1100 if (shouldUseStreaming(currentModel)) {
726 1101 callMxChatStream(originalMessage, function(response) {
727 - 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');
728 1104 }, botId);
729 1105 } else {
730 1106 callMxChat(originalMessage, function(response) {
731 1107 replaceLastMessage("bot", response, '', [], botId);
@@ -873,9 +1249,9 @@
873 1249
874 1250 // Store the message in case we need to retry after session reset
875 1251 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
876 1252
877 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1253 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
878 1254 if (!isStreamingSupported(currentModel)) {
879 1255 callMxChat(message, callback, botId);
880 1256 return;
881 1257 }
@@ -928,13 +1304,40 @@
928 1304
929 1305 let accumulatedContent = '';
930 1306 let testingDataReceived = false;
931 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 the end of the stream so the embed always lands BELOW the
1311 + // streamed text.
1312 + let pendingAppendHtml = '';
932 1313
1314 + // Render the stashed appendix as its own bot bubble below the streamed
1315 + // text — mirrors how it is saved in the transcript, so history replays
1316 + // identically. Called from BOTH ends of a stream: the [DONE] line and
1317 + // the stream simply closing (the OpenAI Responses path and Gemini end
1318 + // without a [DONE] line). The stash is cleared, so a stream that sends
1319 + // [DONE] and then closes renders it once. Any future appendix-type
1320 + // event belongs in here, not in one of the two branches.
1321 + function flushAppendHtml() {
1322 + if (pendingAppendHtml) {
1323 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1324 + pendingAppendHtml = '';
1325 + }
1326 + }
1327 +
1328 + // Abortable stream: a fresh controller per turn, keyed by bot instance.
1329 + // The Stop control (send button swapped in place) aborts both the read
1330 + // loop and the underlying request.
1331 + var streamControl = { controller: new AbortController(), aborted: false };
1332 + mxchatActiveStreams[botId] = streamControl;
1333 + mxchatShowStopButton(botId);
1334 +
933 1335 fetch(mxchatChat.ajax_url, {
934 1336 method: 'POST',
935 1337 body: formData,
936 - credentials: 'same-origin'
1338 + credentials: 'same-origin',
1339 + signal: streamControl.controller.signal
937 1340 })
938 1341 .then(response => {
939 1342 // Store the response for potential fallback handling
940 1343 const responseClone = response.clone();
@@ -1003,8 +1406,10 @@
1003 1406
1004 1407 // Re-enable chat input when stream ends with content
1005 1408 enableChatInput(botId);
1006 1409
1410 + flushAppendHtml();
1411 +
1007 1412 // Scroll the user's last message to the top now that the
1008 1413 // bot's full reply has rendered (gives max reading room).
1009 1414 var $chatBoxDone = getElement(botId, 'chat-box');
1010 1415 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
@@ -1035,8 +1440,10 @@
1035 1440
1036 1441 // Re-enable chat input after streaming completes
1037 1442 enableChatInput(botId);
1038 1443
1444 + flushAppendHtml();
1445 +
1039 1446 // Scroll the user's last message to the top now
1040 1447 // that the bot's full reply has rendered.
1041 1448 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1042 1449 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1070,8 +1477,21 @@
1070 1477 streamingStarted = true;
1071 1478 accumulatedContent += json.content;
1072 1479 updateStreamingMessage(accumulatedContent, botId);
1073 1480 }
1481 + // Stash appendix html (e.g. video embed) for [DONE]
1482 + else if (json.append_html) {
1483 + pendingAppendHtml = json.append_html;
1484 + }
1485 + // Server-side final pass changed the assembled text
1486 + // (ffef6f: dead-link stripping) — swap the rendered
1487 + // bubble for the validated version. Arrives at most
1488 + // once, just before [DONE].
1489 + else if (json.replace_content) {
1490 + streamingStarted = true;
1491 + accumulatedContent = json.replace_content;
1492 + updateStreamingMessage(accumulatedContent, botId);
1493 + }
1074 1494 // Handle complete response in stream (fallback response)
1075 1495 else if (json.text || json.message || json.html) {
1076 1496 handleNonStreamResponse(json, callback, botId);
1077 1497 return;
@@ -1101,8 +1521,9 @@
1101 1521 }
1102 1522
1103 1523 processStream();
1104 1524 }).catch(streamError => {
1525 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1105 1526 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1106 1527 callMxChat(message, callback, botId);
1107 1528 });
1108 1529 }
@@ -1109,8 +1530,9 @@
1109 1530
1110 1531 processStream();
1111 1532 })
1112 1533 .catch(error => {
1534 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1113 1535 // Check if we have server error data with chat mode
1114 1536 if (error && error.isServerError && error.data) {
1115 1537 // Check for chat mode in error data
1116 1538 if (error.data.chat_mode) {
@@ -1171,9 +1593,9 @@
1171 1593 // Re-send the original message with the new session (user message is already displayed)
1172 1594 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1173 1595 if (originalMessage) {
1174 1596 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1175 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1597 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1176 1598 if (shouldUseStreaming(currentModel)) {
1177 1599 callMxChatStream(originalMessage, callback, botId);
1178 1600 } else {
1179 1601 callMxChat(originalMessage, callback, botId);
@@ -1306,8 +1728,15 @@
1306 1728 var $chatBox = getElement(botId, 'chat-box');
1307 1729 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1308 1730
1309 1731 if (tempMessage.length) {
1732 + // aria-busy=true for the whole stream: the bubble is rewritten on
1733 + // every chunk, and without busy a polite live region announces those
1734 + // rewrites continuously. Flipped false once the reply is final, so
1735 + // assistive tech announces the completed message ONCE (plan 67f126).
1736 + if (tempMessage.attr('aria-busy') !== 'true') {
1737 + tempMessage.attr('aria-busy', 'true');
1738 + }
1310 1739 // Update existing message
1311 1740 tempMessage.html(formattedContent);
1312 1741 } else {
1313 1742 // Create new temporary message if it doesn't exist
@@ -1334,28 +1763,98 @@
1334 1763 // Update the event handlers to use the correct function names (using event delegation)
1335 1764 // Use class-based selectors for multi-instance support
1336 1765 $(document).on('click', '.send-button', function() {
1337 1766 var botId = getBotIdFromElement(this);
1338 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1339 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1340 - disableChatInput(botId);
1767 + // While a response is streaming the button is a Stop control.
1768 + if (this.classList.contains('mxchat-stop-mode')) {
1769 + // Same click that just started this stream (an add-on's direct handler
1770 + // ran before this delegated one) — not a Stop press. See
1771 + // mxchatShowStopButton for the full story (plan-4bba64).
1772 + if (this.__mxchatStopJustShown) return;
1773 + mxchatStopStreaming(botId);
1774 + return;
1341 1775 }
1776 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1342 1777 sendMessage(botId);
1343 1778 });
1344 1779
1345 -// Override enter key handler (using event delegation)
1780 +// Enter sends, Shift+Enter inserts a newline (using event delegation).
1781 +// sendMessage() owns the disable: it greys the box only when a real message is
1782 +// about to go out. Disabling here first left the widget locked for good after
1783 +// Enter on an empty box - the send returned early and no reply path ever
1784 +// re-enabled it (plan b67fd5).
1346 1785 $(document).on('keypress', '.chat-input', function(e) {
1347 1786 if (e.which == 13 && !e.shiftKey) {
1348 1787 e.preventDefault();
1349 - var botId = getBotIdFromElement(this);
1350 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1351 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1352 - disableChatInput(botId);
1353 - }
1354 - sendMessage(botId);
1788 + sendMessage(getBotIdFromElement(this));
1355 1789 }
1356 1790 });
1357 1791
1792 +// Chat input character counter + soft limit feedback (plan 7091a2).
1793 +// Language-neutral: numbers + color only, no translatable strings. The counter
1794 +// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1795 +// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1796 +// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1797 +function mxchatUpdateCharCounter(inputEl) {
1798 + if (!inputEl || !inputEl.closest) return;
1799 + var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1800 + var container = inputEl.closest('.input-container');
1801 + if (!container || !max || max <= 0) return;
1802 + var counter = container.querySelector('.mxchat-char-counter');
1803 + if (!counter) return;
1804 + var len = inputEl.value.length;
1805 + var ratio = len / max;
1806 + var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1807 + var cur = counter.querySelector('.mxchat-char-counter-current');
1808 + if (cur) cur.textContent = len;
1809 + var warn = ratio >= nearThreshold && len < max;
1810 + var full = len >= max;
1811 + counter.classList.toggle('is-visible', ratio >= nearThreshold);
1812 + counter.classList.toggle('is-warn', warn);
1813 + counter.classList.toggle('is-full', full);
1814 + container.classList.toggle('mxchat-input-near-limit', warn);
1815 + container.classList.toggle('mxchat-input-at-limit', full);
1816 +}
1817 +
1818 +function mxchatBumpInput(inputEl) {
1819 + var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1820 + if (!container) return;
1821 + container.classList.remove('mxchat-input-bump');
1822 + void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1823 + container.classList.add('mxchat-input-bump');
1824 + clearTimeout($(container).data('mxchatBumpTimeout'));
1825 + var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1826 + $(container).data('mxchatBumpTimeout', t);
1827 +}
1828 +
1829 +// Live counter update on every input.
1830 +$(document).on('input', '.chat-input', function() {
1831 + mxchatUpdateCharCounter(this);
1832 +});
1833 +
1834 +// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1835 +// rejected at the cap (maxlength silently swallows it otherwise).
1836 +$(document).on('keydown', '.chat-input', function(e) {
1837 + var max = parseInt(this.getAttribute('maxlength'), 10);
1838 + if (!max || max <= 0 || this.value.length < max) return;
1839 + if (e.ctrlKey || e.metaKey || e.altKey) return;
1840 + // A single printable char with no selection to overwrite WILL be rejected.
1841 + if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1842 + mxchatBumpInput(this);
1843 + }
1844 +});
1845 +
1846 +// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1847 +$(document).on('paste', '.chat-input', function() {
1848 + var el = this;
1849 + var max = parseInt(el.getAttribute('maxlength'), 10);
1850 + if (!max || max <= 0) return;
1851 + setTimeout(function() {
1852 + mxchatUpdateCharCounter(el);
1853 + if (el.value.length >= max) mxchatBumpInput(el);
1854 + }, 0);
1855 +});
1856 +
1358 1857 // Builds the list of overflow-menu items for a given bot.
1359 1858 // Adding a future item is one push to this array — do NOT hardcode "only download."
1360 1859 function mxchatGetHeaderMenuItems(botId) {
1361 1860 var items = [];
@@ -1373,8 +1872,26 @@
1373 1872 }
1374 1873 });
1375 1874 }
1376 1875
1876 + // "Start new chat" — surfaces the EXISTING per-conversation reset
1877 + // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1878 + // without the site owner disabling chat persistence globally. Default OFF;
1879 + // gated by the reset_chat_enabled option. plan ac2e81.
1880 + if (settings.reset_chat_enabled === 'on') {
1881 + items.push({
1882 + id: 'reset-chat',
1883 + label: settings.reset_chat_label || 'Start new chat',
1884 + 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>',
1885 + action: function() {
1886 + var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1887 + if (window.confirm(confirmMsg)) {
1888 + MxChatInstances.resetChatSession(botId);
1889 + }
1890 + }
1891 + });
1892 + }
1893 +
1377 1894 return items;
1378 1895 }
1379 1896
1380 1897 // Builds a clean markdown transcript of the current conversation and triggers
@@ -1452,30 +1969,31 @@
1452 1969 var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1453 1970 if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1454 1971 }
1455 1972
1456 -// One-time per-widget init: renders menu items, wires open/close,
1457 -// outside-click, Escape, and arrow-key navigation. If no items, hides the trigger.
1458 -function mxchatInitHeaderMenu(botId) {
1459 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1460 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1461 -
1973 +// Renders (or re-renders) the item list for one menu wrap. Split out of
1974 +// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1975 +// rebuild items + trigger visibility WITHOUT re-binding the one-time
1976 +// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1977 +// a rebuild before init (never happens, but harmless) just skips it.
1978 +function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1462 1979 var $trigger = $wrap.find('.mxchat-menu-trigger');
1463 1980 var $menu = $wrap.find('.mxchat-header-menu');
1464 1981 var items = mxchatGetHeaderMenuItems(botId);
1465 1982
1466 - // Initial color sync — covers normal page load.
1467 - mxchatSyncMenuColors(botId, $wrap);
1983 + $menu.empty();
1468 1984
1469 1985 if (!items.length) {
1470 1986 $trigger.hide();
1471 1987 $menu.hide();
1472 - $wrap.data('mxchatMenuReady', true);
1473 1988 return;
1474 1989 }
1475 1990
1476 - // Build the menu items.
1477 - $menu.empty();
1991 + // Clear any inline display:none a previous zero-item render left behind —
1992 + // open/close visibility is governed by the hidden prop + is-open class.
1993 + $trigger.css('display', '');
1994 + $menu.css('display', '');
1995 +
1478 1996 items.forEach(function(item, idx) {
1479 1997 var $btn = $('<button>', {
1480 1998 type: 'button',
1481 1999 'class': 'mxchat-menu-item',
@@ -1488,14 +2006,50 @@
1488 2006 $btn.find('.mxchat-menu-item-label').text(item.label);
1489 2007 $btn.on('click', function(e) {
1490 2008 e.preventDefault();
1491 2009 e.stopPropagation();
1492 - closeMenu();
2010 + if (closeMenuFn) closeMenuFn();
1493 2011 try { item.action(); } catch (err) { /* no-op */ }
1494 2012 });
1495 2013 $menu.append($btn);
1496 2014 });
2015 +}
1497 2016
2017 +// Re-render every menu on the page after a dynamic-settings merge
2018 +// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
2019 +// swapping items under the user mid-interaction yanks focus — and the
2020 +// rebuild runs when it closes instead (closeMenu checks the pending flag).
2021 +function mxchatRebuildHeaderMenus() {
2022 + $('.mxchat-header-menu-wrap').each(function() {
2023 + var $wrap = $(this);
2024 + var botId = $wrap.data('bot-id');
2025 + if (!botId) return;
2026 + if (!$wrap.data('mxchatMenuReady')) {
2027 + mxchatInitHeaderMenu(botId);
2028 + return;
2029 + }
2030 + if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
2031 + $wrap.data('mxchatMenuRebuildPending', true);
2032 + return;
2033 + }
2034 + mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
2035 + });
2036 +}
2037 +
2038 +// One-time per-widget init: renders menu items, wires open/close,
2039 +// outside-click, Escape, and arrow-key navigation. If no items, hides the
2040 +// trigger. Wiring happens even when there are zero items at init, so a
2041 +// later dynamic-settings rebuild that adds items has a working trigger.
2042 +function mxchatInitHeaderMenu(botId) {
2043 + var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
2044 + if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
2045 +
2046 + var $trigger = $wrap.find('.mxchat-menu-trigger');
2047 + var $menu = $wrap.find('.mxchat-header-menu');
2048 +
2049 + // Initial color sync — covers normal page load.
2050 + mxchatSyncMenuColors(botId, $wrap);
2051 +
1498 2052 function openMenu() {
1499 2053 // Re-sync each open in case the active theme changed since init.
1500 2054 mxchatSyncMenuColors(botId, $wrap);
1501 2055 $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
@@ -1509,8 +2063,14 @@
1509 2063 $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1510 2064 $trigger.attr('aria-expanded', 'false');
1511 2065 $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1512 2066 if (returnFocus) $trigger.trigger('focus');
2067 + // A dynamic-settings rebuild that arrived while the menu was open
2068 + // was deferred (mxchatRebuildHeaderMenus) — run it now.
2069 + if ($wrap.data('mxchatMenuRebuildPending')) {
2070 + $wrap.removeData('mxchatMenuRebuildPending');
2071 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2072 + }
1513 2073 }
1514 2074
1515 2075 // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1516 2076 // click-to-collapse handler does not fire.
@@ -1564,8 +2124,13 @@
1564 2124 openMenu();
1565 2125 }
1566 2126 });
1567 2127
2128 + // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2129 + // then do the initial item render.
2130 + $wrap.data('mxchatMenuClose', closeMenu);
2131 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2132 +
1568 2133 $wrap.data('mxchatMenuReady', true);
1569 2134 }
1570 2135
1571 2136 // Initialize header menus for every rendered widget on DOM ready.
@@ -1573,8 +2138,18 @@
1573 2138 $('.mxchat-header-menu-wrap').each(function() {
1574 2139 var botId = $(this).data('bot-id');
1575 2140 if (botId) mxchatInitHeaderMenu(botId);
1576 2141 });
2142 +
2143 + // Embedded (non-floating) widgets are open from the moment the page
2144 + // renders — refresh dynamic settings at init (plan-32db95). Floating
2145 + // widgets refresh on first launcher open instead.
2146 + var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2147 + return !$(this).closest('.floating-chatbot').length;
2148 + }).length > 0;
2149 + if (hasEmbeddedWidget) {
2150 + mxchatRefreshDynamicSettings();
2151 + }
1577 2152 });
1578 2153
1579 2154 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1580 2155 try {
@@ -1651,9 +2226,11 @@
1651 2226
1652 2227 messageDiv.html(fullMessage);
1653 2228
1654 2229 if (isTemporary) {
1655 - messageDiv.addClass('temporary-message');
2230 + // In-flight bubble: hold aria-busy so the live region stays quiet
2231 + // until the content is finalized (plan 67f126).
2232 + messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
1656 2233 }
1657 2234
1658 2235 // Append to the correct chatbot instance's chat-box
1659 2236 var $chatBox = getElement(botId, 'chat-box');
@@ -1684,8 +2261,56 @@
1684 2261 // Error rendering message - silently continue
1685 2262 }
1686 2263 }
1687 2264
2265 +// Link clicks in bot and agent messages: the click is recorded, the browser
2266 +// follows the anchor itself. Navigation must not wait for the tracking
2267 +// request: a window.open() made from that request's callback is outside the
2268 +// visitor's tap, and Safari's pop-up blocker drops it without a message.
2269 +
2270 +// Give a tracked anchor the target it should open in, and noopener on a new tab.
2271 +function prepareTrackedLink($link) {
2272 + if (linkTarget === '_blank' && $link.attr('target') !== '_blank') {
2273 + $link.attr('target', '_blank');
2274 + }
2275 + if ($link.attr('target') === '_blank') {
2276 + var rel = $link.attr('rel') || '';
2277 + if (!/(^|\s)noopener(\s|$)/.test(rel)) {
2278 + $link.attr('rel', $.trim(rel + ' noopener'));
2279 + }
2280 + }
2281 +}
2282 +
2283 +// Record one click, fire-and-forget. sendBeacon survives the page unloading
2284 +// when the link opens in the same tab.
2285 +function trackLinkClick(href, messageContext, botId) {
2286 + var fields = {
2287 + action: 'mxchat_track_url_click',
2288 + session_id: getChatSession(botId),
2289 + url: href,
2290 + message_context: messageContext,
2291 + nonce: mxchatChat.nonce
2292 + };
2293 + try {
2294 + if (navigator.sendBeacon && typeof FormData !== 'undefined') {
2295 + var body = new FormData();
2296 + $.each(fields, function(name, value) {
2297 + body.append(name, value == null ? '' : value);
2298 + });
2299 + if (navigator.sendBeacon(mxchatChat.ajax_url, body)) {
2300 + return;
2301 + }
2302 + }
2303 + } catch (error) {
2304 + // Fall through to the plain request
2305 + }
2306 + $.ajax({
2307 + url: mxchatChat.ajax_url,
2308 + type: 'POST',
2309 + data: fields
2310 + });
2311 +}
2312 +
1688 2313 // Helper function to attach link tracking with proper event handling
1689 2314 function attachLinkTracking(messageDiv, messageText, botId) {
1690 2315 botId = botId || 'default';
1691 2316 // Use a slight delay to ensure DOM is ready
@@ -1703,11 +2328,13 @@
1703 2328 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
1704 2329 // Remove any existing click handlers first
1705 2330 $link.off('click.tracking');
1706 2331
2332 + // The anchor carries its own target: the browser follows it
2333 + prepareTrackedLink($link);
2334 +
1707 2335 // Add new click handler with namespace
1708 2336 $link.on('click.tracking', function(e) {
1709 - e.preventDefault();
1710 2337 e.stopPropagation();
1711 2338
1712 2339 const messageContext = typeof messageText === 'string'
1713 2340 ? messageText.substring(0, 200)
@@ -1712,30 +2339,10 @@
1712 2339 const messageContext = typeof messageText === 'string'
1713 2340 ? messageText.substring(0, 200)
1714 2341 : '';
1715 2342
1716 - // Track the click
1717 - $.ajax({
1718 - url: mxchatChat.ajax_url,
1719 - type: 'POST',
1720 - data: {
1721 - action: 'mxchat_track_url_click',
1722 - session_id: getChatSession(botId),
1723 - url: originalHref,
1724 - message_context: messageContext,
1725 - nonce: mxchatChat.nonce
1726 - },
1727 - complete: function() {
1728 - // Always redirect, even if tracking fails
1729 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
1730 - window.open(originalHref, '_blank');
1731 - } else {
1732 - window.location.href = originalHref;
1733 - }
1734 - }
1735 - });
1736 -
1737 - return false; // Extra insurance to prevent default
2343 + prepareTrackedLink($link);
2344 + trackLinkClick(originalHref, messageContext, botId);
1738 2345 });
1739 2346 }
1740 2347 });
1741 2348 }, 100); // Small delay to ensure DOM is ready
@@ -1775,13 +2382,16 @@
1775 2382
1776 2383 if (images.length > 0) {
1777 2384 fullMessage += '<div class="image-gallery" dir="auto">';
1778 2385 images.forEach(img => {
2386 + const safeTitle = sanitizeUserInput(img.title);
2387 + const safeUrl = escapeAttr(img.image_url);
2388 + const safeThumbnail = escapeAttr(img.thumbnail_url);
1779 2389 fullMessage += `
1780 2390 <div style="margin-bottom: 10px;">
1781 - <strong>${img.title}</strong><br>
1782 - <a href="${img.image_url}" target="_blank">
1783 - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" />
2391 + <strong>${safeTitle}</strong><br>
2392 + <a href="${safeUrl}" target="_blank">
2393 + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
1784 2394 </a>
1785 2395 </div>`;
1786 2396 });
1787 2397 fullMessage += '</div>';
@@ -1788,13 +2398,16 @@
1788 2398 }
1789 2399
1790 2400 if (lastMessageDiv.length) {
1791 2401 // Replace content immediately to prevent visual gap between thinking dots and response
2402 + // aria-busy released AFTER the final content is set, so the live region
2403 + // announces the finished message once (plan 67f126).
1792 2404 lastMessageDiv
1793 2405 .html(fullMessage)
1794 2406 .removeClass('bot-message user-message temporary-message')
1795 2407 .addClass(messageClass)
1796 - .attr('dir', 'auto');
2408 + .attr('dir', 'auto')
2409 + .attr('aria-busy', 'false');
1797 2410
1798 2411 // Only apply inline colors if AI theme is not active (let CSS handle it)
1799 2412 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1800 2413 if (!skipColors) {
@@ -1854,10 +2467,15 @@
1854 2467 var botMessageFontColor = mxchatChat.bot_message_font_color;
1855 2468 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1856 2469
1857 2470 // Build thinking dots HTML - skip inline colors if AI theme is active
2471 + // The dots are decorative; the sr-only span is what the live region
2472 + // announces for the waiting state (plan 67f126). Server-localized
2473 + // string — safe to inject (esc_html__ output, no user content).
1858 2474 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1859 - var thinkingHtml = '<div class="thinking-dots-container">' +
2475 + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2476 + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2477 + '<div class="thinking-dots-container" aria-hidden="true">' +
1860 2478 '<div class="thinking-dots">' +
1861 2479 '<span class="dot"' + dotStyle + '></span>' +
1862 2480 '<span class="dot"' + dotStyle + '></span>' +
1863 2481 '<span class="dot"' + dotStyle + '></span>' +
@@ -1892,27 +2510,42 @@
1892 2510 // Check for % followed by exactly 2 hex digits
1893 2511 return /%[0-9a-fA-F]{2}/.test(url);
1894 2512 }
1895 2513
1896 - // Helper function to safely encode URLs only if needed
2514 + // Helper function to safely encode URLs only if needed, then make the
2515 + // result attribute-safe. "Don't double-encode an already-encoded URL"
2516 + // used to mean "return it untouched" — so a literal quote riding next
2517 + // to a %20 reached href="…" intact and closed the attribute
2518 + // (plan 2db182). Every href this function feeds now goes through
2519 + // escapeAttr(), whether or not the URL was already percent-encoded.
1897 2520 function safeEncodeUrl(url) {
1898 - // If URL already contains encoded characters, return as-is
1899 - if (isUrlEncoded(url)) {
1900 - return url;
1901 - }
1902 - // Otherwise, encode it
1903 - return encodeURI(url);
2521 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2522 + return escapeAttr(encoded);
1904 2523 }
1905 2524
1906 - // Process markdown headers FIRST
1907 - let processedText = formatMarkdownHeaders(inputText);
1908 -
2525 + // Take backslash escapes and code (fenced + inline) out of the text FIRST
2526 + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2527 + // backtick could not protect an underscore and \_ was not an escape at
2528 + // all; the header rule also ran over fenced code. Each escape becomes a
2529 + // placeholder carrying its literal character, each code span a
2530 + // placeholder carrying its rendered HTML; both are restored below, after
2531 + // the styling rules have run, so nothing downstream changes.
2532 + const literals = protectLiterals(inputText);
2533 + let processedText = literals.text;
2534 +
2535 + // Process markdown headers
2536 + processedText = formatMarkdownHeaders(processedText);
2537 +
1909 2538 // Process text styling (bold, italic, strikethrough)
1910 2539 processedText = formatTextStyling(processedText);
1911 -
1912 - // Process code blocks BEFORE processing links
2540 +
2541 + // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2542 + // rendered inside protectLiterals) — still BEFORE processing links
1913 2543 processedText = formatCodeBlocks(processedText);
1914 2544
2545 + // Put the rendered code and the escaped characters back
2546 + processedText = literals.restore(processedText);
2547 +
1915 2548 // Process markdown tables BEFORE converting newlines to paragraphs
1916 2549 processedText = formatMarkdownTables(processedText);
1917 2550
1918 2551 // NOW convert to paragraphs
@@ -1924,10 +2557,13 @@
1924 2557 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
1925 2558 // Clean the URL of any trailing punctuation
1926 2559 let cleanUrl = url.replace(/[.,;!?]+$/, '');
1927 2560 const safeUrl = safeEncodeUrl(cleanUrl);
2561 + // The bracket body is the only link text that can carry angle
2562 + // brackets (the standalone matcher excludes them) — keep it text.
2563 + const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1928 2564 // Return as a proper link without the brackets
1929 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2565 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1930 2566 });
1931 2567
1932 2568 // Process markdown links: [text](url) and [](url)
1933 2569 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2001,9 +2637,17 @@
2001 2637 });
2002 2638
2003 2639 // Process standalone URLs - but NOT if they're already in <a> tags or brackets
2004 2640 // Updated pattern to be more careful about what it matches
2005 - const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2641 + //
2642 + // The prefix group (plan 78618d) is the start of a line, an ordinary
2643 + // character, OR a tag this formatter itself opened or closed (paragraph,
2644 + // list item, table cell, heading, emphasis, line break), so an address
2645 + // that opens a paragraph or sits inside **bold** is linked too. Never
2646 + // an anchor (its text would be linked twice; the lookahead guards that
2647 + // as well) and never a code span, which stays as typed. No lookbehind,
2648 + // for Safari.
2649 + const urlPattern = /(^|[^">=\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2006 2650 processedText = processedText.replace(urlPattern, (match, prefix, url) => {
2007 2651 // Extra check: make sure this isn't already linked
2008 2652 if (match.includes('href=') || match.includes('</a>')) {
2009 2653 return match;
@@ -2014,10 +2658,14 @@
2014 2658 const safeUrl = safeEncodeUrl(cleanUrl);
2015 2659 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2016 2660 });
2017 2661
2018 - // Process www. URLs - but NOT if they're already in <a> tags or brackets
2019 - const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim;
2662 + // Process www. URLs - but NOT if they're already in <a> tags or brackets.
2663 + // The address stops at the same characters the https pattern stops at
2664 + // (plan 78618d): it used to run through the closing paragraph tag, so
2665 + // "Try www.example.com/page." linked to www.example.com/page.%3C/p%3E.
2666 + // Same prefix group as the https pattern, same reasons.
2667 + const wwwPattern = /(^|[^">/\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(www\.[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2020 2668 processedText = processedText.replace(wwwPattern, (match, prefix, url) => {
2021 2669 // Extra check: make sure this isn't already linked
2022 2670 if (match.includes('href=') || match.includes('</a>')) {
2023 2671 return match;
@@ -2031,8 +2679,30 @@
2031 2679
2032 2680 return processedText;
2033 2681 }
2034 2682
2683 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2684 + // A single alternation keeps the two honest with each other: an escaped
2685 + // backtick is consumed as an escape before it can open a code span, and a
2686 + // code span is consumed whole so the escapes inside it stay verbatim.
2687 + // Placeholders are private-use characters — no underscore, asterisk,
2688 + // backtick, hash, pipe or bracket for a later rule to match.
2689 + function protectLiterals(text) {
2690 + const store = [];
2691 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2692 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2693 + return '\uE000' + (store.length - 1) + '\uE001';
2694 + });
2695 + return {
2696 + text: out,
2697 + restore: function(t) {
2698 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2699 + return store[Number(i)];
2700 + });
2701 + }
2702 + };
2703 + }
2704 +
2035 2705 function formatMarkdownHeaders(text) {
2036 2706 // Handle h1 to h6 headers
2037 2707 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2038 2708 const level = hashes.length;
@@ -2059,13 +2729,19 @@
2059 2729 protectedSegments.push(match);
2060 2730 return placeholder;
2061 2731 });
2062 2732
2063 - // Step 1c: Also protect bare URLs that might exist
2733 + // Step 1c: Also protect bare URLs that might exist. Trailing emphasis
2734 + // markers are not part of the address (plan 78618d): **https://x.com**
2735 + // used to protect "https://x.com**", so the bold never paired and the
2736 + // asterisks ended up inside the link. The markers stay outside the
2737 + // placeholder so the styling rules below can pair them.
2064 2738 protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) {
2739 + const markers = match.match(/[*_~]+$/);
2740 + const url = markers ? match.slice(0, match.length - markers[0].length) : match;
2065 2741 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2066 - protectedSegments.push(match);
2067 - return placeholder;
2742 + protectedSegments.push(url);
2743 + return placeholder + (markers ? markers[0] : '');
2068 2744 });
2069 2745
2070 2746 // Step 2: Now apply text styling to the protected text
2071 2747 // Handle bold text (**text**)
@@ -2074,11 +2750,24 @@
2074 2750 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2075 2751 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2076 2752 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2077 2753
2078 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2079 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2080 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2754 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2755 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2756 + // text or a non-word character, the closer must precede the end or a
2757 + // non-word character, and the content may not start or end with a
2758 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2759 + // while "use _this_ word" still italicises. A token whose content is
2760 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2761 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2762 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2763 + // adjacent to another underscore or a word character.
2764 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2765 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2766 + return match;
2767 + }
2768 + return before + '<em>' + content + '</em>';
2769 + });
2081 2770
2082 2771 // Handle strikethrough (~~text~~)
2083 2772 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2084 2773
@@ -2212,8 +2901,24 @@
2212 2901 div.textContent = text;
2213 2902 return div.innerHTML;
2214 2903 }
2215 2904
2905 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2906 + // Percent-encodes the characters that can end or extend an attribute.
2907 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2908 + // never changes where a legitimate link resolves — encodeURI() already
2909 + // produces most of these, this just guarantees them for URLs that skip
2910 + // encodeURI() because they were percent-encoded on arrival.
2911 + function escapeAttr(url) {
2912 + return String(url)
2913 + .replace(/"/g, '%22')
2914 + .replace(/'/g, '%27')
2915 + .replace(/</g, '%3C')
2916 + .replace(/>/g, '%3E')
2917 + .replace(/`/g, '%60')
2918 + .replace(/ /g, '%20');
2919 + }
2920 +
2216 2921 function escapeHtml(unsafe) {
2217 2922 // Skip escaping if it's already escaped or contains HTML code block markup
2218 2923 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2219 2924 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -2557,9 +3262,30 @@
2557 3262 }
2558 3263
2559 3264 // Only process if there are actual messages
2560 3265 if (response.data.conversation.length > 0) {
2561 - // IMPORTANT: Clear existing messages before loading history
3266 + // Restored history must be SILENT to screen readers
3267 + // (plan 67f126): these are DOM additions inside the
3268 + // live region and would otherwise announce as if
3269 + // they just arrived. Lift aria-live for the batch
3270 + // repopulate, restore it after the browser has
3271 + // processed the mutations.
3272 + var mxLiveRegionEl = $chatBox.get(0);
3273 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3274 + if (mxLiveRegionEl) {
3275 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3276 + }
3277 +
3278 + // IMPORTANT: Clear existing messages before loading history.
3279 + // Detach the greeting first and put it back below —
3280 + // it is server-rendered and never stored in the
3281 + // transcript, so the old unconditional .empty()
3282 + // deleted it for the rest of the page life (plan
3283 + // a1a79b). Detach rather than rebuild: intro_message
3284 + // is not localized to JS, and this node already
3285 + // carries the per-bot inline colors and any
3286 + // {visitor_name} substitution already applied to it.
3287 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2562 3288 $chatBox.empty();
2563 3289
2564 3290 $.each(response.data.conversation, function(index, message) {
2565 3291 // Skip agent messages if persistence is off
@@ -2596,15 +3322,28 @@
2596 3322 var content = message.content;
2597 3323 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2598 3324 content = decodeHTMLEntities(content);
2599 3325
3326 + // A visitor's own rows get the same treatment as the
3327 + // live turn (appendMessage escapes user text before
3328 + // linkify). Without this a replayed user message came
3329 + // back as live markup after a refresh (plan 2db182).
3330 + // Bot/agent rows are trusted HTML and stay as they were.
3331 + if (message.role === 'user') {
3332 + content = sanitizeUserInput(content);
3333 + }
3334 +
2600 3335 // Skip linkify for messages containing structured HTML
2601 3336 // (forms, product cards, galleries, etc.) to avoid
2602 3337 // markdown formatting corrupting HTML attributes
2603 - // (e.g. underscores in name="field_name" becoming <em> tags)
2604 - if (content.includes("mxchat-product-card") ||
2605 - content.includes("mxchat-image-gallery") ||
2606 - content.includes("mxchat-featured-products") ||
3338 + // (e.g. underscores in name="field_name" becoming <em> tags).
3339 + // One family check instead of a per-card literal list: any
3340 + // element carrying an mxchat- prefixed class is MxChat-generated
3341 + // structured markup and replays raw. The old list drifted every
3342 + // time an add-on minted a new card class — the filtered-search
3343 + // card ("mxchat-filtered-product-card") missed it and replayed
3344 + // through linkify as visible markup.
3345 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
2607 3346 content.includes("<form") ||
2608 3347 content.includes("<input") ||
2609 3348 content.includes("<select") ||
2610 3349 content.includes("<textarea")) {
@@ -2622,12 +3361,29 @@
2622 3361 instance.processedMessageIds.add(message.id);
2623 3362 }
2624 3363 });
2625 3364
2626 - // Only append messages and scroll if we have content
3365 + // Only append messages and scroll if we have content.
3366 + // Greeting goes back FIRST, above the restored
3367 + // history: "Hello — [earlier conversation]" is the
3368 + // natural reading and matches the order a fresh
3369 + // visitor sees (plan a1a79b).
3370 + if ($mxIntro && $mxIntro.length) {
3371 + $chatBox.append($mxIntro);
3372 + }
2627 3373 $chatBox.append($fragment);
2628 3374 scrollToBottom(botId, true);
2629 3375
3376 + // Re-attach live semantics AFTER the rehydration
3377 + // mutations have been processed with the region off
3378 + // (plan 67f126). Restoring later announces nothing
3379 + // retroactively; new turns announce normally.
3380 + if (mxLiveRegionEl) {
3381 + setTimeout(function() {
3382 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3383 + }, 200);
3384 + }
3385 +
2630 3386 // Collapse quick questions if we have conversation history
2631 3387 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2632 3388 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2633 3389 collapseQuickQuestions(botId);
@@ -2722,10 +3478,10 @@
2722 3478 .then(data => {
2723 3479 if (data.success) {
2724 3480 container.style.display = 'none';
2725 3481 nameElement.textContent = '';
2726 - activePdfFile = null;
2727 - appendMessage('bot', 'PDF removed.');
3482 + instance.activePdfFile = null;
3483 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2728 3484 }
2729 3485 })
2730 3486 .catch(error => {
2731 3487 // Error removing PDF - silently continue
@@ -2731,14 +3487,16 @@
2731 3487 // Error removing PDF - silently continue
2732 3488 });
2733 3489 }
2734 3490
2735 - function removeActiveWord() {
2736 - const container = document.getElementById('active-word-container');
2737 - const nameElement = document.getElementById('active-word-name');
2738 -
2739 - if (!container || !nameElement || !activeWordFile) return;
2740 -
3491 + function removeActiveWord(botId) {
3492 + botId = botId || 'default';
3493 + var instance = MxChatInstances.get(botId);
3494 + const container = getElementDOM(botId, 'active-word-container');
3495 + const nameElement = getElementDOM(botId, 'active-word-name');
3496 +
3497 + if (!container || !nameElement || !instance.activeWordFile) return;
3498 +
2741 3499 fetch(mxchatChat.ajax_url, {
2742 3500 method: 'POST',
2743 3501 headers: {
2744 3502 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2744,9 +3502,9 @@
2744 3502 'Content-Type': 'application/x-www-form-urlencoded',
2745 3503 },
2746 3504 body: new URLSearchParams({
2747 3505 'action': 'mxchat_remove_word',
2748 - 'session_id': sessionId,
3506 + 'session_id': getChatSession(botId),
2749 3507 'nonce': mxchatChat.nonce
2750 3508 })
2751 3509 })
2752 3510 .then(response => response.json())
@@ -2753,10 +3511,10 @@
2753 3511 .then(data => {
2754 3512 if (data.success) {
2755 3513 container.style.display = 'none';
2756 3514 nameElement.textContent = '';
2757 - activeWordFile = null;
2758 - appendMessage('bot', 'Word document removed.');
3515 + instance.activeWordFile = null;
3516 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2759 3517 }
2760 3518 })
2761 3519 .catch(error => {
2762 3520 // Error removing Word document - silently continue
@@ -2915,11 +3673,38 @@
2915 3673 e.stopPropagation();
2916 3674 var botId = getBotIdFromElement(this);
2917 3675 collapseQuickQuestions(botId);
2918 3676 });
3677 +
3678 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3679 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3680 +// Delegated so it also works for embeds restored from chat history.
3681 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3682 + e.preventDefault();
3683 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3684 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3685 + if (!videoId) {
3686 + return;
3687 + }
3688 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3689 + var $iframe = $('<iframe>', {
3690 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3691 + title: title,
3692 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3693 + allowfullscreen: true,
3694 + frameborder: 0
3695 + }).addClass('mxchat-youtube-iframe');
3696 + $wrap.addClass('mxchat-youtube-playing');
3697 + $(this).replaceWith($iframe);
3698 +});
2919 3699
2920 3700 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2921 - $(document).on('click', '.floating-chatbot-button', function() {
3701 + // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3702 + $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3703 + if (e.type === 'keydown') {
3704 + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3705 + e.preventDefault();
3706 + }
2922 3707 var botId = getBotIdFromElement(this);
2923 3708 var $chatbot = getElement(botId, 'floating-chatbot');
2924 3709 var $badge = getElement(botId, 'chat-notification-badge');
2925 3710 var $preChat = getElement(botId, 'pre-chat-message');
@@ -2924,14 +3709,21 @@
2924 3709 var $badge = getElement(botId, 'chat-notification-badge');
2925 3710 var $preChat = getElement(botId, 'pre-chat-message');
2926 3711
2927 3712 if ($chatbot.hasClass('hidden')) {
2928 - $chatbot.removeClass('hidden').addClass('visible');
2929 - $(this).addClass('hidden');
3713 + $chatbot.removeClass('hidden').addClass('visible')
3714 + .attr('aria-modal', 'true').attr('role', 'dialog');
3715 + $(this).addClass('hidden').attr('aria-expanded', 'true');
2930 3716 $badge.hide(); // Hide notification when opening chat
2931 3717 disableScroll();
2932 3718 $preChat.fadeOut(250);
3719 + mxchatFireOpened(botId);
2933 3720
3721 + // First open per page load: re-fetch behavior settings in case
3722 + // this page's inline values came from a stale full-page cache
3723 + // (plan-32db95). Idempotent — later opens are a no-op.
3724 + mxchatRefreshDynamicSettings();
3725 +
2934 3726 // Load chat history for returning visitors (persistence)
2935 3727 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
2936 3728 if (chatPersistenceEnabled) {
2937 3729 MxChatInstances.ensureSession(botId);
@@ -2947,24 +3739,55 @@
2947 3739 // No email collection — still route through showChatContainerForBot
2948 3740 // so the loader is shown while chat history loads
2949 3741 showChatContainerForBot(botId);
2950 3742 }
3743 +
3744 + // Move keyboard focus into the message input after the open transition.
3745 + setTimeout(function() {
3746 + var chatInput = getElementDOM(botId, 'chat-input');
3747 + if (chatInput && !chatInput.disabled) {
3748 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3749 + }
3750 + }, 300);
2951 3751 } else {
2952 - $chatbot.removeClass('visible').addClass('hidden');
2953 - $(this).removeClass('hidden');
3752 + $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3753 + $(this).removeClass('hidden').attr('aria-expanded', 'false');
2954 3754 enableScroll();
2955 3755 checkPreChatDismissal(botId);
2956 3756 }
2957 3757 });
2958 3758
2959 - // Allow clicking anywhere on the title bar to close the chatbot
3759 + // Allow clicking anywhere on the title bar to close the chatbot.
3760 + // Returns keyboard focus to the launcher so keyboard users don't get
3761 + // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3762 + // heuristic-based so mouse-triggered close won't show a focus ring.
2960 3763 $(document).on('click', '.chatbot-top-bar', function() {
2961 3764 var botId = getBotIdFromElement(this);
2962 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2963 - getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3765 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3766 + var $launcher = getElement(botId, 'floating-chatbot-button');
3767 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2964 3768 enableScroll();
3769 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
2965 3770 });
2966 3771
3772 + // Global Escape-key handler — closes any visible chat widget and
3773 + // returns focus to its launcher. Standard modal-dismissal pattern;
3774 + // pairs with aria-modal="true" set on the widget when it opens.
3775 + $(document).on('keydown', function(e) {
3776 + if (e.key !== 'Escape' && e.key !== 'Esc') return;
3777 + var $visible = $('.floating-chatbot.visible');
3778 + if (!$visible.length) return;
3779 + e.preventDefault();
3780 + $visible.each(function() {
3781 + var botId = getBotIdFromElement(this);
3782 + $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3783 + var $launcher = getElement(botId, 'floating-chatbot-button');
3784 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3785 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3786 + });
3787 + enableScroll();
3788 + });
3789 +
2967 3790 $(document).on('click', '.close-pre-chat-message', function(e) {
2968 3791 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
2969 3792 var botId = getBotIdFromElement(this);
2970 3793 handlePreChatDismissal(botId);
@@ -2984,17 +3807,20 @@
2984 3807 var wordInput = getElementDOM(botId, 'word-upload');
2985 3808 if (wordInput) wordInput.click();
2986 3809 });
2987 3810
2988 - // PDF file input change handler
2989 - addSafeEventListener('pdf-upload', 'change', async function(e) {
2990 - const file = e.target.files[0];
2991 -
3811 + // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3812 + $(document).on('change', '.pdf-upload', async function(e) {
3813 + var botId = getBotIdFromElement(this);
3814 + var instance = MxChatInstances.get(botId);
3815 + const file = this.files[0];
3816 + const sessionId = MxChatInstances.ensureSession(botId);
3817 +
2992 3818 if (!file || file.type !== 'application/pdf') {
2993 3819 alert('Please select a valid PDF file.');
2994 3820 return;
2995 3821 }
2996 -
3822 +
2997 3823 if (!sessionId) {
2998 3824 alert('Error: No session ID found');
2999 3825 return;
3000 3826 }
@@ -3002,14 +3828,15 @@
3002 3828 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3003 3829 alert('Error: Ajax configuration missing');
3004 3830 return;
3005 3831 }
3006 -
3832 +
3007 3833 // Disable buttons and show loading state
3008 - const uploadBtn = document.getElementById('pdf-upload-btn');
3009 - const sendBtn = document.getElementById('send-button');
3834 + const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3835 + const sendBtn = getElementDOM(botId, 'send-button');
3836 + if (!uploadBtn) return;
3010 3837 const originalBtnContent = uploadBtn.innerHTML;
3011 -
3838 +
3012 3839 try {
3013 3840 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3014 3841 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3015 3842 const formData = new FormData();
@@ -3016,35 +3843,34 @@
3016 3843 formData.append('action', 'mxchat_upload_pdf');
3017 3844 formData.append('pdf_file', file);
3018 3845 formData.append('session_id', sessionId);
3019 3846 formData.append('nonce', mxchatChat.nonce);
3020 -
3847 +
3021 3848 uploadBtn.disabled = true;
3022 - sendBtn.disabled = true;
3849 + if (sendBtn) sendBtn.disabled = true;
3023 3850 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3024 3851 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3025 3852 </svg>`;
3026 -
3853 +
3027 3854 const response = await fetch(mxchatChat.ajax_url, {
3028 3855 method: 'POST',
3029 3856 body: formData
3030 3857 });
3031 -
3858 +
3032 3859 const data = await response.json();
3033 -
3860 +
3034 3861 if (data.success) {
3035 3862 // Hide popular questions if they exist
3036 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3037 - if (hasQuickQuestions()) {
3038 - collapseQuickQuestions();
3863 + if (hasQuickQuestions(botId)) {
3864 + collapseQuickQuestions(botId);
3039 3865 }
3040 -
3866 +
3041 3867 // Show the active PDF name
3042 - showActivePdf(data.data.filename);
3043 -
3044 - appendMessage('bot', data.data.message);
3045 - scrollToBottom();
3046 - activePdfFile = data.data.filename;
3868 + showActivePdf(data.data.filename, botId);
3869 +
3870 + appendMessage('bot', data.data.message, '', [], false, botId);
3871 + scrollToBottom(botId);
3872 + instance.activePdfFile = data.data.filename;
3047 3873 } else {
3048 3874 alert('Failed to upload PDF. Please try again.');
3049 3875 }
3050 3876 } catch (error) {
@@ -3050,33 +3876,42 @@
3050 3876 } catch (error) {
3051 3877 alert('Error uploading file. Please try again.');
3052 3878 } finally {
3053 3879 uploadBtn.disabled = false;
3054 - sendBtn.disabled = false;
3880 + if (sendBtn) sendBtn.disabled = false;
3055 3881 uploadBtn.innerHTML = originalBtnContent;
3056 3882 this.value = ''; // Reset file input
3057 3883 }
3058 3884 });
3059 3885
3060 - // Word file input change handler
3061 - addSafeEventListener('word-upload', 'change', async function(e) {
3062 - const file = e.target.files[0];
3063 -
3886 + // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3887 + $(document).on('change', '.word-upload', async function(e) {
3888 + var botId = getBotIdFromElement(this);
3889 + var instance = MxChatInstances.get(botId);
3890 + const file = this.files[0];
3891 + const sessionId = MxChatInstances.ensureSession(botId);
3892 +
3064 3893 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3065 3894 alert('Please select a valid Word document (.docx).');
3066 3895 return;
3067 3896 }
3068 -
3897 +
3069 3898 if (!sessionId) {
3070 3899 alert('Error: No session ID found');
3071 3900 return;
3072 3901 }
3073 3902
3903 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3904 + alert('Error: Ajax configuration missing');
3905 + return;
3906 + }
3907 +
3074 3908 // Disable buttons and show loading state
3075 - const uploadBtn = document.getElementById('word-upload-btn');
3076 - const sendBtn = document.getElementById('send-button');
3909 + const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3910 + const sendBtn = getElementDOM(botId, 'send-button');
3911 + if (!uploadBtn) return;
3077 3912 const originalBtnContent = uploadBtn.innerHTML;
3078 -
3913 +
3079 3914 try {
3080 3915 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3081 3916 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3082 3917 const formData = new FormData();
@@ -3083,35 +3918,34 @@
3083 3918 formData.append('action', 'mxchat_upload_word');
3084 3919 formData.append('word_file', file);
3085 3920 formData.append('session_id', sessionId);
3086 3921 formData.append('nonce', mxchatChat.nonce);
3087 -
3922 +
3088 3923 uploadBtn.disabled = true;
3089 - sendBtn.disabled = true;
3924 + if (sendBtn) sendBtn.disabled = true;
3090 3925 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3091 3926 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3092 3927 </svg>`;
3093 -
3928 +
3094 3929 const response = await fetch(mxchatChat.ajax_url, {
3095 3930 method: 'POST',
3096 3931 body: formData
3097 3932 });
3098 -
3933 +
3099 3934 const data = await response.json();
3100 -
3935 +
3101 3936 if (data.success) {
3102 3937 // Hide popular questions if they exist
3103 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3104 - if (hasQuickQuestions()) {
3105 - collapseQuickQuestions();
3938 + if (hasQuickQuestions(botId)) {
3939 + collapseQuickQuestions(botId);
3106 3940 }
3107 -
3941 +
3108 3942 // Show the active Word document name
3109 - showActiveWord(data.data.filename);
3110 -
3111 - appendMessage('bot', data.data.message);
3112 - scrollToBottom();
3113 - activeWordFile = data.data.filename;
3943 + showActiveWord(data.data.filename, botId);
3944 +
3945 + appendMessage('bot', data.data.message, '', [], false, botId);
3946 + scrollToBottom(botId);
3947 + instance.activeWordFile = data.data.filename;
3114 3948 } else {
3115 3949 alert('Failed to upload Word document. Please try again.');
3116 3950 }
3117 3951 } catch (error) {
@@ -3117,25 +3951,25 @@
3117 3951 } catch (error) {
3118 3952 alert('Error uploading file. Please try again.');
3119 3953 } finally {
3120 3954 uploadBtn.disabled = false;
3121 - sendBtn.disabled = false;
3955 + if (sendBtn) sendBtn.disabled = false;
3122 3956 uploadBtn.innerHTML = originalBtnContent;
3123 3957 this.value = ''; // Reset file input
3124 3958 }
3125 3959 });
3126 3960
3127 - // Remove button click handlers
3128 - document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3961 + // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3962 + $(document).on('click', '.remove-pdf-btn', function(e) {
3129 3963 e.preventDefault();
3130 3964 e.stopPropagation();
3131 - removeActivePdf();
3965 + removeActivePdf(getBotIdFromElement(this));
3132 3966 });
3133 -
3134 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3967 +
3968 + $(document).on('click', '.remove-word-btn', function(e) {
3135 3969 e.preventDefault();
3136 3970 e.stopPropagation();
3137 - removeActiveWord();
3971 + removeActiveWord(getBotIdFromElement(this));
3138 3972 });
3139 3973
3140 3974 // Window resize handlers
3141 3975 $(window).on('resize orientationchange', function() {
@@ -3152,9 +3986,12 @@
3152 3986 $('.chat-toolbar').show();
3153 3987 } else {
3154 3988 $('.chat-toolbar').hide();
3155 3989 }
3156 -
3990 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3991 + MxChatInstances.toolbarReady = true;
3992 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3993 +
3157 3994 // Apply toolbar icon colors
3158 3995 const toolbarElements = [
3159 3996 '#mxchat-chatbot .toolbar-btn svg',
3160 3997 '#mxchat-chatbot .active-pdf-name',
@@ -3279,10 +4116,13 @@
3279 4116 function replaceVisitorNamePlaceholder(botId, visitorName) {
3280 4117 var chatBox = getElementDOM(botId, 'chat-box');
3281 4118 if (!chatBox) return;
3282 4119
3283 - // Find the first bot message (intro message)
3284 - var introMessage = chatBox.querySelector('.bot-message');
4120 + // Find the greeting by its marker, not by position (plan a1a79b) —
4121 + // after a persistence restore the first .bot-message is a restored
4122 + // reply, and {visitor_name} was being substituted into that instead.
4123 + // Positional fallback for HTML cached before this release only.
4124 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3285 4125 if (!introMessage) return;
3286 4126
3287 4127 var messageContent = introMessage.querySelector('div[dir="auto"]');
3288 4128 if (!messageContent) return;
@@ -3426,14 +4266,25 @@
3426 4266 return response.json();
3427 4267 })
3428 4268 .then((data) => {
3429 4269 if (data.success) {
3430 - if (data.data.logged_in || data.data.email) {
4270 + // A success response means the server has already decided the
4271 + // required info is on file. With email optional (c0cfaf) that
4272 + // info can be a name and no address, so keying on data.email
4273 + // alone re-prompted a name-only visitor on every page load.
4274 + if (data.data.logged_in || data.data.email || data.data.name) {
3431 4275 showChatContainerForBot(botId);
3432 4276 } else {
3433 4277 showEmailFormForBot(botId);
3434 4278 }
3435 4279 } else {
4280 + // Pre-fill for a signed-in visitor being shown the form
4281 + // (c0cfaf). It arrives on this nocache'd response rather than
4282 + // in the page HTML, which is cacheable. Never overwrite what
4283 + // the visitor has already typed.
4284 + if (data.data) {
4285 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4286 + }
3436 4287 showEmailFormForBot(botId);
3437 4288 }
3438 4289 })
3439 4290 .catch((error) => {
@@ -3440,8 +4291,19 @@
3440 4291 showEmailFormForBot(botId);
3441 4292 });
3442 4293 }
3443 4294
4295 + function prefillEmailForm(botId, name, email) {
4296 + var nameInput = getElementDOM(botId, 'user-name');
4297 + var emailInput = getElementDOM(botId, 'user-email');
4298 + if (nameInput && name && !nameInput.value) {
4299 + nameInput.value = name;
4300 + }
4301 + if (emailInput && email && !emailInput.value) {
4302 + emailInput.value = email;
4303 + }
4304 + }
4305 +
3444 4306 // Event delegation for email form submission
3445 4307 $(document).on('submit', '.email-collection-form', function(e) {
3446 4308 e.preventDefault();
3447 4309 e.stopPropagation();
@@ -3454,23 +4316,34 @@
3454 4316 }
3455 4317
3456 4318 var emailInput = getElementDOM(botId, 'user-email');
3457 4319 var nameInput = getElementDOM(botId, 'user-name');
4320 + var consentInput = getElementDOM(botId, 'user-consent');
3458 4321 var userEmail = emailInput ? emailInput.value.trim() : '';
3459 4322 var userName = nameInput ? nameInput.value.trim() : '';
3460 4323 var sessionId = MxChatInstances.ensureSession(botId);
3461 4324
3462 - // Validate email
3463 - if (!userEmail) {
4325 + // Validate email. The address is optional when the owner turned
4326 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4327 + // still has to be a valid address. The server enforces both rules
4328 + // independently.
4329 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4330 +
4331 + if (emailRequired && !userEmail) {
3464 4332 showEmailError(botId, 'Please enter your email address.');
3465 4333 return false;
3466 4334 }
3467 4335
3468 - if (!isValidEmailAddress(userEmail)) {
4336 + if (userEmail && !isValidEmailAddress(userEmail)) {
3469 4337 showEmailError(botId, 'Please enter a valid email address.');
3470 4338 return false;
3471 4339 }
3472 4340
4341 + if (!emailRequired && !userEmail && !userName) {
4342 + showEmailError(botId, 'Please enter your name or email address.');
4343 + return false;
4344 + }
4345 +
3473 4346 // Validate name if field exists and has content
3474 4347 if (nameInput && userName && !isValidNameInput(userName)) {
3475 4348 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3476 4349 return false;
@@ -3475,8 +4348,15 @@
3475 4348 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3476 4349 return false;
3477 4350 }
3478 4351
4352 + // Consent checkbox (b062c4): backstop behind the native required
4353 + // attribute; the server enforces this independently either way.
4354 + if (consentInput && consentInput.required && !consentInput.checked) {
4355 + showEmailError(botId, 'Please tick the consent box to continue.');
4356 + return false;
4357 + }
4358 +
3479 4359 clearEmailError(botId);
3480 4360 setEmailSubmissionState(botId, true);
3481 4361
3482 4362 // Prepare form data
@@ -3490,8 +4370,14 @@
3490 4370 if (userName) {
3491 4371 formData.append('name', userName);
3492 4372 }
3493 4373
4374 + // Ticked/unticked both travel when the checkbox is rendered, so an
4375 + // optional-consent "no" is recorded as a decision, not an absence.
4376 + if (consentInput) {
4377 + formData.append('consent', consentInput.checked ? '1' : '0');
4378 + }
4379 +
3494 4380 fetch(mxchatChat.ajax_url, {
3495 4381 method: 'POST',
3496 4382 headers: {
3497 4383 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3613,8 +4499,9 @@
3613 4499 $chatbot.removeClass('hidden').addClass('visible');
3614 4500 getElement(botId, 'floating-chatbot-button').addClass('hidden');
3615 4501 handlePreChatDismissal(botId);
3616 4502 disableScroll(); // Disable scroll when chatbot opens
4503 + mxchatFireOpened(botId);
3617 4504
3618 4505 // Load chat history for returning visitors (persistence)
3619 4506 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3620 4507 if (chatPersistenceEnabled) {
@@ -3721,14 +4608,8 @@
3721 4608 if (messageDiv.length > 0) {
3722 4609 const originalHref = $link.attr('href');
3723 4610
3724 4611 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
3725 - e.preventDefault();
3726 - e.stopPropagation();
3727 -
3728 - // Mark as tracked
3729 - $link.attr('data-tracked', 'true');
3730 -
3731 4612 // Get bot ID from the chat box context
3732 4613 var botId = getBotIdFromElement(this);
3733 4614
3734 4615 // Get message context from the message div
@@ -3733,28 +4614,12 @@
3733 4614
3734 4615 // Get message context from the message div
3735 4616 const messageText = messageDiv.text().substring(0, 200);
3736 4617
3737 - $.ajax({
3738 - url: mxchatChat.ajax_url,
3739 - type: 'POST',
3740 - data: {
3741 - action: 'mxchat_track_url_click',
3742 - session_id: getChatSession(botId),
3743 - url: originalHref,
3744 - message_context: messageText,
3745 - nonce: mxchatChat.nonce
3746 - },
3747 - complete: function() {
3748 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
3749 - window.open(originalHref, '_blank');
3750 - } else {
3751 - window.location.href = originalHref;
3752 - }
3753 - }
3754 - });
3755 -
3756 - return false;
4618 + // Same path as attachLinkTracking(): set the target, record the
4619 + // click, and let the browser follow the anchor.
4620 + prepareTrackedLink($link);
4621 + trackLinkClick(originalHref, messageText, botId);
3757 4622 }
3758 4623 }
3759 4624 });
3760 4625
@@ -3780,8 +4645,15 @@
3780 4645 // Initialize chat visibility for all instances
3781 4646 $('.mxchat-chatbot-wrapper').each(function() {
3782 4647 var botId = $(this).data('bot-id') || 'default';
3783 4648 initializeChatVisibility(botId);
4649 + // An embedded widget is on screen from the first paint; a floating one
4650 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4651 + if (isEmbeddedBot(botId)) {
4652 + mxchatFireOpened(botId);
4653 + } else {
4654 + mxchatWatchForOpen(botId);
4655 + }
3784 4656 });
3785 4657
3786 4658 // Make functions globally available for add-ons
3787 4659 window.hasQuickQuestions = hasQuickQuestions;
@@ -3836,13 +4708,26 @@
3836 4708 // SATISFACTION RATING (v3.2.6)
3837 4709 // ============================================================================
3838 4710 // Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
3839 4711 // inactivity following a bot reply. One prompt per session, deduped via
3840 -// localStorage. Disabled site-wide when mxchatChat.satisfaction_rating_enabled
3841 -// is exactly false (default ON).
4712 +// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4713 +// the option (default off) is authoritative.
3842 4714 jQuery(function($) {
3843 4715 if (typeof mxchatChat === 'undefined') return;
3844 - if (mxchatChat.satisfaction_rating_enabled === false || mxchatChat.satisfaction_rating_enabled === 'off') return;
4716 + // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4717 + // '' and true as '1', so this must be an explicit-enable allowlist — the
4718 + // old "disabled when exactly false/'off'" check let '' through and the
4719 + // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4720 + // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4721 + // (boolean-true localizations) working.
4722 + // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4723 + // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4724 + // merges fresh values over mxchatChat (that merge fires on first widget
4725 + // open, after this module has already decided). Re-evaluating would mean
4726 + // restructuring the whole module to late-bind its listeners — not worth it
4727 + // for a prompt that is at worst stale for one page load on a cached page.
4728 + var sre = mxchatChat.satisfaction_rating_enabled;
4729 + if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
3845 4730
3846 4731 // wp_localize_script stringifies ints, so accept both number and numeric string.
3847 4732 var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
3848 4733 var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
@@ -3897,8 +4782,39 @@
3897 4782 if (!bg && !fg) return '';
3898 4783 return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
3899 4784 }
3900 4785
4786 + // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4787 + // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4788 + // and paints the filled "Send" pill so it fills with the bot font color and
4789 + // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4790 + // We paint the submit button DIRECTLY (inline longhand) rather than relying
4791 + // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4792 + // unreliably inside a descendant's `background`, so a bubble-level var would
4793 + // silently fall back to the literal (white-block bug all over again). Inline
4794 + // longhand always wins. Same transparent-guard as the menu so we never paint
4795 + // a see-through value — in that case the CSS literal fallbacks keep it legible.
4796 + function syncRatingBubbleColors(botId) {
4797 + var $chatBox = getChatBoxByBotId(botId);
4798 + if (!$chatBox || !$chatBox.length) return;
4799 + var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4800 + if (!bubbleEl) return;
4801 + var cs = window.getComputedStyle(bubbleEl);
4802 + var fg = cs.color;
4803 + var bg = cs.backgroundColor;
4804 + var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4805 + var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4806 + // Expose on the bubble too, for any inheriting styles / future use.
4807 + if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4808 + if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4809 + // Paint the Send pill directly — the part that actually fixes the bug.
4810 + var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4811 + if (submitEl) {
4812 + if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4813 + if (hasBg) submitEl.style.color = bg; // label = bubble background
4814 + }
4815 + }
4816 +
3901 4817 function copy(key) {
3902 4818 var c = mxchatChat.satisfaction_rating_copy || {};
3903 4819 var d = {
3904 4820 question: 'Was this helpful?',
@@ -3981,8 +4897,9 @@
3981 4897 var $chatBox = getChatBoxByBotId(botId);
3982 4898 if (!$chatBox.length) return;
3983 4899 if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
3984 4900 $chatBox.append(buildPromptHtml(botId));
4901 + syncRatingBubbleColors(botId);
3985 4902 s.promptShown = true;
3986 4903 scrollChatBoxToBottom($chatBox);
3987 4904 }
3988 4905
@@ -4050,8 +4967,9 @@
4050 4967 var rating = parseInt($btn.attr('data-rating'), 10);
4051 4968 if (rating !== 1 && rating !== -1) return;
4052 4969 submitRating(botId, rating, '');
4053 4970 ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4971 + syncRatingBubbleColors(botId);
4054 4972 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4055 4973 });
4056 4974
4057 4975 $(document).on('click', '.mxchat-rating-dismiss', function(e) {
@@ -4068,8 +4986,9 @@
4068 4986 function closeFeedback($fb) {
4069 4987 var botId = $fb.data('bot-id') || 'default';
4070 4988 var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4071 4989 ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4990 + syncRatingBubbleColors(botId);
4072 4991 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4073 4992 }
4074 4993
4075 4994 $(document).on('click', '.mxchat-rating-skip', function(e) {