| @@ -167,8 +167,16 @@ | ||
| 167 | 167 | // Instance registry - tracks all chatbot instances on the page |
| 168 | 168 | const MxChatInstances = { |
| 169 | 169 | instances: {}, |
| 170 | 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 | + | |
| 171 | 179 | // Initialize an instance for a bot |
| 172 | 180 | init: function(botId) { |
| 173 | 181 | if (!this.instances[botId]) { |
| 174 | 182 | // When persistence is OFF, track when this session started |
| @@ -284,9 +292,20 @@ | ||
| 284 | 292 | var newSessionId = generateSessionId(); |
| 285 | 293 | this.setChatSession(botId, newSessionId); |
| 286 | 294 | var $chatBox = getElement(botId, 'chat-box'); |
| 287 | 295 | if ($chatBox.length) { |
| 288 | - $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 | + } | |
| 289 | 308 | } |
| 290 | 309 | if (this.instances[botId]) { |
| 291 | 310 | this.instances[botId].chatHistoryLoaded = false; |
| 292 | 311 | this.instances[botId].processedMessageIds = new Set(); |
| @@ -306,8 +325,50 @@ | ||
| 306 | 325 | } |
| 307 | 326 | }; |
| 308 | 327 | |
| 309 | 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 | + // ==================================== | |
| 310 | 371 | // ELEMENT SELECTOR HELPERS |
| 311 | 372 | // ==================================== |
| 312 | 373 | |
| 313 | 374 | // Check if a specific bot has an AI theme assigned (skip inline colors) |
| @@ -343,8 +404,20 @@ | ||
| 343 | 404 | var id = $floating.attr('id') || ''; |
| 344 | 405 | var match = id.match(/floating-chatbot-(.+)/); |
| 345 | 406 | if (match) return match[1]; |
| 346 | 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 | + } | |
| 347 | 420 | // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id}) |
| 348 | 421 | var elementId = $(element).attr('id') || ''; |
| 349 | 422 | if (elementId) { |
| 350 | 423 | // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id} |
| @@ -384,9 +457,27 @@ | ||
| 384 | 457 | if (parts.length == 2) return parts.pop().split(";").shift(); |
| 385 | 458 | } |
| 386 | 459 | |
| 387 | 460 | function generateSessionId() { |
| 388 | - 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; | |
| 389 | 480 | } |
| 390 | 481 | |
| 391 | 482 | // Legacy function - now delegates to instance manager |
| 392 | 483 | function getChatSession(botId) { |
| @@ -430,23 +521,28 @@ | ||
| 430 | 521 | // ==================================== |
| 431 | 522 | // CONTEXTUAL AWARENESS FUNCTIONALITY |
| 432 | 523 | // ==================================== |
| 433 | 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 | + | |
| 434 | 530 | function getPageContext() { |
| 435 | 531 | // Check if contextual awareness is enabled |
| 436 | 532 | if (mxchatChat.contextual_awareness_toggle !== 'on') { |
| 437 | 533 | return null; |
| 438 | 534 | } |
| 439 | - | |
| 535 | + | |
| 440 | 536 | // Get page URL |
| 441 | 537 | const pageUrl = window.location.href; |
| 442 | - | |
| 538 | + | |
| 443 | 539 | // Get page title |
| 444 | 540 | const pageTitle = document.title || ''; |
| 445 | - | |
| 541 | + | |
| 446 | 542 | // Get main content from the page |
| 447 | 543 | let pageContent = ''; |
| 448 | - | |
| 544 | + | |
| 449 | 545 | // Try to get content from common content areas |
| 450 | 546 | const contentSelectors = [ |
| 451 | 547 | 'main', |
| 452 | 548 | '[role="main"]', |
| @@ -458,9 +554,9 @@ | ||
| 458 | 554 | 'article', |
| 459 | 555 | '#content', |
| 460 | 556 | '#main' |
| 461 | 557 | ]; |
| 462 | - | |
| 558 | + | |
| 463 | 559 | let contentElement = null; |
| 464 | 560 | for (const selector of contentSelectors) { |
| 465 | 561 | contentElement = document.querySelector(selector); |
| 466 | 562 | if (contentElement) { |
| @@ -466,18 +562,31 @@ | ||
| 466 | 562 | if (contentElement) { |
| 467 | 563 | break; |
| 468 | 564 | } |
| 469 | 565 | } |
| 470 | - | |
| 566 | + | |
| 471 | 567 | // If no specific content area found, use body but exclude header, footer, nav, sidebar |
| 472 | 568 | if (!contentElement) { |
| 473 | 569 | contentElement = document.body; |
| 474 | 570 | } |
| 475 | - | |
| 571 | + | |
| 476 | 572 | if (contentElement) { |
| 477 | - // Clone the element to avoid modifying the original | |
| 478 | - const clone = contentElement.cloneNode(true); | |
| 479 | - | |
| 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 | + | |
| 480 | 589 | // Remove unwanted elements |
| 481 | 590 | const unwantedSelectors = [ |
| 482 | 591 | 'header', |
| 483 | 592 | 'footer', |
| @@ -495,16 +604,25 @@ | ||
| 495 | 604 | '#floating-chatbot', |
| 496 | 605 | '#floating-chatbot-button', |
| 497 | 606 | '.mxchat', |
| 498 | 607 | '[class*="chat"]', |
| 499 | - '[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' | |
| 500 | 618 | ]; |
| 501 | - | |
| 619 | + | |
| 502 | 620 | unwantedSelectors.forEach(selector => { |
| 503 | 621 | const elements = clone.querySelectorAll(selector); |
| 504 | 622 | elements.forEach(el => el.remove()); |
| 505 | 623 | }); |
| 506 | - | |
| 624 | + | |
| 507 | 625 | // Extract MxChat context data attributes before getting text content |
| 508 | 626 | const contextData = []; |
| 509 | 627 | clone.querySelectorAll('[data-mxchat-context]').forEach(el => { |
| 510 | 628 | const contextValue = el.dataset.mxchatContext; |
| @@ -511,34 +629,34 @@ | ||
| 511 | 629 | if (contextValue && contextValue.trim()) { |
| 512 | 630 | contextData.push(contextValue); |
| 513 | 631 | } |
| 514 | 632 | }); |
| 515 | - | |
| 633 | + | |
| 516 | 634 | // Get text content and clean it up |
| 517 | 635 | pageContent = clone.textContent || clone.innerText || ''; |
| 518 | - | |
| 636 | + | |
| 519 | 637 | // Add context data to page content if any were found |
| 520 | 638 | if (contextData.length > 0) { |
| 521 | 639 | pageContent += '\n\nAdditional Context:\n' + contextData.join('\n'); |
| 522 | 640 | } |
| 523 | - | |
| 641 | + | |
| 524 | 642 | // Clean up whitespace and limit length |
| 525 | 643 | pageContent = pageContent |
| 526 | 644 | .replace(/\s+/g, ' ') |
| 527 | 645 | .trim() |
| 528 | 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; | |
| 529 | 656 | } |
| 530 | - | |
| 531 | - // Only return context if we have meaningful content | |
| 532 | - if (!pageContent || pageContent.length < 50) { | |
| 533 | - return null; | |
| 534 | - } | |
| 535 | - | |
| 536 | - return { | |
| 537 | - url: pageUrl, | |
| 538 | - title: pageTitle, | |
| 539 | - content: pageContent | |
| 540 | - }; | |
| 657 | + | |
| 658 | + return null; | |
| 541 | 659 | } |
| 542 | 660 | |
| 543 | 661 | // Track originating page when chat starts |
| 544 | 662 | function trackOriginatingPage() { |
| @@ -589,8 +707,26 @@ | ||
| 589 | 707 | sendButton.style.pointerEvents = 'none'; |
| 590 | 708 | } |
| 591 | 709 | } |
| 592 | 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 | + | |
| 593 | 729 | function enableChatInput(botId) { |
| 594 | 730 | botId = botId || 'default'; |
| 595 | 731 | var chatInput = getElementDOM(botId, 'chat-input'); |
| 596 | 732 | var sendButton = getElementDOM(botId, 'send-button'); |
| @@ -596,9 +732,11 @@ | ||
| 596 | 732 | var sendButton = getElementDOM(botId, 'send-button'); |
| 597 | 733 | if (chatInput) { |
| 598 | 734 | chatInput.disabled = false; |
| 599 | 735 | chatInput.style.opacity = '1'; |
| 600 | - chatInput.focus(); | |
| 736 | + if (mxchatShouldAutofocusAfterReply()) { | |
| 737 | + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); } | |
| 738 | + } | |
| 601 | 739 | } |
| 602 | 740 | if (sendButton) { |
| 603 | 741 | sendButton.disabled = false; |
| 604 | 742 | sendButton.style.opacity = '1'; |
| @@ -709,9 +847,12 @@ | ||
| 709 | 847 | if (typeof customMxChatFilter === 'function') { |
| 710 | 848 | message = customMxChatFilter(message, "prompt"); |
| 711 | 849 | } |
| 712 | 850 | |
| 713 | - 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() !== '') { | |
| 714 | 855 | // Don't disable input in live agent mode - let users chat freely |
| 715 | 856 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 716 | 857 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 717 | 858 | if (!isAgentMode) { |
| @@ -728,14 +869,16 @@ | ||
| 728 | 869 | } |
| 729 | 870 | appendThinkingMessage(botId); |
| 730 | 871 | scrollToBottom(botId); |
| 731 | 872 | |
| 732 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 873 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 733 | 874 | |
| 734 | 875 | // Check if streaming is enabled AND supported for this model |
| 735 | 876 | if (shouldUseStreaming(currentModel)) { |
| 736 | 877 | callMxChatStream(message, function(response) { |
| 737 | - 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'); | |
| 738 | 881 | }, botId); |
| 739 | 882 | } else { |
| 740 | 883 | callMxChat(message, function(response) { |
| 741 | 884 | replaceLastMessage("bot", response, '', [], botId); |
| @@ -753,8 +896,14 @@ | ||
| 753 | 896 | if (typeof customMxChatFilter === 'function') { |
| 754 | 897 | message = customMxChatFilter(message, "prompt"); |
| 755 | 898 | } |
| 756 | 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 | + | |
| 757 | 906 | // Don't disable input in live agent mode - let users chat freely |
| 758 | 907 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 759 | 908 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 760 | 909 | if (!isAgentMode) { |
| @@ -768,14 +917,15 @@ | ||
| 768 | 917 | } |
| 769 | 918 | appendThinkingMessage(botId); |
| 770 | 919 | scrollToBottom(botId); |
| 771 | 920 | |
| 772 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 921 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 773 | 922 | |
| 774 | 923 | // Check if streaming is enabled AND supported for this model |
| 775 | 924 | if (shouldUseStreaming(currentModel)) { |
| 776 | 925 | callMxChatStream(message, function(response) { |
| 777 | - 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'); | |
| 778 | 928 | }, botId); |
| 779 | 929 | } else { |
| 780 | 930 | callMxChat(message, function(response) { |
| 781 | 931 | getElement(botId, 'chat-box').find('.temporary-message').remove(); |
| @@ -945,12 +1095,13 @@ | ||
| 945 | 1095 | // Re-send the original message with the new session (user message is already displayed) |
| 946 | 1096 | var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); |
| 947 | 1097 | if (originalMessage) { |
| 948 | 1098 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); |
| 949 | - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1099 | + var currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 950 | 1100 | if (shouldUseStreaming(currentModel)) { |
| 951 | 1101 | callMxChatStream(originalMessage, function(response) { |
| 952 | - 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'); | |
| 953 | 1104 | }, botId); |
| 954 | 1105 | } else { |
| 955 | 1106 | callMxChat(originalMessage, function(response) { |
| 956 | 1107 | replaceLastMessage("bot", response, '', [], botId); |
| @@ -1098,9 +1249,9 @@ | ||
| 1098 | 1249 | |
| 1099 | 1250 | // Store the message in case we need to retry after session reset |
| 1100 | 1251 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message); |
| 1101 | 1252 | |
| 1102 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1253 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 1103 | 1254 | if (!isStreamingSupported(currentModel)) { |
| 1104 | 1255 | callMxChat(message, callback, botId); |
| 1105 | 1256 | return; |
| 1106 | 1257 | } |
| @@ -1153,8 +1304,12 @@ | ||
| 1153 | 1304 | |
| 1154 | 1305 | let accumulatedContent = ''; |
| 1155 | 1306 | let testingDataReceived = false; |
| 1156 | 1307 | let streamingStarted = false; |
| 1308 | + // Server-pushed html to append as its OWN bot bubble once the stream | |
| 1309 | + // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is | |
| 1310 | + // deferred to [DONE] so the embed always lands BELOW the streamed text. | |
| 1311 | + let pendingAppendHtml = ''; | |
| 1157 | 1312 | |
| 1158 | 1313 | // Abortable stream: a fresh controller per turn, keyed by bot instance. |
| 1159 | 1314 | // The Stop control (send button swapped in place) aborts both the read |
| 1160 | 1315 | // loop and the underlying request. |
| @@ -1268,8 +1423,17 @@ | ||
| 1268 | 1423 | |
| 1269 | 1424 | // Re-enable chat input after streaming completes |
| 1270 | 1425 | enableChatInput(botId); |
| 1271 | 1426 | |
| 1427 | + // Render any server-pushed appendix html (e.g. the | |
| 1428 | + // YouTube embed) as its own bot bubble below the | |
| 1429 | + // streamed text — mirrors how it is saved in the | |
| 1430 | + // transcript, so history replays identically. | |
| 1431 | + if (pendingAppendHtml) { | |
| 1432 | + appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1433 | + pendingAppendHtml = ''; | |
| 1434 | + } | |
| 1435 | + | |
| 1272 | 1436 | // Scroll the user's last message to the top now |
| 1273 | 1437 | // that the bot's full reply has rendered. |
| 1274 | 1438 | var $chatBoxStreamDone = getElement(botId, 'chat-box'); |
| 1275 | 1439 | var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last(); |
| @@ -1303,8 +1467,21 @@ | ||
| 1303 | 1467 | streamingStarted = true; |
| 1304 | 1468 | accumulatedContent += json.content; |
| 1305 | 1469 | updateStreamingMessage(accumulatedContent, botId); |
| 1306 | 1470 | } |
| 1471 | + // Stash appendix html (e.g. video embed) for [DONE] | |
| 1472 | + else if (json.append_html) { | |
| 1473 | + pendingAppendHtml = json.append_html; | |
| 1474 | + } | |
| 1475 | + // Server-side final pass changed the assembled text | |
| 1476 | + // (ffef6f: dead-link stripping) — swap the rendered | |
| 1477 | + // bubble for the validated version. Arrives at most | |
| 1478 | + // once, just before [DONE]. | |
| 1479 | + else if (json.replace_content) { | |
| 1480 | + streamingStarted = true; | |
| 1481 | + accumulatedContent = json.replace_content; | |
| 1482 | + updateStreamingMessage(accumulatedContent, botId); | |
| 1483 | + } | |
| 1307 | 1484 | // Handle complete response in stream (fallback response) |
| 1308 | 1485 | else if (json.text || json.message || json.html) { |
| 1309 | 1486 | handleNonStreamResponse(json, callback, botId); |
| 1310 | 1487 | return; |
| @@ -1406,9 +1583,9 @@ | ||
| 1406 | 1583 | // Re-send the original message with the new session (user message is already displayed) |
| 1407 | 1584 | var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); |
| 1408 | 1585 | if (originalMessage) { |
| 1409 | 1586 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); |
| 1410 | - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1587 | + var currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 1411 | 1588 | if (shouldUseStreaming(currentModel)) { |
| 1412 | 1589 | callMxChatStream(originalMessage, callback, botId); |
| 1413 | 1590 | } else { |
| 1414 | 1591 | callMxChat(originalMessage, callback, botId); |
| @@ -1541,8 +1718,15 @@ | ||
| 1541 | 1718 | var $chatBox = getElement(botId, 'chat-box'); |
| 1542 | 1719 | const tempMessage = $chatBox.find('.bot-message.temporary-message').last(); |
| 1543 | 1720 | |
| 1544 | 1721 | if (tempMessage.length) { |
| 1722 | + // aria-busy=true for the whole stream: the bubble is rewritten on | |
| 1723 | + // every chunk, and without busy a polite live region announces those | |
| 1724 | + // rewrites continuously. Flipped false once the reply is final, so | |
| 1725 | + // assistive tech announces the completed message ONCE (plan 67f126). | |
| 1726 | + if (tempMessage.attr('aria-busy') !== 'true') { | |
| 1727 | + tempMessage.attr('aria-busy', 'true'); | |
| 1728 | + } | |
| 1545 | 1729 | // Update existing message |
| 1546 | 1730 | tempMessage.html(formattedContent); |
| 1547 | 1731 | } else { |
| 1548 | 1732 | // Create new temporary message if it doesn't exist |
| @@ -1578,25 +1762,21 @@ | ||
| 1578 | 1762 | if (this.__mxchatStopJustShown) return; |
| 1579 | 1763 | mxchatStopStreaming(botId); |
| 1580 | 1764 | return; |
| 1581 | 1765 | } |
| 1582 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1583 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1584 | - disableChatInput(botId); | |
| 1585 | - } | |
| 1766 | + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5). | |
| 1586 | 1767 | sendMessage(botId); |
| 1587 | 1768 | }); |
| 1588 | 1769 | |
| 1589 | -// Override enter key handler (using event delegation) | |
| 1770 | +// Enter sends, Shift+Enter inserts a newline (using event delegation). | |
| 1771 | +// sendMessage() owns the disable: it greys the box only when a real message is | |
| 1772 | +// about to go out. Disabling here first left the widget locked for good after | |
| 1773 | +// Enter on an empty box - the send returned early and no reply path ever | |
| 1774 | +// re-enabled it (plan b67fd5). | |
| 1590 | 1775 | $(document).on('keypress', '.chat-input', function(e) { |
| 1591 | 1776 | if (e.which == 13 && !e.shiftKey) { |
| 1592 | 1777 | e.preventDefault(); |
| 1593 | - var botId = getBotIdFromElement(this); | |
| 1594 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1595 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1596 | - disableChatInput(botId); | |
| 1597 | - } | |
| 1598 | - sendMessage(botId); | |
| 1778 | + sendMessage(getBotIdFromElement(this)); | |
| 1599 | 1779 | } |
| 1600 | 1780 | }); |
| 1601 | 1781 | |
| 1602 | 1782 | // Chat input character counter + soft limit feedback (plan 7091a2). |
| @@ -2036,9 +2216,11 @@ | ||
| 2036 | 2216 | |
| 2037 | 2217 | messageDiv.html(fullMessage); |
| 2038 | 2218 | |
| 2039 | 2219 | if (isTemporary) { |
| 2040 | - messageDiv.addClass('temporary-message'); | |
| 2220 | + // In-flight bubble: hold aria-busy so the live region stays quiet | |
| 2221 | + // until the content is finalized (plan 67f126). | |
| 2222 | + messageDiv.addClass('temporary-message').attr('aria-busy', 'true'); | |
| 2041 | 2223 | } |
| 2042 | 2224 | |
| 2043 | 2225 | // Append to the correct chatbot instance's chat-box |
| 2044 | 2226 | var $chatBox = getElement(botId, 'chat-box'); |
| @@ -2160,13 +2342,16 @@ | ||
| 2160 | 2342 | |
| 2161 | 2343 | if (images.length > 0) { |
| 2162 | 2344 | fullMessage += '<div class="image-gallery" dir="auto">'; |
| 2163 | 2345 | images.forEach(img => { |
| 2346 | + const safeTitle = sanitizeUserInput(img.title); | |
| 2347 | + const safeUrl = escapeAttr(img.image_url); | |
| 2348 | + const safeThumbnail = escapeAttr(img.thumbnail_url); | |
| 2164 | 2349 | fullMessage += ` |
| 2165 | 2350 | <div style="margin-bottom: 10px;"> |
| 2166 | - <strong>${img.title}</strong><br> | |
| 2167 | - <a href="${img.image_url}" target="_blank"> | |
| 2168 | - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" /> | |
| 2351 | + <strong>${safeTitle}</strong><br> | |
| 2352 | + <a href="${safeUrl}" target="_blank"> | |
| 2353 | + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" /> | |
| 2169 | 2354 | </a> |
| 2170 | 2355 | </div>`; |
| 2171 | 2356 | }); |
| 2172 | 2357 | fullMessage += '</div>'; |
| @@ -2173,13 +2358,16 @@ | ||
| 2173 | 2358 | } |
| 2174 | 2359 | |
| 2175 | 2360 | if (lastMessageDiv.length) { |
| 2176 | 2361 | // Replace content immediately to prevent visual gap between thinking dots and response |
| 2362 | + // aria-busy released AFTER the final content is set, so the live region | |
| 2363 | + // announces the finished message once (plan 67f126). | |
| 2177 | 2364 | lastMessageDiv |
| 2178 | 2365 | .html(fullMessage) |
| 2179 | 2366 | .removeClass('bot-message user-message temporary-message') |
| 2180 | 2367 | .addClass(messageClass) |
| 2181 | - .attr('dir', 'auto'); | |
| 2368 | + .attr('dir', 'auto') | |
| 2369 | + .attr('aria-busy', 'false'); | |
| 2182 | 2370 | |
| 2183 | 2371 | // Only apply inline colors if AI theme is not active (let CSS handle it) |
| 2184 | 2372 | var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId); |
| 2185 | 2373 | if (!skipColors) { |
| @@ -2239,10 +2427,15 @@ | ||
| 2239 | 2427 | var botMessageFontColor = mxchatChat.bot_message_font_color; |
| 2240 | 2428 | var botMessageBgColor = mxchatChat.bot_message_bg_color; |
| 2241 | 2429 | |
| 2242 | 2430 | // Build thinking dots HTML - skip inline colors if AI theme is active |
| 2431 | + // The dots are decorative; the sr-only span is what the live region | |
| 2432 | + // announces for the waiting state (plan 67f126). Server-localized | |
| 2433 | + // string — safe to inject (esc_html__ output, no user content). | |
| 2243 | 2434 | var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"'; |
| 2244 | - var thinkingHtml = '<div class="thinking-dots-container">' + | |
| 2435 | + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing'; | |
| 2436 | + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' + | |
| 2437 | + '<div class="thinking-dots-container" aria-hidden="true">' + | |
| 2245 | 2438 | '<div class="thinking-dots">' + |
| 2246 | 2439 | '<span class="dot"' + dotStyle + '></span>' + |
| 2247 | 2440 | '<span class="dot"' + dotStyle + '></span>' + |
| 2248 | 2441 | '<span class="dot"' + dotStyle + '></span>' + |
| @@ -2277,27 +2470,42 @@ | ||
| 2277 | 2470 | // Check for % followed by exactly 2 hex digits |
| 2278 | 2471 | return /%[0-9a-fA-F]{2}/.test(url); |
| 2279 | 2472 | } |
| 2280 | 2473 | |
| 2281 | - // Helper function to safely encode URLs only if needed | |
| 2474 | + // Helper function to safely encode URLs only if needed, then make the | |
| 2475 | + // result attribute-safe. "Don't double-encode an already-encoded URL" | |
| 2476 | + // used to mean "return it untouched" — so a literal quote riding next | |
| 2477 | + // to a %20 reached href="…" intact and closed the attribute | |
| 2478 | + // (plan 2db182). Every href this function feeds now goes through | |
| 2479 | + // escapeAttr(), whether or not the URL was already percent-encoded. | |
| 2282 | 2480 | function safeEncodeUrl(url) { |
| 2283 | - // If URL already contains encoded characters, return as-is | |
| 2284 | - if (isUrlEncoded(url)) { | |
| 2285 | - return url; | |
| 2286 | - } | |
| 2287 | - // Otherwise, encode it | |
| 2288 | - return encodeURI(url); | |
| 2481 | + const encoded = isUrlEncoded(url) ? url : encodeURI(url); | |
| 2482 | + return escapeAttr(encoded); | |
| 2289 | 2483 | } |
| 2290 | 2484 | |
| 2291 | - // Process markdown headers FIRST | |
| 2292 | - let processedText = formatMarkdownHeaders(inputText); | |
| 2293 | - | |
| 2485 | + // Take backslash escapes and code (fenced + inline) out of the text FIRST | |
| 2486 | + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a | |
| 2487 | + // backtick could not protect an underscore and \_ was not an escape at | |
| 2488 | + // all; the header rule also ran over fenced code. Each escape becomes a | |
| 2489 | + // placeholder carrying its literal character, each code span a | |
| 2490 | + // placeholder carrying its rendered HTML; both are restored below, after | |
| 2491 | + // the styling rules have run, so nothing downstream changes. | |
| 2492 | + const literals = protectLiterals(inputText); | |
| 2493 | + let processedText = literals.text; | |
| 2494 | + | |
| 2495 | + // Process markdown headers | |
| 2496 | + processedText = formatMarkdownHeaders(processedText); | |
| 2497 | + | |
| 2294 | 2498 | // Process text styling (bold, italic, strikethrough) |
| 2295 | 2499 | processedText = formatTextStyling(processedText); |
| 2296 | - | |
| 2297 | - // Process code blocks BEFORE processing links | |
| 2500 | + | |
| 2501 | + // Legacy raw <?php ... ?> blocks (fenced + inline code were already | |
| 2502 | + // rendered inside protectLiterals) — still BEFORE processing links | |
| 2298 | 2503 | processedText = formatCodeBlocks(processedText); |
| 2299 | 2504 | |
| 2505 | + // Put the rendered code and the escaped characters back | |
| 2506 | + processedText = literals.restore(processedText); | |
| 2507 | + | |
| 2300 | 2508 | // Process markdown tables BEFORE converting newlines to paragraphs |
| 2301 | 2509 | processedText = formatMarkdownTables(processedText); |
| 2302 | 2510 | |
| 2303 | 2511 | // NOW convert to paragraphs |
| @@ -2309,10 +2517,13 @@ | ||
| 2309 | 2517 | processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => { |
| 2310 | 2518 | // Clean the URL of any trailing punctuation |
| 2311 | 2519 | let cleanUrl = url.replace(/[.,;!?]+$/, ''); |
| 2312 | 2520 | const safeUrl = safeEncodeUrl(cleanUrl); |
| 2521 | + // The bracket body is the only link text that can carry angle | |
| 2522 | + // brackets (the standalone matcher excludes them) — keep it text. | |
| 2523 | + const safeText = cleanUrl.replace(/</g, '<').replace(/>/g, '>'); | |
| 2313 | 2524 | // Return as a proper link without the brackets |
| 2314 | - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`; | |
| 2525 | + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`; | |
| 2315 | 2526 | }); |
| 2316 | 2527 | |
| 2317 | 2528 | // Process markdown links: [text](url) and [](url) |
| 2318 | 2529 | // Uses balanced parenthesis matching to handle URLs containing parens |
| @@ -2416,8 +2627,30 @@ | ||
| 2416 | 2627 | |
| 2417 | 2628 | return processedText; |
| 2418 | 2629 | } |
| 2419 | 2630 | |
| 2631 | + // One left-to-right pass over backslash escapes and code (plan 9dc34f). | |
| 2632 | + // A single alternation keeps the two honest with each other: an escaped | |
| 2633 | + // backtick is consumed as an escape before it can open a code span, and a | |
| 2634 | + // code span is consumed whole so the escapes inside it stay verbatim. | |
| 2635 | + // Placeholders are private-use characters — no underscore, asterisk, | |
| 2636 | + // backtick, hash, pipe or bracket for a later rule to match. | |
| 2637 | + function protectLiterals(text) { | |
| 2638 | + const store = []; | |
| 2639 | + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) { | |
| 2640 | + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match)); | |
| 2641 | + return '\uE000' + (store.length - 1) + '\uE001'; | |
| 2642 | + }); | |
| 2643 | + return { | |
| 2644 | + text: out, | |
| 2645 | + restore: function(t) { | |
| 2646 | + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) { | |
| 2647 | + return store[Number(i)]; | |
| 2648 | + }); | |
| 2649 | + } | |
| 2650 | + }; | |
| 2651 | + } | |
| 2652 | + | |
| 2420 | 2653 | function formatMarkdownHeaders(text) { |
| 2421 | 2654 | // Handle h1 to h6 headers |
| 2422 | 2655 | return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) { |
| 2423 | 2656 | const level = hashes.length; |
| @@ -2459,11 +2692,24 @@ | ||
| 2459 | 2692 | // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind) |
| 2460 | 2693 | // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another * |
| 2461 | 2694 | protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); |
| 2462 | 2695 | |
| 2463 | - // Handle underscores for italic - Safari-compatible (no lookbehind) | |
| 2464 | - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED | |
| 2465 | - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>'); | |
| 2696 | + // Handle underscores for italic - Safari-compatible (no lookbehind), and | |
| 2697 | + // word-bounded (plan 9dc34f): the opener must follow the start of the | |
| 2698 | + // text or a non-word character, the closer must precede the end or a | |
| 2699 | + // non-word character, and the content may not start or end with a | |
| 2700 | + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed | |
| 2701 | + // while "use _this_ word" still italicises. A token whose content is | |
| 2702 | + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder | |
| 2703 | + // convention, not prose emphasis — it is left verbatim too (ticket 440). | |
| 2704 | + // __PROTECTED_N__ placeholders cannot match: their underscores are all | |
| 2705 | + // adjacent to another underscore or a word character. | |
| 2706 | + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) { | |
| 2707 | + if (/^[\p{Lu}\p{N}]+$/u.test(content)) { | |
| 2708 | + return match; | |
| 2709 | + } | |
| 2710 | + return before + '<em>' + content + '</em>'; | |
| 2711 | + }); | |
| 2466 | 2712 | |
| 2467 | 2713 | // Handle strikethrough (~~text~~) |
| 2468 | 2714 | protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>'); |
| 2469 | 2715 | |
| @@ -2597,8 +2843,24 @@ | ||
| 2597 | 2843 | div.textContent = text; |
| 2598 | 2844 | return div.innerHTML; |
| 2599 | 2845 | } |
| 2600 | 2846 | |
| 2847 | + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182). | |
| 2848 | + // Percent-encodes the characters that can end or extend an attribute. | |
| 2849 | + // Valid in every scheme the widget emits (http, https, tel, mailto) and | |
| 2850 | + // never changes where a legitimate link resolves — encodeURI() already | |
| 2851 | + // produces most of these, this just guarantees them for URLs that skip | |
| 2852 | + // encodeURI() because they were percent-encoded on arrival. | |
| 2853 | + function escapeAttr(url) { | |
| 2854 | + return String(url) | |
| 2855 | + .replace(/"/g, '%22') | |
| 2856 | + .replace(/'/g, '%27') | |
| 2857 | + .replace(/</g, '%3C') | |
| 2858 | + .replace(/>/g, '%3E') | |
| 2859 | + .replace(/`/g, '%60') | |
| 2860 | + .replace(/ /g, '%20'); | |
| 2861 | + } | |
| 2862 | + | |
| 2601 | 2863 | function escapeHtml(unsafe) { |
| 2602 | 2864 | // Skip escaping if it's already escaped or contains HTML code block markup |
| 2603 | 2865 | if (unsafe.includes('<') || unsafe.includes('>') || |
| 2604 | 2866 | unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) { |
| @@ -2942,9 +3204,30 @@ | ||
| 2942 | 3204 | } |
| 2943 | 3205 | |
| 2944 | 3206 | // Only process if there are actual messages |
| 2945 | 3207 | if (response.data.conversation.length > 0) { |
| 2946 | - // IMPORTANT: Clear existing messages before loading history | |
| 3208 | + // Restored history must be SILENT to screen readers | |
| 3209 | + // (plan 67f126): these are DOM additions inside the | |
| 3210 | + // live region and would otherwise announce as if | |
| 3211 | + // they just arrived. Lift aria-live for the batch | |
| 3212 | + // repopulate, restore it after the browser has | |
| 3213 | + // processed the mutations. | |
| 3214 | + var mxLiveRegionEl = $chatBox.get(0); | |
| 3215 | + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null; | |
| 3216 | + if (mxLiveRegionEl) { | |
| 3217 | + mxLiveRegionEl.setAttribute('aria-live', 'off'); | |
| 3218 | + } | |
| 3219 | + | |
| 3220 | + // IMPORTANT: Clear existing messages before loading history. | |
| 3221 | + // Detach the greeting first and put it back below — | |
| 3222 | + // it is server-rendered and never stored in the | |
| 3223 | + // transcript, so the old unconditional .empty() | |
| 3224 | + // deleted it for the rest of the page life (plan | |
| 3225 | + // a1a79b). Detach rather than rebuild: intro_message | |
| 3226 | + // is not localized to JS, and this node already | |
| 3227 | + // carries the per-bot inline colors and any | |
| 3228 | + // {visitor_name} substitution already applied to it. | |
| 3229 | + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach(); | |
| 2947 | 3230 | $chatBox.empty(); |
| 2948 | 3231 | |
| 2949 | 3232 | $.each(response.data.conversation, function(index, message) { |
| 2950 | 3233 | // Skip agent messages if persistence is off |
| @@ -2981,15 +3264,28 @@ | ||
| 2981 | 3264 | var content = message.content; |
| 2982 | 3265 | content = content.replace(/\\'/g, "'").replace(/\\"/g, '"'); |
| 2983 | 3266 | content = decodeHTMLEntities(content); |
| 2984 | 3267 | |
| 3268 | + // A visitor's own rows get the same treatment as the | |
| 3269 | + // live turn (appendMessage escapes user text before | |
| 3270 | + // linkify). Without this a replayed user message came | |
| 3271 | + // back as live markup after a refresh (plan 2db182). | |
| 3272 | + // Bot/agent rows are trusted HTML and stay as they were. | |
| 3273 | + if (message.role === 'user') { | |
| 3274 | + content = sanitizeUserInput(content); | |
| 3275 | + } | |
| 3276 | + | |
| 2985 | 3277 | // Skip linkify for messages containing structured HTML |
| 2986 | 3278 | // (forms, product cards, galleries, etc.) to avoid |
| 2987 | 3279 | // 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") || | |
| 3280 | + // (e.g. underscores in name="field_name" becoming <em> tags). | |
| 3281 | + // One family check instead of a per-card literal list: any | |
| 3282 | + // element carrying an mxchat- prefixed class is MxChat-generated | |
| 3283 | + // structured markup and replays raw. The old list drifted every | |
| 3284 | + // time an add-on minted a new card class — the filtered-search | |
| 3285 | + // card ("mxchat-filtered-product-card") missed it and replayed | |
| 3286 | + // through linkify as visible markup. | |
| 3287 | + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) || | |
| 2992 | 3288 | content.includes("<form") || |
| 2993 | 3289 | content.includes("<input") || |
| 2994 | 3290 | content.includes("<select") || |
| 2995 | 3291 | content.includes("<textarea")) { |
| @@ -3007,12 +3303,29 @@ | ||
| 3007 | 3303 | instance.processedMessageIds.add(message.id); |
| 3008 | 3304 | } |
| 3009 | 3305 | }); |
| 3010 | 3306 | |
| 3011 | - // Only append messages and scroll if we have content | |
| 3307 | + // Only append messages and scroll if we have content. | |
| 3308 | + // Greeting goes back FIRST, above the restored | |
| 3309 | + // history: "Hello — [earlier conversation]" is the | |
| 3310 | + // natural reading and matches the order a fresh | |
| 3311 | + // visitor sees (plan a1a79b). | |
| 3312 | + if ($mxIntro && $mxIntro.length) { | |
| 3313 | + $chatBox.append($mxIntro); | |
| 3314 | + } | |
| 3012 | 3315 | $chatBox.append($fragment); |
| 3013 | 3316 | scrollToBottom(botId, true); |
| 3014 | 3317 | |
| 3318 | + // Re-attach live semantics AFTER the rehydration | |
| 3319 | + // mutations have been processed with the region off | |
| 3320 | + // (plan 67f126). Restoring later announces nothing | |
| 3321 | + // retroactively; new turns announce normally. | |
| 3322 | + if (mxLiveRegionEl) { | |
| 3323 | + setTimeout(function() { | |
| 3324 | + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite'); | |
| 3325 | + }, 200); | |
| 3326 | + } | |
| 3327 | + | |
| 3015 | 3328 | // Collapse quick questions if we have conversation history |
| 3016 | 3329 | // BUT skip auto-collapse for embedded bots (they should stay expanded) |
| 3017 | 3330 | if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) { |
| 3018 | 3331 | collapseQuickQuestions(botId); |
| @@ -3302,8 +3615,30 @@ | ||
| 3302 | 3615 | e.stopPropagation(); |
| 3303 | 3616 | var botId = getBotIdFromElement(this); |
| 3304 | 3617 | collapseQuickQuestions(botId); |
| 3305 | 3618 | }); |
| 3619 | + | |
| 3620 | +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a | |
| 3621 | +// thumbnail facade — no Google iframe exists until the visitor taps play. | |
| 3622 | +// Delegated so it also works for embeds restored from chat history. | |
| 3623 | +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) { | |
| 3624 | + e.preventDefault(); | |
| 3625 | + var $wrap = $(this).closest('.mxchat-youtube-embed'); | |
| 3626 | + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, ''); | |
| 3627 | + if (!videoId) { | |
| 3628 | + return; | |
| 3629 | + } | |
| 3630 | + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video'; | |
| 3631 | + var $iframe = $('<iframe>', { | |
| 3632 | + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0', | |
| 3633 | + title: title, | |
| 3634 | + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', | |
| 3635 | + allowfullscreen: true, | |
| 3636 | + frameborder: 0 | |
| 3637 | + }).addClass('mxchat-youtube-iframe'); | |
| 3638 | + $wrap.addClass('mxchat-youtube-playing'); | |
| 3639 | + $(this).replaceWith($iframe); | |
| 3640 | +}); | |
| 3306 | 3641 | |
| 3307 | 3642 | // Chatbot visibility toggle handlers - use class selector for multi-instance support |
| 3308 | 3643 | // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1). |
| 3309 | 3644 | $(document).on('click keydown', '.floating-chatbot-button', function(e) { |
| @@ -3322,8 +3657,9 @@ | ||
| 3322 | 3657 | $(this).addClass('hidden').attr('aria-expanded', 'true'); |
| 3323 | 3658 | $badge.hide(); // Hide notification when opening chat |
| 3324 | 3659 | disableScroll(); |
| 3325 | 3660 | $preChat.fadeOut(250); |
| 3661 | + mxchatFireOpened(botId); | |
| 3326 | 3662 | |
| 3327 | 3663 | // First open per page load: re-fetch behavior settings in case |
| 3328 | 3664 | // this page's inline values came from a stale full-page cache |
| 3329 | 3665 | // (plan-32db95). Idempotent — later opens are a no-op. |
| @@ -3592,9 +3928,12 @@ | ||
| 3592 | 3928 | $('.chat-toolbar').show(); |
| 3593 | 3929 | } else { |
| 3594 | 3930 | $('.chat-toolbar').hide(); |
| 3595 | 3931 | } |
| 3596 | - | |
| 3932 | + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6). | |
| 3933 | + MxChatInstances.toolbarReady = true; | |
| 3934 | + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' }); | |
| 3935 | + | |
| 3597 | 3936 | // Apply toolbar icon colors |
| 3598 | 3937 | const toolbarElements = [ |
| 3599 | 3938 | '#mxchat-chatbot .toolbar-btn svg', |
| 3600 | 3939 | '#mxchat-chatbot .active-pdf-name', |
| @@ -3719,10 +4058,13 @@ | ||
| 3719 | 4058 | function replaceVisitorNamePlaceholder(botId, visitorName) { |
| 3720 | 4059 | var chatBox = getElementDOM(botId, 'chat-box'); |
| 3721 | 4060 | if (!chatBox) return; |
| 3722 | 4061 | |
| 3723 | - // Find the first bot message (intro message) | |
| 3724 | - var introMessage = chatBox.querySelector('.bot-message'); | |
| 4062 | + // Find the greeting by its marker, not by position (plan a1a79b) — | |
| 4063 | + // after a persistence restore the first .bot-message is a restored | |
| 4064 | + // reply, and {visitor_name} was being substituted into that instead. | |
| 4065 | + // Positional fallback for HTML cached before this release only. | |
| 4066 | + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message'); | |
| 3725 | 4067 | if (!introMessage) return; |
| 3726 | 4068 | |
| 3727 | 4069 | var messageContent = introMessage.querySelector('div[dir="auto"]'); |
| 3728 | 4070 | if (!messageContent) return; |
| @@ -3866,14 +4208,25 @@ | ||
| 3866 | 4208 | return response.json(); |
| 3867 | 4209 | }) |
| 3868 | 4210 | .then((data) => { |
| 3869 | 4211 | if (data.success) { |
| 3870 | - if (data.data.logged_in || data.data.email) { | |
| 4212 | + // A success response means the server has already decided the | |
| 4213 | + // required info is on file. With email optional (c0cfaf) that | |
| 4214 | + // info can be a name and no address, so keying on data.email | |
| 4215 | + // alone re-prompted a name-only visitor on every page load. | |
| 4216 | + if (data.data.logged_in || data.data.email || data.data.name) { | |
| 3871 | 4217 | showChatContainerForBot(botId); |
| 3872 | 4218 | } else { |
| 3873 | 4219 | showEmailFormForBot(botId); |
| 3874 | 4220 | } |
| 3875 | 4221 | } else { |
| 4222 | + // Pre-fill for a signed-in visitor being shown the form | |
| 4223 | + // (c0cfaf). It arrives on this nocache'd response rather than | |
| 4224 | + // in the page HTML, which is cacheable. Never overwrite what | |
| 4225 | + // the visitor has already typed. | |
| 4226 | + if (data.data) { | |
| 4227 | + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email); | |
| 4228 | + } | |
| 3876 | 4229 | showEmailFormForBot(botId); |
| 3877 | 4230 | } |
| 3878 | 4231 | }) |
| 3879 | 4232 | .catch((error) => { |
| @@ -3880,8 +4233,19 @@ | ||
| 3880 | 4233 | showEmailFormForBot(botId); |
| 3881 | 4234 | }); |
| 3882 | 4235 | } |
| 3883 | 4236 | |
| 4237 | + function prefillEmailForm(botId, name, email) { | |
| 4238 | + var nameInput = getElementDOM(botId, 'user-name'); | |
| 4239 | + var emailInput = getElementDOM(botId, 'user-email'); | |
| 4240 | + if (nameInput && name && !nameInput.value) { | |
| 4241 | + nameInput.value = name; | |
| 4242 | + } | |
| 4243 | + if (emailInput && email && !emailInput.value) { | |
| 4244 | + emailInput.value = email; | |
| 4245 | + } | |
| 4246 | + } | |
| 4247 | + | |
| 3884 | 4248 | // Event delegation for email form submission |
| 3885 | 4249 | $(document).on('submit', '.email-collection-form', function(e) { |
| 3886 | 4250 | e.preventDefault(); |
| 3887 | 4251 | e.stopPropagation(); |
| @@ -3894,23 +4258,34 @@ | ||
| 3894 | 4258 | } |
| 3895 | 4259 | |
| 3896 | 4260 | var emailInput = getElementDOM(botId, 'user-email'); |
| 3897 | 4261 | var nameInput = getElementDOM(botId, 'user-name'); |
| 4262 | + var consentInput = getElementDOM(botId, 'user-consent'); | |
| 3898 | 4263 | var userEmail = emailInput ? emailInput.value.trim() : ''; |
| 3899 | 4264 | var userName = nameInput ? nameInput.value.trim() : ''; |
| 3900 | 4265 | var sessionId = MxChatInstances.ensureSession(botId); |
| 3901 | 4266 | |
| 3902 | - // Validate email | |
| 3903 | - if (!userEmail) { | |
| 4267 | + // Validate email. The address is optional when the owner turned | |
| 4268 | + // "Require Email Address" off (c0cfaf) — but anything actually typed | |
| 4269 | + // still has to be a valid address. The server enforces both rules | |
| 4270 | + // independently. | |
| 4271 | + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off'; | |
| 4272 | + | |
| 4273 | + if (emailRequired && !userEmail) { | |
| 3904 | 4274 | showEmailError(botId, 'Please enter your email address.'); |
| 3905 | 4275 | return false; |
| 3906 | 4276 | } |
| 3907 | 4277 | |
| 3908 | - if (!isValidEmailAddress(userEmail)) { | |
| 4278 | + if (userEmail && !isValidEmailAddress(userEmail)) { | |
| 3909 | 4279 | showEmailError(botId, 'Please enter a valid email address.'); |
| 3910 | 4280 | return false; |
| 3911 | 4281 | } |
| 3912 | 4282 | |
| 4283 | + if (!emailRequired && !userEmail && !userName) { | |
| 4284 | + showEmailError(botId, 'Please enter your name or email address.'); | |
| 4285 | + return false; | |
| 4286 | + } | |
| 4287 | + | |
| 3913 | 4288 | // Validate name if field exists and has content |
| 3914 | 4289 | if (nameInput && userName && !isValidNameInput(userName)) { |
| 3915 | 4290 | showEmailError(botId, 'Please enter a valid name (2-100 characters).'); |
| 3916 | 4291 | return false; |
| @@ -3915,8 +4290,15 @@ | ||
| 3915 | 4290 | showEmailError(botId, 'Please enter a valid name (2-100 characters).'); |
| 3916 | 4291 | return false; |
| 3917 | 4292 | } |
| 3918 | 4293 | |
| 4294 | + // Consent checkbox (b062c4): backstop behind the native required | |
| 4295 | + // attribute; the server enforces this independently either way. | |
| 4296 | + if (consentInput && consentInput.required && !consentInput.checked) { | |
| 4297 | + showEmailError(botId, 'Please tick the consent box to continue.'); | |
| 4298 | + return false; | |
| 4299 | + } | |
| 4300 | + | |
| 3919 | 4301 | clearEmailError(botId); |
| 3920 | 4302 | setEmailSubmissionState(botId, true); |
| 3921 | 4303 | |
| 3922 | 4304 | // Prepare form data |
| @@ -3930,8 +4312,14 @@ | ||
| 3930 | 4312 | if (userName) { |
| 3931 | 4313 | formData.append('name', userName); |
| 3932 | 4314 | } |
| 3933 | 4315 | |
| 4316 | + // Ticked/unticked both travel when the checkbox is rendered, so an | |
| 4317 | + // optional-consent "no" is recorded as a decision, not an absence. | |
| 4318 | + if (consentInput) { | |
| 4319 | + formData.append('consent', consentInput.checked ? '1' : '0'); | |
| 4320 | + } | |
| 4321 | + | |
| 3934 | 4322 | fetch(mxchatChat.ajax_url, { |
| 3935 | 4323 | method: 'POST', |
| 3936 | 4324 | headers: { |
| 3937 | 4325 | 'Content-Type': 'application/x-www-form-urlencoded', |
| @@ -4053,8 +4441,9 @@ | ||
| 4053 | 4441 | $chatbot.removeClass('hidden').addClass('visible'); |
| 4054 | 4442 | getElement(botId, 'floating-chatbot-button').addClass('hidden'); |
| 4055 | 4443 | handlePreChatDismissal(botId); |
| 4056 | 4444 | disableScroll(); // Disable scroll when chatbot opens |
| 4445 | + mxchatFireOpened(botId); | |
| 4057 | 4446 | |
| 4058 | 4447 | // Load chat history for returning visitors (persistence) |
| 4059 | 4448 | var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; |
| 4060 | 4449 | if (chatPersistenceEnabled) { |
| @@ -4220,8 +4609,15 @@ | ||
| 4220 | 4609 | // Initialize chat visibility for all instances |
| 4221 | 4610 | $('.mxchat-chatbot-wrapper').each(function() { |
| 4222 | 4611 | var botId = $(this).data('bot-id') || 'default'; |
| 4223 | 4612 | initializeChatVisibility(botId); |
| 4613 | + // An embedded widget is on screen from the first paint; a floating one | |
| 4614 | + // reports mxchat:opened when its window is shown (plan 65c9b6). | |
| 4615 | + if (isEmbeddedBot(botId)) { | |
| 4616 | + mxchatFireOpened(botId); | |
| 4617 | + } else { | |
| 4618 | + mxchatWatchForOpen(botId); | |
| 4619 | + } | |
| 4224 | 4620 | }); |
| 4225 | 4621 | |
| 4226 | 4622 | // Make functions globally available for add-ons |
| 4227 | 4623 | window.hasQuickQuestions = hasQuickQuestions; |