| @@ -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', |