PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
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 +211 -22 3.2.11trunk View file →
@@ -284,9 +284,20 @@
284 284 var newSessionId = generateSessionId();
285 285 this.setChatSession(botId, newSessionId);
286 286 var $chatBox = getElement(botId, 'chat-box');
287 287 if ($chatBox.length) {
288 - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
288 + // Keep the greeting, drop everything else. Identify the greeting
289 + // by its marker class, NOT by position (plan a1a79b): after a
290 + // chat-persistence restore the first .bot-message is a real
291 + // reply, so ":not(:first)" left a stale answer sitting at the
292 + // top of an otherwise empty box. The positional fallback only
293 + // runs when the marker is absent — a page served from HTML cache
294 + // that predates this release — and behaves exactly as before.
295 + if ($chatBox.find('.mxchat-intro-message').length) {
296 + $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
297 + } else {
298 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
299 + }
289 300 }
290 301 if (this.instances[botId]) {
291 302 this.instances[botId].chatHistoryLoaded = false;
292 303 this.instances[botId].processedMessageIds = new Set();
@@ -343,8 +354,20 @@
343 354 var id = $floating.attr('id') || '';
344 355 var match = id.match(/floating-chatbot-(.+)/);
345 356 if (match) return match[1];
346 357 }
358 + // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
359 + // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
360 + // .close-pre-chat-message button, which carries only a class and no id — miss both
361 + // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
362 + // and read the suffix. (closest() includes the element itself, so a click directly on
363 + // #pre-chat-message-{bot_id} resolves here too.)
364 + var $preChat = $(element).closest('[id^="pre-chat-message-"]');
365 + if ($preChat.length) {
366 + var preId = $preChat.attr('id') || '';
367 + var preMatch = preId.match(/^pre-chat-message-(.+)$/);
368 + if (preMatch) return preMatch[1];
369 + }
347 370 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
348 371 var elementId = $(element).attr('id') || '';
349 372 if (elementId) {
350 373 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -384,9 +407,27 @@
384 407 if (parts.length == 2) return parts.pop().split(";").shift();
385 408 }
386 409
387 410 function generateSessionId() {
388 - return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
411 + // Session IDs function as the de-facto bearer token for an anonymous
412 + // chat, so generate them with a CSPRNG when available. Math.random is a
413 + // legacy fallback for ancient/sandboxed environments that lack
414 + // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
415 + // code pattern-matches on it). (plan-0c17b5)
416 + var rand;
417 + try {
418 + if (window.crypto && window.crypto.getRandomValues) {
419 + var buf = new Uint8Array(16); // 128 bits
420 + window.crypto.getRandomValues(buf);
421 + rand = Array.prototype.map.call(buf, function (b) {
422 + return ('0' + b.toString(16)).slice(-2);
423 + }).join('');
424 + }
425 + } catch (e) {}
426 + if (!rand) {
427 + rand = Math.random().toString(36).substr(2, 9); // legacy fallback
428 + }
429 + return 'mxchat_chat_' + rand;
389 430 }
390 431
391 432 // Legacy function - now delegates to instance manager
392 433 function getChatSession(botId) {
@@ -589,8 +630,26 @@
589 630 sendButton.style.pointerEvents = 'none';
590 631 }
591 632 }
592 633
634 +// Whether the input may grab focus after a completed reply (plan 03799f).
635 +// On coarse-pointer devices focusing a text input summons the on-screen
636 +// keyboard over the answer the visitor is trying to read, so 'auto' (the
637 +// default) focuses only on fine-pointer devices. The site-wide
638 +// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
639 +// NOT used on widget open (:~3424) — that focus is a deliberate act and is
640 +// what makes the widget keyboard-accessible.
641 +function mxchatShouldAutofocusAfterReply() {
642 + var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
643 + if (pref === 'on') return true;
644 + if (pref === 'off') return false;
645 + try {
646 + return !window.matchMedia('(pointer: coarse)').matches;
647 + } catch (err) {
648 + return true;
649 + }
650 +}
651 +
593 652 function enableChatInput(botId) {
594 653 botId = botId || 'default';
595 654 var chatInput = getElementDOM(botId, 'chat-input');
596 655 var sendButton = getElementDOM(botId, 'send-button');
@@ -596,9 +655,11 @@
596 655 var sendButton = getElementDOM(botId, 'send-button');
597 656 if (chatInput) {
598 657 chatInput.disabled = false;
599 658 chatInput.style.opacity = '1';
600 - chatInput.focus();
659 + if (mxchatShouldAutofocusAfterReply()) {
660 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 + }
601 662 }
602 663 if (sendButton) {
603 664 sendButton.disabled = false;
604 665 sendButton.style.opacity = '1';
@@ -728,14 +789,16 @@
728 789 }
729 790 appendThinkingMessage(botId);
730 791 scrollToBottom(botId);
731 792
732 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
793 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
733 794
734 795 // Check if streaming is enabled AND supported for this model
735 796 if (shouldUseStreaming(currentModel)) {
736 797 callMxChatStream(message, function(response) {
737 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
798 + // Content is final: releasing aria-busy lets the live region
799 + // announce the completed reply once (plan 67f126).
800 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
738 801 }, botId);
739 802 } else {
740 803 callMxChat(message, function(response) {
741 804 replaceLastMessage("bot", response, '', [], botId);
@@ -768,14 +831,15 @@
768 831 }
769 832 appendThinkingMessage(botId);
770 833 scrollToBottom(botId);
771 834
772 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
835 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
773 836
774 837 // Check if streaming is enabled AND supported for this model
775 838 if (shouldUseStreaming(currentModel)) {
776 839 callMxChatStream(message, function(response) {
777 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
840 + // Final content — release aria-busy so the reply announces once (67f126).
841 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
778 842 }, botId);
779 843 } else {
780 844 callMxChat(message, function(response) {
781 845 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -945,12 +1009,13 @@
945 1009 // Re-send the original message with the new session (user message is already displayed)
946 1010 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
947 1011 if (originalMessage) {
948 1012 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
949 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1013 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
950 1014 if (shouldUseStreaming(currentModel)) {
951 1015 callMxChatStream(originalMessage, function(response) {
952 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1016 + // Final content — release aria-busy so the reply announces once (67f126).
1017 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
953 1018 }, botId);
954 1019 } else {
955 1020 callMxChat(originalMessage, function(response) {
956 1021 replaceLastMessage("bot", response, '', [], botId);
@@ -1098,9 +1163,9 @@
1098 1163
1099 1164 // Store the message in case we need to retry after session reset
1100 1165 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1101 1166
1102 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1167 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
1103 1168 if (!isStreamingSupported(currentModel)) {
1104 1169 callMxChat(message, callback, botId);
1105 1170 return;
1106 1171 }
@@ -1153,8 +1218,12 @@
1153 1218
1154 1219 let accumulatedContent = '';
1155 1220 let testingDataReceived = false;
1156 1221 let streamingStarted = false;
1222 + // Server-pushed html to append as its OWN bot bubble once the stream
1223 + // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1224 + // deferred to [DONE] so the embed always lands BELOW the streamed text.
1225 + let pendingAppendHtml = '';
1157 1226
1158 1227 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1159 1228 // The Stop control (send button swapped in place) aborts both the read
1160 1229 // loop and the underlying request.
@@ -1268,8 +1337,17 @@
1268 1337
1269 1338 // Re-enable chat input after streaming completes
1270 1339 enableChatInput(botId);
1271 1340
1341 + // Render any server-pushed appendix html (e.g. the
1342 + // YouTube embed) as its own bot bubble below the
1343 + // streamed text — mirrors how it is saved in the
1344 + // transcript, so history replays identically.
1345 + if (pendingAppendHtml) {
1346 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1347 + pendingAppendHtml = '';
1348 + }
1349 +
1272 1350 // Scroll the user's last message to the top now
1273 1351 // that the bot's full reply has rendered.
1274 1352 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1275 1353 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1303,8 +1381,21 @@
1303 1381 streamingStarted = true;
1304 1382 accumulatedContent += json.content;
1305 1383 updateStreamingMessage(accumulatedContent, botId);
1306 1384 }
1385 + // Stash appendix html (e.g. video embed) for [DONE]
1386 + else if (json.append_html) {
1387 + pendingAppendHtml = json.append_html;
1388 + }
1389 + // Server-side final pass changed the assembled text
1390 + // (ffef6f: dead-link stripping) — swap the rendered
1391 + // bubble for the validated version. Arrives at most
1392 + // once, just before [DONE].
1393 + else if (json.replace_content) {
1394 + streamingStarted = true;
1395 + accumulatedContent = json.replace_content;
1396 + updateStreamingMessage(accumulatedContent, botId);
1397 + }
1307 1398 // Handle complete response in stream (fallback response)
1308 1399 else if (json.text || json.message || json.html) {
1309 1400 handleNonStreamResponse(json, callback, botId);
1310 1401 return;
@@ -1406,9 +1497,9 @@
1406 1497 // Re-send the original message with the new session (user message is already displayed)
1407 1498 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1408 1499 if (originalMessage) {
1409 1500 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1410 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1501 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1411 1502 if (shouldUseStreaming(currentModel)) {
1412 1503 callMxChatStream(originalMessage, callback, botId);
1413 1504 } else {
1414 1505 callMxChat(originalMessage, callback, botId);
@@ -1541,8 +1632,15 @@
1541 1632 var $chatBox = getElement(botId, 'chat-box');
1542 1633 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1543 1634
1544 1635 if (tempMessage.length) {
1636 + // aria-busy=true for the whole stream: the bubble is rewritten on
1637 + // every chunk, and without busy a polite live region announces those
1638 + // rewrites continuously. Flipped false once the reply is final, so
1639 + // assistive tech announces the completed message ONCE (plan 67f126).
1640 + if (tempMessage.attr('aria-busy') !== 'true') {
1641 + tempMessage.attr('aria-busy', 'true');
1642 + }
1545 1643 // Update existing message
1546 1644 tempMessage.html(formattedContent);
1547 1645 } else {
1548 1646 // Create new temporary message if it doesn't exist
@@ -2036,9 +2134,11 @@
2036 2134
2037 2135 messageDiv.html(fullMessage);
2038 2136
2039 2137 if (isTemporary) {
2040 - messageDiv.addClass('temporary-message');
2138 + // In-flight bubble: hold aria-busy so the live region stays quiet
2139 + // until the content is finalized (plan 67f126).
2140 + messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
2041 2141 }
2042 2142
2043 2143 // Append to the correct chatbot instance's chat-box
2044 2144 var $chatBox = getElement(botId, 'chat-box');
@@ -2173,13 +2273,16 @@
2173 2273 }
2174 2274
2175 2275 if (lastMessageDiv.length) {
2176 2276 // Replace content immediately to prevent visual gap between thinking dots and response
2277 + // aria-busy released AFTER the final content is set, so the live region
2278 + // announces the finished message once (plan 67f126).
2177 2279 lastMessageDiv
2178 2280 .html(fullMessage)
2179 2281 .removeClass('bot-message user-message temporary-message')
2180 2282 .addClass(messageClass)
2181 - .attr('dir', 'auto');
2283 + .attr('dir', 'auto')
2284 + .attr('aria-busy', 'false');
2182 2285
2183 2286 // Only apply inline colors if AI theme is not active (let CSS handle it)
2184 2287 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2185 2288 if (!skipColors) {
@@ -2239,10 +2342,15 @@
2239 2342 var botMessageFontColor = mxchatChat.bot_message_font_color;
2240 2343 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2241 2344
2242 2345 // Build thinking dots HTML - skip inline colors if AI theme is active
2346 + // The dots are decorative; the sr-only span is what the live region
2347 + // announces for the waiting state (plan 67f126). Server-localized
2348 + // string — safe to inject (esc_html__ output, no user content).
2243 2349 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2244 - var thinkingHtml = '<div class="thinking-dots-container">' +
2350 + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2351 + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2352 + '<div class="thinking-dots-container" aria-hidden="true">' +
2245 2353 '<div class="thinking-dots">' +
2246 2354 '<span class="dot"' + dotStyle + '></span>' +
2247 2355 '<span class="dot"' + dotStyle + '></span>' +
2248 2356 '<span class="dot"' + dotStyle + '></span>' +
@@ -2942,9 +3050,30 @@
2942 3050 }
2943 3051
2944 3052 // Only process if there are actual messages
2945 3053 if (response.data.conversation.length > 0) {
2946 - // IMPORTANT: Clear existing messages before loading history
3054 + // Restored history must be SILENT to screen readers
3055 + // (plan 67f126): these are DOM additions inside the
3056 + // live region and would otherwise announce as if
3057 + // they just arrived. Lift aria-live for the batch
3058 + // repopulate, restore it after the browser has
3059 + // processed the mutations.
3060 + var mxLiveRegionEl = $chatBox.get(0);
3061 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3062 + if (mxLiveRegionEl) {
3063 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3064 + }
3065 +
3066 + // IMPORTANT: Clear existing messages before loading history.
3067 + // Detach the greeting first and put it back below —
3068 + // it is server-rendered and never stored in the
3069 + // transcript, so the old unconditional .empty()
3070 + // deleted it for the rest of the page life (plan
3071 + // a1a79b). Detach rather than rebuild: intro_message
3072 + // is not localized to JS, and this node already
3073 + // carries the per-bot inline colors and any
3074 + // {visitor_name} substitution already applied to it.
3075 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2947 3076 $chatBox.empty();
2948 3077
2949 3078 $.each(response.data.conversation, function(index, message) {
2950 3079 // Skip agent messages if persistence is off
@@ -2984,12 +3113,16 @@
2984 3113
2985 3114 // Skip linkify for messages containing structured HTML
2986 3115 // (forms, product cards, galleries, etc.) to avoid
2987 3116 // markdown formatting corrupting HTML attributes
2988 - // (e.g. underscores in name="field_name" becoming <em> tags)
2989 - if (content.includes("mxchat-product-card") ||
2990 - content.includes("mxchat-image-gallery") ||
2991 - content.includes("mxchat-featured-products") ||
3117 + // (e.g. underscores in name="field_name" becoming <em> tags).
3118 + // One family check instead of a per-card literal list: any
3119 + // element carrying an mxchat- prefixed class is MxChat-generated
3120 + // structured markup and replays raw. The old list drifted every
3121 + // time an add-on minted a new card class — the filtered-search
3122 + // card ("mxchat-filtered-product-card") missed it and replayed
3123 + // through linkify as visible markup.
3124 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
2992 3125 content.includes("<form") ||
2993 3126 content.includes("<input") ||
2994 3127 content.includes("<select") ||
2995 3128 content.includes("<textarea")) {
@@ -3007,12 +3140,29 @@
3007 3140 instance.processedMessageIds.add(message.id);
3008 3141 }
3009 3142 });
3010 3143
3011 - // Only append messages and scroll if we have content
3144 + // Only append messages and scroll if we have content.
3145 + // Greeting goes back FIRST, above the restored
3146 + // history: "Hello — [earlier conversation]" is the
3147 + // natural reading and matches the order a fresh
3148 + // visitor sees (plan a1a79b).
3149 + if ($mxIntro && $mxIntro.length) {
3150 + $chatBox.append($mxIntro);
3151 + }
3012 3152 $chatBox.append($fragment);
3013 3153 scrollToBottom(botId, true);
3014 3154
3155 + // Re-attach live semantics AFTER the rehydration
3156 + // mutations have been processed with the region off
3157 + // (plan 67f126). Restoring later announces nothing
3158 + // retroactively; new turns announce normally.
3159 + if (mxLiveRegionEl) {
3160 + setTimeout(function() {
3161 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3162 + }, 200);
3163 + }
3164 +
3015 3165 // Collapse quick questions if we have conversation history
3016 3166 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3017 3167 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3018 3168 collapseQuickQuestions(botId);
@@ -3302,8 +3452,30 @@
3302 3452 e.stopPropagation();
3303 3453 var botId = getBotIdFromElement(this);
3304 3454 collapseQuickQuestions(botId);
3305 3455 });
3456 +
3457 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3458 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3459 +// Delegated so it also works for embeds restored from chat history.
3460 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3461 + e.preventDefault();
3462 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3463 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3464 + if (!videoId) {
3465 + return;
3466 + }
3467 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3468 + var $iframe = $('<iframe>', {
3469 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3470 + title: title,
3471 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3472 + allowfullscreen: true,
3473 + frameborder: 0
3474 + }).addClass('mxchat-youtube-iframe');
3475 + $wrap.addClass('mxchat-youtube-playing');
3476 + $(this).replaceWith($iframe);
3477 +});
3306 3478
3307 3479 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3308 3480 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3309 3481 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
@@ -3719,10 +3891,13 @@
3719 3891 function replaceVisitorNamePlaceholder(botId, visitorName) {
3720 3892 var chatBox = getElementDOM(botId, 'chat-box');
3721 3893 if (!chatBox) return;
3722 3894
3723 - // Find the first bot message (intro message)
3724 - var introMessage = chatBox.querySelector('.bot-message');
3895 + // Find the greeting by its marker, not by position (plan a1a79b) —
3896 + // after a persistence restore the first .bot-message is a restored
3897 + // reply, and {visitor_name} was being substituted into that instead.
3898 + // Positional fallback for HTML cached before this release only.
3899 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3725 3900 if (!introMessage) return;
3726 3901
3727 3902 var messageContent = introMessage.querySelector('div[dir="auto"]');
3728 3903 if (!messageContent) return;
@@ -3894,8 +4069,9 @@
3894 4069 }
3895 4070
3896 4071 var emailInput = getElementDOM(botId, 'user-email');
3897 4072 var nameInput = getElementDOM(botId, 'user-name');
4073 + var consentInput = getElementDOM(botId, 'user-consent');
3898 4074 var userEmail = emailInput ? emailInput.value.trim() : '';
3899 4075 var userName = nameInput ? nameInput.value.trim() : '';
3900 4076 var sessionId = MxChatInstances.ensureSession(botId);
3901 4077
@@ -3915,8 +4091,15 @@
3915 4091 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3916 4092 return false;
3917 4093 }
3918 4094
4095 + // Consent checkbox (b062c4): backstop behind the native required
4096 + // attribute; the server enforces this independently either way.
4097 + if (consentInput && consentInput.required && !consentInput.checked) {
4098 + showEmailError(botId, 'Please tick the consent box to continue.');
4099 + return false;
4100 + }
4101 +
3919 4102 clearEmailError(botId);
3920 4103 setEmailSubmissionState(botId, true);
3921 4104
3922 4105 // Prepare form data
@@ -3928,8 +4111,14 @@
3928 4111 });
3929 4112
3930 4113 if (userName) {
3931 4114 formData.append('name', userName);
4115 + }
4116 +
4117 + // Ticked/unticked both travel when the checkbox is rendered, so an
4118 + // optional-consent "no" is recorded as a decision, not an absence.
4119 + if (consentInput) {
4120 + formData.append('consent', consentInput.checked ? '1' : '0');
3932 4121 }
3933 4122
3934 4123 fetch(mxchatChat.ajax_url, {
3935 4124 method: 'POST',