PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.8
MxChat – AI Chatbot & Content Generation for WordPress v3.2.8
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | js/chat-script.js +702 -25 3.2.33.2.8 View file →
@@ -1,23 +1,111 @@
1 1 jQuery(document).ready(function($) {
2 2
3 - // Nonce refresh is deferred until first user interaction (ensureSession)
4 - // to avoid admin-ajax calls on passive page loads.
5 - var nonceRefreshed = false;
6 - function refreshNonceIfNeeded(callback) {
7 - if (nonceRefreshed || typeof mxchatChat === 'undefined' || !mxchatChat.ajax_url) {
3 + // Nonce refresh — v2 (plan-6a68c9).
4 + //
5 + // The widget no longer relies on a nonce embedded in inline cached HTML.
6 + // Before each chat-send / stream-send / upload, we call the REST endpoint
7 + // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The
8 + // endpoint creates the nonce with action `mxchat_chat_send`; the server-side
9 + // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a
10 + // 30-day backwards-compat window so cached pages still in users' browsers
11 + // (which carry the legacy inline-localized nonce) keep working.
12 + //
13 + // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are
14 + // 24h but we refetch at half-life so a freshly-cached-page user never sees
15 + // a borderline-stale nonce).
16 + var cachedFreshNonce = null;
17 + var cachedFreshNonceFetchedAt = 0;
18 + var NONCE_TTL_MS = 12 * 60 * 60 * 1000;
19 + var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
20 + var nonceRefreshCallbacks = [];
21 +
22 + function getRestNonceUrl() {
23 + if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) {
24 + return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce';
25 + }
26 + // Fallback: derive from current origin if mxchatChat.rest_url isn't set.
27 + return window.location.origin + '/wp-json/mxchat/v1/nonce';
28 + }
29 +
30 + function fetchFreshNonceFromRest() {
31 + return fetch(getRestNonceUrl(), {
32 + credentials: 'same-origin',
33 + headers: { 'Accept': 'application/json' }
34 + }).then(function (resp) {
35 + if (!resp.ok) {
36 + throw new Error('REST nonce fetch failed: ' + resp.status);
37 + }
38 + return resp.json();
39 + }).then(function (data) {
40 + if (data && data.nonce) {
41 + return data.nonce;
42 + }
43 + throw new Error('REST nonce response had no nonce field.');
44 + });
45 + }
46 +
47 + /**
48 + * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
49 + * Tries REST endpoint first (cache-bypass design); falls back to the legacy
50 + * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
51 + * calls share the same in-flight refresh.
52 + */
53 + function withFreshNonce(callback) {
54 + if (typeof mxchatChat === 'undefined') {
8 55 if (callback) callback();
9 56 return;
10 57 }
11 - nonceRefreshed = true;
12 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
13 - if (res && res.success && res.data && res.data.nonce) {
14 - mxchatChat.nonce = res.data.nonce;
58 + var now = Date.now();
59 + if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 + mxchatChat.nonce = cachedFreshNonce;
61 + if (callback) callback();
62 + return;
63 + }
64 + if (callback) nonceRefreshCallbacks.push(callback);
65 + if (nonceRefreshState === 'pending') return;
66 + nonceRefreshState = 'pending';
67 +
68 + var resolved = function (nonce) {
69 + if (nonce) {
70 + cachedFreshNonce = nonce;
71 + cachedFreshNonceFetchedAt = Date.now();
72 + mxchatChat.nonce = nonce;
15 73 }
16 - if (callback) callback();
17 - });
74 + nonceRefreshState = 'done';
75 + var pending = nonceRefreshCallbacks;
76 + nonceRefreshCallbacks = [];
77 + pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78 + };
79 +
80 + fetchFreshNonceFromRest()
81 + .then(resolved)
82 + .catch(function () {
83 + // Fallback to the legacy admin-ajax refresh path (issued with the
84 + // old action `mxchat_chat_nonce`; the server still accepts both
85 + // during the compat window).
86 + if (mxchatChat.ajax_url) {
87 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88 + .done(function (res) {
89 + if (res && res.success && res.data && res.data.nonce) {
90 + resolved(res.data.nonce);
91 + return;
92 + }
93 + resolved(null);
94 + })
95 + .fail(function () { resolved(null); });
96 + } else {
97 + resolved(null);
98 + }
99 + });
18 100 }
19 101
102 + // Backwards-compat alias — every existing caller in this file (and any
103 + // out-of-tree consumer that hit this internal API) keeps working unchanged.
104 + function refreshNonceIfNeeded(callback) {
105 + return withFreshNonce(callback);
106 + }
107 +
20 108 // ====================================
21 109 // MULTI-INSTANCE MANAGEMENT SYSTEM
22 110 // ====================================
23 111
@@ -622,8 +710,13 @@
622 710 sessionId = generateSessionId();
623 711 MxChatInstances.setChatSession(botId, sessionId);
624 712 }
625 713
714 + // Wait for the page-cache nonce refresh to complete before firing the
715 + // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
716 + // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
717 + // callback guarantees we read the fresh value. See plan-c5457f.
718 + refreshNonceIfNeeded(function() {
626 719 // Prepare AJAX data
627 720 const ajaxData = {
628 721 action: 'mxchat_handle_chat_request',
629 722 message: message,
@@ -634,14 +727,14 @@
634 727 bot_id: botId,
635 728 // Pass session start timestamp so AI context matches what user sees
636 729 session_start_timestamp: instance.sessionStartTimestamp || 0
637 730 };
638 -
731 +
639 732 // Add page context if available
640 733 if (pageContext) {
641 734 ajaxData.page_context = JSON.stringify(pageContext);
642 735 }
643 -
736 +
644 737 // CHECK FOR VISION FLAGS AND ADD THEM
645 738 if (window.mxchatVisionProcessed) {
646 739 ajaxData.vision_processed = true;
647 740 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -650,9 +743,9 @@
650 743 window.mxchatVisionProcessed = false;
651 744 window.mxchatOriginalMessage = null;
652 745 window.mxchatVisionImagesCount = 0;
653 746 }
654 -
747 +
655 748 $.ajax({
656 749 url: mxchatChat.ajax_url,
657 750 type: 'POST',
658 751 dataType: 'json',
@@ -841,8 +934,9 @@
841 934
842 935 replaceLastMessage("bot", errorMessage, '', [], botId);
843 936 }
844 937 });
938 + }); // refreshNonceIfNeeded
845 939 }
846 940
847 941 function callMxChatStream(message, callback, botId) {
848 942 botId = botId || getMxChatBotId();
@@ -871,8 +965,11 @@
871 965 streamSessionId = generateSessionId();
872 966 MxChatInstances.setChatSession(botId, streamSessionId);
873 967 }
874 968
969 + // Wait for the page-cache nonce refresh before constructing formData (which
970 + // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
971 + refreshNonceIfNeeded(function() {
875 972 const formData = new FormData();
876 973 formData.append('action', 'mxchat_stream_chat');
877 974 formData.append('message', message);
878 975 formData.append('session_id', streamSessionId);
@@ -976,8 +1073,16 @@
976 1073
977 1074 // Re-enable chat input when stream ends with content
978 1075 enableChatInput(botId);
979 1076
1077 + // Scroll the user's last message to the top now that the
1078 + // bot's full reply has rendered (gives max reading room).
1079 + var $chatBoxDone = getElement(botId, 'chat-box');
1080 + var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1081 + if ($lastUserMsgDone.length) {
1082 + scrollElementToTop($lastUserMsgDone, botId);
1083 + }
1084 +
980 1085 if (callback) {
981 1086 callback(accumulatedContent);
982 1087 }
983 1088 return;
@@ -1000,8 +1105,16 @@
1000 1105
1001 1106 // Re-enable chat input after streaming completes
1002 1107 enableChatInput(botId);
1003 1108
1109 + // Scroll the user's last message to the top now
1110 + // that the bot's full reply has rendered.
1111 + var $chatBoxStreamDone = getElement(botId, 'chat-box');
1112 + var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1113 + if ($lastUserMsgStreamDone.length) {
1114 + scrollElementToTop($lastUserMsgStreamDone, botId);
1115 + }
1116 +
1004 1117 if (callback) {
1005 1118 callback(accumulatedContent);
1006 1119 }
1007 1120 return;
@@ -1080,8 +1193,9 @@
1080 1193 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1081 1194 callMxChat(message, callback, botId);
1082 1195 }
1083 1196 });
1197 + }); // refreshNonceIfNeeded
1084 1198 }
1085 1199
1086 1200 // Helper function to handle non-streaming responses
1087 1201 function handleNonStreamResponse(data, callback, botId) {
@@ -1310,9 +1424,229 @@
1310 1424 sendMessage(botId);
1311 1425 }
1312 1426 });
1313 1427
1314 -
1428 +// Builds the list of overflow-menu items for a given bot.
1429 +// Adding a future item is one push to this array — do NOT hardcode "only download."
1430 +function mxchatGetHeaderMenuItems(botId) {
1431 + var items = [];
1432 + var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1433 +
1434 + // The `print_button_*` keys still gate this item for back-compat with
1435 + // existing user options. The action is now a transcript download, not print.
1436 + if (settings.print_button_enabled === 'on') {
1437 + items.push({
1438 + id: 'download-transcript',
1439 + label: settings.print_button_label || 'Download Transcript',
1440 + icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1441 + action: function() {
1442 + mxchatDownloadTranscript(botId);
1443 + }
1444 + });
1445 + }
1446 +
1447 + return items;
1448 +}
1449 +
1450 +// Builds a clean markdown transcript of the current conversation and triggers
1451 +// a file download. Used by the "Download Transcript" menu item.
1452 +function mxchatDownloadTranscript(botId) {
1453 + var $chatBox = getElement(botId, 'chat-box');
1454 + if (!$chatBox || !$chatBox.length) return;
1455 +
1456 + var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1457 + var headerTitle = settings.print_header_title || 'Chat transcript';
1458 + var now = new Date();
1459 + var stamp = now.toLocaleString();
1460 +
1461 + var lines = [];
1462 + lines.push('# ' + headerTitle);
1463 + lines.push('');
1464 + lines.push('Exported: ' + stamp);
1465 + lines.push('');
1466 + lines.push('---');
1467 + lines.push('');
1468 +
1469 + $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1470 + var $msg = $(this);
1471 + // Skip thinking placeholders and any in-flight temporary messages.
1472 + if ($msg.find('.thinking-dots').length) return;
1473 + if ($msg.hasClass('temporary-message')) return;
1474 +
1475 + var sender;
1476 + if ($msg.hasClass('user-message')) sender = 'User';
1477 + else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1478 + else sender = 'AI Agent';
1479 +
1480 + // Strip interactive UI from the cloned message so we get the conversation text.
1481 + var $clone = $msg.clone();
1482 + $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1483 + var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1484 + if (!text) return;
1485 +
1486 + lines.push('**' + sender + '**');
1487 + lines.push('');
1488 + lines.push(text);
1489 + lines.push('');
1490 + });
1491 +
1492 + var content = lines.join('\n');
1493 + var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1494 + var fname = 'mxchat-transcript-' + iso + '.md';
1495 + var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1496 + var url = URL.createObjectURL(blob);
1497 + var a = document.createElement('a');
1498 + a.href = url;
1499 + a.download = fname;
1500 + a.style.display = 'none';
1501 + document.body.appendChild(a);
1502 + a.click();
1503 + setTimeout(function() {
1504 + if (a.parentNode) a.parentNode.removeChild(a);
1505 + URL.revokeObjectURL(url);
1506 + }, 100);
1507 +}
1508 +
1509 +// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1510 +// on the menu wrap, so the dropdown matches whatever paints the bubble —
1511 +// saved options, AI theme CSS, or the mxchat-theme add-on.
1512 +function mxchatSyncMenuColors(botId, $wrap) {
1513 + if (!$wrap || !$wrap.length) return;
1514 + var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1515 + if (!$bot.length) return;
1516 + var cs = window.getComputedStyle($bot[0]);
1517 + if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1518 + $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1519 + }
1520 + // Bot text color usually lives on a child div, not .bot-message itself.
1521 + var $textChild = $bot.find('[style*="color"]').first();
1522 + var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1523 + if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1524 +}
1525 +
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 +
1532 + var $trigger = $wrap.find('.mxchat-menu-trigger');
1533 + var $menu = $wrap.find('.mxchat-header-menu');
1534 + var items = mxchatGetHeaderMenuItems(botId);
1535 +
1536 + // Initial color sync — covers normal page load.
1537 + mxchatSyncMenuColors(botId, $wrap);
1538 +
1539 + if (!items.length) {
1540 + $trigger.hide();
1541 + $menu.hide();
1542 + $wrap.data('mxchatMenuReady', true);
1543 + return;
1544 + }
1545 +
1546 + // Build the menu items.
1547 + $menu.empty();
1548 + items.forEach(function(item, idx) {
1549 + var $btn = $('<button>', {
1550 + type: 'button',
1551 + 'class': 'mxchat-menu-item',
1552 + 'role': 'menuitem',
1553 + 'tabindex': '-1',
1554 + 'data-menu-id': item.id,
1555 + html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1556 + '<span class="mxchat-menu-item-label"></span>'
1557 + });
1558 + $btn.find('.mxchat-menu-item-label').text(item.label);
1559 + $btn.on('click', function(e) {
1560 + e.preventDefault();
1561 + e.stopPropagation();
1562 + closeMenu();
1563 + try { item.action(); } catch (err) { /* no-op */ }
1564 + });
1565 + $menu.append($btn);
1566 + });
1567 +
1568 + function openMenu() {
1569 + // Re-sync each open in case the active theme changed since init.
1570 + mxchatSyncMenuColors(botId, $wrap);
1571 + $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1572 + $trigger.attr('aria-expanded', 'true');
1573 + // Focus the first item for keyboard users
1574 + setTimeout(function() {
1575 + $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1576 + }, 0);
1577 + }
1578 + function closeMenu(returnFocus) {
1579 + $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1580 + $trigger.attr('aria-expanded', 'false');
1581 + $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1582 + if (returnFocus) $trigger.trigger('focus');
1583 + }
1584 +
1585 + // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1586 + // click-to-collapse handler does not fire.
1587 + $trigger.on('click', function(e) {
1588 + e.preventDefault();
1589 + e.stopPropagation();
1590 + if ($menu.hasClass('is-open')) closeMenu();
1591 + else openMenu();
1592 + });
1593 +
1594 + // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1595 + $menu.on('click', function(e) {
1596 + e.stopPropagation();
1597 + });
1598 +
1599 + // Outside click closes the menu.
1600 + $(document).on('click.mxchatMenu-' + botId, function(e) {
1601 + if (!$menu.hasClass('is-open')) return;
1602 + if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
1603 + closeMenu();
1604 + });
1605 +
1606 + // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
1607 + $menu.on('keydown', '.mxchat-menu-item', function(e) {
1608 + var $items = $menu.find('.mxchat-menu-item');
1609 + var idx = $items.index(this);
1610 + if (e.key === 'Escape') {
1611 + e.preventDefault();
1612 + closeMenu(true);
1613 + } else if (e.key === 'ArrowDown') {
1614 + e.preventDefault();
1615 + var $next = $items.eq((idx + 1) % $items.length);
1616 + $items.attr('tabindex', '-1');
1617 + $next.attr('tabindex', '0').trigger('focus');
1618 + } else if (e.key === 'ArrowUp') {
1619 + e.preventDefault();
1620 + var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
1621 + $items.attr('tabindex', '-1');
1622 + $prev.attr('tabindex', '0').trigger('focus');
1623 + } else if (e.key === 'Enter' || e.key === ' ') {
1624 + e.preventDefault();
1625 + $(this).trigger('click');
1626 + }
1627 + });
1628 + $trigger.on('keydown', function(e) {
1629 + if (e.key === 'Escape' && $menu.hasClass('is-open')) {
1630 + e.preventDefault();
1631 + closeMenu(true);
1632 + } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
1633 + e.preventDefault();
1634 + openMenu();
1635 + }
1636 + });
1637 +
1638 + $wrap.data('mxchatMenuReady', true);
1639 +}
1640 +
1641 +// Initialize header menus for every rendered widget on DOM ready.
1642 +$(function() {
1643 + $('.mxchat-header-menu-wrap').each(function() {
1644 + var botId = $(this).data('bot-id');
1645 + if (botId) mxchatInitHeaderMenu(botId);
1646 + });
1647 +});
1648 +
1315 1649 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1316 1650 try {
1317 1651 // Determine styles based on sender type
1318 1652 let messageClass, bgColor, fontColor;
@@ -1404,8 +1738,12 @@
1404 1738 if (lastUserMessage.length) {
1405 1739 scrollElementToTop(lastUserMessage, botId);
1406 1740 }
1407 1741 }
1742 +
1743 + if ((sender === "bot" || sender === "agent") && !isTemporary) {
1744 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
1745 + }
1408 1746 });
1409 1747
1410 1748 if (messageText.id) {
1411 1749 var instance = MxChatInstances.get(botId);
@@ -1552,8 +1890,12 @@
1552 1890 }
1553 1891
1554 1892 // Re-enable chat input after response is displayed
1555 1893 enableChatInput(botId);
1894 +
1895 + if (sender === "bot" || sender === "agent") {
1896 + if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
1897 + }
1556 1898 } else {
1557 1899 appendMessage(sender, responseText, responseHtml, images, false, botId);
1558 1900 // Re-enable chat input after response is displayed
1559 1901 enableChatInput(botId);
@@ -2006,13 +2348,14 @@
2006 2348 requestAnimationFrame(smoothScroll);
2007 2349 }
2008 2350 }
2009 2351
2010 - function scrollElementToTop(element, botId) {
2352 + function scrollElementToTop(element, botId, topOffset) {
2011 2353 botId = botId || 'default';
2354 + topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2012 2355 var chatBox = getElement(botId, 'chat-box');
2013 2356 var elementTop = element.position().top + chatBox.scrollTop();
2014 - chatBox.animate({ scrollTop: elementTop }, 500);
2357 + chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
2015 2358 }
2016 2359
2017 2360 function showChatWidget(botId) {
2018 2361 botId = botId || 'default';
@@ -2644,9 +2987,14 @@
2644 2987 collapseQuickQuestions(botId);
2645 2988 });
2646 2989
2647 2990 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2648 - $(document).on('click', '.floating-chatbot-button', function() {
2991 + // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
2992 + $(document).on('click keydown', '.floating-chatbot-button', function(e) {
2993 + if (e.type === 'keydown') {
2994 + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
2995 + e.preventDefault();
2996 + }
2649 2997 var botId = getBotIdFromElement(this);
2650 2998 var $chatbot = getElement(botId, 'floating-chatbot');
2651 2999 var $badge = getElement(botId, 'chat-notification-badge');
2652 3000 var $preChat = getElement(botId, 'pre-chat-message');
@@ -2651,10 +2999,11 @@
2651 2999 var $badge = getElement(botId, 'chat-notification-badge');
2652 3000 var $preChat = getElement(botId, 'pre-chat-message');
2653 3001
2654 3002 if ($chatbot.hasClass('hidden')) {
2655 - $chatbot.removeClass('hidden').addClass('visible');
2656 - $(this).addClass('hidden');
3003 + $chatbot.removeClass('hidden').addClass('visible')
3004 + .attr('aria-modal', 'true').attr('role', 'dialog');
3005 + $(this).addClass('hidden').attr('aria-expanded', 'true');
2657 3006 $badge.hide(); // Hide notification when opening chat
2658 3007 disableScroll();
2659 3008 $preChat.fadeOut(250);
2660 3009
@@ -2674,24 +3023,55 @@
2674 3023 // No email collection — still route through showChatContainerForBot
2675 3024 // so the loader is shown while chat history loads
2676 3025 showChatContainerForBot(botId);
2677 3026 }
3027 +
3028 + // Move keyboard focus into the message input after the open transition.
3029 + setTimeout(function() {
3030 + var chatInput = getElementDOM(botId, 'chat-input');
3031 + if (chatInput && !chatInput.disabled) {
3032 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3033 + }
3034 + }, 300);
2678 3035 } else {
2679 - $chatbot.removeClass('visible').addClass('hidden');
2680 - $(this).removeClass('hidden');
3036 + $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3037 + $(this).removeClass('hidden').attr('aria-expanded', 'false');
2681 3038 enableScroll();
2682 3039 checkPreChatDismissal(botId);
2683 3040 }
2684 3041 });
2685 3042
2686 - // Allow clicking anywhere on the title bar to close the chatbot
3043 + // Allow clicking anywhere on the title bar to close the chatbot.
3044 + // Returns keyboard focus to the launcher so keyboard users don't get
3045 + // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3046 + // heuristic-based so mouse-triggered close won't show a focus ring.
2687 3047 $(document).on('click', '.chatbot-top-bar', function() {
2688 3048 var botId = getBotIdFromElement(this);
2689 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2690 - getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3049 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3050 + var $launcher = getElement(botId, 'floating-chatbot-button');
3051 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2691 3052 enableScroll();
3053 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
2692 3054 });
2693 3055
3056 + // Global Escape-key handler — closes any visible chat widget and
3057 + // returns focus to its launcher. Standard modal-dismissal pattern;
3058 + // pairs with aria-modal="true" set on the widget when it opens.
3059 + $(document).on('keydown', function(e) {
3060 + if (e.key !== 'Escape' && e.key !== 'Esc') return;
3061 + var $visible = $('.floating-chatbot.visible');
3062 + if (!$visible.length) return;
3063 + e.preventDefault();
3064 + $visible.each(function() {
3065 + var botId = getBotIdFromElement(this);
3066 + $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3067 + var $launcher = getElement(botId, 'floating-chatbot-button');
3068 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3069 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3070 + });
3071 + enableScroll();
3072 + });
3073 +
2694 3074 $(document).on('click', '.close-pre-chat-message', function(e) {
2695 3075 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
2696 3076 var botId = getBotIdFromElement(this);
2697 3077 handlePreChatDismissal(botId);
@@ -2736,8 +3116,10 @@
2736 3116 const sendBtn = document.getElementById('send-button');
2737 3117 const originalBtnContent = uploadBtn.innerHTML;
2738 3118
2739 3119 try {
3120 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3121 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2740 3122 const formData = new FormData();
2741 3123 formData.append('action', 'mxchat_upload_pdf');
2742 3124 formData.append('pdf_file', file);
2743 3125 formData.append('session_id', sessionId);
@@ -2801,8 +3183,10 @@
2801 3183 const sendBtn = document.getElementById('send-button');
2802 3184 const originalBtnContent = uploadBtn.innerHTML;
2803 3185
2804 3186 try {
3187 + // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3188 + await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
2805 3189 const formData = new FormData();
2806 3190 formData.append('action', 'mxchat_upload_word');
2807 3191 formData.append('word_file', file);
2808 3192 formData.append('session_id', sessionId);
@@ -3552,6 +3936,299 @@
3552 3936 }, 2000);
3553 3937 });
3554 3938 }
3555 3939 }
3940 +});
3941 +
3942 +// ============================================================================
3943 +// SATISFACTION RATING (v3.2.6)
3944 +// ============================================================================
3945 +// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
3946 +// 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).
3949 +jQuery(function($) {
3950 + if (typeof mxchatChat === 'undefined') return;
3951 + if (mxchatChat.satisfaction_rating_enabled === false || mxchatChat.satisfaction_rating_enabled === 'off') return;
3952 +
3953 + // wp_localize_script stringifies ints, so accept both number and numeric string.
3954 + var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
3955 + var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
3956 + if (!isFinite(idleSeconds)) idleSeconds = 60;
3957 + if (idleSeconds < 5) idleSeconds = 5;
3958 + if (idleSeconds > 600) idleSeconds = 600;
3959 + var IDLE_MS = idleSeconds * 1000;
3960 + var MIN_BOT_REPLIES = 2;
3961 + var ratingState = {};
3962 +
3963 + function getState(botId) {
3964 + if (!ratingState[botId]) {
3965 + ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
3966 + }
3967 + return ratingState[botId];
3968 + }
3969 +
3970 + function getSessionId(botId) {
3971 + if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
3972 + return MxChatInstances.getChatSession(botId);
3973 + }
3974 + return null;
3975 + }
3976 +
3977 + function isAlreadyRated(sessionId) {
3978 + if (!sessionId) return false;
3979 + try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
3980 + }
3981 +
3982 + function markRated(sessionId) {
3983 + if (!sessionId) return;
3984 + try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
3985 + }
3986 +
3987 + function esc(s) {
3988 + return String(s == null ? '' : s)
3989 + .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
3990 + .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
3991 + }
3992 +
3993 + // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
3994 + function ratingSkipInlineColors(botId) {
3995 + if (mxchatChat.skip_inline_colors) return true;
3996 + var botAssignments = mxchatChat.bot_theme_assignments || {};
3997 + return botAssignments.hasOwnProperty(botId);
3998 + }
3999 +
4000 + function botBubbleStyleAttr(botId) {
4001 + if (ratingSkipInlineColors(botId)) return '';
4002 + var bg = mxchatChat.bot_message_bg_color;
4003 + var fg = mxchatChat.bot_message_font_color;
4004 + if (!bg && !fg) return '';
4005 + return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4006 + }
4007 +
4008 + // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4009 + // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4010 + // and paints the filled "Send" pill so it fills with the bot font color and
4011 + // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4012 + // We paint the submit button DIRECTLY (inline longhand) rather than relying
4013 + // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4014 + // unreliably inside a descendant's `background`, so a bubble-level var would
4015 + // silently fall back to the literal (white-block bug all over again). Inline
4016 + // longhand always wins. Same transparent-guard as the menu so we never paint
4017 + // a see-through value — in that case the CSS literal fallbacks keep it legible.
4018 + function syncRatingBubbleColors(botId) {
4019 + var $chatBox = getChatBoxByBotId(botId);
4020 + if (!$chatBox || !$chatBox.length) return;
4021 + var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4022 + if (!bubbleEl) return;
4023 + var cs = window.getComputedStyle(bubbleEl);
4024 + var fg = cs.color;
4025 + var bg = cs.backgroundColor;
4026 + var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4027 + var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4028 + // Expose on the bubble too, for any inheriting styles / future use.
4029 + if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4030 + if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4031 + // Paint the Send pill directly — the part that actually fixes the bug.
4032 + var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4033 + if (submitEl) {
4034 + if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4035 + if (hasBg) submitEl.style.color = bg; // label = bubble background
4036 + }
4037 + }
4038 +
4039 + function copy(key) {
4040 + var c = mxchatChat.satisfaction_rating_copy || {};
4041 + var d = {
4042 + question: 'Was this helpful?',
4043 + helpful: 'Helpful',
4044 + not_helpful: 'Not helpful',
4045 + dismiss: 'Dismiss',
4046 + thanks: 'Thanks! Anything we should improve? (optional)',
4047 + placeholder: 'Tell us what could be better…',
4048 + send: 'Send',
4049 + skip: 'Skip',
4050 + saved: 'Thanks for the feedback.'
4051 + };
4052 + return c[key] || d[key];
4053 + }
4054 +
4055 + function thumbUpSvg() {
4056 + return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
4057 + }
4058 + function thumbDownSvg() {
4059 + return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
4060 + }
4061 +
4062 + function buildPromptHtml(botId) {
4063 + var styleAttr = botBubbleStyleAttr(botId);
4064 + return ''
4065 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4066 + + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4067 + + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4068 + + '<div class="mxchat-rating-actions">'
4069 + + '<span class="mxchat-rating-buttons">'
4070 + + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4071 + + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4072 + + '</span>'
4073 + + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4074 + + '</div>'
4075 + + '</div>'
4076 + + '</div>';
4077 + }
4078 +
4079 + function buildFeedbackHtml(botId, rating) {
4080 + var styleAttr = botBubbleStyleAttr(botId);
4081 + return ''
4082 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4083 + + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4084 + + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4085 + + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4086 + + '<div class="mxchat-rating-feedback-actions">'
4087 + + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4088 + + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4089 + + '</div>'
4090 + + '</div>'
4091 + + '</div>';
4092 + }
4093 +
4094 + function buildSavedHtml(botId) {
4095 + var styleAttr = botBubbleStyleAttr(botId);
4096 + return ''
4097 + + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4098 + + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4099 + + '</div>';
4100 + }
4101 +
4102 + function getChatBoxByBotId(botId) {
4103 + var $byId = $('#chat-box-' + botId);
4104 + if ($byId.length) return $byId.first();
4105 + return $('.chat-box').first();
4106 + }
4107 +
4108 + function scrollChatBoxToBottom($chatBox) {
4109 + if (!$chatBox || !$chatBox.length) return;
4110 + $chatBox.scrollTop($chatBox[0].scrollHeight);
4111 + }
4112 +
4113 + function showPrompt(botId) {
4114 + var s = getState(botId);
4115 + if (s.promptShown || s.dismissed) return;
4116 + var sessionId = getSessionId(botId);
4117 + if (!sessionId) return;
4118 + if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4119 + var $chatBox = getChatBoxByBotId(botId);
4120 + if (!$chatBox.length) return;
4121 + if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4122 + $chatBox.append(buildPromptHtml(botId));
4123 + syncRatingBubbleColors(botId);
4124 + s.promptShown = true;
4125 + scrollChatBoxToBottom($chatBox);
4126 + }
4127 +
4128 + function submitRating(botId, rating, feedback) {
4129 + var sessionId = getSessionId(botId);
4130 + if (!sessionId) return;
4131 + $.post(mxchatChat.ajax_url, {
4132 + action: 'mxchat_save_rating',
4133 + session_id: sessionId,
4134 + bot_id: botId,
4135 + rating: rating,
4136 + feedback: feedback || ''
4137 + });
4138 + markRated(sessionId);
4139 + }
4140 +
4141 + function onBotReply(botId) {
4142 + var s = getState(botId);
4143 + s.botReplies += 1;
4144 + if (s.promptShown || s.dismissed) return;
4145 + var sessionId = getSessionId(botId);
4146 + if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4147 + if (s.botReplies < MIN_BOT_REPLIES) return;
4148 + if (s.idleTimer) clearTimeout(s.idleTimer);
4149 + s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4150 + }
4151 +
4152 + function onUserMessage(botId) {
4153 + var s = getState(botId);
4154 + if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4155 + }
4156 +
4157 + function botIdFromChatBox(el) {
4158 + var id = el && el.id ? el.id : '';
4159 + return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4160 + }
4161 +
4162 + function setupObserver(chatBox) {
4163 + var botId = botIdFromChatBox(chatBox);
4164 + try {
4165 + var observer = new MutationObserver(function(mutations) {
4166 + mutations.forEach(function(m) {
4167 + for (var i = 0; i < m.addedNodes.length; i++) {
4168 + var node = m.addedNodes[i];
4169 + if (!node || node.nodeType !== 1) continue;
4170 + var $n = $(node);
4171 + if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4172 + if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
4173 + else if ($n.hasClass('user-message')) onUserMessage(botId);
4174 + }
4175 + });
4176 + });
4177 + observer.observe(chatBox, { childList: true });
4178 + } catch (e) { /* noop */ }
4179 + }
4180 +
4181 + $('.chat-box').each(function() { setupObserver(this); });
4182 +
4183 + $(document).on('click', '.mxchat-rating-btn', function(e) {
4184 + e.preventDefault();
4185 + var $btn = $(this);
4186 + var $prompt = $btn.closest('.mxchat-rating-prompt');
4187 + var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4188 + var botId = $prompt.data('bot-id') || 'default';
4189 + var rating = parseInt($btn.attr('data-rating'), 10);
4190 + if (rating !== 1 && rating !== -1) return;
4191 + submitRating(botId, rating, '');
4192 + ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4193 + syncRatingBubbleColors(botId);
4194 + scrollChatBoxToBottom(getChatBoxByBotId(botId));
4195 + });
4196 +
4197 + $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4198 + e.preventDefault();
4199 + var $prompt = $(this).closest('.mxchat-rating-prompt');
4200 + var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4201 + var botId = $prompt.data('bot-id') || 'default';
4202 + var s = getState(botId);
4203 + s.dismissed = true;
4204 + markRated(getSessionId(botId));
4205 + ($wrap.length ? $wrap : $prompt).remove();
4206 + });
4207 +
4208 + function closeFeedback($fb) {
4209 + var botId = $fb.data('bot-id') || 'default';
4210 + var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4211 + ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4212 + syncRatingBubbleColors(botId);
4213 + scrollChatBoxToBottom(getChatBoxByBotId(botId));
4214 + }
4215 +
4216 + $(document).on('click', '.mxchat-rating-skip', function(e) {
4217 + e.preventDefault();
4218 + closeFeedback($(this).closest('.mxchat-rating-feedback'));
4219 + });
4220 +
4221 + $(document).on('click', '.mxchat-rating-submit', function(e) {
4222 + e.preventDefault();
4223 + var $fb = $(this).closest('.mxchat-rating-feedback');
4224 + var botId = $fb.data('bot-id') || 'default';
4225 + var rating = parseInt($fb.attr('data-rating'), 10);
4226 + if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4227 + var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4228 + if (text !== '') {
4229 + submitRating(botId, rating, text);
4230 + }
4231 + closeFeedback($fb);
4232 + });
3556 4233 });
3557 4234