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 +973 -213 3.2.8 → 3.2.24 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,40 @@
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 the end of the stream so the embed always lands BELOW the
1311 + // streamed text.
1312 + let pendingAppendHtml = '';
1002 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 +
1003 1335 fetch(mxchatChat.ajax_url, {
1004 1336 method: 'POST',
1005 1337 body: formData,
1006 - credentials: 'same-origin'
1338 + credentials: 'same-origin',
1339 + signal: streamControl.controller.signal
1007 1340 })
1008 1341 .then(response => {
1009 1342 // Store the response for potential fallback handling
1010 1343 const responseClone = response.clone();
@@ -1073,8 +1406,10 @@
1073 1406
1074 1407 // Re-enable chat input when stream ends with content
1075 1408 enableChatInput(botId);
1076 1409
1410 + flushAppendHtml();
1411 +
1077 1412 // Scroll the user's last message to the top now that the
1078 1413 // bot's full reply has rendered (gives max reading room).
1079 1414 var $chatBoxDone = getElement(botId, 'chat-box');
1080 1415 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
@@ -1105,8 +1440,10 @@
1105 1440
1106 1441 // Re-enable chat input after streaming completes
1107 1442 enableChatInput(botId);
1108 1443
1444 + flushAppendHtml();
1445 +
1109 1446 // Scroll the user's last message to the top now
1110 1447 // that the bot's full reply has rendered.
1111 1448 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1112 1449 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1140,8 +1477,21 @@
1140 1477 streamingStarted = true;
1141 1478 accumulatedContent += json.content;
1142 1479 updateStreamingMessage(accumulatedContent, botId);
1143 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 + }
1144 1494 // Handle complete response in stream (fallback response)
1145 1495 else if (json.text || json.message || json.html) {
1146 1496 handleNonStreamResponse(json, callback, botId);
1147 1497 return;
@@ -1171,8 +1521,9 @@
1171 1521 }
1172 1522
1173 1523 processStream();
1174 1524 }).catch(streamError => {
1525 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1175 1526 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1176 1527 callMxChat(message, callback, botId);
1177 1528 });
1178 1529 }
@@ -1179,8 +1530,9 @@
1179 1530
1180 1531 processStream();
1181 1532 })
1182 1533 .catch(error => {
1534 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1183 1535 // Check if we have server error data with chat mode
1184 1536 if (error && error.isServerError && error.data) {
1185 1537 // Check for chat mode in error data
1186 1538 if (error.data.chat_mode) {
@@ -1241,9 +1593,9 @@
1241 1593 // Re-send the original message with the new session (user message is already displayed)
1242 1594 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1243 1595 if (originalMessage) {
1244 1596 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1245 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1597 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1246 1598 if (shouldUseStreaming(currentModel)) {
1247 1599 callMxChatStream(originalMessage, callback, botId);
1248 1600 } else {
1249 1601 callMxChat(originalMessage, callback, botId);
@@ -1376,8 +1728,15 @@
1376 1728 var $chatBox = getElement(botId, 'chat-box');
1377 1729 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1378 1730
1379 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 + }
1380 1739 // Update existing message
1381 1740 tempMessage.html(formattedContent);
1382 1741 } else {
1383 1742 // Create new temporary message if it doesn't exist
@@ -1404,28 +1763,98 @@
1404 1763 // Update the event handlers to use the correct function names (using event delegation)
1405 1764 // Use class-based selectors for multi-instance support
1406 1765 $(document).on('click', '.send-button', function() {
1407 1766 var botId = getBotIdFromElement(this);
1408 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1409 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1410 - 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;
1411 1775 }
1776 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1412 1777 sendMessage(botId);
1413 1778 });
1414 1779
1415 -// 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).
1416 1785 $(document).on('keypress', '.chat-input', function(e) {
1417 1786 if (e.which == 13 && !e.shiftKey) {
1418 1787 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);
1788 + sendMessage(getBotIdFromElement(this));
1425 1789 }
1426 1790 });
1427 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 +
1428 1857 // Builds the list of overflow-menu items for a given bot.
1429 1858 // Adding a future item is one push to this array — do NOT hardcode "only download."
1430 1859 function mxchatGetHeaderMenuItems(botId) {
1431 1860 var items = [];
@@ -1443,8 +1872,26 @@
1443 1872 }
1444 1873 });
1445 1874 }
1446 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 +
1447 1894 return items;
1448 1895 }
1449 1896
1450 1897 // Builds a clean markdown transcript of the current conversation and triggers
@@ -1522,30 +1969,31 @@
1522 1969 var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1523 1970 if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1524 1971 }
1525 1972
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 -
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) {
1532 1979 var $trigger = $wrap.find('.mxchat-menu-trigger');
1533 1980 var $menu = $wrap.find('.mxchat-header-menu');
1534 1981 var items = mxchatGetHeaderMenuItems(botId);
1535 1982
1536 - // Initial color sync — covers normal page load.
1537 - mxchatSyncMenuColors(botId, $wrap);
1983 + $menu.empty();
1538 1984
1539 1985 if (!items.length) {
1540 1986 $trigger.hide();
1541 1987 $menu.hide();
1542 - $wrap.data('mxchatMenuReady', true);
1543 1988 return;
1544 1989 }
1545 1990
1546 - // Build the menu items.
1547 - $menu.empty();
1991 + // Clear any inline display:none a previous zero-item render left behind —
1992 + // open/close visibility is governed by the hidden prop + is-open class.
1993 + $trigger.css('display', '');
1994 + $menu.css('display', '');
1995 +
1548 1996 items.forEach(function(item, idx) {
1549 1997 var $btn = $('<button>', {
1550 1998 type: 'button',
1551 1999 'class': 'mxchat-menu-item',
@@ -1558,14 +2006,50 @@
1558 2006 $btn.find('.mxchat-menu-item-label').text(item.label);
1559 2007 $btn.on('click', function(e) {
1560 2008 e.preventDefault();
1561 2009 e.stopPropagation();
1562 - closeMenu();
2010 + if (closeMenuFn) closeMenuFn();
1563 2011 try { item.action(); } catch (err) { /* no-op */ }
1564 2012 });
1565 2013 $menu.append($btn);
1566 2014 });
2015 +}
1567 2016
2017 +// Re-render every menu on the page after a dynamic-settings merge
2018 +// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
2019 +// swapping items under the user mid-interaction yanks focus — and the
2020 +// rebuild runs when it closes instead (closeMenu checks the pending flag).
2021 +function mxchatRebuildHeaderMenus() {
2022 + $('.mxchat-header-menu-wrap').each(function() {
2023 + var $wrap = $(this);
2024 + var botId = $wrap.data('bot-id');
2025 + if (!botId) return;
2026 + if (!$wrap.data('mxchatMenuReady')) {
2027 + mxchatInitHeaderMenu(botId);
2028 + return;
2029 + }
2030 + if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
2031 + $wrap.data('mxchatMenuRebuildPending', true);
2032 + return;
2033 + }
2034 + mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
2035 + });
2036 +}
2037 +
2038 +// One-time per-widget init: renders menu items, wires open/close,
2039 +// outside-click, Escape, and arrow-key navigation. If no items, hides the
2040 +// trigger. Wiring happens even when there are zero items at init, so a
2041 +// later dynamic-settings rebuild that adds items has a working trigger.
2042 +function mxchatInitHeaderMenu(botId) {
2043 + var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
2044 + if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
2045 +
2046 + var $trigger = $wrap.find('.mxchat-menu-trigger');
2047 + var $menu = $wrap.find('.mxchat-header-menu');
2048 +
2049 + // Initial color sync — covers normal page load.
2050 + mxchatSyncMenuColors(botId, $wrap);
2051 +
1568 2052 function openMenu() {
1569 2053 // Re-sync each open in case the active theme changed since init.
1570 2054 mxchatSyncMenuColors(botId, $wrap);
1571 2055 $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
@@ -1579,8 +2063,14 @@
1579 2063 $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1580 2064 $trigger.attr('aria-expanded', 'false');
1581 2065 $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1582 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 + }
1583 2073 }
1584 2074
1585 2075 // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1586 2076 // click-to-collapse handler does not fire.
@@ -1634,8 +2124,13 @@
1634 2124 openMenu();
1635 2125 }
1636 2126 });
1637 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 +
1638 2133 $wrap.data('mxchatMenuReady', true);
1639 2134 }
1640 2135
1641 2136 // Initialize header menus for every rendered widget on DOM ready.
@@ -1643,8 +2138,18 @@
1643 2138 $('.mxchat-header-menu-wrap').each(function() {
1644 2139 var botId = $(this).data('bot-id');
1645 2140 if (botId) mxchatInitHeaderMenu(botId);
1646 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 + }
1647 2152 });
1648 2153
1649 2154 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1650 2155 try {
@@ -1721,9 +2226,11 @@
1721 2226
1722 2227 messageDiv.html(fullMessage);
1723 2228
1724 2229 if (isTemporary) {
1725 - 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');
1726 2233 }
1727 2234
1728 2235 // Append to the correct chatbot instance's chat-box
1729 2236 var $chatBox = getElement(botId, 'chat-box');
@@ -1754,8 +2261,56 @@
1754 2261 // Error rendering message - silently continue
1755 2262 }
1756 2263 }
1757 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 +
1758 2313 // Helper function to attach link tracking with proper event handling
1759 2314 function attachLinkTracking(messageDiv, messageText, botId) {
1760 2315 botId = botId || 'default';
1761 2316 // Use a slight delay to ensure DOM is ready
@@ -1773,11 +2328,13 @@
1773 2328 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
1774 2329 // Remove any existing click handlers first
1775 2330 $link.off('click.tracking');
1776 2331
2332 + // The anchor carries its own target: the browser follows it
2333 + prepareTrackedLink($link);
2334 +
1777 2335 // Add new click handler with namespace
1778 2336 $link.on('click.tracking', function(e) {
1779 - e.preventDefault();
1780 2337 e.stopPropagation();
1781 2338
1782 2339 const messageContext = typeof messageText === 'string'
1783 2340 ? messageText.substring(0, 200)
@@ -1782,30 +2339,10 @@
1782 2339 const messageContext = typeof messageText === 'string'
1783 2340 ? messageText.substring(0, 200)
1784 2341 : '';
1785 2342
1786 - // Track the click
1787 - $.ajax({
1788 - url: mxchatChat.ajax_url,
1789 - type: 'POST',
1790 - data: {
1791 - action: 'mxchat_track_url_click',
1792 - session_id: getChatSession(botId),
1793 - url: originalHref,
1794 - message_context: messageContext,
1795 - nonce: mxchatChat.nonce
1796 - },
1797 - complete: function() {
1798 - // Always redirect, even if tracking fails
1799 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
1800 - window.open(originalHref, '_blank');
1801 - } else {
1802 - window.location.href = originalHref;
1803 - }
1804 - }
1805 - });
1806 -
1807 - return false; // Extra insurance to prevent default
2343 + prepareTrackedLink($link);
2344 + trackLinkClick(originalHref, messageContext, botId);
1808 2345 });
1809 2346 }
1810 2347 });
1811 2348 }, 100); // Small delay to ensure DOM is ready
@@ -1845,13 +2382,16 @@
1845 2382
1846 2383 if (images.length > 0) {
1847 2384 fullMessage += '<div class="image-gallery" dir="auto">';
1848 2385 images.forEach(img => {
2386 + const safeTitle = sanitizeUserInput(img.title);
2387 + const safeUrl = escapeAttr(img.image_url);
2388 + const safeThumbnail = escapeAttr(img.thumbnail_url);
1849 2389 fullMessage += `
1850 2390 <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;" />
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;" />
1854 2394 </a>
1855 2395 </div>`;
1856 2396 });
1857 2397 fullMessage += '</div>';
@@ -1858,13 +2398,16 @@
1858 2398 }
1859 2399
1860 2400 if (lastMessageDiv.length) {
1861 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).
1862 2404 lastMessageDiv
1863 2405 .html(fullMessage)
1864 2406 .removeClass('bot-message user-message temporary-message')
1865 2407 .addClass(messageClass)
1866 - .attr('dir', 'auto');
2408 + .attr('dir', 'auto')
2409 + .attr('aria-busy', 'false');
1867 2410
1868 2411 // Only apply inline colors if AI theme is not active (let CSS handle it)
1869 2412 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1870 2413 if (!skipColors) {
@@ -1924,10 +2467,15 @@
1924 2467 var botMessageFontColor = mxchatChat.bot_message_font_color;
1925 2468 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1926 2469
1927 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).
1928 2474 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1929 - 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">' +
1930 2478 '<div class="thinking-dots">' +
1931 2479 '<span class="dot"' + dotStyle + '></span>' +
1932 2480 '<span class="dot"' + dotStyle + '></span>' +
1933 2481 '<span class="dot"' + dotStyle + '></span>' +
@@ -1962,27 +2510,42 @@
1962 2510 // Check for % followed by exactly 2 hex digits
1963 2511 return /%[0-9a-fA-F]{2}/.test(url);
1964 2512 }
1965 2513
1966 - // 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.
1967 2520 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);
2521 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2522 + return escapeAttr(encoded);
1974 2523 }
1975 2524
1976 - // Process markdown headers FIRST
1977 - let processedText = formatMarkdownHeaders(inputText);
1978 -
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 +
1979 2538 // Process text styling (bold, italic, strikethrough)
1980 2539 processedText = formatTextStyling(processedText);
1981 -
1982 - // 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
1983 2543 processedText = formatCodeBlocks(processedText);
1984 2544
2545 + // Put the rendered code and the escaped characters back
2546 + processedText = literals.restore(processedText);
2547 +
1985 2548 // Process markdown tables BEFORE converting newlines to paragraphs
1986 2549 processedText = formatMarkdownTables(processedText);
1987 2550
1988 2551 // NOW convert to paragraphs
@@ -1994,10 +2557,13 @@
1994 2557 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
1995 2558 // Clean the URL of any trailing punctuation
1996 2559 let cleanUrl = url.replace(/[.,;!?]+$/, '');
1997 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;');
1998 2564 // Return as a proper link without the brackets
1999 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2565 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2000 2566 });
2001 2567
2002 2568 // Process markdown links: [text](url) and [](url)
2003 2569 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2101,8 +2667,30 @@
2101 2667
2102 2668 return processedText;
2103 2669 }
2104 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 +
2105 2693 function formatMarkdownHeaders(text) {
2106 2694 // Handle h1 to h6 headers
2107 2695 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2108 2696 const level = hashes.length;
@@ -2144,11 +2732,24 @@
2144 2732 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2145 2733 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2146 2734 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2147 2735
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>');
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 + });
2151 2752
2152 2753 // Handle strikethrough (~~text~~)
2153 2754 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2154 2755
@@ -2282,8 +2883,24 @@
2282 2883 div.textContent = text;
2283 2884 return div.innerHTML;
2284 2885 }
2285 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 +
2286 2903 function escapeHtml(unsafe) {
2287 2904 // Skip escaping if it's already escaped or contains HTML code block markup
2288 2905 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2289 2906 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -2627,9 +3244,30 @@
2627 3244 }
2628 3245
2629 3246 // Only process if there are actual messages
2630 3247 if (response.data.conversation.length > 0) {
2631 - // 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();
2632 3270 $chatBox.empty();
2633 3271
2634 3272 $.each(response.data.conversation, function(index, message) {
2635 3273 // Skip agent messages if persistence is off
@@ -2666,15 +3304,28 @@
2666 3304 var content = message.content;
2667 3305 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2668 3306 content = decodeHTMLEntities(content);
2669 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 +
2670 3317 // Skip linkify for messages containing structured HTML
2671 3318 // (forms, product cards, galleries, etc.) to avoid
2672 3319 // 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") ||
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) ||
2677 3328 content.includes("<form") ||
2678 3329 content.includes("<input") ||
2679 3330 content.includes("<select") ||
2680 3331 content.includes("<textarea")) {
@@ -2692,12 +3343,29 @@
2692 3343 instance.processedMessageIds.add(message.id);
2693 3344 }
2694 3345 });
2695 3346
2696 - // 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 + }
2697 3355 $chatBox.append($fragment);
2698 3356 scrollToBottom(botId, true);
2699 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 +
2700 3368 // Collapse quick questions if we have conversation history
2701 3369 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2702 3370 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2703 3371 collapseQuickQuestions(botId);
@@ -2792,10 +3460,10 @@
2792 3460 .then(data => {
2793 3461 if (data.success) {
2794 3462 container.style.display = 'none';
2795 3463 nameElement.textContent = '';
2796 - activePdfFile = null;
2797 - appendMessage('bot', 'PDF removed.');
3464 + instance.activePdfFile = null;
3465 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2798 3466 }
2799 3467 })
2800 3468 .catch(error => {
2801 3469 // Error removing PDF - silently continue
@@ -2801,14 +3469,16 @@
2801 3469 // Error removing PDF - silently continue
2802 3470 });
2803 3471 }
2804 3472
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 -
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 +
2811 3481 fetch(mxchatChat.ajax_url, {
2812 3482 method: 'POST',
2813 3483 headers: {
2814 3484 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2814,9 +3484,9 @@
2814 3484 'Content-Type': 'application/x-www-form-urlencoded',
2815 3485 },
2816 3486 body: new URLSearchParams({
2817 3487 'action': 'mxchat_remove_word',
2818 - 'session_id': sessionId,
3488 + 'session_id': getChatSession(botId),
2819 3489 'nonce': mxchatChat.nonce
2820 3490 })
2821 3491 })
2822 3492 .then(response => response.json())
@@ -2823,10 +3493,10 @@
2823 3493 .then(data => {
2824 3494 if (data.success) {
2825 3495 container.style.display = 'none';
2826 3496 nameElement.textContent = '';
2827 - activeWordFile = null;
2828 - appendMessage('bot', 'Word document removed.');
3497 + instance.activeWordFile = null;
3498 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2829 3499 }
2830 3500 })
2831 3501 .catch(error => {
2832 3502 // Error removing Word document - silently continue
@@ -2985,8 +3655,30 @@
2985 3655 e.stopPropagation();
2986 3656 var botId = getBotIdFromElement(this);
2987 3657 collapseQuickQuestions(botId);
2988 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 +});
2989 3681
2990 3682 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2991 3683 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
2992 3684 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
@@ -3005,9 +3697,15 @@
3005 3697 $(this).addClass('hidden').attr('aria-expanded', 'true');
3006 3698 $badge.hide(); // Hide notification when opening chat
3007 3699 disableScroll();
3008 3700 $preChat.fadeOut(250);
3701 + mxchatFireOpened(botId);
3009 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 +
3010 3708 // Load chat history for returning visitors (persistence)
3011 3709 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3012 3710 if (chatPersistenceEnabled) {
3013 3711 MxChatInstances.ensureSession(botId);
@@ -3091,17 +3789,20 @@
3091 3789 var wordInput = getElementDOM(botId, 'word-upload');
3092 3790 if (wordInput) wordInput.click();
3093 3791 });
3094 3792
3095 - // PDF file input change handler
3096 - addSafeEventListener('pdf-upload', 'change', async function(e) {
3097 - const file = e.target.files[0];
3098 -
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 +
3099 3800 if (!file || file.type !== 'application/pdf') {
3100 3801 alert('Please select a valid PDF file.');
3101 3802 return;
3102 3803 }
3103 -
3804 +
3104 3805 if (!sessionId) {
3105 3806 alert('Error: No session ID found');
3106 3807 return;
3107 3808 }
@@ -3109,14 +3810,15 @@
3109 3810 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3110 3811 alert('Error: Ajax configuration missing');
3111 3812 return;
3112 3813 }
3113 -
3814 +
3114 3815 // Disable buttons and show loading state
3115 - const uploadBtn = document.getElementById('pdf-upload-btn');
3116 - 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;
3117 3819 const originalBtnContent = uploadBtn.innerHTML;
3118 -
3820 +
3119 3821 try {
3120 3822 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3121 3823 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3122 3824 const formData = new FormData();
@@ -3123,35 +3825,34 @@
3123 3825 formData.append('action', 'mxchat_upload_pdf');
3124 3826 formData.append('pdf_file', file);
3125 3827 formData.append('session_id', sessionId);
3126 3828 formData.append('nonce', mxchatChat.nonce);
3127 -
3829 +
3128 3830 uploadBtn.disabled = true;
3129 - sendBtn.disabled = true;
3831 + if (sendBtn) sendBtn.disabled = true;
3130 3832 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3131 3833 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3132 3834 </svg>`;
3133 -
3835 +
3134 3836 const response = await fetch(mxchatChat.ajax_url, {
3135 3837 method: 'POST',
3136 3838 body: formData
3137 3839 });
3138 -
3840 +
3139 3841 const data = await response.json();
3140 -
3842 +
3141 3843 if (data.success) {
3142 3844 // Hide popular questions if they exist
3143 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3144 - if (hasQuickQuestions()) {
3145 - collapseQuickQuestions();
3845 + if (hasQuickQuestions(botId)) {
3846 + collapseQuickQuestions(botId);
3146 3847 }
3147 -
3848 +
3148 3849 // Show the active PDF name
3149 - showActivePdf(data.data.filename);
3150 -
3151 - appendMessage('bot', data.data.message);
3152 - scrollToBottom();
3153 - 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;
3154 3855 } else {
3155 3856 alert('Failed to upload PDF. Please try again.');
3156 3857 }
3157 3858 } catch (error) {
@@ -3157,33 +3858,42 @@
3157 3858 } catch (error) {
3158 3859 alert('Error uploading file. Please try again.');
3159 3860 } finally {
3160 3861 uploadBtn.disabled = false;
3161 - sendBtn.disabled = false;
3862 + if (sendBtn) sendBtn.disabled = false;
3162 3863 uploadBtn.innerHTML = originalBtnContent;
3163 3864 this.value = ''; // Reset file input
3164 3865 }
3165 3866 });
3166 3867
3167 - // Word file input change handler
3168 - addSafeEventListener('word-upload', 'change', async function(e) {
3169 - const file = e.target.files[0];
3170 -
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 +
3171 3875 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3172 3876 alert('Please select a valid Word document (.docx).');
3173 3877 return;
3174 3878 }
3175 -
3879 +
3176 3880 if (!sessionId) {
3177 3881 alert('Error: No session ID found');
3178 3882 return;
3179 3883 }
3180 3884
3885 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3886 + alert('Error: Ajax configuration missing');
3887 + return;
3888 + }
3889 +
3181 3890 // Disable buttons and show loading state
3182 - const uploadBtn = document.getElementById('word-upload-btn');
3183 - 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;
3184 3894 const originalBtnContent = uploadBtn.innerHTML;
3185 -
3895 +
3186 3896 try {
3187 3897 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3188 3898 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3189 3899 const formData = new FormData();
@@ -3190,35 +3900,34 @@
3190 3900 formData.append('action', 'mxchat_upload_word');
3191 3901 formData.append('word_file', file);
3192 3902 formData.append('session_id', sessionId);
3193 3903 formData.append('nonce', mxchatChat.nonce);
3194 -
3904 +
3195 3905 uploadBtn.disabled = true;
3196 - sendBtn.disabled = true;
3906 + if (sendBtn) sendBtn.disabled = true;
3197 3907 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3198 3908 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3199 3909 </svg>`;
3200 -
3910 +
3201 3911 const response = await fetch(mxchatChat.ajax_url, {
3202 3912 method: 'POST',
3203 3913 body: formData
3204 3914 });
3205 -
3915 +
3206 3916 const data = await response.json();
3207 -
3917 +
3208 3918 if (data.success) {
3209 3919 // Hide popular questions if they exist
3210 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3211 - if (hasQuickQuestions()) {
3212 - collapseQuickQuestions();
3920 + if (hasQuickQuestions(botId)) {
3921 + collapseQuickQuestions(botId);
3213 3922 }
3214 -
3923 +
3215 3924 // 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;
3925 + showActiveWord(data.data.filename, botId);
3926 +
3927 + appendMessage('bot', data.data.message, '', [], false, botId);
3928 + scrollToBottom(botId);
3929 + instance.activeWordFile = data.data.filename;
3221 3930 } else {
3222 3931 alert('Failed to upload Word document. Please try again.');
3223 3932 }
3224 3933 } catch (error) {
@@ -3224,25 +3933,25 @@
3224 3933 } catch (error) {
3225 3934 alert('Error uploading file. Please try again.');
3226 3935 } finally {
3227 3936 uploadBtn.disabled = false;
3228 - sendBtn.disabled = false;
3937 + if (sendBtn) sendBtn.disabled = false;
3229 3938 uploadBtn.innerHTML = originalBtnContent;
3230 3939 this.value = ''; // Reset file input
3231 3940 }
3232 3941 });
3233 3942
3234 - // Remove button click handlers
3235 - 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) {
3236 3945 e.preventDefault();
3237 3946 e.stopPropagation();
3238 - removeActivePdf();
3947 + removeActivePdf(getBotIdFromElement(this));
3239 3948 });
3240 -
3241 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3949 +
3950 + $(document).on('click', '.remove-word-btn', function(e) {
3242 3951 e.preventDefault();
3243 3952 e.stopPropagation();
3244 - removeActiveWord();
3953 + removeActiveWord(getBotIdFromElement(this));
3245 3954 });
3246 3955
3247 3956 // Window resize handlers
3248 3957 $(window).on('resize orientationchange', function() {
@@ -3259,9 +3968,12 @@
3259 3968 $('.chat-toolbar').show();
3260 3969 } else {
3261 3970 $('.chat-toolbar').hide();
3262 3971 }
3263 -
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 +
3264 3976 // Apply toolbar icon colors
3265 3977 const toolbarElements = [
3266 3978 '#mxchat-chatbot .toolbar-btn svg',
3267 3979 '#mxchat-chatbot .active-pdf-name',
@@ -3386,10 +4098,13 @@
3386 4098 function replaceVisitorNamePlaceholder(botId, visitorName) {
3387 4099 var chatBox = getElementDOM(botId, 'chat-box');
3388 4100 if (!chatBox) return;
3389 4101
3390 - // Find the first bot message (intro message)
3391 - 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');
3392 4107 if (!introMessage) return;
3393 4108
3394 4109 var messageContent = introMessage.querySelector('div[dir="auto"]');
3395 4110 if (!messageContent) return;
@@ -3533,14 +4248,25 @@
3533 4248 return response.json();
3534 4249 })
3535 4250 .then((data) => {
3536 4251 if (data.success) {
3537 - 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) {
3538 4257 showChatContainerForBot(botId);
3539 4258 } else {
3540 4259 showEmailFormForBot(botId);
3541 4260 }
3542 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 + }
3543 4269 showEmailFormForBot(botId);
3544 4270 }
3545 4271 })
3546 4272 .catch((error) => {
@@ -3547,8 +4273,19 @@
3547 4273 showEmailFormForBot(botId);
3548 4274 });
3549 4275 }
3550 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 +
3551 4288 // Event delegation for email form submission
3552 4289 $(document).on('submit', '.email-collection-form', function(e) {
3553 4290 e.preventDefault();
3554 4291 e.stopPropagation();
@@ -3561,23 +4298,34 @@
3561 4298 }
3562 4299
3563 4300 var emailInput = getElementDOM(botId, 'user-email');
3564 4301 var nameInput = getElementDOM(botId, 'user-name');
4302 + var consentInput = getElementDOM(botId, 'user-consent');
3565 4303 var userEmail = emailInput ? emailInput.value.trim() : '';
3566 4304 var userName = nameInput ? nameInput.value.trim() : '';
3567 4305 var sessionId = MxChatInstances.ensureSession(botId);
3568 4306
3569 - // Validate email
3570 - 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) {
3571 4314 showEmailError(botId, 'Please enter your email address.');
3572 4315 return false;
3573 4316 }
3574 4317
3575 - if (!isValidEmailAddress(userEmail)) {
4318 + if (userEmail && !isValidEmailAddress(userEmail)) {
3576 4319 showEmailError(botId, 'Please enter a valid email address.');
3577 4320 return false;
3578 4321 }
3579 4322
4323 + if (!emailRequired && !userEmail && !userName) {
4324 + showEmailError(botId, 'Please enter your name or email address.');
4325 + return false;
4326 + }
4327 +
3580 4328 // Validate name if field exists and has content
3581 4329 if (nameInput && userName && !isValidNameInput(userName)) {
3582 4330 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3583 4331 return false;
@@ -3582,8 +4330,15 @@
3582 4330 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3583 4331 return false;
3584 4332 }
3585 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 +
3586 4341 clearEmailError(botId);
3587 4342 setEmailSubmissionState(botId, true);
3588 4343
3589 4344 // Prepare form data
@@ -3597,8 +4352,14 @@
3597 4352 if (userName) {
3598 4353 formData.append('name', userName);
3599 4354 }
3600 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 +
3601 4362 fetch(mxchatChat.ajax_url, {
3602 4363 method: 'POST',
3603 4364 headers: {
3604 4365 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3720,8 +4481,9 @@
3720 4481 $chatbot.removeClass('hidden').addClass('visible');
3721 4482 getElement(botId, 'floating-chatbot-button').addClass('hidden');
3722 4483 handlePreChatDismissal(botId);
3723 4484 disableScroll(); // Disable scroll when chatbot opens
4485 + mxchatFireOpened(botId);
3724 4486
3725 4487 // Load chat history for returning visitors (persistence)
3726 4488 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3727 4489 if (chatPersistenceEnabled) {
@@ -3828,14 +4590,8 @@
3828 4590 if (messageDiv.length > 0) {
3829 4591 const originalHref = $link.attr('href');
3830 4592
3831 4593 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
3832 - e.preventDefault();
3833 - e.stopPropagation();
3834 -
3835 - // Mark as tracked
3836 - $link.attr('data-tracked', 'true');
3837 -
3838 4594 // Get bot ID from the chat box context
3839 4595 var botId = getBotIdFromElement(this);
3840 4596
3841 4597 // Get message context from the message div
@@ -3840,28 +4596,12 @@
3840 4596
3841 4597 // Get message context from the message div
3842 4598 const messageText = messageDiv.text().substring(0, 200);
3843 4599
3844 - $.ajax({
3845 - url: mxchatChat.ajax_url,
3846 - type: 'POST',
3847 - data: {
3848 - action: 'mxchat_track_url_click',
3849 - session_id: getChatSession(botId),
3850 - url: originalHref,
3851 - message_context: messageText,
3852 - nonce: mxchatChat.nonce
3853 - },
3854 - complete: function() {
3855 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
3856 - window.open(originalHref, '_blank');
3857 - } else {
3858 - window.location.href = originalHref;
3859 - }
3860 - }
3861 - });
3862 -
3863 - 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);
3864 4604 }
3865 4605 }
3866 4606 });
3867 4607
@@ -3887,8 +4627,15 @@
3887 4627 // Initialize chat visibility for all instances
3888 4628 $('.mxchat-chatbot-wrapper').each(function() {
3889 4629 var botId = $(this).data('bot-id') || 'default';
3890 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 + }
3891 4638 });
3892 4639
3893 4640 // Make functions globally available for add-ons
3894 4641 window.hasQuickQuestions = hasQuickQuestions;
@@ -3943,13 +4690,26 @@
3943 4690 // SATISFACTION RATING (v3.2.6)
3944 4691 // ============================================================================
3945 4692 // Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
3946 4693 // 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).
4694 +// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4695 +// the option (default off) is authoritative.
3949 4696 jQuery(function($) {
3950 4697 if (typeof mxchatChat === 'undefined') return;
3951 - if (mxchatChat.satisfaction_rating_enabled === false || mxchatChat.satisfaction_rating_enabled === 'off') 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;
3952 4712
3953 4713 // wp_localize_script stringifies ints, so accept both number and numeric string.
3954 4714 var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
3955 4715 var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);