PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
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 +1658 -221 3.2.3 → 3.2.24 View file →
@@ -1,20 +1,163 @@
1 1 jQuery(document).ready(function($) {
2 2
3 - // Nonce refresh is deferred until first user interaction (ensureSession)
4 - // to avoid admin-ajax calls on passive page loads.
5 - var nonceRefreshed = false;
6 - function refreshNonceIfNeeded(callback) {
7 - if (nonceRefreshed || typeof mxchatChat === 'undefined' || !mxchatChat.ajax_url) {
3 + // Nonce refresh — v2 (plan-6a68c9).
4 + //
5 + // The widget no longer relies on a nonce embedded in inline cached HTML.
6 + // Before each chat-send / stream-send / upload, we call the REST endpoint
7 + // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The
8 + // endpoint creates the nonce with action `mxchat_chat_send`; the server-side
9 + // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a
10 + // 30-day backwards-compat window so cached pages still in users' browsers
11 + // (which carry the legacy inline-localized nonce) keep working.
12 + //
13 + // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are
14 + // 24h but we refetch at half-life so a freshly-cached-page user never sees
15 + // a borderline-stale nonce).
16 + var cachedFreshNonce = null;
17 + var cachedFreshNonceFetchedAt = 0;
18 + var NONCE_TTL_MS = 12 * 60 * 60 * 1000;
19 + var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
20 + var nonceRefreshCallbacks = [];
21 +
22 + function getRestNonceUrl() {
23 + if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) {
24 + return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce';
25 + }
26 + // Fallback: derive from current origin if mxchatChat.rest_url isn't set.
27 + return window.location.origin + '/wp-json/mxchat/v1/nonce';
28 + }
29 +
30 + function fetchFreshNonceFromRest() {
31 + return fetch(getRestNonceUrl(), {
32 + credentials: 'same-origin',
33 + headers: { 'Accept': 'application/json' }
34 + }).then(function (resp) {
35 + if (!resp.ok) {
36 + throw new Error('REST nonce fetch failed: ' + resp.status);
37 + }
38 + return resp.json();
39 + }).then(function (data) {
40 + if (data && data.nonce) {
41 + return data.nonce;
42 + }
43 + throw new Error('REST nonce response had no nonce field.');
44 + });
45 + }
46 +
47 + /**
48 + * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
49 + * Tries REST endpoint first (cache-bypass design); falls back to the legacy
50 + * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
51 + * calls share the same in-flight refresh.
52 + */
53 + function withFreshNonce(callback) {
54 + if (typeof mxchatChat === 'undefined') {
8 55 if (callback) callback();
9 56 return;
10 57 }
11 - nonceRefreshed = true;
12 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
13 - if (res && res.success && res.data && res.data.nonce) {
14 - mxchatChat.nonce = res.data.nonce;
58 + var now = Date.now();
59 + if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 + mxchatChat.nonce = cachedFreshNonce;
61 + if (callback) callback();
62 + return;
63 + }
64 + if (callback) nonceRefreshCallbacks.push(callback);
65 + if (nonceRefreshState === 'pending') return;
66 + nonceRefreshState = 'pending';
67 +
68 + var resolved = function (nonce) {
69 + if (nonce) {
70 + cachedFreshNonce = nonce;
71 + cachedFreshNonceFetchedAt = Date.now();
72 + mxchatChat.nonce = nonce;
15 73 }
16 - if (callback) callback();
74 + nonceRefreshState = 'done';
75 + var pending = nonceRefreshCallbacks;
76 + nonceRefreshCallbacks = [];
77 + pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78 + };
79 +
80 + fetchFreshNonceFromRest()
81 + .then(resolved)
82 + .catch(function () {
83 + // Fallback to the legacy admin-ajax refresh path (issued with the
84 + // old action `mxchat_chat_nonce`; the server still accepts both
85 + // during the compat window).
86 + if (mxchatChat.ajax_url) {
87 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88 + .done(function (res) {
89 + if (res && res.success && res.data && res.data.nonce) {
90 + resolved(res.data.nonce);
91 + return;
92 + }
93 + resolved(null);
94 + })
95 + .fail(function () { resolved(null); });
96 + } else {
97 + resolved(null);
98 + }
99 + });
100 + }
101 +
102 + // Backwards-compat alias — every existing caller in this file (and any
103 + // out-of-tree consumer that hit this internal API) keeps working unchanged.
104 + function refreshNonceIfNeeded(callback) {
105 + return withFreshNonce(callback);
106 + }
107 +
108 + // Dynamic-settings refresh (plan-32db95).
109 + //
110 + // Every widget setting ships inline in cached page HTML, so behind a
111 + // full-page cache the site owner can't purge (host cache, CDN, the
112 + // browser itself) a toggled setting looks broken until the cache turns
113 + // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114 + // on the FIRST widget open per page load we ask the nonce endpoint for
115 + // the current behavior-gate settings (?with_settings=1), merge them over
116 + // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117 + // they're server-inline-styled, so a runtime swap would visibly flash.
118 + // On any failure we keep the inline values silently (nonce-fallback
119 + // posture). At most one request per page load, only if a widget opens.
120 + var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121 +
122 + function mxchatRefreshDynamicSettings() {
123 + if (dynamicSettingsState !== 'idle') return;
124 + if (typeof mxchatChat === 'undefined') return;
125 + dynamicSettingsState = 'pending';
126 +
127 + var applied = function (data) {
128 + dynamicSettingsState = 'done';
129 + if (!data) return; // endpoint unavailable — inline values stand.
130 + if (data.nonce) {
131 + // Seed the nonce cache too: saves the first send's REST
132 + // round-trip and keeps us under the endpoint's rate limit.
133 + cachedFreshNonce = data.nonce;
134 + cachedFreshNonceFetchedAt = Date.now();
135 + mxchatChat.nonce = data.nonce;
136 + }
137 + if (data.settings && typeof data.settings === 'object') {
138 + $.extend(mxchatChat, data.settings);
139 + mxchatRebuildHeaderMenus();
140 + }
141 + };
142 +
143 + fetch(getRestNonceUrl() + '?with_settings=1', {
144 + credentials: 'same-origin',
145 + headers: { 'Accept': 'application/json' }
146 + }).then(function (resp) {
147 + if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148 + return resp.json();
149 + }).then(applied).catch(function () {
150 + // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151 + if (mxchatChat.ajax_url) {
152 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153 + .done(function (res) {
154 + applied(res && res.success && res.data ? res.data : null);
155 + })
156 + .fail(function () { applied(null); });
157 + } else {
158 + applied(null);
159 + }
17 160 });
18 161 }
19 162
20 163 // ====================================
@@ -24,8 +167,16 @@
24 167 // Instance registry - tracks all chatbot instances on the page
25 168 const MxChatInstances = {
26 169 instances: {},
27 170
171 + // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The
172 + // events below fire once; a script that loads late checks these instead.
173 + toolbarReady: false,
174 + opened: {},
175 + hasOpened: function(botId) {
176 + return !!this.opened[botId || 'default'];
177 + },
178 +
28 179 // Initialize an instance for a bot
29 180 init: function(botId) {
30 181 if (!this.instances[botId]) {
31 182 // When persistence is OFF, track when this session started
@@ -141,9 +292,20 @@
141 292 var newSessionId = generateSessionId();
142 293 this.setChatSession(botId, newSessionId);
143 294 var $chatBox = getElement(botId, 'chat-box');
144 295 if ($chatBox.length) {
145 - $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 + }
146 308 }
147 309 if (this.instances[botId]) {
148 310 this.instances[botId].chatHistoryLoaded = false;
149 311 this.instances[botId].processedMessageIds = new Set();
@@ -163,8 +325,50 @@
163 325 }
164 326 };
165 327
166 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 + // ====================================
167 371 // ELEMENT SELECTOR HELPERS
168 372 // ====================================
169 373
170 374 // Check if a specific bot has an AI theme assigned (skip inline colors)
@@ -200,8 +404,20 @@
200 404 var id = $floating.attr('id') || '';
201 405 var match = id.match(/floating-chatbot-(.+)/);
202 406 if (match) return match[1];
203 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 + }
204 420 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
205 421 var elementId = $(element).attr('id') || '';
206 422 if (elementId) {
207 423 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -241,9 +457,27 @@
241 457 if (parts.length == 2) return parts.pop().split(";").shift();
242 458 }
243 459
244 460 function generateSessionId() {
245 - 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;
246 480 }
247 481
248 482 // Legacy function - now delegates to instance manager
249 483 function getChatSession(botId) {
@@ -287,23 +521,28 @@
287 521 // ====================================
288 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
289 523 // ====================================
290 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 +
291 530 function getPageContext() {
292 531 // Check if contextual awareness is enabled
293 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
294 533 return null;
295 534 }
296 -
535 +
297 536 // Get page URL
298 537 const pageUrl = window.location.href;
299 -
538 +
300 539 // Get page title
301 540 const pageTitle = document.title || '';
302 -
541 +
303 542 // Get main content from the page
304 543 let pageContent = '';
305 -
544 +
306 545 // Try to get content from common content areas
307 546 const contentSelectors = [
308 547 'main',
309 548 '[role="main"]',
@@ -315,9 +554,9 @@
315 554 'article',
316 555 '#content',
317 556 '#main'
318 557 ];
319 -
558 +
320 559 let contentElement = null;
321 560 for (const selector of contentSelectors) {
322 561 contentElement = document.querySelector(selector);
323 562 if (contentElement) {
@@ -323,18 +562,31 @@
323 562 if (contentElement) {
324 563 break;
325 564 }
326 565 }
327 -
566 +
328 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
329 568 if (!contentElement) {
330 569 contentElement = document.body;
331 570 }
332 -
571 +
333 572 if (contentElement) {
334 - // Clone the element to avoid modifying the original
335 - const clone = contentElement.cloneNode(true);
336 -
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 +
337 589 // Remove unwanted elements
338 590 const unwantedSelectors = [
339 591 'header',
340 592 'footer',
@@ -352,16 +604,25 @@
352 604 '#floating-chatbot',
353 605 '#floating-chatbot-button',
354 606 '.mxchat',
355 607 '[class*="chat"]',
356 - '[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'
357 618 ];
358 -
619 +
359 620 unwantedSelectors.forEach(selector => {
360 621 const elements = clone.querySelectorAll(selector);
361 622 elements.forEach(el => el.remove());
362 623 });
363 -
624 +
364 625 // Extract MxChat context data attributes before getting text content
365 626 const contextData = [];
366 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
367 628 const contextValue = el.dataset.mxchatContext;
@@ -368,34 +629,34 @@
368 629 if (contextValue && contextValue.trim()) {
369 630 contextData.push(contextValue);
370 631 }
371 632 });
372 -
633 +
373 634 // Get text content and clean it up
374 635 pageContent = clone.textContent || clone.innerText || '';
375 -
636 +
376 637 // Add context data to page content if any were found
377 638 if (contextData.length > 0) {
378 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
379 640 }
380 -
641 +
381 642 // Clean up whitespace and limit length
382 643 pageContent = pageContent
383 644 .replace(/\s+/g, ' ')
384 645 .trim()
385 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;
386 656 }
387 -
388 - // Only return context if we have meaningful content
389 - if (!pageContent || pageContent.length < 50) {
390 - return null;
391 - }
392 -
393 - return {
394 - url: pageUrl,
395 - title: pageTitle,
396 - content: pageContent
397 - };
657 +
658 + return null;
398 659 }
399 660
400 661 // Track originating page when chat starts
401 662 function trackOriginatingPage() {
@@ -446,8 +707,26 @@
446 707 sendButton.style.pointerEvents = 'none';
447 708 }
448 709 }
449 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 +
450 729 function enableChatInput(botId) {
451 730 botId = botId || 'default';
452 731 var chatInput = getElementDOM(botId, 'chat-input');
453 732 var sendButton = getElementDOM(botId, 'send-button');
@@ -453,9 +732,11 @@
453 732 var sendButton = getElementDOM(botId, 'send-button');
454 733 if (chatInput) {
455 734 chatInput.disabled = false;
456 735 chatInput.style.opacity = '1';
457 - chatInput.focus();
736 + if (mxchatShouldAutofocusAfterReply()) {
737 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 + }
458 739 }
459 740 if (sendButton) {
460 741 sendButton.disabled = false;
461 742 sendButton.style.opacity = '1';
@@ -460,10 +741,102 @@
460 741 sendButton.disabled = false;
461 742 sendButton.style.opacity = '1';
462 743 sendButton.style.pointerEvents = 'auto';
463 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);
464 748 }
465 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 +
466 839 // Update your existing sendMessage function
467 840 function sendMessage(botId) {
468 841 botId = botId || 'default';
469 842 MxChatInstances.ensureSession(botId);
@@ -474,9 +847,12 @@
474 847 if (typeof customMxChatFilter === 'function') {
475 848 message = customMxChatFilter(message, "prompt");
476 849 }
477 850
478 - 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() !== '') {
479 855 // Don't disable input in live agent mode - let users chat freely
480 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
481 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
482 858 if (!isAgentMode) {
@@ -484,8 +860,9 @@
484 860 }
485 861
486 862 appendMessage("user", message, '', [], false, botId);
487 863 $chatInput.val('');
864 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
488 865 $chatInput.css('height', 'auto');
489 866
490 867 if (hasQuickQuestions(botId)) {
491 868 collapseQuickQuestions(botId);
@@ -492,14 +869,16 @@
492 869 }
493 870 appendThinkingMessage(botId);
494 871 scrollToBottom(botId);
495 872
496 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
873 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
497 874
498 875 // Check if streaming is enabled AND supported for this model
499 876 if (shouldUseStreaming(currentModel)) {
500 877 callMxChatStream(message, function(response) {
501 - 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');
502 881 }, botId);
503 882 } else {
504 883 callMxChat(message, function(response) {
505 884 replaceLastMessage("bot", response, '', [], botId);
@@ -517,8 +896,14 @@
517 896 if (typeof customMxChatFilter === 'function') {
518 897 message = customMxChatFilter(message, "prompt");
519 898 }
520 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 +
521 906 // Don't disable input in live agent mode - let users chat freely
522 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
523 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
524 909 if (!isAgentMode) {
@@ -532,14 +917,15 @@
532 917 }
533 918 appendThinkingMessage(botId);
534 919 scrollToBottom(botId);
535 920
536 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
921 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
537 922
538 923 // Check if streaming is enabled AND supported for this model
539 924 if (shouldUseStreaming(currentModel)) {
540 925 callMxChatStream(message, function(response) {
541 - 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');
542 928 }, botId);
543 929 } else {
544 930 callMxChat(message, function(response) {
545 931 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -603,8 +989,15 @@
603 989
604 990 function callMxChat(message, callback, botId) {
605 991 botId = botId || getMxChatBotId();
606 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 +
607 1000 // Store the message in case we need to retry after session reset
608 1001 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
609 1002
610 1003 // Get page context if contextual awareness is enabled
@@ -622,8 +1015,13 @@
622 1015 sessionId = generateSessionId();
623 1016 MxChatInstances.setChatSession(botId, sessionId);
624 1017 }
625 1018
1019 + // Wait for the page-cache nonce refresh to complete before firing the
1020 + // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
1021 + // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
1022 + // callback guarantees we read the fresh value. See plan-c5457f.
1023 + refreshNonceIfNeeded(function() {
626 1024 // Prepare AJAX data
627 1025 const ajaxData = {
628 1026 action: 'mxchat_handle_chat_request',
629 1027 message: message,
@@ -634,14 +1032,14 @@
634 1032 bot_id: botId,
635 1033 // Pass session start timestamp so AI context matches what user sees
636 1034 session_start_timestamp: instance.sessionStartTimestamp || 0
637 1035 };
638 -
1036 +
639 1037 // Add page context if available
640 1038 if (pageContext) {
641 1039 ajaxData.page_context = JSON.stringify(pageContext);
642 1040 }
643 -
1041 +
644 1042 // CHECK FOR VISION FLAGS AND ADD THEM
645 1043 if (window.mxchatVisionProcessed) {
646 1044 ajaxData.vision_processed = true;
647 1045 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -650,9 +1048,9 @@
650 1048 window.mxchatVisionProcessed = false;
651 1049 window.mxchatOriginalMessage = null;
652 1050 window.mxchatVisionImagesCount = 0;
653 1051 }
654 -
1052 +
655 1053 $.ajax({
656 1054 url: mxchatChat.ajax_url,
657 1055 type: 'POST',
658 1056 dataType: 'json',
@@ -697,12 +1095,13 @@
697 1095 // Re-send the original message with the new session (user message is already displayed)
698 1096 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
699 1097 if (originalMessage) {
700 1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
701 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1099 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
702 1100 if (shouldUseStreaming(currentModel)) {
703 1101 callMxChatStream(originalMessage, function(response) {
704 - 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');
705 1104 }, botId);
706 1105 } else {
707 1106 callMxChat(originalMessage, function(response) {
708 1107 replaceLastMessage("bot", response, '', [], botId);
@@ -841,8 +1240,9 @@
841 1240
842 1241 replaceLastMessage("bot", errorMessage, '', [], botId);
843 1242 }
844 1243 });
1244 + }); // refreshNonceIfNeeded
845 1245 }
846 1246
847 1247 function callMxChatStream(message, callback, botId) {
848 1248 botId = botId || getMxChatBotId();
@@ -849,9 +1249,9 @@
849 1249
850 1250 // Store the message in case we need to retry after session reset
851 1251 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
852 1252
853 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1253 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
854 1254 if (!isStreamingSupported(currentModel)) {
855 1255 callMxChat(message, callback, botId);
856 1256 return;
857 1257 }
@@ -871,8 +1271,11 @@
871 1271 streamSessionId = generateSessionId();
872 1272 MxChatInstances.setChatSession(botId, streamSessionId);
873 1273 }
874 1274
1275 + // Wait for the page-cache nonce refresh before constructing formData (which
1276 + // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1277 + refreshNonceIfNeeded(function() {
875 1278 const formData = new FormData();
876 1279 formData.append('action', 'mxchat_stream_chat');
877 1280 formData.append('message', message);
878 1281 formData.append('session_id', streamSessionId);
@@ -901,13 +1304,40 @@
901 1304
902 1305 let accumulatedContent = '';
903 1306 let testingDataReceived = false;
904 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 = '';
905 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 +
906 1335 fetch(mxchatChat.ajax_url, {
907 1336 method: 'POST',
908 1337 body: formData,
909 - credentials: 'same-origin'
1338 + credentials: 'same-origin',
1339 + signal: streamControl.controller.signal
910 1340 })
911 1341 .then(response => {
912 1342 // Store the response for potential fallback handling
913 1343 const responseClone = response.clone();
@@ -976,8 +1406,18 @@
976 1406
977 1407 // Re-enable chat input when stream ends with content
978 1408 enableChatInput(botId);
979 1409
1410 + flushAppendHtml();
1411 +
1412 + // Scroll the user's last message to the top now that the
1413 + // bot's full reply has rendered (gives max reading room).
1414 + var $chatBoxDone = getElement(botId, 'chat-box');
1415 + var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1416 + if ($lastUserMsgDone.length) {
1417 + scrollElementToTop($lastUserMsgDone, botId);
1418 + }
1419 +
980 1420 if (callback) {
981 1421 callback(accumulatedContent);
982 1422 }
983 1423 return;
@@ -1000,8 +1440,18 @@
1000 1440
1001 1441 // Re-enable chat input after streaming completes
1002 1442 enableChatInput(botId);
1003 1443
1444 + flushAppendHtml();
1445 +
1446 + // Scroll the user's last message to the top now
1447 + // that the bot's full reply has rendered.
1448 + var $chatBoxStreamDone = getElement(botId, 'chat-box');
1449 + var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1450 + if ($lastUserMsgStreamDone.length) {
1451 + scrollElementToTop($lastUserMsgStreamDone, botId);
1452 + }
1453 +
1004 1454 if (callback) {
1005 1455 callback(accumulatedContent);
1006 1456 }
1007 1457 return;
@@ -1027,8 +1477,21 @@
1027 1477 streamingStarted = true;
1028 1478 accumulatedContent += json.content;
1029 1479 updateStreamingMessage(accumulatedContent, botId);
1030 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 + }
1031 1494 // Handle complete response in stream (fallback response)
1032 1495 else if (json.text || json.message || json.html) {
1033 1496 handleNonStreamResponse(json, callback, botId);
1034 1497 return;
@@ -1058,8 +1521,9 @@
1058 1521 }
1059 1522
1060 1523 processStream();
1061 1524 }).catch(streamError => {
1525 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1062 1526 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1063 1527 callMxChat(message, callback, botId);
1064 1528 });
1065 1529 }
@@ -1066,8 +1530,9 @@
1066 1530
1067 1531 processStream();
1068 1532 })
1069 1533 .catch(error => {
1534 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1070 1535 // Check if we have server error data with chat mode
1071 1536 if (error && error.isServerError && error.data) {
1072 1537 // Check for chat mode in error data
1073 1538 if (error.data.chat_mode) {
@@ -1080,8 +1545,9 @@
1080 1545 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1081 1546 callMxChat(message, callback, botId);
1082 1547 }
1083 1548 });
1549 + }); // refreshNonceIfNeeded
1084 1550 }
1085 1551
1086 1552 // Helper function to handle non-streaming responses
1087 1553 function handleNonStreamResponse(data, callback, botId) {
@@ -1127,9 +1593,9 @@
1127 1593 // Re-send the original message with the new session (user message is already displayed)
1128 1594 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1129 1595 if (originalMessage) {
1130 1596 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1131 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1597 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1132 1598 if (shouldUseStreaming(currentModel)) {
1133 1599 callMxChatStream(originalMessage, callback, botId);
1134 1600 } else {
1135 1601 callMxChat(originalMessage, callback, botId);
@@ -1262,8 +1728,15 @@
1262 1728 var $chatBox = getElement(botId, 'chat-box');
1263 1729 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1264 1730
1265 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 + }
1266 1739 // Update existing message
1267 1740 tempMessage.html(formattedContent);
1268 1741 } else {
1269 1742 // Create new temporary message if it doesn't exist
@@ -1290,29 +1763,395 @@
1290 1763 // Update the event handlers to use the correct function names (using event delegation)
1291 1764 // Use class-based selectors for multi-instance support
1292 1765 $(document).on('click', '.send-button', function() {
1293 1766 var botId = getBotIdFromElement(this);
1294 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1295 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1296 - 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;
1297 1775 }
1776 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1298 1777 sendMessage(botId);
1299 1778 });
1300 1779
1301 -// 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).
1302 1785 $(document).on('keypress', '.chat-input', function(e) {
1303 1786 if (e.which == 13 && !e.shiftKey) {
1304 1787 e.preventDefault();
1305 - var botId = getBotIdFromElement(this);
1306 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1307 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1308 - disableChatInput(botId);
1788 + sendMessage(getBotIdFromElement(this));
1789 + }
1790 +});
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 +
1857 +// Builds the list of overflow-menu items for a given bot.
1858 +// Adding a future item is one push to this array — do NOT hardcode "only download."
1859 +function mxchatGetHeaderMenuItems(botId) {
1860 + var items = [];
1861 + var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1862 +
1863 + // The `print_button_*` keys still gate this item for back-compat with
1864 + // existing user options. The action is now a transcript download, not print.
1865 + if (settings.print_button_enabled === 'on') {
1866 + items.push({
1867 + id: 'download-transcript',
1868 + label: settings.print_button_label || 'Download Transcript',
1869 + icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1870 + action: function() {
1871 + mxchatDownloadTranscript(botId);
1872 + }
1873 + });
1874 + }
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 +
1894 + return items;
1895 +}
1896 +
1897 +// Builds a clean markdown transcript of the current conversation and triggers
1898 +// a file download. Used by the "Download Transcript" menu item.
1899 +function mxchatDownloadTranscript(botId) {
1900 + var $chatBox = getElement(botId, 'chat-box');
1901 + if (!$chatBox || !$chatBox.length) return;
1902 +
1903 + var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1904 + var headerTitle = settings.print_header_title || 'Chat transcript';
1905 + var now = new Date();
1906 + var stamp = now.toLocaleString();
1907 +
1908 + var lines = [];
1909 + lines.push('# ' + headerTitle);
1910 + lines.push('');
1911 + lines.push('Exported: ' + stamp);
1912 + lines.push('');
1913 + lines.push('---');
1914 + lines.push('');
1915 +
1916 + $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1917 + var $msg = $(this);
1918 + // Skip thinking placeholders and any in-flight temporary messages.
1919 + if ($msg.find('.thinking-dots').length) return;
1920 + if ($msg.hasClass('temporary-message')) return;
1921 +
1922 + var sender;
1923 + if ($msg.hasClass('user-message')) sender = 'User';
1924 + else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1925 + else sender = 'AI Agent';
1926 +
1927 + // Strip interactive UI from the cloned message so we get the conversation text.
1928 + var $clone = $msg.clone();
1929 + $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1930 + var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1931 + if (!text) return;
1932 +
1933 + lines.push('**' + sender + '**');
1934 + lines.push('');
1935 + lines.push(text);
1936 + lines.push('');
1937 + });
1938 +
1939 + var content = lines.join('\n');
1940 + var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1941 + var fname = 'mxchat-transcript-' + iso + '.md';
1942 + var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1943 + var url = URL.createObjectURL(blob);
1944 + var a = document.createElement('a');
1945 + a.href = url;
1946 + a.download = fname;
1947 + a.style.display = 'none';
1948 + document.body.appendChild(a);
1949 + a.click();
1950 + setTimeout(function() {
1951 + if (a.parentNode) a.parentNode.removeChild(a);
1952 + URL.revokeObjectURL(url);
1953 + }, 100);
1954 +}
1955 +
1956 +// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1957 +// on the menu wrap, so the dropdown matches whatever paints the bubble —
1958 +// saved options, AI theme CSS, or the mxchat-theme add-on.
1959 +function mxchatSyncMenuColors(botId, $wrap) {
1960 + if (!$wrap || !$wrap.length) return;
1961 + var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1962 + if (!$bot.length) return;
1963 + var cs = window.getComputedStyle($bot[0]);
1964 + if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1965 + $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1966 + }
1967 + // Bot text color usually lives on a child div, not .bot-message itself.
1968 + var $textChild = $bot.find('[style*="color"]').first();
1969 + var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1970 + if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1971 +}
1972 +
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) {
1979 + var $trigger = $wrap.find('.mxchat-menu-trigger');
1980 + var $menu = $wrap.find('.mxchat-header-menu');
1981 + var items = mxchatGetHeaderMenuItems(botId);
1982 +
1983 + $menu.empty();
1984 +
1985 + if (!items.length) {
1986 + $trigger.hide();
1987 + $menu.hide();
1988 + return;
1989 + }
1990 +
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 +
1996 + items.forEach(function(item, idx) {
1997 + var $btn = $('<button>', {
1998 + type: 'button',
1999 + 'class': 'mxchat-menu-item',
2000 + 'role': 'menuitem',
2001 + 'tabindex': '-1',
2002 + 'data-menu-id': item.id,
2003 + html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
2004 + '<span class="mxchat-menu-item-label"></span>'
2005 + });
2006 + $btn.find('.mxchat-menu-item-label').text(item.label);
2007 + $btn.on('click', function(e) {
2008 + e.preventDefault();
2009 + e.stopPropagation();
2010 + if (closeMenuFn) closeMenuFn();
2011 + try { item.action(); } catch (err) { /* no-op */ }
2012 + });
2013 + $menu.append($btn);
2014 + });
2015 +}
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;
1309 2029 }
1310 - sendMessage(botId);
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 +
2052 + function openMenu() {
2053 + // Re-sync each open in case the active theme changed since init.
2054 + mxchatSyncMenuColors(botId, $wrap);
2055 + $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
2056 + $trigger.attr('aria-expanded', 'true');
2057 + // Focus the first item for keyboard users
2058 + setTimeout(function() {
2059 + $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
2060 + }, 0);
1311 2061 }
2062 + function closeMenu(returnFocus) {
2063 + $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
2064 + $trigger.attr('aria-expanded', 'false');
2065 + $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
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 + }
2073 + }
2074 +
2075 + // Toggle on trigger click — stop propagation so the .chatbot-top-bar
2076 + // click-to-collapse handler does not fire.
2077 + $trigger.on('click', function(e) {
2078 + e.preventDefault();
2079 + e.stopPropagation();
2080 + if ($menu.hasClass('is-open')) closeMenu();
2081 + else openMenu();
2082 + });
2083 +
2084 + // Don't let clicks inside the menu bubble to the top-bar collapse handler.
2085 + $menu.on('click', function(e) {
2086 + e.stopPropagation();
2087 + });
2088 +
2089 + // Outside click closes the menu.
2090 + $(document).on('click.mxchatMenu-' + botId, function(e) {
2091 + if (!$menu.hasClass('is-open')) return;
2092 + if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
2093 + closeMenu();
2094 + });
2095 +
2096 + // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
2097 + $menu.on('keydown', '.mxchat-menu-item', function(e) {
2098 + var $items = $menu.find('.mxchat-menu-item');
2099 + var idx = $items.index(this);
2100 + if (e.key === 'Escape') {
2101 + e.preventDefault();
2102 + closeMenu(true);
2103 + } else if (e.key === 'ArrowDown') {
2104 + e.preventDefault();
2105 + var $next = $items.eq((idx + 1) % $items.length);
2106 + $items.attr('tabindex', '-1');
2107 + $next.attr('tabindex', '0').trigger('focus');
2108 + } else if (e.key === 'ArrowUp') {
2109 + e.preventDefault();
2110 + var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2111 + $items.attr('tabindex', '-1');
2112 + $prev.attr('tabindex', '0').trigger('focus');
2113 + } else if (e.key === 'Enter' || e.key === ' ') {
2114 + e.preventDefault();
2115 + $(this).trigger('click');
2116 + }
2117 + });
2118 + $trigger.on('keydown', function(e) {
2119 + if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2120 + e.preventDefault();
2121 + closeMenu(true);
2122 + } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2123 + e.preventDefault();
2124 + openMenu();
2125 + }
2126 + });
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 +
2133 + $wrap.data('mxchatMenuReady', true);
2134 +}
2135 +
2136 +// Initialize header menus for every rendered widget on DOM ready.
2137 +$(function() {
2138 + $('.mxchat-header-menu-wrap').each(function() {
2139 + var botId = $(this).data('bot-id');
2140 + if (botId) mxchatInitHeaderMenu(botId);
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 + }
1312 2152 });
1313 2153
1314 -
1315 2154 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1316 2155 try {
1317 2156 // Determine styles based on sender type
1318 2157 let messageClass, bgColor, fontColor;
@@ -1387,9 +2226,11 @@
1387 2226
1388 2227 messageDiv.html(fullMessage);
1389 2228
1390 2229 if (isTemporary) {
1391 - 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');
1392 2233 }
1393 2234
1394 2235 // Append to the correct chatbot instance's chat-box
1395 2236 var $chatBox = getElement(botId, 'chat-box');
@@ -1404,8 +2245,12 @@
1404 2245 if (lastUserMessage.length) {
1405 2246 scrollElementToTop(lastUserMessage, botId);
1406 2247 }
1407 2248 }
2249 +
2250 + if ((sender === "bot" || sender === "agent") && !isTemporary) {
2251 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2252 + }
1408 2253 });
1409 2254
1410 2255 if (messageText.id) {
1411 2256 var instance = MxChatInstances.get(botId);
@@ -1416,8 +2261,56 @@
1416 2261 // Error rendering message - silently continue
1417 2262 }
1418 2263 }
1419 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 +
1420 2313 // Helper function to attach link tracking with proper event handling
1421 2314 function attachLinkTracking(messageDiv, messageText, botId) {
1422 2315 botId = botId || 'default';
1423 2316 // Use a slight delay to ensure DOM is ready
@@ -1435,11 +2328,13 @@
1435 2328 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
1436 2329 // Remove any existing click handlers first
1437 2330 $link.off('click.tracking');
1438 2331
2332 + // The anchor carries its own target: the browser follows it
2333 + prepareTrackedLink($link);
2334 +
1439 2335 // Add new click handler with namespace
1440 2336 $link.on('click.tracking', function(e) {
1441 - e.preventDefault();
1442 2337 e.stopPropagation();
1443 2338
1444 2339 const messageContext = typeof messageText === 'string'
1445 2340 ? messageText.substring(0, 200)
@@ -1444,30 +2339,10 @@
1444 2339 const messageContext = typeof messageText === 'string'
1445 2340 ? messageText.substring(0, 200)
1446 2341 : '';
1447 2342
1448 - // Track the click
1449 - $.ajax({
1450 - url: mxchatChat.ajax_url,
1451 - type: 'POST',
1452 - data: {
1453 - action: 'mxchat_track_url_click',
1454 - session_id: getChatSession(botId),
1455 - url: originalHref,
1456 - message_context: messageContext,
1457 - nonce: mxchatChat.nonce
1458 - },
1459 - complete: function() {
1460 - // Always redirect, even if tracking fails
1461 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
1462 - window.open(originalHref, '_blank');
1463 - } else {
1464 - window.location.href = originalHref;
1465 - }
1466 - }
1467 - });
1468 -
1469 - return false; // Extra insurance to prevent default
2343 + prepareTrackedLink($link);
2344 + trackLinkClick(originalHref, messageContext, botId);
1470 2345 });
1471 2346 }
1472 2347 });
1473 2348 }, 100); // Small delay to ensure DOM is ready
@@ -1507,13 +2382,16 @@
1507 2382
1508 2383 if (images.length > 0) {
1509 2384 fullMessage += '<div class="image-gallery" dir="auto">';
1510 2385 images.forEach(img => {
2386 + const safeTitle = sanitizeUserInput(img.title);
2387 + const safeUrl = escapeAttr(img.image_url);
2388 + const safeThumbnail = escapeAttr(img.thumbnail_url);
1511 2389 fullMessage += `
1512 2390 <div style="margin-bottom: 10px;">
1513 - <strong>${img.title}</strong><br>
1514 - <a href="${img.image_url}" target="_blank">
1515 - <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;" />
1516 2394 </a>
1517 2395 </div>`;
1518 2396 });
1519 2397 fullMessage += '</div>';
@@ -1520,13 +2398,16 @@
1520 2398 }
1521 2399
1522 2400 if (lastMessageDiv.length) {
1523 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).
1524 2404 lastMessageDiv
1525 2405 .html(fullMessage)
1526 2406 .removeClass('bot-message user-message temporary-message')
1527 2407 .addClass(messageClass)
1528 - .attr('dir', 'auto');
2408 + .attr('dir', 'auto')
2409 + .attr('aria-busy', 'false');
1529 2410
1530 2411 // Only apply inline colors if AI theme is not active (let CSS handle it)
1531 2412 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1532 2413 if (!skipColors) {
@@ -1552,8 +2433,12 @@
1552 2433 }
1553 2434
1554 2435 // Re-enable chat input after response is displayed
1555 2436 enableChatInput(botId);
2437 +
2438 + if (sender === "bot" || sender === "agent") {
2439 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2440 + }
1556 2441 } else {
1557 2442 appendMessage(sender, responseText, responseHtml, images, false, botId);
1558 2443 // Re-enable chat input after response is displayed
1559 2444 enableChatInput(botId);
@@ -1582,10 +2467,15 @@
1582 2467 var botMessageFontColor = mxchatChat.bot_message_font_color;
1583 2468 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1584 2469
1585 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).
1586 2474 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1587 - 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">' +
1588 2478 '<div class="thinking-dots">' +
1589 2479 '<span class="dot"' + dotStyle + '></span>' +
1590 2480 '<span class="dot"' + dotStyle + '></span>' +
1591 2481 '<span class="dot"' + dotStyle + '></span>' +
@@ -1620,27 +2510,42 @@
1620 2510 // Check for % followed by exactly 2 hex digits
1621 2511 return /%[0-9a-fA-F]{2}/.test(url);
1622 2512 }
1623 2513
1624 - // 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.
1625 2520 function safeEncodeUrl(url) {
1626 - // If URL already contains encoded characters, return as-is
1627 - if (isUrlEncoded(url)) {
1628 - return url;
1629 - }
1630 - // Otherwise, encode it
1631 - return encodeURI(url);
2521 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2522 + return escapeAttr(encoded);
1632 2523 }
1633 2524
1634 - // Process markdown headers FIRST
1635 - let processedText = formatMarkdownHeaders(inputText);
1636 -
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 +
1637 2538 // Process text styling (bold, italic, strikethrough)
1638 2539 processedText = formatTextStyling(processedText);
1639 -
1640 - // 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
1641 2543 processedText = formatCodeBlocks(processedText);
1642 2544
2545 + // Put the rendered code and the escaped characters back
2546 + processedText = literals.restore(processedText);
2547 +
1643 2548 // Process markdown tables BEFORE converting newlines to paragraphs
1644 2549 processedText = formatMarkdownTables(processedText);
1645 2550
1646 2551 // NOW convert to paragraphs
@@ -1652,10 +2557,13 @@
1652 2557 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
1653 2558 // Clean the URL of any trailing punctuation
1654 2559 let cleanUrl = url.replace(/[.,;!?]+$/, '');
1655 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;');
1656 2564 // Return as a proper link without the brackets
1657 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2565 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1658 2566 });
1659 2567
1660 2568 // Process markdown links: [text](url) and [](url)
1661 2569 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -1759,8 +2667,30 @@
1759 2667
1760 2668 return processedText;
1761 2669 }
1762 2670
2671 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2672 + // A single alternation keeps the two honest with each other: an escaped
2673 + // backtick is consumed as an escape before it can open a code span, and a
2674 + // code span is consumed whole so the escapes inside it stay verbatim.
2675 + // Placeholders are private-use characters — no underscore, asterisk,
2676 + // backtick, hash, pipe or bracket for a later rule to match.
2677 + function protectLiterals(text) {
2678 + const store = [];
2679 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2680 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2681 + return '\uE000' + (store.length - 1) + '\uE001';
2682 + });
2683 + return {
2684 + text: out,
2685 + restore: function(t) {
2686 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2687 + return store[Number(i)];
2688 + });
2689 + }
2690 + };
2691 + }
2692 +
1763 2693 function formatMarkdownHeaders(text) {
1764 2694 // Handle h1 to h6 headers
1765 2695 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
1766 2696 const level = hashes.length;
@@ -1802,11 +2732,24 @@
1802 2732 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
1803 2733 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
1804 2734 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
1805 2735
1806 - // Handle underscores for italic - Safari-compatible (no lookbehind)
1807 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
1808 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2736 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2737 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2738 + // text or a non-word character, the closer must precede the end or a
2739 + // non-word character, and the content may not start or end with a
2740 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2741 + // while "use _this_ word" still italicises. A token whose content is
2742 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2743 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2744 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2745 + // adjacent to another underscore or a word character.
2746 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2747 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2748 + return match;
2749 + }
2750 + return before + '<em>' + content + '</em>';
2751 + });
1809 2752
1810 2753 // Handle strikethrough (~~text~~)
1811 2754 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
1812 2755
@@ -1940,8 +2883,24 @@
1940 2883 div.textContent = text;
1941 2884 return div.innerHTML;
1942 2885 }
1943 2886
2887 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2888 + // Percent-encodes the characters that can end or extend an attribute.
2889 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2890 + // never changes where a legitimate link resolves — encodeURI() already
2891 + // produces most of these, this just guarantees them for URLs that skip
2892 + // encodeURI() because they were percent-encoded on arrival.
2893 + function escapeAttr(url) {
2894 + return String(url)
2895 + .replace(/"/g, '%22')
2896 + .replace(/'/g, '%27')
2897 + .replace(/</g, '%3C')
2898 + .replace(/>/g, '%3E')
2899 + .replace(/`/g, '%60')
2900 + .replace(/ /g, '%20');
2901 + }
2902 +
1944 2903 function escapeHtml(unsafe) {
1945 2904 // Skip escaping if it's already escaped or contains HTML code block markup
1946 2905 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
1947 2906 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -2006,13 +2965,14 @@
2006 2965 requestAnimationFrame(smoothScroll);
2007 2966 }
2008 2967 }
2009 2968
2010 - function scrollElementToTop(element, botId) {
2969 + function scrollElementToTop(element, botId, topOffset) {
2011 2970 botId = botId || 'default';
2971 + topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2012 2972 var chatBox = getElement(botId, 'chat-box');
2013 2973 var elementTop = element.position().top + chatBox.scrollTop();
2014 - chatBox.animate({ scrollTop: elementTop }, 500);
2974 + chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
2015 2975 }
2016 2976
2017 2977 function showChatWidget(botId) {
2018 2978 botId = botId || 'default';
@@ -2284,9 +3244,30 @@
2284 3244 }
2285 3245
2286 3246 // Only process if there are actual messages
2287 3247 if (response.data.conversation.length > 0) {
2288 - // IMPORTANT: Clear existing messages before loading history
3248 + // Restored history must be SILENT to screen readers
3249 + // (plan 67f126): these are DOM additions inside the
3250 + // live region and would otherwise announce as if
3251 + // they just arrived. Lift aria-live for the batch
3252 + // repopulate, restore it after the browser has
3253 + // processed the mutations.
3254 + var mxLiveRegionEl = $chatBox.get(0);
3255 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3256 + if (mxLiveRegionEl) {
3257 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3258 + }
3259 +
3260 + // IMPORTANT: Clear existing messages before loading history.
3261 + // Detach the greeting first and put it back below —
3262 + // it is server-rendered and never stored in the
3263 + // transcript, so the old unconditional .empty()
3264 + // deleted it for the rest of the page life (plan
3265 + // a1a79b). Detach rather than rebuild: intro_message
3266 + // is not localized to JS, and this node already
3267 + // carries the per-bot inline colors and any
3268 + // {visitor_name} substitution already applied to it.
3269 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2289 3270 $chatBox.empty();
2290 3271
2291 3272 $.each(response.data.conversation, function(index, message) {
2292 3273 // Skip agent messages if persistence is off
@@ -2323,15 +3304,28 @@
2323 3304 var content = message.content;
2324 3305 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2325 3306 content = decodeHTMLEntities(content);
2326 3307
3308 + // A visitor's own rows get the same treatment as the
3309 + // live turn (appendMessage escapes user text before
3310 + // linkify). Without this a replayed user message came
3311 + // back as live markup after a refresh (plan 2db182).
3312 + // Bot/agent rows are trusted HTML and stay as they were.
3313 + if (message.role === 'user') {
3314 + content = sanitizeUserInput(content);
3315 + }
3316 +
2327 3317 // Skip linkify for messages containing structured HTML
2328 3318 // (forms, product cards, galleries, etc.) to avoid
2329 3319 // markdown formatting corrupting HTML attributes
2330 - // (e.g. underscores in name="field_name" becoming <em> tags)
2331 - if (content.includes("mxchat-product-card") ||
2332 - content.includes("mxchat-image-gallery") ||
2333 - content.includes("mxchat-featured-products") ||
3320 + // (e.g. underscores in name="field_name" becoming <em> tags).
3321 + // One family check instead of a per-card literal list: any
3322 + // element carrying an mxchat- prefixed class is MxChat-generated
3323 + // structured markup and replays raw. The old list drifted every
3324 + // time an add-on minted a new card class — the filtered-search
3325 + // card ("mxchat-filtered-product-card") missed it and replayed
3326 + // through linkify as visible markup.
3327 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
2334 3328 content.includes("<form") ||
2335 3329 content.includes("<input") ||
2336 3330 content.includes("<select") ||
2337 3331 content.includes("<textarea")) {
@@ -2349,12 +3343,29 @@
2349 3343 instance.processedMessageIds.add(message.id);
2350 3344 }
2351 3345 });
2352 3346
2353 - // Only append messages and scroll if we have content
3347 + // Only append messages and scroll if we have content.
3348 + // Greeting goes back FIRST, above the restored
3349 + // history: "Hello — [earlier conversation]" is the
3350 + // natural reading and matches the order a fresh
3351 + // visitor sees (plan a1a79b).
3352 + if ($mxIntro && $mxIntro.length) {
3353 + $chatBox.append($mxIntro);
3354 + }
2354 3355 $chatBox.append($fragment);
2355 3356 scrollToBottom(botId, true);
2356 3357
3358 + // Re-attach live semantics AFTER the rehydration
3359 + // mutations have been processed with the region off
3360 + // (plan 67f126). Restoring later announces nothing
3361 + // retroactively; new turns announce normally.
3362 + if (mxLiveRegionEl) {
3363 + setTimeout(function() {
3364 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3365 + }, 200);
3366 + }
3367 +
2357 3368 // Collapse quick questions if we have conversation history
2358 3369 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2359 3370 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2360 3371 collapseQuickQuestions(botId);
@@ -2449,10 +3460,10 @@
2449 3460 .then(data => {
2450 3461 if (data.success) {
2451 3462 container.style.display = 'none';
2452 3463 nameElement.textContent = '';
2453 - activePdfFile = null;
2454 - appendMessage('bot', 'PDF removed.');
3464 + instance.activePdfFile = null;
3465 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2455 3466 }
2456 3467 })
2457 3468 .catch(error => {
2458 3469 // Error removing PDF - silently continue
@@ -2458,14 +3469,16 @@
2458 3469 // Error removing PDF - silently continue
2459 3470 });
2460 3471 }
2461 3472
2462 - function removeActiveWord() {
2463 - const container = document.getElementById('active-word-container');
2464 - const nameElement = document.getElementById('active-word-name');
2465 -
2466 - if (!container || !nameElement || !activeWordFile) return;
2467 -
3473 + function removeActiveWord(botId) {
3474 + botId = botId || 'default';
3475 + var instance = MxChatInstances.get(botId);
3476 + const container = getElementDOM(botId, 'active-word-container');
3477 + const nameElement = getElementDOM(botId, 'active-word-name');
3478 +
3479 + if (!container || !nameElement || !instance.activeWordFile) return;
3480 +
2468 3481 fetch(mxchatChat.ajax_url, {
2469 3482 method: 'POST',
2470 3483 headers: {
2471 3484 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2471,9 +3484,9 @@
2471 3484 'Content-Type': 'application/x-www-form-urlencoded',
2472 3485 },
2473 3486 body: new URLSearchParams({
2474 3487 'action': 'mxchat_remove_word',
2475 - 'session_id': sessionId,
3488 + 'session_id': getChatSession(botId),
2476 3489 'nonce': mxchatChat.nonce
2477 3490 })
2478 3491 })
2479 3492 .then(response => response.json())
@@ -2480,10 +3493,10 @@
2480 3493 .then(data => {
2481 3494 if (data.success) {
2482 3495 container.style.display = 'none';
2483 3496 nameElement.textContent = '';
2484 - activeWordFile = null;
2485 - appendMessage('bot', 'Word document removed.');
3497 + instance.activeWordFile = null;
3498 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2486 3499 }
2487 3500 })
2488 3501 .catch(error => {
2489 3502 // Error removing Word document - silently continue
@@ -2642,11 +3655,38 @@
2642 3655 e.stopPropagation();
2643 3656 var botId = getBotIdFromElement(this);
2644 3657 collapseQuickQuestions(botId);
2645 3658 });
3659 +
3660 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3661 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3662 +// Delegated so it also works for embeds restored from chat history.
3663 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3664 + e.preventDefault();
3665 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3666 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3667 + if (!videoId) {
3668 + return;
3669 + }
3670 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3671 + var $iframe = $('<iframe>', {
3672 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3673 + title: title,
3674 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3675 + allowfullscreen: true,
3676 + frameborder: 0
3677 + }).addClass('mxchat-youtube-iframe');
3678 + $wrap.addClass('mxchat-youtube-playing');
3679 + $(this).replaceWith($iframe);
3680 +});
2646 3681
2647 3682 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2648 - $(document).on('click', '.floating-chatbot-button', function() {
3683 + // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3684 + $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3685 + if (e.type === 'keydown') {
3686 + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3687 + e.preventDefault();
3688 + }
2649 3689 var botId = getBotIdFromElement(this);
2650 3690 var $chatbot = getElement(botId, 'floating-chatbot');
2651 3691 var $badge = getElement(botId, 'chat-notification-badge');
2652 3692 var $preChat = getElement(botId, 'pre-chat-message');
@@ -2651,14 +3691,21 @@
2651 3691 var $badge = getElement(botId, 'chat-notification-badge');
2652 3692 var $preChat = getElement(botId, 'pre-chat-message');
2653 3693
2654 3694 if ($chatbot.hasClass('hidden')) {
2655 - $chatbot.removeClass('hidden').addClass('visible');
2656 - $(this).addClass('hidden');
3695 + $chatbot.removeClass('hidden').addClass('visible')
3696 + .attr('aria-modal', 'true').attr('role', 'dialog');
3697 + $(this).addClass('hidden').attr('aria-expanded', 'true');
2657 3698 $badge.hide(); // Hide notification when opening chat
2658 3699 disableScroll();
2659 3700 $preChat.fadeOut(250);
3701 + mxchatFireOpened(botId);
2660 3702
3703 + // First open per page load: re-fetch behavior settings in case
3704 + // this page's inline values came from a stale full-page cache
3705 + // (plan-32db95). Idempotent — later opens are a no-op.
3706 + mxchatRefreshDynamicSettings();
3707 +
2661 3708 // Load chat history for returning visitors (persistence)
2662 3709 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
2663 3710 if (chatPersistenceEnabled) {
2664 3711 MxChatInstances.ensureSession(botId);
@@ -2674,24 +3721,55 @@
2674 3721 // No email collection — still route through showChatContainerForBot
2675 3722 // so the loader is shown while chat history loads
2676 3723 showChatContainerForBot(botId);
2677 3724 }
3725 +
3726 + // Move keyboard focus into the message input after the open transition.
3727 + setTimeout(function() {
3728 + var chatInput = getElementDOM(botId, 'chat-input');
3729 + if (chatInput && !chatInput.disabled) {
3730 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3731 + }
3732 + }, 300);
2678 3733 } else {
2679 - $chatbot.removeClass('visible').addClass('hidden');
2680 - $(this).removeClass('hidden');
3734 + $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3735 + $(this).removeClass('hidden').attr('aria-expanded', 'false');
2681 3736 enableScroll();
2682 3737 checkPreChatDismissal(botId);
2683 3738 }
2684 3739 });
2685 3740
2686 - // Allow clicking anywhere on the title bar to close the chatbot
3741 + // Allow clicking anywhere on the title bar to close the chatbot.
3742 + // Returns keyboard focus to the launcher so keyboard users don't get
3743 + // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3744 + // heuristic-based so mouse-triggered close won't show a focus ring.
2687 3745 $(document).on('click', '.chatbot-top-bar', function() {
2688 3746 var botId = getBotIdFromElement(this);
2689 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2690 - getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3747 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3748 + var $launcher = getElement(botId, 'floating-chatbot-button');
3749 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2691 3750 enableScroll();
3751 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
2692 3752 });
2693 3753
3754 + // Global Escape-key handler — closes any visible chat widget and
3755 + // returns focus to its launcher. Standard modal-dismissal pattern;
3756 + // pairs with aria-modal="true" set on the widget when it opens.
3757 + $(document).on('keydown', function(e) {
3758 + if (e.key !== 'Escape' && e.key !== 'Esc') return;
3759 + var $visible = $('.floating-chatbot.visible');
3760 + if (!$visible.length) return;
3761 + e.preventDefault();
3762 + $visible.each(function() {
3763 + var botId = getBotIdFromElement(this);
3764 + $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3765 + var $launcher = getElement(botId, 'floating-chatbot-button');
3766 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3767 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3768 + });
3769 + enableScroll();
3770 + });
3771 +
2694 3772 $(document).on('click', '.close-pre-chat-message', function(e) {
2695 3773 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
2696 3774 var botId = getBotIdFromElement(this);
2697 3775 handlePreChatDismissal(botId);
@@ -2711,17 +3789,20 @@
2711 3789 var wordInput = getElementDOM(botId, 'word-upload');
2712 3790 if (wordInput) wordInput.click();
2713 3791 });
2714 3792
2715 - // PDF file input change handler
2716 - addSafeEventListener('pdf-upload', 'change', async function(e) {
2717 - const file = e.target.files[0];
2718 -
3793 + // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3794 + $(document).on('change', '.pdf-upload', async function(e) {
3795 + var botId = getBotIdFromElement(this);
3796 + var instance = MxChatInstances.get(botId);
3797 + const file = this.files[0];
3798 + const sessionId = MxChatInstances.ensureSession(botId);
3799 +
2719 3800 if (!file || file.type !== 'application/pdf') {
2720 3801 alert('Please select a valid PDF file.');
2721 3802 return;
2722 3803 }
2723 -
3804 +
2724 3805 if (!sessionId) {
2725 3806 alert('Error: No session ID found');
2726 3807 return;
2727 3808 }
@@ -2729,47 +3810,49 @@
2729 3810 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
2730 3811 alert('Error: Ajax configuration missing');
2731 3812 return;
2732 3813 }
2733 -
3814 +
2734 3815 // Disable buttons and show loading state
2735 - const uploadBtn = document.getElementById('pdf-upload-btn');
2736 - const sendBtn = document.getElementById('send-button');
3816 + const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3817 + const sendBtn = getElementDOM(botId, 'send-button');
3818 + if (!uploadBtn) return;
2737 3819 const originalBtnContent = uploadBtn.innerHTML;
2738 -
3820 +
2739 3821 try {
3822 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3823 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2740 3824 const formData = new FormData();
2741 3825 formData.append('action', 'mxchat_upload_pdf');
2742 3826 formData.append('pdf_file', file);
2743 3827 formData.append('session_id', sessionId);
2744 3828 formData.append('nonce', mxchatChat.nonce);
2745 -
3829 +
2746 3830 uploadBtn.disabled = true;
2747 - sendBtn.disabled = true;
3831 + if (sendBtn) sendBtn.disabled = true;
2748 3832 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
2749 3833 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
2750 3834 </svg>`;
2751 -
3835 +
2752 3836 const response = await fetch(mxchatChat.ajax_url, {
2753 3837 method: 'POST',
2754 3838 body: formData
2755 3839 });
2756 -
3840 +
2757 3841 const data = await response.json();
2758 -
3842 +
2759 3843 if (data.success) {
2760 3844 // Hide popular questions if they exist
2761 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2762 - if (hasQuickQuestions()) {
2763 - collapseQuickQuestions();
3845 + if (hasQuickQuestions(botId)) {
3846 + collapseQuickQuestions(botId);
2764 3847 }
2765 -
3848 +
2766 3849 // Show the active PDF name
2767 - showActivePdf(data.data.filename);
2768 -
2769 - appendMessage('bot', data.data.message);
2770 - scrollToBottom();
2771 - activePdfFile = data.data.filename;
3850 + showActivePdf(data.data.filename, botId);
3851 +
3852 + appendMessage('bot', data.data.message, '', [], false, botId);
3853 + scrollToBottom(botId);
3854 + instance.activePdfFile = data.data.filename;
2772 3855 } else {
2773 3856 alert('Failed to upload PDF. Please try again.');
2774 3857 }
2775 3858 } catch (error) {
@@ -2775,66 +3858,76 @@
2775 3858 } catch (error) {
2776 3859 alert('Error uploading file. Please try again.');
2777 3860 } finally {
2778 3861 uploadBtn.disabled = false;
2779 - sendBtn.disabled = false;
3862 + if (sendBtn) sendBtn.disabled = false;
2780 3863 uploadBtn.innerHTML = originalBtnContent;
2781 3864 this.value = ''; // Reset file input
2782 3865 }
2783 3866 });
2784 3867
2785 - // Word file input change handler
2786 - addSafeEventListener('word-upload', 'change', async function(e) {
2787 - const file = e.target.files[0];
2788 -
3868 + // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3869 + $(document).on('change', '.word-upload', async function(e) {
3870 + var botId = getBotIdFromElement(this);
3871 + var instance = MxChatInstances.get(botId);
3872 + const file = this.files[0];
3873 + const sessionId = MxChatInstances.ensureSession(botId);
3874 +
2789 3875 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
2790 3876 alert('Please select a valid Word document (.docx).');
2791 3877 return;
2792 3878 }
2793 -
3879 +
2794 3880 if (!sessionId) {
2795 3881 alert('Error: No session ID found');
2796 3882 return;
2797 3883 }
2798 3884
3885 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3886 + alert('Error: Ajax configuration missing');
3887 + return;
3888 + }
3889 +
2799 3890 // Disable buttons and show loading state
2800 - const uploadBtn = document.getElementById('word-upload-btn');
2801 - const sendBtn = document.getElementById('send-button');
3891 + const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3892 + const sendBtn = getElementDOM(botId, 'send-button');
3893 + if (!uploadBtn) return;
2802 3894 const originalBtnContent = uploadBtn.innerHTML;
2803 -
3895 +
2804 3896 try {
3897 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3898 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2805 3899 const formData = new FormData();
2806 3900 formData.append('action', 'mxchat_upload_word');
2807 3901 formData.append('word_file', file);
2808 3902 formData.append('session_id', sessionId);
2809 3903 formData.append('nonce', mxchatChat.nonce);
2810 -
3904 +
2811 3905 uploadBtn.disabled = true;
2812 - sendBtn.disabled = true;
3906 + if (sendBtn) sendBtn.disabled = true;
2813 3907 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
2814 3908 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
2815 3909 </svg>`;
2816 -
3910 +
2817 3911 const response = await fetch(mxchatChat.ajax_url, {
2818 3912 method: 'POST',
2819 3913 body: formData
2820 3914 });
2821 -
3915 +
2822 3916 const data = await response.json();
2823 -
3917 +
2824 3918 if (data.success) {
2825 3919 // Hide popular questions if they exist
2826 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2827 - if (hasQuickQuestions()) {
2828 - collapseQuickQuestions();
3920 + if (hasQuickQuestions(botId)) {
3921 + collapseQuickQuestions(botId);
2829 3922 }
2830 -
3923 +
2831 3924 // Show the active Word document name
2832 - showActiveWord(data.data.filename);
2833 -
2834 - appendMessage('bot', data.data.message);
2835 - scrollToBottom();
2836 - activeWordFile = data.data.filename;
3925 + showActiveWord(data.data.filename, botId);
3926 +
3927 + appendMessage('bot', data.data.message, '', [], false, botId);
3928 + scrollToBottom(botId);
3929 + instance.activeWordFile = data.data.filename;
2837 3930 } else {
2838 3931 alert('Failed to upload Word document. Please try again.');
2839 3932 }
2840 3933 } catch (error) {
@@ -2840,25 +3933,25 @@
2840 3933 } catch (error) {
2841 3934 alert('Error uploading file. Please try again.');
2842 3935 } finally {
2843 3936 uploadBtn.disabled = false;
2844 - sendBtn.disabled = false;
3937 + if (sendBtn) sendBtn.disabled = false;
2845 3938 uploadBtn.innerHTML = originalBtnContent;
2846 3939 this.value = ''; // Reset file input
2847 3940 }
2848 3941 });
2849 3942
2850 - // Remove button click handlers
2851 - document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3943 + // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3944 + $(document).on('click', '.remove-pdf-btn', function(e) {
2852 3945 e.preventDefault();
2853 3946 e.stopPropagation();
2854 - removeActivePdf();
3947 + removeActivePdf(getBotIdFromElement(this));
2855 3948 });
2856 -
2857 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3949 +
3950 + $(document).on('click', '.remove-word-btn', function(e) {
2858 3951 e.preventDefault();
2859 3952 e.stopPropagation();
2860 - removeActiveWord();
3953 + removeActiveWord(getBotIdFromElement(this));
2861 3954 });
2862 3955
2863 3956 // Window resize handlers
2864 3957 $(window).on('resize orientationchange', function() {
@@ -2875,9 +3968,12 @@
2875 3968 $('.chat-toolbar').show();
2876 3969 } else {
2877 3970 $('.chat-toolbar').hide();
2878 3971 }
2879 -
3972 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3973 + MxChatInstances.toolbarReady = true;
3974 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3975 +
2880 3976 // Apply toolbar icon colors
2881 3977 const toolbarElements = [
2882 3978 '#mxchat-chatbot .toolbar-btn svg',
2883 3979 '#mxchat-chatbot .active-pdf-name',
@@ -3002,10 +4098,13 @@
3002 4098 function replaceVisitorNamePlaceholder(botId, visitorName) {
3003 4099 var chatBox = getElementDOM(botId, 'chat-box');
3004 4100 if (!chatBox) return;
3005 4101
3006 - // Find the first bot message (intro message)
3007 - var introMessage = chatBox.querySelector('.bot-message');
4102 + // Find the greeting by its marker, not by position (plan a1a79b) —
4103 + // after a persistence restore the first .bot-message is a restored
4104 + // reply, and {visitor_name} was being substituted into that instead.
4105 + // Positional fallback for HTML cached before this release only.
4106 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3008 4107 if (!introMessage) return;
3009 4108
3010 4109 var messageContent = introMessage.querySelector('div[dir="auto"]');
3011 4110 if (!messageContent) return;
@@ -3149,14 +4248,25 @@
3149 4248 return response.json();
3150 4249 })
3151 4250 .then((data) => {
3152 4251 if (data.success) {
3153 - if (data.data.logged_in || data.data.email) {
4252 + // A success response means the server has already decided the
4253 + // required info is on file. With email optional (c0cfaf) that
4254 + // info can be a name and no address, so keying on data.email
4255 + // alone re-prompted a name-only visitor on every page load.
4256 + if (data.data.logged_in || data.data.email || data.data.name) {
3154 4257 showChatContainerForBot(botId);
3155 4258 } else {
3156 4259 showEmailFormForBot(botId);
3157 4260 }
3158 4261 } else {
4262 + // Pre-fill for a signed-in visitor being shown the form
4263 + // (c0cfaf). It arrives on this nocache'd response rather than
4264 + // in the page HTML, which is cacheable. Never overwrite what
4265 + // the visitor has already typed.
4266 + if (data.data) {
4267 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4268 + }
3159 4269 showEmailFormForBot(botId);
3160 4270 }
3161 4271 })
3162 4272 .catch((error) => {
@@ -3163,8 +4273,19 @@
3163 4273 showEmailFormForBot(botId);
3164 4274 });
3165 4275 }
3166 4276
4277 + function prefillEmailForm(botId, name, email) {
4278 + var nameInput = getElementDOM(botId, 'user-name');
4279 + var emailInput = getElementDOM(botId, 'user-email');
4280 + if (nameInput && name && !nameInput.value) {
4281 + nameInput.value = name;
4282 + }
4283 + if (emailInput && email && !emailInput.value) {
4284 + emailInput.value = email;
4285 + }
4286 + }
4287 +
3167 4288 // Event delegation for email form submission
3168 4289 $(document).on('submit', '.email-collection-form', function(e) {
3169 4290 e.preventDefault();
3170 4291 e.stopPropagation();
@@ -3177,23 +4298,34 @@
3177 4298 }
3178 4299
3179 4300 var emailInput = getElementDOM(botId, 'user-email');
3180 4301 var nameInput = getElementDOM(botId, 'user-name');
4302 + var consentInput = getElementDOM(botId, 'user-consent');
3181 4303 var userEmail = emailInput ? emailInput.value.trim() : '';
3182 4304 var userName = nameInput ? nameInput.value.trim() : '';
3183 4305 var sessionId = MxChatInstances.ensureSession(botId);
3184 4306
3185 - // Validate email
3186 - if (!userEmail) {
4307 + // Validate email. The address is optional when the owner turned
4308 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4309 + // still has to be a valid address. The server enforces both rules
4310 + // independently.
4311 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4312 +
4313 + if (emailRequired && !userEmail) {
3187 4314 showEmailError(botId, 'Please enter your email address.');
3188 4315 return false;
3189 4316 }
3190 4317
3191 - if (!isValidEmailAddress(userEmail)) {
4318 + if (userEmail && !isValidEmailAddress(userEmail)) {
3192 4319 showEmailError(botId, 'Please enter a valid email address.');
3193 4320 return false;
3194 4321 }
3195 4322
4323 + if (!emailRequired && !userEmail && !userName) {
4324 + showEmailError(botId, 'Please enter your name or email address.');
4325 + return false;
4326 + }
4327 +
3196 4328 // Validate name if field exists and has content
3197 4329 if (nameInput && userName && !isValidNameInput(userName)) {
3198 4330 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3199 4331 return false;
@@ -3198,8 +4330,15 @@
3198 4330 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3199 4331 return false;
3200 4332 }
3201 4333
4334 + // Consent checkbox (b062c4): backstop behind the native required
4335 + // attribute; the server enforces this independently either way.
4336 + if (consentInput && consentInput.required && !consentInput.checked) {
4337 + showEmailError(botId, 'Please tick the consent box to continue.');
4338 + return false;
4339 + }
4340 +
3202 4341 clearEmailError(botId);
3203 4342 setEmailSubmissionState(botId, true);
3204 4343
3205 4344 // Prepare form data
@@ -3213,8 +4352,14 @@
3213 4352 if (userName) {
3214 4353 formData.append('name', userName);
3215 4354 }
3216 4355
4356 + // Ticked/unticked both travel when the checkbox is rendered, so an
4357 + // optional-consent "no" is recorded as a decision, not an absence.
4358 + if (consentInput) {
4359 + formData.append('consent', consentInput.checked ? '1' : '0');
4360 + }
4361 +
3217 4362 fetch(mxchatChat.ajax_url, {
3218 4363 method: 'POST',
3219 4364 headers: {
3220 4365 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3336,8 +4481,9 @@
3336 4481 $chatbot.removeClass('hidden').addClass('visible');
3337 4482 getElement(botId, 'floating-chatbot-button').addClass('hidden');
3338 4483 handlePreChatDismissal(botId);
3339 4484 disableScroll(); // Disable scroll when chatbot opens
4485 + mxchatFireOpened(botId);
3340 4486
3341 4487 // Load chat history for returning visitors (persistence)
3342 4488 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3343 4489 if (chatPersistenceEnabled) {
@@ -3444,14 +4590,8 @@
3444 4590 if (messageDiv.length > 0) {
3445 4591 const originalHref = $link.attr('href');
3446 4592
3447 4593 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
3448 - e.preventDefault();
3449 - e.stopPropagation();
3450 -
3451 - // Mark as tracked
3452 - $link.attr('data-tracked', 'true');
3453 -
3454 4594 // Get bot ID from the chat box context
3455 4595 var botId = getBotIdFromElement(this);
3456 4596
3457 4597 // Get message context from the message div
@@ -3456,28 +4596,12 @@
3456 4596
3457 4597 // Get message context from the message div
3458 4598 const messageText = messageDiv.text().substring(0, 200);
3459 4599
3460 - $.ajax({
3461 - url: mxchatChat.ajax_url,
3462 - type: 'POST',
3463 - data: {
3464 - action: 'mxchat_track_url_click',
3465 - session_id: getChatSession(botId),
3466 - url: originalHref,
3467 - message_context: messageText,
3468 - nonce: mxchatChat.nonce
3469 - },
3470 - complete: function() {
3471 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
3472 - window.open(originalHref, '_blank');
3473 - } else {
3474 - window.location.href = originalHref;
3475 - }
3476 - }
3477 - });
3478 -
3479 - return false;
4600 + // Same path as attachLinkTracking(): set the target, record the
4601 + // click, and let the browser follow the anchor.
4602 + prepareTrackedLink($link);
4603 + trackLinkClick(originalHref, messageText, botId);
3480 4604 }
3481 4605 }
3482 4606 });
3483 4607
@@ -3503,8 +4627,15 @@
3503 4627 // Initialize chat visibility for all instances
3504 4628 $('.mxchat-chatbot-wrapper').each(function() {
3505 4629 var botId = $(this).data('bot-id') || 'default';
3506 4630 initializeChatVisibility(botId);
4631 + // An embedded widget is on screen from the first paint; a floating one
4632 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4633 + if (isEmbeddedBot(botId)) {
4634 + mxchatFireOpened(botId);
4635 + } else {
4636 + mxchatWatchForOpen(botId);
4637 + }
3507 4638 });
3508 4639
3509 4640 // Make functions globally available for add-ons
3510 4641 window.hasQuickQuestions = hasQuickQuestions;
@@ -3552,6 +4683,312 @@
3552 4683 }, 2000);
3553 4684 });
3554 4685 }
3555 4686 }
4687 +});
4688 +
4689 +// ============================================================================
4690 +// SATISFACTION RATING (v3.2.6)
4691 +// ============================================================================
4692 +// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4693 +// inactivity following a bot reply. One prompt per session, deduped via
4694 +// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4695 +// the option (default off) is authoritative.
4696 +jQuery(function($) {
4697 + if (typeof mxchatChat === 'undefined') return;
4698 + // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4699 + // '' and true as '1', so this must be an explicit-enable allowlist — the
4700 + // old "disabled when exactly false/'off'" check let '' through and the
4701 + // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4702 + // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4703 + // (boolean-true localizations) working.
4704 + // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4705 + // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4706 + // merges fresh values over mxchatChat (that merge fires on first widget
4707 + // open, after this module has already decided). Re-evaluating would mean
4708 + // restructuring the whole module to late-bind its listeners — not worth it
4709 + // for a prompt that is at worst stale for one page load on a cached page.
4710 + var sre = mxchatChat.satisfaction_rating_enabled;
4711 + if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4712 +
4713 + // wp_localize_script stringifies ints, so accept both number and numeric string.
4714 + var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4715 + var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4716 + if (!isFinite(idleSeconds)) idleSeconds = 60;
4717 + if (idleSeconds < 5) idleSeconds = 5;
4718 + if (idleSeconds > 600) idleSeconds = 600;
4719 + var IDLE_MS = idleSeconds * 1000;
4720 + var MIN_BOT_REPLIES = 2;
4721 + var ratingState = {};
4722 +
4723 + function getState(botId) {
4724 + if (!ratingState[botId]) {
4725 + ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4726 + }
4727 + return ratingState[botId];
4728 + }
4729 +
4730 + function getSessionId(botId) {
4731 + if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4732 + return MxChatInstances.getChatSession(botId);
4733 + }
4734 + return null;
4735 + }
4736 +
4737 + function isAlreadyRated(sessionId) {
4738 + if (!sessionId) return false;
4739 + try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4740 + }
4741 +
4742 + function markRated(sessionId) {
4743 + if (!sessionId) return;
4744 + try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4745 + }
4746 +
4747 + function esc(s) {
4748 + return String(s == null ? '' : s)
4749 + .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4750 + .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4751 + }
4752 +
4753 + // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4754 + function ratingSkipInlineColors(botId) {
4755 + if (mxchatChat.skip_inline_colors) return true;
4756 + var botAssignments = mxchatChat.bot_theme_assignments || {};
4757 + return botAssignments.hasOwnProperty(botId);
4758 + }
4759 +
4760 + function botBubbleStyleAttr(botId) {
4761 + if (ratingSkipInlineColors(botId)) return '';
4762 + var bg = mxchatChat.bot_message_bg_color;
4763 + var fg = mxchatChat.bot_message_font_color;
4764 + if (!bg && !fg) return '';
4765 + return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4766 + }
4767 +
4768 + // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4769 + // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4770 + // and paints the filled "Send" pill so it fills with the bot font color and
4771 + // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4772 + // We paint the submit button DIRECTLY (inline longhand) rather than relying
4773 + // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4774 + // unreliably inside a descendant's `background`, so a bubble-level var would
4775 + // silently fall back to the literal (white-block bug all over again). Inline
4776 + // longhand always wins. Same transparent-guard as the menu so we never paint
4777 + // a see-through value — in that case the CSS literal fallbacks keep it legible.
4778 + function syncRatingBubbleColors(botId) {
4779 + var $chatBox = getChatBoxByBotId(botId);
4780 + if (!$chatBox || !$chatBox.length) return;
4781 + var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4782 + if (!bubbleEl) return;
4783 + var cs = window.getComputedStyle(bubbleEl);
4784 + var fg = cs.color;
4785 + var bg = cs.backgroundColor;
4786 + var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4787 + var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4788 + // Expose on the bubble too, for any inheriting styles / future use.
4789 + if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4790 + if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4791 + // Paint the Send pill directly — the part that actually fixes the bug.
4792 + var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4793 + if (submitEl) {
4794 + if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4795 + if (hasBg) submitEl.style.color = bg; // label = bubble background
4796 + }
4797 + }
4798 +
4799 + function copy(key) {
4800 + var c = mxchatChat.satisfaction_rating_copy || {};
4801 + var d = {
4802 + question: 'Was this helpful?',
4803 + helpful: 'Helpful',
4804 + not_helpful: 'Not helpful',
4805 + dismiss: 'Dismiss',
4806 + thanks: 'Thanks! Anything we should improve? (optional)',
4807 + placeholder: 'Tell us what could be better…',
4808 + send: 'Send',
4809 + skip: 'Skip',
4810 + saved: 'Thanks for the feedback.'
4811 + };
4812 + return c[key] || d[key];
4813 + }
4814 +
4815 + function thumbUpSvg() {
4816 + return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
4817 + }
4818 + function thumbDownSvg() {
4819 + return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
4820 + }
4821 +
4822 + function buildPromptHtml(botId) {
4823 + var styleAttr = botBubbleStyleAttr(botId);
4824 + return ''
4825 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4826 + + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4827 + + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4828 + + '<div class="mxchat-rating-actions">'
4829 + + '<span class="mxchat-rating-buttons">'
4830 + + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4831 + + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4832 + + '</span>'
4833 + + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4834 + + '</div>'
4835 + + '</div>'
4836 + + '</div>';
4837 + }
4838 +
4839 + function buildFeedbackHtml(botId, rating) {
4840 + var styleAttr = botBubbleStyleAttr(botId);
4841 + return ''
4842 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4843 + + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4844 + + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4845 + + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4846 + + '<div class="mxchat-rating-feedback-actions">'
4847 + + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4848 + + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4849 + + '</div>'
4850 + + '</div>'
4851 + + '</div>';
4852 + }
4853 +
4854 + function buildSavedHtml(botId) {
4855 + var styleAttr = botBubbleStyleAttr(botId);
4856 + return ''
4857 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4858 + + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4859 + + '</div>';
4860 + }
4861 +
4862 + function getChatBoxByBotId(botId) {
4863 + var $byId = $('#chat-box-' + botId);
4864 + if ($byId.length) return $byId.first();
4865 + return $('.chat-box').first();
4866 + }
4867 +
4868 + function scrollChatBoxToBottom($chatBox) {
4869 + if (!$chatBox || !$chatBox.length) return;
4870 + $chatBox.scrollTop($chatBox[0].scrollHeight);
4871 + }
4872 +
4873 + function showPrompt(botId) {
4874 + var s = getState(botId);
4875 + if (s.promptShown || s.dismissed) return;
4876 + var sessionId = getSessionId(botId);
4877 + if (!sessionId) return;
4878 + if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4879 + var $chatBox = getChatBoxByBotId(botId);
4880 + if (!$chatBox.length) return;
4881 + if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4882 + $chatBox.append(buildPromptHtml(botId));
4883 + syncRatingBubbleColors(botId);
4884 + s.promptShown = true;
4885 + scrollChatBoxToBottom($chatBox);
4886 + }
4887 +
4888 + function submitRating(botId, rating, feedback) {
4889 + var sessionId = getSessionId(botId);
4890 + if (!sessionId) return;
4891 + $.post(mxchatChat.ajax_url, {
4892 + action: 'mxchat_save_rating',
4893 + session_id: sessionId,
4894 + bot_id: botId,
4895 + rating: rating,
4896 + feedback: feedback || ''
4897 + });
4898 + markRated(sessionId);
4899 + }
4900 +
4901 + function onBotReply(botId) {
4902 + var s = getState(botId);
4903 + s.botReplies += 1;
4904 + if (s.promptShown || s.dismissed) return;
4905 + var sessionId = getSessionId(botId);
4906 + if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4907 + if (s.botReplies < MIN_BOT_REPLIES) return;
4908 + if (s.idleTimer) clearTimeout(s.idleTimer);
4909 + s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4910 + }
4911 +
4912 + function onUserMessage(botId) {
4913 + var s = getState(botId);
4914 + if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4915 + }
4916 +
4917 + function botIdFromChatBox(el) {
4918 + var id = el && el.id ? el.id : '';
4919 + return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4920 + }
4921 +
4922 + function setupObserver(chatBox) {
4923 + var botId = botIdFromChatBox(chatBox);
4924 + try {
4925 + var observer = new MutationObserver(function(mutations) {
4926 + mutations.forEach(function(m) {
4927 + for (var i = 0; i < m.addedNodes.length; i++) {
4928 + var node = m.addedNodes[i];
4929 + if (!node || node.nodeType !== 1) continue;
4930 + var $n = $(node);
4931 + if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4932 + if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
4933 + else if ($n.hasClass('user-message')) onUserMessage(botId);
4934 + }
4935 + });
4936 + });
4937 + observer.observe(chatBox, { childList: true });
4938 + } catch (e) { /* noop */ }
4939 + }
4940 +
4941 + $('.chat-box').each(function() { setupObserver(this); });
4942 +
4943 + $(document).on('click', '.mxchat-rating-btn', function(e) {
4944 + e.preventDefault();
4945 + var $btn = $(this);
4946 + var $prompt = $btn.closest('.mxchat-rating-prompt');
4947 + var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4948 + var botId = $prompt.data('bot-id') || 'default';
4949 + var rating = parseInt($btn.attr('data-rating'), 10);
4950 + if (rating !== 1 && rating !== -1) return;
4951 + submitRating(botId, rating, '');
4952 + ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4953 + syncRatingBubbleColors(botId);
4954 + scrollChatBoxToBottom(getChatBoxByBotId(botId));
4955 + });
4956 +
4957 + $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4958 + e.preventDefault();
4959 + var $prompt = $(this).closest('.mxchat-rating-prompt');
4960 + var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4961 + var botId = $prompt.data('bot-id') || 'default';
4962 + var s = getState(botId);
4963 + s.dismissed = true;
4964 + markRated(getSessionId(botId));
4965 + ($wrap.length ? $wrap : $prompt).remove();
4966 + });
4967 +
4968 + function closeFeedback($fb) {
4969 + var botId = $fb.data('bot-id') || 'default';
4970 + var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4971 + ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4972 + syncRatingBubbleColors(botId);
4973 + scrollChatBoxToBottom(getChatBoxByBotId(botId));
4974 + }
4975 +
4976 + $(document).on('click', '.mxchat-rating-skip', function(e) {
4977 + e.preventDefault();
4978 + closeFeedback($(this).closest('.mxchat-rating-feedback'));
4979 + });
4980 +
4981 + $(document).on('click', '.mxchat-rating-submit', function(e) {
4982 + e.preventDefault();
4983 + var $fb = $(this).closest('.mxchat-rating-feedback');
4984 + var botId = $fb.data('bot-id') || 'default';
4985 + var rating = parseInt($fb.attr('data-rating'), 10);
4986 + if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4987 + var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4988 + if (text !== '') {
4989 + submitRating(botId, rating, text);
4990 + }
4991 + closeFeedback($fb);
4992 + });
3556 4993 });
3557 4994