PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | js/chat-script.js +906 -164 3.2.8 → 3.2.23 View file →
@@ -104,8 +104,63 @@
104 104 function refreshNonceIfNeeded(callback) {
105 105 return withFreshNonce(callback);
106 106 }
107 107
108 + // Dynamic-settings refresh (plan-32db95).
109 + //
110 + // Every widget setting ships inline in cached page HTML, so behind a
111 + // full-page cache the site owner can't purge (host cache, CDN, the
112 + // browser itself) a toggled setting looks broken until the cache turns
113 + // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114 + // on the FIRST widget open per page load we ask the nonce endpoint for
115 + // the current behavior-gate settings (?with_settings=1), merge them over
116 + // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117 + // they're server-inline-styled, so a runtime swap would visibly flash.
118 + // On any failure we keep the inline values silently (nonce-fallback
119 + // posture). At most one request per page load, only if a widget opens.
120 + var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121 +
122 + function mxchatRefreshDynamicSettings() {
123 + if (dynamicSettingsState !== 'idle') return;
124 + if (typeof mxchatChat === 'undefined') return;
125 + dynamicSettingsState = 'pending';
126 +
127 + var applied = function (data) {
128 + dynamicSettingsState = 'done';
129 + if (!data) return; // endpoint unavailable — inline values stand.
130 + if (data.nonce) {
131 + // Seed the nonce cache too: saves the first send's REST
132 + // round-trip and keeps us under the endpoint's rate limit.
133 + cachedFreshNonce = data.nonce;
134 + cachedFreshNonceFetchedAt = Date.now();
135 + mxchatChat.nonce = data.nonce;
136 + }
137 + if (data.settings && typeof data.settings === 'object') {
138 + $.extend(mxchatChat, data.settings);
139 + mxchatRebuildHeaderMenus();
140 + }
141 + };
142 +
143 + fetch(getRestNonceUrl() + '?with_settings=1', {
144 + credentials: 'same-origin',
145 + headers: { 'Accept': 'application/json' }
146 + }).then(function (resp) {
147 + if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148 + return resp.json();
149 + }).then(applied).catch(function () {
150 + // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151 + if (mxchatChat.ajax_url) {
152 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153 + .done(function (res) {
154 + applied(res && res.success && res.data ? res.data : null);
155 + })
156 + .fail(function () { applied(null); });
157 + } else {
158 + applied(null);
159 + }
160 + });
161 + }
162 +
108 163 // ====================================
109 164 // MULTI-INSTANCE MANAGEMENT SYSTEM
110 165 // ====================================
111 166
@@ -112,8 +167,16 @@
112 167 // Instance registry - tracks all chatbot instances on the page
113 168 const MxChatInstances = {
114 169 instances: {},
115 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 +
116 179 // Initialize an instance for a bot
117 180 init: function(botId) {
118 181 if (!this.instances[botId]) {
119 182 // When persistence is OFF, track when this session started
@@ -229,9 +292,20 @@
229 292 var newSessionId = generateSessionId();
230 293 this.setChatSession(botId, newSessionId);
231 294 var $chatBox = getElement(botId, 'chat-box');
232 295 if ($chatBox.length) {
233 - $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 + }
234 308 }
235 309 if (this.instances[botId]) {
236 310 this.instances[botId].chatHistoryLoaded = false;
237 311 this.instances[botId].processedMessageIds = new Set();
@@ -251,8 +325,50 @@
251 325 }
252 326 };
253 327
254 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 + // ====================================
255 371 // ELEMENT SELECTOR HELPERS
256 372 // ====================================
257 373
258 374 // Check if a specific bot has an AI theme assigned (skip inline colors)
@@ -288,8 +404,20 @@
288 404 var id = $floating.attr('id') || '';
289 405 var match = id.match(/floating-chatbot-(.+)/);
290 406 if (match) return match[1];
291 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 + }
292 420 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
293 421 var elementId = $(element).attr('id') || '';
294 422 if (elementId) {
295 423 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -329,9 +457,27 @@
329 457 if (parts.length == 2) return parts.pop().split(";").shift();
330 458 }
331 459
332 460 function generateSessionId() {
333 - 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;
334 480 }
335 481
336 482 // Legacy function - now delegates to instance manager
337 483 function getChatSession(botId) {
@@ -375,23 +521,28 @@
375 521 // ====================================
376 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
377 523 // ====================================
378 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 +
379 530 function getPageContext() {
380 531 // Check if contextual awareness is enabled
381 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
382 533 return null;
383 534 }
384 -
535 +
385 536 // Get page URL
386 537 const pageUrl = window.location.href;
387 -
538 +
388 539 // Get page title
389 540 const pageTitle = document.title || '';
390 -
541 +
391 542 // Get main content from the page
392 543 let pageContent = '';
393 -
544 +
394 545 // Try to get content from common content areas
395 546 const contentSelectors = [
396 547 'main',
397 548 '[role="main"]',
@@ -403,9 +554,9 @@
403 554 'article',
404 555 '#content',
405 556 '#main'
406 557 ];
407 -
558 +
408 559 let contentElement = null;
409 560 for (const selector of contentSelectors) {
410 561 contentElement = document.querySelector(selector);
411 562 if (contentElement) {
@@ -411,18 +562,31 @@
411 562 if (contentElement) {
412 563 break;
413 564 }
414 565 }
415 -
566 +
416 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
417 568 if (!contentElement) {
418 569 contentElement = document.body;
419 570 }
420 -
571 +
421 572 if (contentElement) {
422 - // Clone the element to avoid modifying the original
423 - const clone = contentElement.cloneNode(true);
424 -
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 +
425 589 // Remove unwanted elements
426 590 const unwantedSelectors = [
427 591 'header',
428 592 'footer',
@@ -440,16 +604,25 @@
440 604 '#floating-chatbot',
441 605 '#floating-chatbot-button',
442 606 '.mxchat',
443 607 '[class*="chat"]',
444 - '[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'
445 618 ];
446 -
619 +
447 620 unwantedSelectors.forEach(selector => {
448 621 const elements = clone.querySelectorAll(selector);
449 622 elements.forEach(el => el.remove());
450 623 });
451 -
624 +
452 625 // Extract MxChat context data attributes before getting text content
453 626 const contextData = [];
454 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
455 628 const contextValue = el.dataset.mxchatContext;
@@ -456,34 +629,34 @@
456 629 if (contextValue && contextValue.trim()) {
457 630 contextData.push(contextValue);
458 631 }
459 632 });
460 -
633 +
461 634 // Get text content and clean it up
462 635 pageContent = clone.textContent || clone.innerText || '';
463 -
636 +
464 637 // Add context data to page content if any were found
465 638 if (contextData.length > 0) {
466 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
467 640 }
468 -
641 +
469 642 // Clean up whitespace and limit length
470 643 pageContent = pageContent
471 644 .replace(/\s+/g, ' ')
472 645 .trim()
473 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;
474 656 }
475 -
476 - // Only return context if we have meaningful content
477 - if (!pageContent || pageContent.length < 50) {
478 - return null;
479 - }
480 -
481 - return {
482 - url: pageUrl,
483 - title: pageTitle,
484 - content: pageContent
485 - };
657 +
658 + return null;
486 659 }
487 660
488 661 // Track originating page when chat starts
489 662 function trackOriginatingPage() {
@@ -534,8 +707,26 @@
534 707 sendButton.style.pointerEvents = 'none';
535 708 }
536 709 }
537 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 +
538 729 function enableChatInput(botId) {
539 730 botId = botId || 'default';
540 731 var chatInput = getElementDOM(botId, 'chat-input');
541 732 var sendButton = getElementDOM(botId, 'send-button');
@@ -541,9 +732,11 @@
541 732 var sendButton = getElementDOM(botId, 'send-button');
542 733 if (chatInput) {
543 734 chatInput.disabled = false;
544 735 chatInput.style.opacity = '1';
545 - chatInput.focus();
736 + if (mxchatShouldAutofocusAfterReply()) {
737 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 + }
546 739 }
547 740 if (sendButton) {
548 741 sendButton.disabled = false;
549 742 sendButton.style.opacity = '1';
@@ -548,10 +741,102 @@
548 741 sendButton.disabled = false;
549 742 sendButton.style.opacity = '1';
550 743 sendButton.style.pointerEvents = 'auto';
551 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);
552 748 }
553 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 +
554 839 // Update your existing sendMessage function
555 840 function sendMessage(botId) {
556 841 botId = botId || 'default';
557 842 MxChatInstances.ensureSession(botId);
@@ -562,9 +847,12 @@
562 847 if (typeof customMxChatFilter === 'function') {
563 848 message = customMxChatFilter(message, "prompt");
564 849 }
565 850
566 - 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() !== '') {
567 855 // Don't disable input in live agent mode - let users chat freely
568 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
569 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
570 858 if (!isAgentMode) {
@@ -572,8 +860,9 @@
572 860 }
573 861
574 862 appendMessage("user", message, '', [], false, botId);
575 863 $chatInput.val('');
864 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
576 865 $chatInput.css('height', 'auto');
577 866
578 867 if (hasQuickQuestions(botId)) {
579 868 collapseQuickQuestions(botId);
@@ -580,14 +869,16 @@
580 869 }
581 870 appendThinkingMessage(botId);
582 871 scrollToBottom(botId);
583 872
584 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
873 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
585 874
586 875 // Check if streaming is enabled AND supported for this model
587 876 if (shouldUseStreaming(currentModel)) {
588 877 callMxChatStream(message, function(response) {
589 - 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');
590 881 }, botId);
591 882 } else {
592 883 callMxChat(message, function(response) {
593 884 replaceLastMessage("bot", response, '', [], botId);
@@ -605,8 +896,14 @@
605 896 if (typeof customMxChatFilter === 'function') {
606 897 message = customMxChatFilter(message, "prompt");
607 898 }
608 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 +
609 906 // Don't disable input in live agent mode - let users chat freely
610 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
611 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
612 909 if (!isAgentMode) {
@@ -620,14 +917,15 @@
620 917 }
621 918 appendThinkingMessage(botId);
622 919 scrollToBottom(botId);
623 920
624 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
921 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
625 922
626 923 // Check if streaming is enabled AND supported for this model
627 924 if (shouldUseStreaming(currentModel)) {
628 925 callMxChatStream(message, function(response) {
629 - 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');
630 928 }, botId);
631 929 } else {
632 930 callMxChat(message, function(response) {
633 931 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -691,8 +989,15 @@
691 989
692 990 function callMxChat(message, callback, botId) {
693 991 botId = botId || getMxChatBotId();
694 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 +
695 1000 // Store the message in case we need to retry after session reset
696 1001 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
697 1002
698 1003 // Get page context if contextual awareness is enabled
@@ -790,12 +1095,13 @@
790 1095 // Re-send the original message with the new session (user message is already displayed)
791 1096 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
792 1097 if (originalMessage) {
793 1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
794 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1099 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
795 1100 if (shouldUseStreaming(currentModel)) {
796 1101 callMxChatStream(originalMessage, function(response) {
797 - 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');
798 1104 }, botId);
799 1105 } else {
800 1106 callMxChat(originalMessage, function(response) {
801 1107 replaceLastMessage("bot", response, '', [], botId);
@@ -943,9 +1249,9 @@
943 1249
944 1250 // Store the message in case we need to retry after session reset
945 1251 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
946 1252
947 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1253 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
948 1254 if (!isStreamingSupported(currentModel)) {
949 1255 callMxChat(message, callback, botId);
950 1256 return;
951 1257 }
@@ -998,13 +1304,25 @@
998 1304
999 1305 let accumulatedContent = '';
1000 1306 let testingDataReceived = false;
1001 1307 let streamingStarted = false;
1308 + // Server-pushed html to append as its OWN bot bubble once the stream
1309 + // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1310 + // deferred to [DONE] so the embed always lands BELOW the streamed text.
1311 + let pendingAppendHtml = '';
1002 1312
1313 + // Abortable stream: a fresh controller per turn, keyed by bot instance.
1314 + // The Stop control (send button swapped in place) aborts both the read
1315 + // loop and the underlying request.
1316 + var streamControl = { controller: new AbortController(), aborted: false };
1317 + mxchatActiveStreams[botId] = streamControl;
1318 + mxchatShowStopButton(botId);
1319 +
1003 1320 fetch(mxchatChat.ajax_url, {
1004 1321 method: 'POST',
1005 1322 body: formData,
1006 - credentials: 'same-origin'
1323 + credentials: 'same-origin',
1324 + signal: streamControl.controller.signal
1007 1325 })
1008 1326 .then(response => {
1009 1327 // Store the response for potential fallback handling
1010 1328 const responseClone = response.clone();
@@ -1105,8 +1423,17 @@
1105 1423
1106 1424 // Re-enable chat input after streaming completes
1107 1425 enableChatInput(botId);
1108 1426
1427 + // Render any server-pushed appendix html (e.g. the
1428 + // YouTube embed) as its own bot bubble below the
1429 + // streamed text — mirrors how it is saved in the
1430 + // transcript, so history replays identically.
1431 + if (pendingAppendHtml) {
1432 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1433 + pendingAppendHtml = '';
1434 + }
1435 +
1109 1436 // Scroll the user's last message to the top now
1110 1437 // that the bot's full reply has rendered.
1111 1438 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1112 1439 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1140,8 +1467,21 @@
1140 1467 streamingStarted = true;
1141 1468 accumulatedContent += json.content;
1142 1469 updateStreamingMessage(accumulatedContent, botId);
1143 1470 }
1471 + // Stash appendix html (e.g. video embed) for [DONE]
1472 + else if (json.append_html) {
1473 + pendingAppendHtml = json.append_html;
1474 + }
1475 + // Server-side final pass changed the assembled text
1476 + // (ffef6f: dead-link stripping) — swap the rendered
1477 + // bubble for the validated version. Arrives at most
1478 + // once, just before [DONE].
1479 + else if (json.replace_content) {
1480 + streamingStarted = true;
1481 + accumulatedContent = json.replace_content;
1482 + updateStreamingMessage(accumulatedContent, botId);
1483 + }
1144 1484 // Handle complete response in stream (fallback response)
1145 1485 else if (json.text || json.message || json.html) {
1146 1486 handleNonStreamResponse(json, callback, botId);
1147 1487 return;
@@ -1171,8 +1511,9 @@
1171 1511 }
1172 1512
1173 1513 processStream();
1174 1514 }).catch(streamError => {
1515 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1175 1516 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1176 1517 callMxChat(message, callback, botId);
1177 1518 });
1178 1519 }
@@ -1179,8 +1520,9 @@
1179 1520
1180 1521 processStream();
1181 1522 })
1182 1523 .catch(error => {
1524 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1183 1525 // Check if we have server error data with chat mode
1184 1526 if (error && error.isServerError && error.data) {
1185 1527 // Check for chat mode in error data
1186 1528 if (error.data.chat_mode) {
@@ -1241,9 +1583,9 @@
1241 1583 // Re-send the original message with the new session (user message is already displayed)
1242 1584 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1243 1585 if (originalMessage) {
1244 1586 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1245 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1587 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1246 1588 if (shouldUseStreaming(currentModel)) {
1247 1589 callMxChatStream(originalMessage, callback, botId);
1248 1590 } else {
1249 1591 callMxChat(originalMessage, callback, botId);
@@ -1376,8 +1718,15 @@
1376 1718 var $chatBox = getElement(botId, 'chat-box');
1377 1719 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1378 1720
1379 1721 if (tempMessage.length) {
1722 + // aria-busy=true for the whole stream: the bubble is rewritten on
1723 + // every chunk, and without busy a polite live region announces those
1724 + // rewrites continuously. Flipped false once the reply is final, so
1725 + // assistive tech announces the completed message ONCE (plan 67f126).
1726 + if (tempMessage.attr('aria-busy') !== 'true') {
1727 + tempMessage.attr('aria-busy', 'true');
1728 + }
1380 1729 // Update existing message
1381 1730 tempMessage.html(formattedContent);
1382 1731 } else {
1383 1732 // Create new temporary message if it doesn't exist
@@ -1404,28 +1753,98 @@
1404 1753 // Update the event handlers to use the correct function names (using event delegation)
1405 1754 // Use class-based selectors for multi-instance support
1406 1755 $(document).on('click', '.send-button', function() {
1407 1756 var botId = getBotIdFromElement(this);
1408 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1409 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1410 - disableChatInput(botId);
1757 + // While a response is streaming the button is a Stop control.
1758 + if (this.classList.contains('mxchat-stop-mode')) {
1759 + // Same click that just started this stream (an add-on's direct handler
1760 + // ran before this delegated one) — not a Stop press. See
1761 + // mxchatShowStopButton for the full story (plan-4bba64).
1762 + if (this.__mxchatStopJustShown) return;
1763 + mxchatStopStreaming(botId);
1764 + return;
1411 1765 }
1766 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1412 1767 sendMessage(botId);
1413 1768 });
1414 1769
1415 -// Override enter key handler (using event delegation)
1770 +// Enter sends, Shift+Enter inserts a newline (using event delegation).
1771 +// sendMessage() owns the disable: it greys the box only when a real message is
1772 +// about to go out. Disabling here first left the widget locked for good after
1773 +// Enter on an empty box - the send returned early and no reply path ever
1774 +// re-enabled it (plan b67fd5).
1416 1775 $(document).on('keypress', '.chat-input', function(e) {
1417 1776 if (e.which == 13 && !e.shiftKey) {
1418 1777 e.preventDefault();
1419 - var botId = getBotIdFromElement(this);
1420 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1421 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1422 - disableChatInput(botId);
1423 - }
1424 - sendMessage(botId);
1778 + sendMessage(getBotIdFromElement(this));
1425 1779 }
1426 1780 });
1427 1781
1782 +// Chat input character counter + soft limit feedback (plan 7091a2).
1783 +// Language-neutral: numbers + color only, no translatable strings. The counter
1784 +// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1785 +// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1786 +// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1787 +function mxchatUpdateCharCounter(inputEl) {
1788 + if (!inputEl || !inputEl.closest) return;
1789 + var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1790 + var container = inputEl.closest('.input-container');
1791 + if (!container || !max || max <= 0) return;
1792 + var counter = container.querySelector('.mxchat-char-counter');
1793 + if (!counter) return;
1794 + var len = inputEl.value.length;
1795 + var ratio = len / max;
1796 + var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1797 + var cur = counter.querySelector('.mxchat-char-counter-current');
1798 + if (cur) cur.textContent = len;
1799 + var warn = ratio >= nearThreshold && len < max;
1800 + var full = len >= max;
1801 + counter.classList.toggle('is-visible', ratio >= nearThreshold);
1802 + counter.classList.toggle('is-warn', warn);
1803 + counter.classList.toggle('is-full', full);
1804 + container.classList.toggle('mxchat-input-near-limit', warn);
1805 + container.classList.toggle('mxchat-input-at-limit', full);
1806 +}
1807 +
1808 +function mxchatBumpInput(inputEl) {
1809 + var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1810 + if (!container) return;
1811 + container.classList.remove('mxchat-input-bump');
1812 + void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1813 + container.classList.add('mxchat-input-bump');
1814 + clearTimeout($(container).data('mxchatBumpTimeout'));
1815 + var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1816 + $(container).data('mxchatBumpTimeout', t);
1817 +}
1818 +
1819 +// Live counter update on every input.
1820 +$(document).on('input', '.chat-input', function() {
1821 + mxchatUpdateCharCounter(this);
1822 +});
1823 +
1824 +// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1825 +// rejected at the cap (maxlength silently swallows it otherwise).
1826 +$(document).on('keydown', '.chat-input', function(e) {
1827 + var max = parseInt(this.getAttribute('maxlength'), 10);
1828 + if (!max || max <= 0 || this.value.length < max) return;
1829 + if (e.ctrlKey || e.metaKey || e.altKey) return;
1830 + // A single printable char with no selection to overwrite WILL be rejected.
1831 + if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1832 + mxchatBumpInput(this);
1833 + }
1834 +});
1835 +
1836 +// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1837 +$(document).on('paste', '.chat-input', function() {
1838 + var el = this;
1839 + var max = parseInt(el.getAttribute('maxlength'), 10);
1840 + if (!max || max <= 0) return;
1841 + setTimeout(function() {
1842 + mxchatUpdateCharCounter(el);
1843 + if (el.value.length >= max) mxchatBumpInput(el);
1844 + }, 0);
1845 +});
1846 +
1428 1847 // Builds the list of overflow-menu items for a given bot.
1429 1848 // Adding a future item is one push to this array — do NOT hardcode "only download."
1430 1849 function mxchatGetHeaderMenuItems(botId) {
1431 1850 var items = [];
@@ -1443,8 +1862,26 @@
1443 1862 }
1444 1863 });
1445 1864 }
1446 1865
1866 + // "Start new chat" — surfaces the EXISTING per-conversation reset
1867 + // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1868 + // without the site owner disabling chat persistence globally. Default OFF;
1869 + // gated by the reset_chat_enabled option. plan ac2e81.
1870 + if (settings.reset_chat_enabled === 'on') {
1871 + items.push({
1872 + id: 'reset-chat',
1873 + label: settings.reset_chat_label || 'Start new chat',
1874 + icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>',
1875 + action: function() {
1876 + var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1877 + if (window.confirm(confirmMsg)) {
1878 + MxChatInstances.resetChatSession(botId);
1879 + }
1880 + }
1881 + });
1882 + }
1883 +
1447 1884 return items;
1448 1885 }
1449 1886
1450 1887 // Builds a clean markdown transcript of the current conversation and triggers
@@ -1522,30 +1959,31 @@
1522 1959 var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1523 1960 if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1524 1961 }
1525 1962
1526 -// One-time per-widget init: renders menu items, wires open/close,
1527 -// outside-click, Escape, and arrow-key navigation. If no items, hides the trigger.
1528 -function mxchatInitHeaderMenu(botId) {
1529 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1530 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1531 -
1963 +// Renders (or re-renders) the item list for one menu wrap. Split out of
1964 +// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1965 +// rebuild items + trigger visibility WITHOUT re-binding the one-time
1966 +// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1967 +// a rebuild before init (never happens, but harmless) just skips it.
1968 +function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1532 1969 var $trigger = $wrap.find('.mxchat-menu-trigger');
1533 1970 var $menu = $wrap.find('.mxchat-header-menu');
1534 1971 var items = mxchatGetHeaderMenuItems(botId);
1535 1972
1536 - // Initial color sync — covers normal page load.
1537 - mxchatSyncMenuColors(botId, $wrap);
1973 + $menu.empty();
1538 1974
1539 1975 if (!items.length) {
1540 1976 $trigger.hide();
1541 1977 $menu.hide();
1542 - $wrap.data('mxchatMenuReady', true);
1543 1978 return;
1544 1979 }
1545 1980
1546 - // Build the menu items.
1547 - $menu.empty();
1981 + // Clear any inline display:none a previous zero-item render left behind —
1982 + // open/close visibility is governed by the hidden prop + is-open class.
1983 + $trigger.css('display', '');
1984 + $menu.css('display', '');
1985 +
1548 1986 items.forEach(function(item, idx) {
1549 1987 var $btn = $('<button>', {
1550 1988 type: 'button',
1551 1989 'class': 'mxchat-menu-item',
@@ -1558,14 +1996,50 @@
1558 1996 $btn.find('.mxchat-menu-item-label').text(item.label);
1559 1997 $btn.on('click', function(e) {
1560 1998 e.preventDefault();
1561 1999 e.stopPropagation();
1562 - closeMenu();
2000 + if (closeMenuFn) closeMenuFn();
1563 2001 try { item.action(); } catch (err) { /* no-op */ }
1564 2002 });
1565 2003 $menu.append($btn);
1566 2004 });
2005 +}
1567 2006
2007 +// Re-render every menu on the page after a dynamic-settings merge
2008 +// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
2009 +// swapping items under the user mid-interaction yanks focus — and the
2010 +// rebuild runs when it closes instead (closeMenu checks the pending flag).
2011 +function mxchatRebuildHeaderMenus() {
2012 + $('.mxchat-header-menu-wrap').each(function() {
2013 + var $wrap = $(this);
2014 + var botId = $wrap.data('bot-id');
2015 + if (!botId) return;
2016 + if (!$wrap.data('mxchatMenuReady')) {
2017 + mxchatInitHeaderMenu(botId);
2018 + return;
2019 + }
2020 + if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
2021 + $wrap.data('mxchatMenuRebuildPending', true);
2022 + return;
2023 + }
2024 + mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
2025 + });
2026 +}
2027 +
2028 +// One-time per-widget init: renders menu items, wires open/close,
2029 +// outside-click, Escape, and arrow-key navigation. If no items, hides the
2030 +// trigger. Wiring happens even when there are zero items at init, so a
2031 +// later dynamic-settings rebuild that adds items has a working trigger.
2032 +function mxchatInitHeaderMenu(botId) {
2033 + var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
2034 + if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
2035 +
2036 + var $trigger = $wrap.find('.mxchat-menu-trigger');
2037 + var $menu = $wrap.find('.mxchat-header-menu');
2038 +
2039 + // Initial color sync — covers normal page load.
2040 + mxchatSyncMenuColors(botId, $wrap);
2041 +
1568 2042 function openMenu() {
1569 2043 // Re-sync each open in case the active theme changed since init.
1570 2044 mxchatSyncMenuColors(botId, $wrap);
1571 2045 $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
@@ -1579,8 +2053,14 @@
1579 2053 $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1580 2054 $trigger.attr('aria-expanded', 'false');
1581 2055 $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1582 2056 if (returnFocus) $trigger.trigger('focus');
2057 + // A dynamic-settings rebuild that arrived while the menu was open
2058 + // was deferred (mxchatRebuildHeaderMenus) — run it now.
2059 + if ($wrap.data('mxchatMenuRebuildPending')) {
2060 + $wrap.removeData('mxchatMenuRebuildPending');
2061 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2062 + }
1583 2063 }
1584 2064
1585 2065 // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1586 2066 // click-to-collapse handler does not fire.
@@ -1634,8 +2114,13 @@
1634 2114 openMenu();
1635 2115 }
1636 2116 });
1637 2117
2118 + // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2119 + // then do the initial item render.
2120 + $wrap.data('mxchatMenuClose', closeMenu);
2121 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2122 +
1638 2123 $wrap.data('mxchatMenuReady', true);
1639 2124 }
1640 2125
1641 2126 // Initialize header menus for every rendered widget on DOM ready.
@@ -1643,8 +2128,18 @@
1643 2128 $('.mxchat-header-menu-wrap').each(function() {
1644 2129 var botId = $(this).data('bot-id');
1645 2130 if (botId) mxchatInitHeaderMenu(botId);
1646 2131 });
2132 +
2133 + // Embedded (non-floating) widgets are open from the moment the page
2134 + // renders — refresh dynamic settings at init (plan-32db95). Floating
2135 + // widgets refresh on first launcher open instead.
2136 + var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2137 + return !$(this).closest('.floating-chatbot').length;
2138 + }).length > 0;
2139 + if (hasEmbeddedWidget) {
2140 + mxchatRefreshDynamicSettings();
2141 + }
1647 2142 });
1648 2143
1649 2144 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1650 2145 try {
@@ -1721,9 +2216,11 @@
1721 2216
1722 2217 messageDiv.html(fullMessage);
1723 2218
1724 2219 if (isTemporary) {
1725 - messageDiv.addClass('temporary-message');
2220 + // In-flight bubble: hold aria-busy so the live region stays quiet
2221 + // until the content is finalized (plan 67f126).
2222 + messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
1726 2223 }
1727 2224
1728 2225 // Append to the correct chatbot instance's chat-box
1729 2226 var $chatBox = getElement(botId, 'chat-box');
@@ -1845,13 +2342,16 @@
1845 2342
1846 2343 if (images.length > 0) {
1847 2344 fullMessage += '<div class="image-gallery" dir="auto">';
1848 2345 images.forEach(img => {
2346 + const safeTitle = sanitizeUserInput(img.title);
2347 + const safeUrl = escapeAttr(img.image_url);
2348 + const safeThumbnail = escapeAttr(img.thumbnail_url);
1849 2349 fullMessage += `
1850 2350 <div style="margin-bottom: 10px;">
1851 - <strong>${img.title}</strong><br>
1852 - <a href="${img.image_url}" target="_blank">
1853 - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" />
2351 + <strong>${safeTitle}</strong><br>
2352 + <a href="${safeUrl}" target="_blank">
2353 + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
1854 2354 </a>
1855 2355 </div>`;
1856 2356 });
1857 2357 fullMessage += '</div>';
@@ -1858,13 +2358,16 @@
1858 2358 }
1859 2359
1860 2360 if (lastMessageDiv.length) {
1861 2361 // Replace content immediately to prevent visual gap between thinking dots and response
2362 + // aria-busy released AFTER the final content is set, so the live region
2363 + // announces the finished message once (plan 67f126).
1862 2364 lastMessageDiv
1863 2365 .html(fullMessage)
1864 2366 .removeClass('bot-message user-message temporary-message')
1865 2367 .addClass(messageClass)
1866 - .attr('dir', 'auto');
2368 + .attr('dir', 'auto')
2369 + .attr('aria-busy', 'false');
1867 2370
1868 2371 // Only apply inline colors if AI theme is not active (let CSS handle it)
1869 2372 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1870 2373 if (!skipColors) {
@@ -1924,10 +2427,15 @@
1924 2427 var botMessageFontColor = mxchatChat.bot_message_font_color;
1925 2428 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1926 2429
1927 2430 // Build thinking dots HTML - skip inline colors if AI theme is active
2431 + // The dots are decorative; the sr-only span is what the live region
2432 + // announces for the waiting state (plan 67f126). Server-localized
2433 + // string — safe to inject (esc_html__ output, no user content).
1928 2434 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1929 - var thinkingHtml = '<div class="thinking-dots-container">' +
2435 + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2436 + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2437 + '<div class="thinking-dots-container" aria-hidden="true">' +
1930 2438 '<div class="thinking-dots">' +
1931 2439 '<span class="dot"' + dotStyle + '></span>' +
1932 2440 '<span class="dot"' + dotStyle + '></span>' +
1933 2441 '<span class="dot"' + dotStyle + '></span>' +
@@ -1962,27 +2470,42 @@
1962 2470 // Check for % followed by exactly 2 hex digits
1963 2471 return /%[0-9a-fA-F]{2}/.test(url);
1964 2472 }
1965 2473
1966 - // Helper function to safely encode URLs only if needed
2474 + // Helper function to safely encode URLs only if needed, then make the
2475 + // result attribute-safe. "Don't double-encode an already-encoded URL"
2476 + // used to mean "return it untouched" — so a literal quote riding next
2477 + // to a %20 reached href="…" intact and closed the attribute
2478 + // (plan 2db182). Every href this function feeds now goes through
2479 + // escapeAttr(), whether or not the URL was already percent-encoded.
1967 2480 function safeEncodeUrl(url) {
1968 - // If URL already contains encoded characters, return as-is
1969 - if (isUrlEncoded(url)) {
1970 - return url;
1971 - }
1972 - // Otherwise, encode it
1973 - return encodeURI(url);
2481 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2482 + return escapeAttr(encoded);
1974 2483 }
1975 2484
1976 - // Process markdown headers FIRST
1977 - let processedText = formatMarkdownHeaders(inputText);
1978 -
2485 + // Take backslash escapes and code (fenced + inline) out of the text FIRST
2486 + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2487 + // backtick could not protect an underscore and \_ was not an escape at
2488 + // all; the header rule also ran over fenced code. Each escape becomes a
2489 + // placeholder carrying its literal character, each code span a
2490 + // placeholder carrying its rendered HTML; both are restored below, after
2491 + // the styling rules have run, so nothing downstream changes.
2492 + const literals = protectLiterals(inputText);
2493 + let processedText = literals.text;
2494 +
2495 + // Process markdown headers
2496 + processedText = formatMarkdownHeaders(processedText);
2497 +
1979 2498 // Process text styling (bold, italic, strikethrough)
1980 2499 processedText = formatTextStyling(processedText);
1981 -
1982 - // Process code blocks BEFORE processing links
2500 +
2501 + // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2502 + // rendered inside protectLiterals) — still BEFORE processing links
1983 2503 processedText = formatCodeBlocks(processedText);
1984 2504
2505 + // Put the rendered code and the escaped characters back
2506 + processedText = literals.restore(processedText);
2507 +
1985 2508 // Process markdown tables BEFORE converting newlines to paragraphs
1986 2509 processedText = formatMarkdownTables(processedText);
1987 2510
1988 2511 // NOW convert to paragraphs
@@ -1994,10 +2517,13 @@
1994 2517 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
1995 2518 // Clean the URL of any trailing punctuation
1996 2519 let cleanUrl = url.replace(/[.,;!?]+$/, '');
1997 2520 const safeUrl = safeEncodeUrl(cleanUrl);
2521 + // The bracket body is the only link text that can carry angle
2522 + // brackets (the standalone matcher excludes them) — keep it text.
2523 + const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1998 2524 // Return as a proper link without the brackets
1999 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2525 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2000 2526 });
2001 2527
2002 2528 // Process markdown links: [text](url) and [](url)
2003 2529 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2101,8 +2627,30 @@
2101 2627
2102 2628 return processedText;
2103 2629 }
2104 2630
2631 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2632 + // A single alternation keeps the two honest with each other: an escaped
2633 + // backtick is consumed as an escape before it can open a code span, and a
2634 + // code span is consumed whole so the escapes inside it stay verbatim.
2635 + // Placeholders are private-use characters — no underscore, asterisk,
2636 + // backtick, hash, pipe or bracket for a later rule to match.
2637 + function protectLiterals(text) {
2638 + const store = [];
2639 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2640 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2641 + return '\uE000' + (store.length - 1) + '\uE001';
2642 + });
2643 + return {
2644 + text: out,
2645 + restore: function(t) {
2646 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2647 + return store[Number(i)];
2648 + });
2649 + }
2650 + };
2651 + }
2652 +
2105 2653 function formatMarkdownHeaders(text) {
2106 2654 // Handle h1 to h6 headers
2107 2655 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2108 2656 const level = hashes.length;
@@ -2144,11 +2692,24 @@
2144 2692 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2145 2693 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2146 2694 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2147 2695
2148 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2149 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2150 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2696 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2697 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2698 + // text or a non-word character, the closer must precede the end or a
2699 + // non-word character, and the content may not start or end with a
2700 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2701 + // while "use _this_ word" still italicises. A token whose content is
2702 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2703 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2704 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2705 + // adjacent to another underscore or a word character.
2706 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2707 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2708 + return match;
2709 + }
2710 + return before + '<em>' + content + '</em>';
2711 + });
2151 2712
2152 2713 // Handle strikethrough (~~text~~)
2153 2714 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2154 2715
@@ -2282,8 +2843,24 @@
2282 2843 div.textContent = text;
2283 2844 return div.innerHTML;
2284 2845 }
2285 2846
2847 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2848 + // Percent-encodes the characters that can end or extend an attribute.
2849 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2850 + // never changes where a legitimate link resolves — encodeURI() already
2851 + // produces most of these, this just guarantees them for URLs that skip
2852 + // encodeURI() because they were percent-encoded on arrival.
2853 + function escapeAttr(url) {
2854 + return String(url)
2855 + .replace(/"/g, '%22')
2856 + .replace(/'/g, '%27')
2857 + .replace(/</g, '%3C')
2858 + .replace(/>/g, '%3E')
2859 + .replace(/`/g, '%60')
2860 + .replace(/ /g, '%20');
2861 + }
2862 +
2286 2863 function escapeHtml(unsafe) {
2287 2864 // Skip escaping if it's already escaped or contains HTML code block markup
2288 2865 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2289 2866 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -2627,9 +3204,30 @@
2627 3204 }
2628 3205
2629 3206 // Only process if there are actual messages
2630 3207 if (response.data.conversation.length > 0) {
2631 - // IMPORTANT: Clear existing messages before loading history
3208 + // Restored history must be SILENT to screen readers
3209 + // (plan 67f126): these are DOM additions inside the
3210 + // live region and would otherwise announce as if
3211 + // they just arrived. Lift aria-live for the batch
3212 + // repopulate, restore it after the browser has
3213 + // processed the mutations.
3214 + var mxLiveRegionEl = $chatBox.get(0);
3215 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3216 + if (mxLiveRegionEl) {
3217 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3218 + }
3219 +
3220 + // IMPORTANT: Clear existing messages before loading history.
3221 + // Detach the greeting first and put it back below —
3222 + // it is server-rendered and never stored in the
3223 + // transcript, so the old unconditional .empty()
3224 + // deleted it for the rest of the page life (plan
3225 + // a1a79b). Detach rather than rebuild: intro_message
3226 + // is not localized to JS, and this node already
3227 + // carries the per-bot inline colors and any
3228 + // {visitor_name} substitution already applied to it.
3229 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2632 3230 $chatBox.empty();
2633 3231
2634 3232 $.each(response.data.conversation, function(index, message) {
2635 3233 // Skip agent messages if persistence is off
@@ -2666,15 +3264,28 @@
2666 3264 var content = message.content;
2667 3265 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2668 3266 content = decodeHTMLEntities(content);
2669 3267
3268 + // A visitor's own rows get the same treatment as the
3269 + // live turn (appendMessage escapes user text before
3270 + // linkify). Without this a replayed user message came
3271 + // back as live markup after a refresh (plan 2db182).
3272 + // Bot/agent rows are trusted HTML and stay as they were.
3273 + if (message.role === 'user') {
3274 + content = sanitizeUserInput(content);
3275 + }
3276 +
2670 3277 // Skip linkify for messages containing structured HTML
2671 3278 // (forms, product cards, galleries, etc.) to avoid
2672 3279 // markdown formatting corrupting HTML attributes
2673 - // (e.g. underscores in name="field_name" becoming <em> tags)
2674 - if (content.includes("mxchat-product-card") ||
2675 - content.includes("mxchat-image-gallery") ||
2676 - content.includes("mxchat-featured-products") ||
3280 + // (e.g. underscores in name="field_name" becoming <em> tags).
3281 + // One family check instead of a per-card literal list: any
3282 + // element carrying an mxchat- prefixed class is MxChat-generated
3283 + // structured markup and replays raw. The old list drifted every
3284 + // time an add-on minted a new card class — the filtered-search
3285 + // card ("mxchat-filtered-product-card") missed it and replayed
3286 + // through linkify as visible markup.
3287 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
2677 3288 content.includes("<form") ||
2678 3289 content.includes("<input") ||
2679 3290 content.includes("<select") ||
2680 3291 content.includes("<textarea")) {
@@ -2692,12 +3303,29 @@
2692 3303 instance.processedMessageIds.add(message.id);
2693 3304 }
2694 3305 });
2695 3306
2696 - // Only append messages and scroll if we have content
3307 + // Only append messages and scroll if we have content.
3308 + // Greeting goes back FIRST, above the restored
3309 + // history: "Hello — [earlier conversation]" is the
3310 + // natural reading and matches the order a fresh
3311 + // visitor sees (plan a1a79b).
3312 + if ($mxIntro && $mxIntro.length) {
3313 + $chatBox.append($mxIntro);
3314 + }
2697 3315 $chatBox.append($fragment);
2698 3316 scrollToBottom(botId, true);
2699 3317
3318 + // Re-attach live semantics AFTER the rehydration
3319 + // mutations have been processed with the region off
3320 + // (plan 67f126). Restoring later announces nothing
3321 + // retroactively; new turns announce normally.
3322 + if (mxLiveRegionEl) {
3323 + setTimeout(function() {
3324 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3325 + }, 200);
3326 + }
3327 +
2700 3328 // Collapse quick questions if we have conversation history
2701 3329 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2702 3330 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2703 3331 collapseQuickQuestions(botId);
@@ -2792,10 +3420,10 @@
2792 3420 .then(data => {
2793 3421 if (data.success) {
2794 3422 container.style.display = 'none';
2795 3423 nameElement.textContent = '';
2796 - activePdfFile = null;
2797 - appendMessage('bot', 'PDF removed.');
3424 + instance.activePdfFile = null;
3425 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2798 3426 }
2799 3427 })
2800 3428 .catch(error => {
2801 3429 // Error removing PDF - silently continue
@@ -2801,14 +3429,16 @@
2801 3429 // Error removing PDF - silently continue
2802 3430 });
2803 3431 }
2804 3432
2805 - function removeActiveWord() {
2806 - const container = document.getElementById('active-word-container');
2807 - const nameElement = document.getElementById('active-word-name');
2808 -
2809 - if (!container || !nameElement || !activeWordFile) return;
2810 -
3433 + function removeActiveWord(botId) {
3434 + botId = botId || 'default';
3435 + var instance = MxChatInstances.get(botId);
3436 + const container = getElementDOM(botId, 'active-word-container');
3437 + const nameElement = getElementDOM(botId, 'active-word-name');
3438 +
3439 + if (!container || !nameElement || !instance.activeWordFile) return;
3440 +
2811 3441 fetch(mxchatChat.ajax_url, {
2812 3442 method: 'POST',
2813 3443 headers: {
2814 3444 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2814,9 +3444,9 @@
2814 3444 'Content-Type': 'application/x-www-form-urlencoded',
2815 3445 },
2816 3446 body: new URLSearchParams({
2817 3447 'action': 'mxchat_remove_word',
2818 - 'session_id': sessionId,
3448 + 'session_id': getChatSession(botId),
2819 3449 'nonce': mxchatChat.nonce
2820 3450 })
2821 3451 })
2822 3452 .then(response => response.json())
@@ -2823,10 +3453,10 @@
2823 3453 .then(data => {
2824 3454 if (data.success) {
2825 3455 container.style.display = 'none';
2826 3456 nameElement.textContent = '';
2827 - activeWordFile = null;
2828 - appendMessage('bot', 'Word document removed.');
3457 + instance.activeWordFile = null;
3458 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2829 3459 }
2830 3460 })
2831 3461 .catch(error => {
2832 3462 // Error removing Word document - silently continue
@@ -2985,8 +3615,30 @@
2985 3615 e.stopPropagation();
2986 3616 var botId = getBotIdFromElement(this);
2987 3617 collapseQuickQuestions(botId);
2988 3618 });
3619 +
3620 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3621 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3622 +// Delegated so it also works for embeds restored from chat history.
3623 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3624 + e.preventDefault();
3625 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3626 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3627 + if (!videoId) {
3628 + return;
3629 + }
3630 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3631 + var $iframe = $('<iframe>', {
3632 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3633 + title: title,
3634 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3635 + allowfullscreen: true,
3636 + frameborder: 0
3637 + }).addClass('mxchat-youtube-iframe');
3638 + $wrap.addClass('mxchat-youtube-playing');
3639 + $(this).replaceWith($iframe);
3640 +});
2989 3641
2990 3642 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2991 3643 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
2992 3644 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
@@ -3005,9 +3657,15 @@
3005 3657 $(this).addClass('hidden').attr('aria-expanded', 'true');
3006 3658 $badge.hide(); // Hide notification when opening chat
3007 3659 disableScroll();
3008 3660 $preChat.fadeOut(250);
3661 + mxchatFireOpened(botId);
3009 3662
3663 + // First open per page load: re-fetch behavior settings in case
3664 + // this page's inline values came from a stale full-page cache
3665 + // (plan-32db95). Idempotent — later opens are a no-op.
3666 + mxchatRefreshDynamicSettings();
3667 +
3010 3668 // Load chat history for returning visitors (persistence)
3011 3669 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3012 3670 if (chatPersistenceEnabled) {
3013 3671 MxChatInstances.ensureSession(botId);
@@ -3091,17 +3749,20 @@
3091 3749 var wordInput = getElementDOM(botId, 'word-upload');
3092 3750 if (wordInput) wordInput.click();
3093 3751 });
3094 3752
3095 - // PDF file input change handler
3096 - addSafeEventListener('pdf-upload', 'change', async function(e) {
3097 - const file = e.target.files[0];
3098 -
3753 + // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3754 + $(document).on('change', '.pdf-upload', async function(e) {
3755 + var botId = getBotIdFromElement(this);
3756 + var instance = MxChatInstances.get(botId);
3757 + const file = this.files[0];
3758 + const sessionId = MxChatInstances.ensureSession(botId);
3759 +
3099 3760 if (!file || file.type !== 'application/pdf') {
3100 3761 alert('Please select a valid PDF file.');
3101 3762 return;
3102 3763 }
3103 -
3764 +
3104 3765 if (!sessionId) {
3105 3766 alert('Error: No session ID found');
3106 3767 return;
3107 3768 }
@@ -3109,14 +3770,15 @@
3109 3770 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3110 3771 alert('Error: Ajax configuration missing');
3111 3772 return;
3112 3773 }
3113 -
3774 +
3114 3775 // Disable buttons and show loading state
3115 - const uploadBtn = document.getElementById('pdf-upload-btn');
3116 - const sendBtn = document.getElementById('send-button');
3776 + const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3777 + const sendBtn = getElementDOM(botId, 'send-button');
3778 + if (!uploadBtn) return;
3117 3779 const originalBtnContent = uploadBtn.innerHTML;
3118 -
3780 +
3119 3781 try {
3120 3782 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3121 3783 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3122 3784 const formData = new FormData();
@@ -3123,35 +3785,34 @@
3123 3785 formData.append('action', 'mxchat_upload_pdf');
3124 3786 formData.append('pdf_file', file);
3125 3787 formData.append('session_id', sessionId);
3126 3788 formData.append('nonce', mxchatChat.nonce);
3127 -
3789 +
3128 3790 uploadBtn.disabled = true;
3129 - sendBtn.disabled = true;
3791 + if (sendBtn) sendBtn.disabled = true;
3130 3792 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3131 3793 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3132 3794 </svg>`;
3133 -
3795 +
3134 3796 const response = await fetch(mxchatChat.ajax_url, {
3135 3797 method: 'POST',
3136 3798 body: formData
3137 3799 });
3138 -
3800 +
3139 3801 const data = await response.json();
3140 -
3802 +
3141 3803 if (data.success) {
3142 3804 // Hide popular questions if they exist
3143 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3144 - if (hasQuickQuestions()) {
3145 - collapseQuickQuestions();
3805 + if (hasQuickQuestions(botId)) {
3806 + collapseQuickQuestions(botId);
3146 3807 }
3147 -
3808 +
3148 3809 // Show the active PDF name
3149 - showActivePdf(data.data.filename);
3150 -
3151 - appendMessage('bot', data.data.message);
3152 - scrollToBottom();
3153 - activePdfFile = data.data.filename;
3810 + showActivePdf(data.data.filename, botId);
3811 +
3812 + appendMessage('bot', data.data.message, '', [], false, botId);
3813 + scrollToBottom(botId);
3814 + instance.activePdfFile = data.data.filename;
3154 3815 } else {
3155 3816 alert('Failed to upload PDF. Please try again.');
3156 3817 }
3157 3818 } catch (error) {
@@ -3157,33 +3818,42 @@
3157 3818 } catch (error) {
3158 3819 alert('Error uploading file. Please try again.');
3159 3820 } finally {
3160 3821 uploadBtn.disabled = false;
3161 - sendBtn.disabled = false;
3822 + if (sendBtn) sendBtn.disabled = false;
3162 3823 uploadBtn.innerHTML = originalBtnContent;
3163 3824 this.value = ''; // Reset file input
3164 3825 }
3165 3826 });
3166 3827
3167 - // Word file input change handler
3168 - addSafeEventListener('word-upload', 'change', async function(e) {
3169 - const file = e.target.files[0];
3170 -
3828 + // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3829 + $(document).on('change', '.word-upload', async function(e) {
3830 + var botId = getBotIdFromElement(this);
3831 + var instance = MxChatInstances.get(botId);
3832 + const file = this.files[0];
3833 + const sessionId = MxChatInstances.ensureSession(botId);
3834 +
3171 3835 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3172 3836 alert('Please select a valid Word document (.docx).');
3173 3837 return;
3174 3838 }
3175 -
3839 +
3176 3840 if (!sessionId) {
3177 3841 alert('Error: No session ID found');
3178 3842 return;
3179 3843 }
3180 3844
3845 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3846 + alert('Error: Ajax configuration missing');
3847 + return;
3848 + }
3849 +
3181 3850 // Disable buttons and show loading state
3182 - const uploadBtn = document.getElementById('word-upload-btn');
3183 - const sendBtn = document.getElementById('send-button');
3851 + const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3852 + const sendBtn = getElementDOM(botId, 'send-button');
3853 + if (!uploadBtn) return;
3184 3854 const originalBtnContent = uploadBtn.innerHTML;
3185 -
3855 +
3186 3856 try {
3187 3857 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3188 3858 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3189 3859 const formData = new FormData();
@@ -3190,35 +3860,34 @@
3190 3860 formData.append('action', 'mxchat_upload_word');
3191 3861 formData.append('word_file', file);
3192 3862 formData.append('session_id', sessionId);
3193 3863 formData.append('nonce', mxchatChat.nonce);
3194 -
3864 +
3195 3865 uploadBtn.disabled = true;
3196 - sendBtn.disabled = true;
3866 + if (sendBtn) sendBtn.disabled = true;
3197 3867 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3198 3868 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3199 3869 </svg>`;
3200 -
3870 +
3201 3871 const response = await fetch(mxchatChat.ajax_url, {
3202 3872 method: 'POST',
3203 3873 body: formData
3204 3874 });
3205 -
3875 +
3206 3876 const data = await response.json();
3207 -
3877 +
3208 3878 if (data.success) {
3209 3879 // Hide popular questions if they exist
3210 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3211 - if (hasQuickQuestions()) {
3212 - collapseQuickQuestions();
3880 + if (hasQuickQuestions(botId)) {
3881 + collapseQuickQuestions(botId);
3213 3882 }
3214 -
3883 +
3215 3884 // Show the active Word document name
3216 - showActiveWord(data.data.filename);
3217 -
3218 - appendMessage('bot', data.data.message);
3219 - scrollToBottom();
3220 - activeWordFile = data.data.filename;
3885 + showActiveWord(data.data.filename, botId);
3886 +
3887 + appendMessage('bot', data.data.message, '', [], false, botId);
3888 + scrollToBottom(botId);
3889 + instance.activeWordFile = data.data.filename;
3221 3890 } else {
3222 3891 alert('Failed to upload Word document. Please try again.');
3223 3892 }
3224 3893 } catch (error) {
@@ -3224,25 +3893,25 @@
3224 3893 } catch (error) {
3225 3894 alert('Error uploading file. Please try again.');
3226 3895 } finally {
3227 3896 uploadBtn.disabled = false;
3228 - sendBtn.disabled = false;
3897 + if (sendBtn) sendBtn.disabled = false;
3229 3898 uploadBtn.innerHTML = originalBtnContent;
3230 3899 this.value = ''; // Reset file input
3231 3900 }
3232 3901 });
3233 3902
3234 - // Remove button click handlers
3235 - document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3903 + // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3904 + $(document).on('click', '.remove-pdf-btn', function(e) {
3236 3905 e.preventDefault();
3237 3906 e.stopPropagation();
3238 - removeActivePdf();
3907 + removeActivePdf(getBotIdFromElement(this));
3239 3908 });
3240 -
3241 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3909 +
3910 + $(document).on('click', '.remove-word-btn', function(e) {
3242 3911 e.preventDefault();
3243 3912 e.stopPropagation();
3244 - removeActiveWord();
3913 + removeActiveWord(getBotIdFromElement(this));
3245 3914 });
3246 3915
3247 3916 // Window resize handlers
3248 3917 $(window).on('resize orientationchange', function() {
@@ -3259,9 +3928,12 @@
3259 3928 $('.chat-toolbar').show();
3260 3929 } else {
3261 3930 $('.chat-toolbar').hide();
3262 3931 }
3263 -
3932 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3933 + MxChatInstances.toolbarReady = true;
3934 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3935 +
3264 3936 // Apply toolbar icon colors
3265 3937 const toolbarElements = [
3266 3938 '#mxchat-chatbot .toolbar-btn svg',
3267 3939 '#mxchat-chatbot .active-pdf-name',
@@ -3386,10 +4058,13 @@
3386 4058 function replaceVisitorNamePlaceholder(botId, visitorName) {
3387 4059 var chatBox = getElementDOM(botId, 'chat-box');
3388 4060 if (!chatBox) return;
3389 4061
3390 - // Find the first bot message (intro message)
3391 - var introMessage = chatBox.querySelector('.bot-message');
4062 + // Find the greeting by its marker, not by position (plan a1a79b) —
4063 + // after a persistence restore the first .bot-message is a restored
4064 + // reply, and {visitor_name} was being substituted into that instead.
4065 + // Positional fallback for HTML cached before this release only.
4066 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3392 4067 if (!introMessage) return;
3393 4068
3394 4069 var messageContent = introMessage.querySelector('div[dir="auto"]');
3395 4070 if (!messageContent) return;
@@ -3533,14 +4208,25 @@
3533 4208 return response.json();
3534 4209 })
3535 4210 .then((data) => {
3536 4211 if (data.success) {
3537 - if (data.data.logged_in || data.data.email) {
4212 + // A success response means the server has already decided the
4213 + // required info is on file. With email optional (c0cfaf) that
4214 + // info can be a name and no address, so keying on data.email
4215 + // alone re-prompted a name-only visitor on every page load.
4216 + if (data.data.logged_in || data.data.email || data.data.name) {
3538 4217 showChatContainerForBot(botId);
3539 4218 } else {
3540 4219 showEmailFormForBot(botId);
3541 4220 }
3542 4221 } else {
4222 + // Pre-fill for a signed-in visitor being shown the form
4223 + // (c0cfaf). It arrives on this nocache'd response rather than
4224 + // in the page HTML, which is cacheable. Never overwrite what
4225 + // the visitor has already typed.
4226 + if (data.data) {
4227 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4228 + }
3543 4229 showEmailFormForBot(botId);
3544 4230 }
3545 4231 })
3546 4232 .catch((error) => {
@@ -3547,8 +4233,19 @@
3547 4233 showEmailFormForBot(botId);
3548 4234 });
3549 4235 }
3550 4236
4237 + function prefillEmailForm(botId, name, email) {
4238 + var nameInput = getElementDOM(botId, 'user-name');
4239 + var emailInput = getElementDOM(botId, 'user-email');
4240 + if (nameInput && name && !nameInput.value) {
4241 + nameInput.value = name;
4242 + }
4243 + if (emailInput && email && !emailInput.value) {
4244 + emailInput.value = email;
4245 + }
4246 + }
4247 +
3551 4248 // Event delegation for email form submission
3552 4249 $(document).on('submit', '.email-collection-form', function(e) {
3553 4250 e.preventDefault();
3554 4251 e.stopPropagation();
@@ -3561,23 +4258,34 @@
3561 4258 }
3562 4259
3563 4260 var emailInput = getElementDOM(botId, 'user-email');
3564 4261 var nameInput = getElementDOM(botId, 'user-name');
4262 + var consentInput = getElementDOM(botId, 'user-consent');
3565 4263 var userEmail = emailInput ? emailInput.value.trim() : '';
3566 4264 var userName = nameInput ? nameInput.value.trim() : '';
3567 4265 var sessionId = MxChatInstances.ensureSession(botId);
3568 4266
3569 - // Validate email
3570 - if (!userEmail) {
4267 + // Validate email. The address is optional when the owner turned
4268 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4269 + // still has to be a valid address. The server enforces both rules
4270 + // independently.
4271 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4272 +
4273 + if (emailRequired && !userEmail) {
3571 4274 showEmailError(botId, 'Please enter your email address.');
3572 4275 return false;
3573 4276 }
3574 4277
3575 - if (!isValidEmailAddress(userEmail)) {
4278 + if (userEmail && !isValidEmailAddress(userEmail)) {
3576 4279 showEmailError(botId, 'Please enter a valid email address.');
3577 4280 return false;
3578 4281 }
3579 4282
4283 + if (!emailRequired && !userEmail && !userName) {
4284 + showEmailError(botId, 'Please enter your name or email address.');
4285 + return false;
4286 + }
4287 +
3580 4288 // Validate name if field exists and has content
3581 4289 if (nameInput && userName && !isValidNameInput(userName)) {
3582 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3583 4291 return false;
@@ -3582,8 +4290,15 @@
3582 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3583 4291 return false;
3584 4292 }
3585 4293
4294 + // Consent checkbox (b062c4): backstop behind the native required
4295 + // attribute; the server enforces this independently either way.
4296 + if (consentInput && consentInput.required && !consentInput.checked) {
4297 + showEmailError(botId, 'Please tick the consent box to continue.');
4298 + return false;
4299 + }
4300 +
3586 4301 clearEmailError(botId);
3587 4302 setEmailSubmissionState(botId, true);
3588 4303
3589 4304 // Prepare form data
@@ -3597,8 +4312,14 @@
3597 4312 if (userName) {
3598 4313 formData.append('name', userName);
3599 4314 }
3600 4315
4316 + // Ticked/unticked both travel when the checkbox is rendered, so an
4317 + // optional-consent "no" is recorded as a decision, not an absence.
4318 + if (consentInput) {
4319 + formData.append('consent', consentInput.checked ? '1' : '0');
4320 + }
4321 +
3601 4322 fetch(mxchatChat.ajax_url, {
3602 4323 method: 'POST',
3603 4324 headers: {
3604 4325 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3720,8 +4441,9 @@
3720 4441 $chatbot.removeClass('hidden').addClass('visible');
3721 4442 getElement(botId, 'floating-chatbot-button').addClass('hidden');
3722 4443 handlePreChatDismissal(botId);
3723 4444 disableScroll(); // Disable scroll when chatbot opens
4445 + mxchatFireOpened(botId);
3724 4446
3725 4447 // Load chat history for returning visitors (persistence)
3726 4448 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3727 4449 if (chatPersistenceEnabled) {
@@ -3887,8 +4609,15 @@
3887 4609 // Initialize chat visibility for all instances
3888 4610 $('.mxchat-chatbot-wrapper').each(function() {
3889 4611 var botId = $(this).data('bot-id') || 'default';
3890 4612 initializeChatVisibility(botId);
4613 + // An embedded widget is on screen from the first paint; a floating one
4614 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4615 + if (isEmbeddedBot(botId)) {
4616 + mxchatFireOpened(botId);
4617 + } else {
4618 + mxchatWatchForOpen(botId);
4619 + }
3891 4620 });
3892 4621
3893 4622 // Make functions globally available for add-ons
3894 4623 window.hasQuickQuestions = hasQuickQuestions;
@@ -3943,13 +4672,26 @@
3943 4672 // SATISFACTION RATING (v3.2.6)
3944 4673 // ============================================================================
3945 4674 // Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
3946 4675 // inactivity following a bot reply. One prompt per session, deduped via
3947 -// localStorage. Disabled site-wide when mxchatChat.satisfaction_rating_enabled
3948 -// is exactly false (default ON).
4676 +// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4677 +// the option (default off) is authoritative.
3949 4678 jQuery(function($) {
3950 4679 if (typeof mxchatChat === 'undefined') return;
3951 - if (mxchatChat.satisfaction_rating_enabled === false || mxchatChat.satisfaction_rating_enabled === 'off') return;
4680 + // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4681 + // '' and true as '1', so this must be an explicit-enable allowlist — the
4682 + // old "disabled when exactly false/'off'" check let '' through and the
4683 + // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4684 + // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4685 + // (boolean-true localizations) working.
4686 + // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4687 + // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4688 + // merges fresh values over mxchatChat (that merge fires on first widget
4689 + // open, after this module has already decided). Re-evaluating would mean
4690 + // restructuring the whole module to late-bind its listeners — not worth it
4691 + // for a prompt that is at worst stale for one page load on a cached page.
4692 + var sre = mxchatChat.satisfaction_rating_enabled;
4693 + if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
3952 4694
3953 4695 // wp_localize_script stringifies ints, so accept both number and numeric string.
3954 4696 var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
3955 4697 var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);