| @@ -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) |
| @@ -460,23 +521,28 @@ | ||
| 460 | 521 | // ==================================== |
| 461 | 522 | // CONTEXTUAL AWARENESS FUNCTIONALITY |
| 462 | 523 | // ==================================== |
| 463 | 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 | + | |
| 464 | 530 | function getPageContext() { |
| 465 | 531 | // Check if contextual awareness is enabled |
| 466 | 532 | if (mxchatChat.contextual_awareness_toggle !== 'on') { |
| 467 | 533 | return null; |
| 468 | 534 | } |
| 469 | - | |
| 535 | + | |
| 470 | 536 | // Get page URL |
| 471 | 537 | const pageUrl = window.location.href; |
| 472 | - | |
| 538 | + | |
| 473 | 539 | // Get page title |
| 474 | 540 | const pageTitle = document.title || ''; |
| 475 | - | |
| 541 | + | |
| 476 | 542 | // Get main content from the page |
| 477 | 543 | let pageContent = ''; |
| 478 | - | |
| 544 | + | |
| 479 | 545 | // Try to get content from common content areas |
| 480 | 546 | const contentSelectors = [ |
| 481 | 547 | 'main', |
| 482 | 548 | '[role="main"]', |
| @@ -488,9 +554,9 @@ | ||
| 488 | 554 | 'article', |
| 489 | 555 | '#content', |
| 490 | 556 | '#main' |
| 491 | 557 | ]; |
| 492 | - | |
| 558 | + | |
| 493 | 559 | let contentElement = null; |
| 494 | 560 | for (const selector of contentSelectors) { |
| 495 | 561 | contentElement = document.querySelector(selector); |
| 496 | 562 | if (contentElement) { |
| @@ -496,18 +562,31 @@ | ||
| 496 | 562 | if (contentElement) { |
| 497 | 563 | break; |
| 498 | 564 | } |
| 499 | 565 | } |
| 500 | - | |
| 566 | + | |
| 501 | 567 | // If no specific content area found, use body but exclude header, footer, nav, sidebar |
| 502 | 568 | if (!contentElement) { |
| 503 | 569 | contentElement = document.body; |
| 504 | 570 | } |
| 505 | - | |
| 571 | + | |
| 506 | 572 | if (contentElement) { |
| 507 | - // Clone the element to avoid modifying the original | |
| 508 | - const clone = contentElement.cloneNode(true); | |
| 509 | - | |
| 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 | + | |
| 510 | 589 | // Remove unwanted elements |
| 511 | 590 | const unwantedSelectors = [ |
| 512 | 591 | 'header', |
| 513 | 592 | 'footer', |
| @@ -525,16 +604,25 @@ | ||
| 525 | 604 | '#floating-chatbot', |
| 526 | 605 | '#floating-chatbot-button', |
| 527 | 606 | '.mxchat', |
| 528 | 607 | '[class*="chat"]', |
| 529 | - '[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' | |
| 530 | 618 | ]; |
| 531 | - | |
| 619 | + | |
| 532 | 620 | unwantedSelectors.forEach(selector => { |
| 533 | 621 | const elements = clone.querySelectorAll(selector); |
| 534 | 622 | elements.forEach(el => el.remove()); |
| 535 | 623 | }); |
| 536 | - | |
| 624 | + | |
| 537 | 625 | // Extract MxChat context data attributes before getting text content |
| 538 | 626 | const contextData = []; |
| 539 | 627 | clone.querySelectorAll('[data-mxchat-context]').forEach(el => { |
| 540 | 628 | const contextValue = el.dataset.mxchatContext; |
| @@ -541,34 +629,34 @@ | ||
| 541 | 629 | if (contextValue && contextValue.trim()) { |
| 542 | 630 | contextData.push(contextValue); |
| 543 | 631 | } |
| 544 | 632 | }); |
| 545 | - | |
| 633 | + | |
| 546 | 634 | // Get text content and clean it up |
| 547 | 635 | pageContent = clone.textContent || clone.innerText || ''; |
| 548 | - | |
| 636 | + | |
| 549 | 637 | // Add context data to page content if any were found |
| 550 | 638 | if (contextData.length > 0) { |
| 551 | 639 | pageContent += '\n\nAdditional Context:\n' + contextData.join('\n'); |
| 552 | 640 | } |
| 553 | - | |
| 641 | + | |
| 554 | 642 | // Clean up whitespace and limit length |
| 555 | 643 | pageContent = pageContent |
| 556 | 644 | .replace(/\s+/g, ' ') |
| 557 | 645 | .trim() |
| 558 | 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; | |
| 559 | 656 | } |
| 560 | - | |
| 561 | - // Only return context if we have meaningful content | |
| 562 | - if (!pageContent || pageContent.length < 50) { | |
| 563 | - return null; | |
| 564 | - } | |
| 565 | - | |
| 566 | - return { | |
| 567 | - url: pageUrl, | |
| 568 | - title: pageTitle, | |
| 569 | - content: pageContent | |
| 570 | - }; | |
| 657 | + | |
| 658 | + return null; | |
| 571 | 659 | } |
| 572 | 660 | |
| 573 | 661 | // Track originating page when chat starts |
| 574 | 662 | function trackOriginatingPage() { |
| @@ -619,8 +707,26 @@ | ||
| 619 | 707 | sendButton.style.pointerEvents = 'none'; |
| 620 | 708 | } |
| 621 | 709 | } |
| 622 | 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 | + | |
| 623 | 729 | function enableChatInput(botId) { |
| 624 | 730 | botId = botId || 'default'; |
| 625 | 731 | var chatInput = getElementDOM(botId, 'chat-input'); |
| 626 | 732 | var sendButton = getElementDOM(botId, 'send-button'); |
| @@ -626,9 +732,11 @@ | ||
| 626 | 732 | var sendButton = getElementDOM(botId, 'send-button'); |
| 627 | 733 | if (chatInput) { |
| 628 | 734 | chatInput.disabled = false; |
| 629 | 735 | chatInput.style.opacity = '1'; |
| 630 | - chatInput.focus(); | |
| 736 | + if (mxchatShouldAutofocusAfterReply()) { | |
| 737 | + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); } | |
| 738 | + } | |
| 631 | 739 | } |
| 632 | 740 | if (sendButton) { |
| 633 | 741 | sendButton.disabled = false; |
| 634 | 742 | sendButton.style.opacity = '1'; |
| @@ -739,9 +847,12 @@ | ||
| 739 | 847 | if (typeof customMxChatFilter === 'function') { |
| 740 | 848 | message = customMxChatFilter(message, "prompt"); |
| 741 | 849 | } |
| 742 | 850 | |
| 743 | - 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() !== '') { | |
| 744 | 855 | // Don't disable input in live agent mode - let users chat freely |
| 745 | 856 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 746 | 857 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 747 | 858 | if (!isAgentMode) { |
| @@ -758,14 +869,16 @@ | ||
| 758 | 869 | } |
| 759 | 870 | appendThinkingMessage(botId); |
| 760 | 871 | scrollToBottom(botId); |
| 761 | 872 | |
| 762 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 873 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 763 | 874 | |
| 764 | 875 | // Check if streaming is enabled AND supported for this model |
| 765 | 876 | if (shouldUseStreaming(currentModel)) { |
| 766 | 877 | callMxChatStream(message, function(response) { |
| 767 | - 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'); | |
| 768 | 881 | }, botId); |
| 769 | 882 | } else { |
| 770 | 883 | callMxChat(message, function(response) { |
| 771 | 884 | replaceLastMessage("bot", response, '', [], botId); |
| @@ -783,8 +896,14 @@ | ||
| 783 | 896 | if (typeof customMxChatFilter === 'function') { |
| 784 | 897 | message = customMxChatFilter(message, "prompt"); |
| 785 | 898 | } |
| 786 | 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 | + | |
| 787 | 906 | // Don't disable input in live agent mode - let users chat freely |
| 788 | 907 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 789 | 908 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 790 | 909 | if (!isAgentMode) { |
| @@ -798,14 +917,15 @@ | ||
| 798 | 917 | } |
| 799 | 918 | appendThinkingMessage(botId); |
| 800 | 919 | scrollToBottom(botId); |
| 801 | 920 | |
| 802 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 921 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 803 | 922 | |
| 804 | 923 | // Check if streaming is enabled AND supported for this model |
| 805 | 924 | if (shouldUseStreaming(currentModel)) { |
| 806 | 925 | callMxChatStream(message, function(response) { |
| 807 | - 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'); | |
| 808 | 928 | }, botId); |
| 809 | 929 | } else { |
| 810 | 930 | callMxChat(message, function(response) { |
| 811 | 931 | getElement(botId, 'chat-box').find('.temporary-message').remove(); |
| @@ -975,12 +1095,13 @@ | ||
| 975 | 1095 | // Re-send the original message with the new session (user message is already displayed) |
| 976 | 1096 | var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); |
| 977 | 1097 | if (originalMessage) { |
| 978 | 1098 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); |
| 979 | - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1099 | + var currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 980 | 1100 | if (shouldUseStreaming(currentModel)) { |
| 981 | 1101 | callMxChatStream(originalMessage, function(response) { |
| 982 | - 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'); | |
| 983 | 1104 | }, botId); |
| 984 | 1105 | } else { |
| 985 | 1106 | callMxChat(originalMessage, function(response) { |
| 986 | 1107 | replaceLastMessage("bot", response, '', [], botId); |
| @@ -1128,9 +1249,9 @@ | ||
| 1128 | 1249 | |
| 1129 | 1250 | // Store the message in case we need to retry after session reset |
| 1130 | 1251 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message); |
| 1131 | 1252 | |
| 1132 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1253 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 1133 | 1254 | if (!isStreamingSupported(currentModel)) { |
| 1134 | 1255 | callMxChat(message, callback, botId); |
| 1135 | 1256 | return; |
| 1136 | 1257 | } |
| @@ -1185,11 +1306,26 @@ | ||
| 1185 | 1306 | let testingDataReceived = false; |
| 1186 | 1307 | let streamingStarted = false; |
| 1187 | 1308 | // Server-pushed html to append as its OWN bot bubble once the stream |
| 1188 | 1309 | // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is |
| 1189 | - // deferred to [DONE] so the embed always lands BELOW the streamed text. | |
| 1310 | + // deferred to the end of the stream so the embed always lands BELOW the | |
| 1311 | + // streamed text. | |
| 1190 | 1312 | let pendingAppendHtml = ''; |
| 1191 | 1313 | |
| 1314 | + // Render the stashed appendix as its own bot bubble below the streamed | |
| 1315 | + // text — mirrors how it is saved in the transcript, so history replays | |
| 1316 | + // identically. Called from BOTH ends of a stream: the [DONE] line and | |
| 1317 | + // the stream simply closing (the OpenAI Responses path and Gemini end | |
| 1318 | + // without a [DONE] line). The stash is cleared, so a stream that sends | |
| 1319 | + // [DONE] and then closes renders it once. Any future appendix-type | |
| 1320 | + // event belongs in here, not in one of the two branches. | |
| 1321 | + function flushAppendHtml() { | |
| 1322 | + if (pendingAppendHtml) { | |
| 1323 | + appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1324 | + pendingAppendHtml = ''; | |
| 1325 | + } | |
| 1326 | + } | |
| 1327 | + | |
| 1192 | 1328 | // Abortable stream: a fresh controller per turn, keyed by bot instance. |
| 1193 | 1329 | // The Stop control (send button swapped in place) aborts both the read |
| 1194 | 1330 | // loop and the underlying request. |
| 1195 | 1331 | var streamControl = { controller: new AbortController(), aborted: false }; |
| @@ -1270,8 +1406,10 @@ | ||
| 1270 | 1406 | |
| 1271 | 1407 | // Re-enable chat input when stream ends with content |
| 1272 | 1408 | enableChatInput(botId); |
| 1273 | 1409 | |
| 1410 | + flushAppendHtml(); | |
| 1411 | + | |
| 1274 | 1412 | // Scroll the user's last message to the top now that the |
| 1275 | 1413 | // bot's full reply has rendered (gives max reading room). |
| 1276 | 1414 | var $chatBoxDone = getElement(botId, 'chat-box'); |
| 1277 | 1415 | var $lastUserMsgDone = $chatBoxDone.find('.user-message').last(); |
| @@ -1302,16 +1440,9 @@ | ||
| 1302 | 1440 | |
| 1303 | 1441 | // Re-enable chat input after streaming completes |
| 1304 | 1442 | enableChatInput(botId); |
| 1305 | 1443 | |
| 1306 | - // Render any server-pushed appendix html (e.g. the | |
| 1307 | - // YouTube embed) as its own bot bubble below the | |
| 1308 | - // streamed text — mirrors how it is saved in the | |
| 1309 | - // transcript, so history replays identically. | |
| 1310 | - if (pendingAppendHtml) { | |
| 1311 | - appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1312 | - pendingAppendHtml = ''; | |
| 1313 | - } | |
| 1444 | + flushAppendHtml(); | |
| 1314 | 1445 | |
| 1315 | 1446 | // Scroll the user's last message to the top now |
| 1316 | 1447 | // that the bot's full reply has rendered. |
| 1317 | 1448 | var $chatBoxStreamDone = getElement(botId, 'chat-box'); |
| @@ -1350,8 +1481,17 @@ | ||
| 1350 | 1481 | // Stash appendix html (e.g. video embed) for [DONE] |
| 1351 | 1482 | else if (json.append_html) { |
| 1352 | 1483 | pendingAppendHtml = json.append_html; |
| 1353 | 1484 | } |
| 1485 | + // Server-side final pass changed the assembled text | |
| 1486 | + // (ffef6f: dead-link stripping) — swap the rendered | |
| 1487 | + // bubble for the validated version. Arrives at most | |
| 1488 | + // once, just before [DONE]. | |
| 1489 | + else if (json.replace_content) { | |
| 1490 | + streamingStarted = true; | |
| 1491 | + accumulatedContent = json.replace_content; | |
| 1492 | + updateStreamingMessage(accumulatedContent, botId); | |
| 1493 | + } | |
| 1354 | 1494 | // Handle complete response in stream (fallback response) |
| 1355 | 1495 | else if (json.text || json.message || json.html) { |
| 1356 | 1496 | handleNonStreamResponse(json, callback, botId); |
| 1357 | 1497 | return; |
| @@ -1453,9 +1593,9 @@ | ||
| 1453 | 1593 | // Re-send the original message with the new session (user message is already displayed) |
| 1454 | 1594 | var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); |
| 1455 | 1595 | if (originalMessage) { |
| 1456 | 1596 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); |
| 1457 | - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1597 | + var currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 1458 | 1598 | if (shouldUseStreaming(currentModel)) { |
| 1459 | 1599 | callMxChatStream(originalMessage, callback, botId); |
| 1460 | 1600 | } else { |
| 1461 | 1601 | callMxChat(originalMessage, callback, botId); |
| @@ -1588,8 +1728,15 @@ | ||
| 1588 | 1728 | var $chatBox = getElement(botId, 'chat-box'); |
| 1589 | 1729 | const tempMessage = $chatBox.find('.bot-message.temporary-message').last(); |
| 1590 | 1730 | |
| 1591 | 1731 | if (tempMessage.length) { |
| 1732 | + // aria-busy=true for the whole stream: the bubble is rewritten on | |
| 1733 | + // every chunk, and without busy a polite live region announces those | |
| 1734 | + // rewrites continuously. Flipped false once the reply is final, so | |
| 1735 | + // assistive tech announces the completed message ONCE (plan 67f126). | |
| 1736 | + if (tempMessage.attr('aria-busy') !== 'true') { | |
| 1737 | + tempMessage.attr('aria-busy', 'true'); | |
| 1738 | + } | |
| 1592 | 1739 | // Update existing message |
| 1593 | 1740 | tempMessage.html(formattedContent); |
| 1594 | 1741 | } else { |
| 1595 | 1742 | // Create new temporary message if it doesn't exist |
| @@ -1625,25 +1772,21 @@ | ||
| 1625 | 1772 | if (this.__mxchatStopJustShown) return; |
| 1626 | 1773 | mxchatStopStreaming(botId); |
| 1627 | 1774 | return; |
| 1628 | 1775 | } |
| 1629 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1630 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1631 | - disableChatInput(botId); | |
| 1632 | - } | |
| 1776 | + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5). | |
| 1633 | 1777 | sendMessage(botId); |
| 1634 | 1778 | }); |
| 1635 | 1779 | |
| 1636 | -// Override enter key handler (using event delegation) | |
| 1780 | +// Enter sends, Shift+Enter inserts a newline (using event delegation). | |
| 1781 | +// sendMessage() owns the disable: it greys the box only when a real message is | |
| 1782 | +// about to go out. Disabling here first left the widget locked for good after | |
| 1783 | +// Enter on an empty box - the send returned early and no reply path ever | |
| 1784 | +// re-enabled it (plan b67fd5). | |
| 1637 | 1785 | $(document).on('keypress', '.chat-input', function(e) { |
| 1638 | 1786 | if (e.which == 13 && !e.shiftKey) { |
| 1639 | 1787 | e.preventDefault(); |
| 1640 | - var botId = getBotIdFromElement(this); | |
| 1641 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1642 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1643 | - disableChatInput(botId); | |
| 1644 | - } | |
| 1645 | - sendMessage(botId); | |
| 1788 | + sendMessage(getBotIdFromElement(this)); | |
| 1646 | 1789 | } |
| 1647 | 1790 | }); |
| 1648 | 1791 | |
| 1649 | 1792 | // Chat input character counter + soft limit feedback (plan 7091a2). |
| @@ -2083,9 +2226,11 @@ | ||
| 2083 | 2226 | |
| 2084 | 2227 | messageDiv.html(fullMessage); |
| 2085 | 2228 | |
| 2086 | 2229 | if (isTemporary) { |
| 2087 | - messageDiv.addClass('temporary-message'); | |
| 2230 | + // In-flight bubble: hold aria-busy so the live region stays quiet | |
| 2231 | + // until the content is finalized (plan 67f126). | |
| 2232 | + messageDiv.addClass('temporary-message').attr('aria-busy', 'true'); | |
| 2088 | 2233 | } |
| 2089 | 2234 | |
| 2090 | 2235 | // Append to the correct chatbot instance's chat-box |
| 2091 | 2236 | var $chatBox = getElement(botId, 'chat-box'); |
| @@ -2116,8 +2261,56 @@ | ||
| 2116 | 2261 | // Error rendering message - silently continue |
| 2117 | 2262 | } |
| 2118 | 2263 | } |
| 2119 | 2264 | |
| 2265 | +// Link clicks in bot and agent messages: the click is recorded, the browser | |
| 2266 | +// follows the anchor itself. Navigation must not wait for the tracking | |
| 2267 | +// request: a window.open() made from that request's callback is outside the | |
| 2268 | +// visitor's tap, and Safari's pop-up blocker drops it without a message. | |
| 2269 | + | |
| 2270 | +// Give a tracked anchor the target it should open in, and noopener on a new tab. | |
| 2271 | +function prepareTrackedLink($link) { | |
| 2272 | + if (linkTarget === '_blank' && $link.attr('target') !== '_blank') { | |
| 2273 | + $link.attr('target', '_blank'); | |
| 2274 | + } | |
| 2275 | + if ($link.attr('target') === '_blank') { | |
| 2276 | + var rel = $link.attr('rel') || ''; | |
| 2277 | + if (!/(^|\s)noopener(\s|$)/.test(rel)) { | |
| 2278 | + $link.attr('rel', $.trim(rel + ' noopener')); | |
| 2279 | + } | |
| 2280 | + } | |
| 2281 | +} | |
| 2282 | + | |
| 2283 | +// Record one click, fire-and-forget. sendBeacon survives the page unloading | |
| 2284 | +// when the link opens in the same tab. | |
| 2285 | +function trackLinkClick(href, messageContext, botId) { | |
| 2286 | + var fields = { | |
| 2287 | + action: 'mxchat_track_url_click', | |
| 2288 | + session_id: getChatSession(botId), | |
| 2289 | + url: href, | |
| 2290 | + message_context: messageContext, | |
| 2291 | + nonce: mxchatChat.nonce | |
| 2292 | + }; | |
| 2293 | + try { | |
| 2294 | + if (navigator.sendBeacon && typeof FormData !== 'undefined') { | |
| 2295 | + var body = new FormData(); | |
| 2296 | + $.each(fields, function(name, value) { | |
| 2297 | + body.append(name, value == null ? '' : value); | |
| 2298 | + }); | |
| 2299 | + if (navigator.sendBeacon(mxchatChat.ajax_url, body)) { | |
| 2300 | + return; | |
| 2301 | + } | |
| 2302 | + } | |
| 2303 | + } catch (error) { | |
| 2304 | + // Fall through to the plain request | |
| 2305 | + } | |
| 2306 | + $.ajax({ | |
| 2307 | + url: mxchatChat.ajax_url, | |
| 2308 | + type: 'POST', | |
| 2309 | + data: fields | |
| 2310 | + }); | |
| 2311 | +} | |
| 2312 | + | |
| 2120 | 2313 | // Helper function to attach link tracking with proper event handling |
| 2121 | 2314 | function attachLinkTracking(messageDiv, messageText, botId) { |
| 2122 | 2315 | botId = botId || 'default'; |
| 2123 | 2316 | // Use a slight delay to ensure DOM is ready |
| @@ -2135,11 +2328,13 @@ | ||
| 2135 | 2328 | if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { |
| 2136 | 2329 | // Remove any existing click handlers first |
| 2137 | 2330 | $link.off('click.tracking'); |
| 2138 | 2331 | |
| 2332 | + // The anchor carries its own target: the browser follows it | |
| 2333 | + prepareTrackedLink($link); | |
| 2334 | + | |
| 2139 | 2335 | // Add new click handler with namespace |
| 2140 | 2336 | $link.on('click.tracking', function(e) { |
| 2141 | - e.preventDefault(); | |
| 2142 | 2337 | e.stopPropagation(); |
| 2143 | 2338 | |
| 2144 | 2339 | const messageContext = typeof messageText === 'string' |
| 2145 | 2340 | ? messageText.substring(0, 200) |
| @@ -2144,30 +2339,10 @@ | ||
| 2144 | 2339 | const messageContext = typeof messageText === 'string' |
| 2145 | 2340 | ? messageText.substring(0, 200) |
| 2146 | 2341 | : ''; |
| 2147 | 2342 | |
| 2148 | - // Track the click | |
| 2149 | - $.ajax({ | |
| 2150 | - url: mxchatChat.ajax_url, | |
| 2151 | - type: 'POST', | |
| 2152 | - data: { | |
| 2153 | - action: 'mxchat_track_url_click', | |
| 2154 | - session_id: getChatSession(botId), | |
| 2155 | - url: originalHref, | |
| 2156 | - message_context: messageContext, | |
| 2157 | - nonce: mxchatChat.nonce | |
| 2158 | - }, | |
| 2159 | - complete: function() { | |
| 2160 | - // Always redirect, even if tracking fails | |
| 2161 | - if ($link.attr('target') === '_blank' || linkTarget === '_blank') { | |
| 2162 | - window.open(originalHref, '_blank'); | |
| 2163 | - } else { | |
| 2164 | - window.location.href = originalHref; | |
| 2165 | - } | |
| 2166 | - } | |
| 2167 | - }); | |
| 2168 | - | |
| 2169 | - return false; // Extra insurance to prevent default | |
| 2343 | + prepareTrackedLink($link); | |
| 2344 | + trackLinkClick(originalHref, messageContext, botId); | |
| 2170 | 2345 | }); |
| 2171 | 2346 | } |
| 2172 | 2347 | }); |
| 2173 | 2348 | }, 100); // Small delay to ensure DOM is ready |
| @@ -2207,13 +2382,16 @@ | ||
| 2207 | 2382 | |
| 2208 | 2383 | if (images.length > 0) { |
| 2209 | 2384 | fullMessage += '<div class="image-gallery" dir="auto">'; |
| 2210 | 2385 | images.forEach(img => { |
| 2386 | + const safeTitle = sanitizeUserInput(img.title); | |
| 2387 | + const safeUrl = escapeAttr(img.image_url); | |
| 2388 | + const safeThumbnail = escapeAttr(img.thumbnail_url); | |
| 2211 | 2389 | fullMessage += ` |
| 2212 | 2390 | <div style="margin-bottom: 10px;"> |
| 2213 | - <strong>${img.title}</strong><br> | |
| 2214 | - <a href="${img.image_url}" target="_blank"> | |
| 2215 | - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" /> | |
| 2391 | + <strong>${safeTitle}</strong><br> | |
| 2392 | + <a href="${safeUrl}" target="_blank"> | |
| 2393 | + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" /> | |
| 2216 | 2394 | </a> |
| 2217 | 2395 | </div>`; |
| 2218 | 2396 | }); |
| 2219 | 2397 | fullMessage += '</div>'; |
| @@ -2220,13 +2398,16 @@ | ||
| 2220 | 2398 | } |
| 2221 | 2399 | |
| 2222 | 2400 | if (lastMessageDiv.length) { |
| 2223 | 2401 | // Replace content immediately to prevent visual gap between thinking dots and response |
| 2402 | + // aria-busy released AFTER the final content is set, so the live region | |
| 2403 | + // announces the finished message once (plan 67f126). | |
| 2224 | 2404 | lastMessageDiv |
| 2225 | 2405 | .html(fullMessage) |
| 2226 | 2406 | .removeClass('bot-message user-message temporary-message') |
| 2227 | 2407 | .addClass(messageClass) |
| 2228 | - .attr('dir', 'auto'); | |
| 2408 | + .attr('dir', 'auto') | |
| 2409 | + .attr('aria-busy', 'false'); | |
| 2229 | 2410 | |
| 2230 | 2411 | // Only apply inline colors if AI theme is not active (let CSS handle it) |
| 2231 | 2412 | var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId); |
| 2232 | 2413 | if (!skipColors) { |
| @@ -2286,10 +2467,15 @@ | ||
| 2286 | 2467 | var botMessageFontColor = mxchatChat.bot_message_font_color; |
| 2287 | 2468 | var botMessageBgColor = mxchatChat.bot_message_bg_color; |
| 2288 | 2469 | |
| 2289 | 2470 | // Build thinking dots HTML - skip inline colors if AI theme is active |
| 2471 | + // The dots are decorative; the sr-only span is what the live region | |
| 2472 | + // announces for the waiting state (plan 67f126). Server-localized | |
| 2473 | + // string — safe to inject (esc_html__ output, no user content). | |
| 2290 | 2474 | var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"'; |
| 2291 | - var thinkingHtml = '<div class="thinking-dots-container">' + | |
| 2475 | + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing'; | |
| 2476 | + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' + | |
| 2477 | + '<div class="thinking-dots-container" aria-hidden="true">' + | |
| 2292 | 2478 | '<div class="thinking-dots">' + |
| 2293 | 2479 | '<span class="dot"' + dotStyle + '></span>' + |
| 2294 | 2480 | '<span class="dot"' + dotStyle + '></span>' + |
| 2295 | 2481 | '<span class="dot"' + dotStyle + '></span>' + |
| @@ -2324,27 +2510,42 @@ | ||
| 2324 | 2510 | // Check for % followed by exactly 2 hex digits |
| 2325 | 2511 | return /%[0-9a-fA-F]{2}/.test(url); |
| 2326 | 2512 | } |
| 2327 | 2513 | |
| 2328 | - // Helper function to safely encode URLs only if needed | |
| 2514 | + // Helper function to safely encode URLs only if needed, then make the | |
| 2515 | + // result attribute-safe. "Don't double-encode an already-encoded URL" | |
| 2516 | + // used to mean "return it untouched" — so a literal quote riding next | |
| 2517 | + // to a %20 reached href="…" intact and closed the attribute | |
| 2518 | + // (plan 2db182). Every href this function feeds now goes through | |
| 2519 | + // escapeAttr(), whether or not the URL was already percent-encoded. | |
| 2329 | 2520 | function safeEncodeUrl(url) { |
| 2330 | - // If URL already contains encoded characters, return as-is | |
| 2331 | - if (isUrlEncoded(url)) { | |
| 2332 | - return url; | |
| 2333 | - } | |
| 2334 | - // Otherwise, encode it | |
| 2335 | - return encodeURI(url); | |
| 2521 | + const encoded = isUrlEncoded(url) ? url : encodeURI(url); | |
| 2522 | + return escapeAttr(encoded); | |
| 2336 | 2523 | } |
| 2337 | 2524 | |
| 2338 | - // Process markdown headers FIRST | |
| 2339 | - let processedText = formatMarkdownHeaders(inputText); | |
| 2340 | - | |
| 2525 | + // Take backslash escapes and code (fenced + inline) out of the text FIRST | |
| 2526 | + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a | |
| 2527 | + // backtick could not protect an underscore and \_ was not an escape at | |
| 2528 | + // all; the header rule also ran over fenced code. Each escape becomes a | |
| 2529 | + // placeholder carrying its literal character, each code span a | |
| 2530 | + // placeholder carrying its rendered HTML; both are restored below, after | |
| 2531 | + // the styling rules have run, so nothing downstream changes. | |
| 2532 | + const literals = protectLiterals(inputText); | |
| 2533 | + let processedText = literals.text; | |
| 2534 | + | |
| 2535 | + // Process markdown headers | |
| 2536 | + processedText = formatMarkdownHeaders(processedText); | |
| 2537 | + | |
| 2341 | 2538 | // Process text styling (bold, italic, strikethrough) |
| 2342 | 2539 | processedText = formatTextStyling(processedText); |
| 2343 | - | |
| 2344 | - // Process code blocks BEFORE processing links | |
| 2540 | + | |
| 2541 | + // Legacy raw <?php ... ?> blocks (fenced + inline code were already | |
| 2542 | + // rendered inside protectLiterals) — still BEFORE processing links | |
| 2345 | 2543 | processedText = formatCodeBlocks(processedText); |
| 2346 | 2544 | |
| 2545 | + // Put the rendered code and the escaped characters back | |
| 2546 | + processedText = literals.restore(processedText); | |
| 2547 | + | |
| 2347 | 2548 | // Process markdown tables BEFORE converting newlines to paragraphs |
| 2348 | 2549 | processedText = formatMarkdownTables(processedText); |
| 2349 | 2550 | |
| 2350 | 2551 | // NOW convert to paragraphs |
| @@ -2356,10 +2557,13 @@ | ||
| 2356 | 2557 | processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => { |
| 2357 | 2558 | // Clean the URL of any trailing punctuation |
| 2358 | 2559 | let cleanUrl = url.replace(/[.,;!?]+$/, ''); |
| 2359 | 2560 | const safeUrl = safeEncodeUrl(cleanUrl); |
| 2561 | + // The bracket body is the only link text that can carry angle | |
| 2562 | + // brackets (the standalone matcher excludes them) — keep it text. | |
| 2563 | + const safeText = cleanUrl.replace(/</g, '<').replace(/>/g, '>'); | |
| 2360 | 2564 | // Return as a proper link without the brackets |
| 2361 | - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`; | |
| 2565 | + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`; | |
| 2362 | 2566 | }); |
| 2363 | 2567 | |
| 2364 | 2568 | // Process markdown links: [text](url) and [](url) |
| 2365 | 2569 | // Uses balanced parenthesis matching to handle URLs containing parens |
| @@ -2433,9 +2637,17 @@ | ||
| 2433 | 2637 | }); |
| 2434 | 2638 | |
| 2435 | 2639 | // Process standalone URLs - but NOT if they're already in <a> tags or brackets |
| 2436 | 2640 | // Updated pattern to be more careful about what it matches |
| 2437 | - const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim; | |
| 2641 | + // | |
| 2642 | + // The prefix group (plan 78618d) is the start of a line, an ordinary | |
| 2643 | + // character, OR a tag this formatter itself opened or closed (paragraph, | |
| 2644 | + // list item, table cell, heading, emphasis, line break), so an address | |
| 2645 | + // that opens a paragraph or sits inside **bold** is linked too. Never | |
| 2646 | + // an anchor (its text would be linked twice; the lookahead guards that | |
| 2647 | + // as well) and never a code span, which stays as typed. No lookbehind, | |
| 2648 | + // for Safari. | |
| 2649 | + const urlPattern = /(^|[^">=\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim; | |
| 2438 | 2650 | processedText = processedText.replace(urlPattern, (match, prefix, url) => { |
| 2439 | 2651 | // Extra check: make sure this isn't already linked |
| 2440 | 2652 | if (match.includes('href=') || match.includes('</a>')) { |
| 2441 | 2653 | return match; |
| @@ -2446,10 +2658,14 @@ | ||
| 2446 | 2658 | const safeUrl = safeEncodeUrl(cleanUrl); |
| 2447 | 2659 | return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`; |
| 2448 | 2660 | }); |
| 2449 | 2661 | |
| 2450 | - // Process www. URLs - but NOT if they're already in <a> tags or brackets | |
| 2451 | - const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim; | |
| 2662 | + // Process www. URLs - but NOT if they're already in <a> tags or brackets. | |
| 2663 | + // The address stops at the same characters the https pattern stops at | |
| 2664 | + // (plan 78618d): it used to run through the closing paragraph tag, so | |
| 2665 | + // "Try www.example.com/page." linked to www.example.com/page.%3C/p%3E. | |
| 2666 | + // Same prefix group as the https pattern, same reasons. | |
| 2667 | + const wwwPattern = /(^|[^">/\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(www\.[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim; | |
| 2452 | 2668 | processedText = processedText.replace(wwwPattern, (match, prefix, url) => { |
| 2453 | 2669 | // Extra check: make sure this isn't already linked |
| 2454 | 2670 | if (match.includes('href=') || match.includes('</a>')) { |
| 2455 | 2671 | return match; |
| @@ -2463,8 +2679,30 @@ | ||
| 2463 | 2679 | |
| 2464 | 2680 | return processedText; |
| 2465 | 2681 | } |
| 2466 | 2682 | |
| 2683 | + // One left-to-right pass over backslash escapes and code (plan 9dc34f). | |
| 2684 | + // A single alternation keeps the two honest with each other: an escaped | |
| 2685 | + // backtick is consumed as an escape before it can open a code span, and a | |
| 2686 | + // code span is consumed whole so the escapes inside it stay verbatim. | |
| 2687 | + // Placeholders are private-use characters — no underscore, asterisk, | |
| 2688 | + // backtick, hash, pipe or bracket for a later rule to match. | |
| 2689 | + function protectLiterals(text) { | |
| 2690 | + const store = []; | |
| 2691 | + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) { | |
| 2692 | + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match)); | |
| 2693 | + return '\uE000' + (store.length - 1) + '\uE001'; | |
| 2694 | + }); | |
| 2695 | + return { | |
| 2696 | + text: out, | |
| 2697 | + restore: function(t) { | |
| 2698 | + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) { | |
| 2699 | + return store[Number(i)]; | |
| 2700 | + }); | |
| 2701 | + } | |
| 2702 | + }; | |
| 2703 | + } | |
| 2704 | + | |
| 2467 | 2705 | function formatMarkdownHeaders(text) { |
| 2468 | 2706 | // Handle h1 to h6 headers |
| 2469 | 2707 | return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) { |
| 2470 | 2708 | const level = hashes.length; |
| @@ -2491,13 +2729,19 @@ | ||
| 2491 | 2729 | protectedSegments.push(match); |
| 2492 | 2730 | return placeholder; |
| 2493 | 2731 | }); |
| 2494 | 2732 | |
| 2495 | - // Step 1c: Also protect bare URLs that might exist | |
| 2733 | + // Step 1c: Also protect bare URLs that might exist. Trailing emphasis | |
| 2734 | + // markers are not part of the address (plan 78618d): **https://x.com** | |
| 2735 | + // used to protect "https://x.com**", so the bold never paired and the | |
| 2736 | + // asterisks ended up inside the link. The markers stay outside the | |
| 2737 | + // placeholder so the styling rules below can pair them. | |
| 2496 | 2738 | protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) { |
| 2739 | + const markers = match.match(/[*_~]+$/); | |
| 2740 | + const url = markers ? match.slice(0, match.length - markers[0].length) : match; | |
| 2497 | 2741 | const placeholder = `__PROTECTED_${protectedSegments.length}__`; |
| 2498 | - protectedSegments.push(match); | |
| 2499 | - return placeholder; | |
| 2742 | + protectedSegments.push(url); | |
| 2743 | + return placeholder + (markers ? markers[0] : ''); | |
| 2500 | 2744 | }); |
| 2501 | 2745 | |
| 2502 | 2746 | // Step 2: Now apply text styling to the protected text |
| 2503 | 2747 | // Handle bold text (**text**) |
| @@ -2506,11 +2750,24 @@ | ||
| 2506 | 2750 | // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind) |
| 2507 | 2751 | // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another * |
| 2508 | 2752 | protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); |
| 2509 | 2753 | |
| 2510 | - // Handle underscores for italic - Safari-compatible (no lookbehind) | |
| 2511 | - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED | |
| 2512 | - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>'); | |
| 2754 | + // Handle underscores for italic - Safari-compatible (no lookbehind), and | |
| 2755 | + // word-bounded (plan 9dc34f): the opener must follow the start of the | |
| 2756 | + // text or a non-word character, the closer must precede the end or a | |
| 2757 | + // non-word character, and the content may not start or end with a | |
| 2758 | + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed | |
| 2759 | + // while "use _this_ word" still italicises. A token whose content is | |
| 2760 | + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder | |
| 2761 | + // convention, not prose emphasis — it is left verbatim too (ticket 440). | |
| 2762 | + // __PROTECTED_N__ placeholders cannot match: their underscores are all | |
| 2763 | + // adjacent to another underscore or a word character. | |
| 2764 | + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) { | |
| 2765 | + if (/^[\p{Lu}\p{N}]+$/u.test(content)) { | |
| 2766 | + return match; | |
| 2767 | + } | |
| 2768 | + return before + '<em>' + content + '</em>'; | |
| 2769 | + }); | |
| 2513 | 2770 | |
| 2514 | 2771 | // Handle strikethrough (~~text~~) |
| 2515 | 2772 | protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>'); |
| 2516 | 2773 | |
| @@ -2644,8 +2901,24 @@ | ||
| 2644 | 2901 | div.textContent = text; |
| 2645 | 2902 | return div.innerHTML; |
| 2646 | 2903 | } |
| 2647 | 2904 | |
| 2905 | + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182). | |
| 2906 | + // Percent-encodes the characters that can end or extend an attribute. | |
| 2907 | + // Valid in every scheme the widget emits (http, https, tel, mailto) and | |
| 2908 | + // never changes where a legitimate link resolves — encodeURI() already | |
| 2909 | + // produces most of these, this just guarantees them for URLs that skip | |
| 2910 | + // encodeURI() because they were percent-encoded on arrival. | |
| 2911 | + function escapeAttr(url) { | |
| 2912 | + return String(url) | |
| 2913 | + .replace(/"/g, '%22') | |
| 2914 | + .replace(/'/g, '%27') | |
| 2915 | + .replace(/</g, '%3C') | |
| 2916 | + .replace(/>/g, '%3E') | |
| 2917 | + .replace(/`/g, '%60') | |
| 2918 | + .replace(/ /g, '%20'); | |
| 2919 | + } | |
| 2920 | + | |
| 2648 | 2921 | function escapeHtml(unsafe) { |
| 2649 | 2922 | // Skip escaping if it's already escaped or contains HTML code block markup |
| 2650 | 2923 | if (unsafe.includes('<') || unsafe.includes('>') || |
| 2651 | 2924 | unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) { |
| @@ -2989,9 +3262,30 @@ | ||
| 2989 | 3262 | } |
| 2990 | 3263 | |
| 2991 | 3264 | // Only process if there are actual messages |
| 2992 | 3265 | if (response.data.conversation.length > 0) { |
| 2993 | - // IMPORTANT: Clear existing messages before loading history | |
| 3266 | + // Restored history must be SILENT to screen readers | |
| 3267 | + // (plan 67f126): these are DOM additions inside the | |
| 3268 | + // live region and would otherwise announce as if | |
| 3269 | + // they just arrived. Lift aria-live for the batch | |
| 3270 | + // repopulate, restore it after the browser has | |
| 3271 | + // processed the mutations. | |
| 3272 | + var mxLiveRegionEl = $chatBox.get(0); | |
| 3273 | + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null; | |
| 3274 | + if (mxLiveRegionEl) { | |
| 3275 | + mxLiveRegionEl.setAttribute('aria-live', 'off'); | |
| 3276 | + } | |
| 3277 | + | |
| 3278 | + // IMPORTANT: Clear existing messages before loading history. | |
| 3279 | + // Detach the greeting first and put it back below — | |
| 3280 | + // it is server-rendered and never stored in the | |
| 3281 | + // transcript, so the old unconditional .empty() | |
| 3282 | + // deleted it for the rest of the page life (plan | |
| 3283 | + // a1a79b). Detach rather than rebuild: intro_message | |
| 3284 | + // is not localized to JS, and this node already | |
| 3285 | + // carries the per-bot inline colors and any | |
| 3286 | + // {visitor_name} substitution already applied to it. | |
| 3287 | + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach(); | |
| 2994 | 3288 | $chatBox.empty(); |
| 2995 | 3289 | |
| 2996 | 3290 | $.each(response.data.conversation, function(index, message) { |
| 2997 | 3291 | // Skip agent messages if persistence is off |
| @@ -3028,16 +3322,28 @@ | ||
| 3028 | 3322 | var content = message.content; |
| 3029 | 3323 | content = content.replace(/\\'/g, "'").replace(/\\"/g, '"'); |
| 3030 | 3324 | content = decodeHTMLEntities(content); |
| 3031 | 3325 | |
| 3326 | + // A visitor's own rows get the same treatment as the | |
| 3327 | + // live turn (appendMessage escapes user text before | |
| 3328 | + // linkify). Without this a replayed user message came | |
| 3329 | + // back as live markup after a refresh (plan 2db182). | |
| 3330 | + // Bot/agent rows are trusted HTML and stay as they were. | |
| 3331 | + if (message.role === 'user') { | |
| 3332 | + content = sanitizeUserInput(content); | |
| 3333 | + } | |
| 3334 | + | |
| 3032 | 3335 | // Skip linkify for messages containing structured HTML |
| 3033 | 3336 | // (forms, product cards, galleries, etc.) to avoid |
| 3034 | 3337 | // markdown formatting corrupting HTML attributes |
| 3035 | - // (e.g. underscores in name="field_name" becoming <em> tags) | |
| 3036 | - if (content.includes("mxchat-product-card") || | |
| 3037 | - content.includes("mxchat-image-gallery") || | |
| 3038 | - content.includes("mxchat-featured-products") || | |
| 3039 | - content.includes("mxchat-youtube-embed") || | |
| 3338 | + // (e.g. underscores in name="field_name" becoming <em> tags). | |
| 3339 | + // One family check instead of a per-card literal list: any | |
| 3340 | + // element carrying an mxchat- prefixed class is MxChat-generated | |
| 3341 | + // structured markup and replays raw. The old list drifted every | |
| 3342 | + // time an add-on minted a new card class — the filtered-search | |
| 3343 | + // card ("mxchat-filtered-product-card") missed it and replayed | |
| 3344 | + // through linkify as visible markup. | |
| 3345 | + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) || | |
| 3040 | 3346 | content.includes("<form") || |
| 3041 | 3347 | content.includes("<input") || |
| 3042 | 3348 | content.includes("<select") || |
| 3043 | 3349 | content.includes("<textarea")) { |
| @@ -3055,12 +3361,29 @@ | ||
| 3055 | 3361 | instance.processedMessageIds.add(message.id); |
| 3056 | 3362 | } |
| 3057 | 3363 | }); |
| 3058 | 3364 | |
| 3059 | - // Only append messages and scroll if we have content | |
| 3365 | + // Only append messages and scroll if we have content. | |
| 3366 | + // Greeting goes back FIRST, above the restored | |
| 3367 | + // history: "Hello — [earlier conversation]" is the | |
| 3368 | + // natural reading and matches the order a fresh | |
| 3369 | + // visitor sees (plan a1a79b). | |
| 3370 | + if ($mxIntro && $mxIntro.length) { | |
| 3371 | + $chatBox.append($mxIntro); | |
| 3372 | + } | |
| 3060 | 3373 | $chatBox.append($fragment); |
| 3061 | 3374 | scrollToBottom(botId, true); |
| 3062 | 3375 | |
| 3376 | + // Re-attach live semantics AFTER the rehydration | |
| 3377 | + // mutations have been processed with the region off | |
| 3378 | + // (plan 67f126). Restoring later announces nothing | |
| 3379 | + // retroactively; new turns announce normally. | |
| 3380 | + if (mxLiveRegionEl) { | |
| 3381 | + setTimeout(function() { | |
| 3382 | + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite'); | |
| 3383 | + }, 200); | |
| 3384 | + } | |
| 3385 | + | |
| 3063 | 3386 | // Collapse quick questions if we have conversation history |
| 3064 | 3387 | // BUT skip auto-collapse for embedded bots (they should stay expanded) |
| 3065 | 3388 | if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) { |
| 3066 | 3389 | collapseQuickQuestions(botId); |
| @@ -3392,8 +3715,9 @@ | ||
| 3392 | 3715 | $(this).addClass('hidden').attr('aria-expanded', 'true'); |
| 3393 | 3716 | $badge.hide(); // Hide notification when opening chat |
| 3394 | 3717 | disableScroll(); |
| 3395 | 3718 | $preChat.fadeOut(250); |
| 3719 | + mxchatFireOpened(botId); | |
| 3396 | 3720 | |
| 3397 | 3721 | // First open per page load: re-fetch behavior settings in case |
| 3398 | 3722 | // this page's inline values came from a stale full-page cache |
| 3399 | 3723 | // (plan-32db95). Idempotent — later opens are a no-op. |
| @@ -3662,9 +3986,12 @@ | ||
| 3662 | 3986 | $('.chat-toolbar').show(); |
| 3663 | 3987 | } else { |
| 3664 | 3988 | $('.chat-toolbar').hide(); |
| 3665 | 3989 | } |
| 3666 | - | |
| 3990 | + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6). | |
| 3991 | + MxChatInstances.toolbarReady = true; | |
| 3992 | + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' }); | |
| 3993 | + | |
| 3667 | 3994 | // Apply toolbar icon colors |
| 3668 | 3995 | const toolbarElements = [ |
| 3669 | 3996 | '#mxchat-chatbot .toolbar-btn svg', |
| 3670 | 3997 | '#mxchat-chatbot .active-pdf-name', |
| @@ -3789,10 +4116,13 @@ | ||
| 3789 | 4116 | function replaceVisitorNamePlaceholder(botId, visitorName) { |
| 3790 | 4117 | var chatBox = getElementDOM(botId, 'chat-box'); |
| 3791 | 4118 | if (!chatBox) return; |
| 3792 | 4119 | |
| 3793 | - // Find the first bot message (intro message) | |
| 3794 | - var introMessage = chatBox.querySelector('.bot-message'); | |
| 4120 | + // Find the greeting by its marker, not by position (plan a1a79b) — | |
| 4121 | + // after a persistence restore the first .bot-message is a restored | |
| 4122 | + // reply, and {visitor_name} was being substituted into that instead. | |
| 4123 | + // Positional fallback for HTML cached before this release only. | |
| 4124 | + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message'); | |
| 3795 | 4125 | if (!introMessage) return; |
| 3796 | 4126 | |
| 3797 | 4127 | var messageContent = introMessage.querySelector('div[dir="auto"]'); |
| 3798 | 4128 | if (!messageContent) return; |
| @@ -3936,14 +4266,25 @@ | ||
| 3936 | 4266 | return response.json(); |
| 3937 | 4267 | }) |
| 3938 | 4268 | .then((data) => { |
| 3939 | 4269 | if (data.success) { |
| 3940 | - if (data.data.logged_in || data.data.email) { | |
| 4270 | + // A success response means the server has already decided the | |
| 4271 | + // required info is on file. With email optional (c0cfaf) that | |
| 4272 | + // info can be a name and no address, so keying on data.email | |
| 4273 | + // alone re-prompted a name-only visitor on every page load. | |
| 4274 | + if (data.data.logged_in || data.data.email || data.data.name) { | |
| 3941 | 4275 | showChatContainerForBot(botId); |
| 3942 | 4276 | } else { |
| 3943 | 4277 | showEmailFormForBot(botId); |
| 3944 | 4278 | } |
| 3945 | 4279 | } else { |
| 4280 | + // Pre-fill for a signed-in visitor being shown the form | |
| 4281 | + // (c0cfaf). It arrives on this nocache'd response rather than | |
| 4282 | + // in the page HTML, which is cacheable. Never overwrite what | |
| 4283 | + // the visitor has already typed. | |
| 4284 | + if (data.data) { | |
| 4285 | + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email); | |
| 4286 | + } | |
| 3946 | 4287 | showEmailFormForBot(botId); |
| 3947 | 4288 | } |
| 3948 | 4289 | }) |
| 3949 | 4290 | .catch((error) => { |
| @@ -3950,8 +4291,19 @@ | ||
| 3950 | 4291 | showEmailFormForBot(botId); |
| 3951 | 4292 | }); |
| 3952 | 4293 | } |
| 3953 | 4294 | |
| 4295 | + function prefillEmailForm(botId, name, email) { | |
| 4296 | + var nameInput = getElementDOM(botId, 'user-name'); | |
| 4297 | + var emailInput = getElementDOM(botId, 'user-email'); | |
| 4298 | + if (nameInput && name && !nameInput.value) { | |
| 4299 | + nameInput.value = name; | |
| 4300 | + } | |
| 4301 | + if (emailInput && email && !emailInput.value) { | |
| 4302 | + emailInput.value = email; | |
| 4303 | + } | |
| 4304 | + } | |
| 4305 | + | |
| 3954 | 4306 | // Event delegation for email form submission |
| 3955 | 4307 | $(document).on('submit', '.email-collection-form', function(e) { |
| 3956 | 4308 | e.preventDefault(); |
| 3957 | 4309 | e.stopPropagation(); |
| @@ -3964,23 +4316,34 @@ | ||
| 3964 | 4316 | } |
| 3965 | 4317 | |
| 3966 | 4318 | var emailInput = getElementDOM(botId, 'user-email'); |
| 3967 | 4319 | var nameInput = getElementDOM(botId, 'user-name'); |
| 4320 | + var consentInput = getElementDOM(botId, 'user-consent'); | |
| 3968 | 4321 | var userEmail = emailInput ? emailInput.value.trim() : ''; |
| 3969 | 4322 | var userName = nameInput ? nameInput.value.trim() : ''; |
| 3970 | 4323 | var sessionId = MxChatInstances.ensureSession(botId); |
| 3971 | 4324 | |
| 3972 | - // Validate email | |
| 3973 | - if (!userEmail) { | |
| 4325 | + // Validate email. The address is optional when the owner turned | |
| 4326 | + // "Require Email Address" off (c0cfaf) — but anything actually typed | |
| 4327 | + // still has to be a valid address. The server enforces both rules | |
| 4328 | + // independently. | |
| 4329 | + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off'; | |
| 4330 | + | |
| 4331 | + if (emailRequired && !userEmail) { | |
| 3974 | 4332 | showEmailError(botId, 'Please enter your email address.'); |
| 3975 | 4333 | return false; |
| 3976 | 4334 | } |
| 3977 | 4335 | |
| 3978 | - if (!isValidEmailAddress(userEmail)) { | |
| 4336 | + if (userEmail && !isValidEmailAddress(userEmail)) { | |
| 3979 | 4337 | showEmailError(botId, 'Please enter a valid email address.'); |
| 3980 | 4338 | return false; |
| 3981 | 4339 | } |
| 3982 | 4340 | |
| 4341 | + if (!emailRequired && !userEmail && !userName) { | |
| 4342 | + showEmailError(botId, 'Please enter your name or email address.'); | |
| 4343 | + return false; | |
| 4344 | + } | |
| 4345 | + | |
| 3983 | 4346 | // Validate name if field exists and has content |
| 3984 | 4347 | if (nameInput && userName && !isValidNameInput(userName)) { |
| 3985 | 4348 | showEmailError(botId, 'Please enter a valid name (2-100 characters).'); |
| 3986 | 4349 | return false; |
| @@ -3985,8 +4348,15 @@ | ||
| 3985 | 4348 | showEmailError(botId, 'Please enter a valid name (2-100 characters).'); |
| 3986 | 4349 | return false; |
| 3987 | 4350 | } |
| 3988 | 4351 | |
| 4352 | + // Consent checkbox (b062c4): backstop behind the native required | |
| 4353 | + // attribute; the server enforces this independently either way. | |
| 4354 | + if (consentInput && consentInput.required && !consentInput.checked) { | |
| 4355 | + showEmailError(botId, 'Please tick the consent box to continue.'); | |
| 4356 | + return false; | |
| 4357 | + } | |
| 4358 | + | |
| 3989 | 4359 | clearEmailError(botId); |
| 3990 | 4360 | setEmailSubmissionState(botId, true); |
| 3991 | 4361 | |
| 3992 | 4362 | // Prepare form data |
| @@ -4000,8 +4370,14 @@ | ||
| 4000 | 4370 | if (userName) { |
| 4001 | 4371 | formData.append('name', userName); |
| 4002 | 4372 | } |
| 4003 | 4373 | |
| 4374 | + // Ticked/unticked both travel when the checkbox is rendered, so an | |
| 4375 | + // optional-consent "no" is recorded as a decision, not an absence. | |
| 4376 | + if (consentInput) { | |
| 4377 | + formData.append('consent', consentInput.checked ? '1' : '0'); | |
| 4378 | + } | |
| 4379 | + | |
| 4004 | 4380 | fetch(mxchatChat.ajax_url, { |
| 4005 | 4381 | method: 'POST', |
| 4006 | 4382 | headers: { |
| 4007 | 4383 | 'Content-Type': 'application/x-www-form-urlencoded', |
| @@ -4123,8 +4499,9 @@ | ||
| 4123 | 4499 | $chatbot.removeClass('hidden').addClass('visible'); |
| 4124 | 4500 | getElement(botId, 'floating-chatbot-button').addClass('hidden'); |
| 4125 | 4501 | handlePreChatDismissal(botId); |
| 4126 | 4502 | disableScroll(); // Disable scroll when chatbot opens |
| 4503 | + mxchatFireOpened(botId); | |
| 4127 | 4504 | |
| 4128 | 4505 | // Load chat history for returning visitors (persistence) |
| 4129 | 4506 | var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; |
| 4130 | 4507 | if (chatPersistenceEnabled) { |
| @@ -4231,14 +4608,8 @@ | ||
| 4231 | 4608 | if (messageDiv.length > 0) { |
| 4232 | 4609 | const originalHref = $link.attr('href'); |
| 4233 | 4610 | |
| 4234 | 4611 | if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { |
| 4235 | - e.preventDefault(); | |
| 4236 | - e.stopPropagation(); | |
| 4237 | - | |
| 4238 | - // Mark as tracked | |
| 4239 | - $link.attr('data-tracked', 'true'); | |
| 4240 | - | |
| 4241 | 4612 | // Get bot ID from the chat box context |
| 4242 | 4613 | var botId = getBotIdFromElement(this); |
| 4243 | 4614 | |
| 4244 | 4615 | // Get message context from the message div |
| @@ -4243,28 +4614,12 @@ | ||
| 4243 | 4614 | |
| 4244 | 4615 | // Get message context from the message div |
| 4245 | 4616 | const messageText = messageDiv.text().substring(0, 200); |
| 4246 | 4617 | |
| 4247 | - $.ajax({ | |
| 4248 | - url: mxchatChat.ajax_url, | |
| 4249 | - type: 'POST', | |
| 4250 | - data: { | |
| 4251 | - action: 'mxchat_track_url_click', | |
| 4252 | - session_id: getChatSession(botId), | |
| 4253 | - url: originalHref, | |
| 4254 | - message_context: messageText, | |
| 4255 | - nonce: mxchatChat.nonce | |
| 4256 | - }, | |
| 4257 | - complete: function() { | |
| 4258 | - if ($link.attr('target') === '_blank' || linkTarget === '_blank') { | |
| 4259 | - window.open(originalHref, '_blank'); | |
| 4260 | - } else { | |
| 4261 | - window.location.href = originalHref; | |
| 4262 | - } | |
| 4263 | - } | |
| 4264 | - }); | |
| 4265 | - | |
| 4266 | - return false; | |
| 4618 | + // Same path as attachLinkTracking(): set the target, record the | |
| 4619 | + // click, and let the browser follow the anchor. | |
| 4620 | + prepareTrackedLink($link); | |
| 4621 | + trackLinkClick(originalHref, messageText, botId); | |
| 4267 | 4622 | } |
| 4268 | 4623 | } |
| 4269 | 4624 | }); |
| 4270 | 4625 | |
| @@ -4290,8 +4645,15 @@ | ||
| 4290 | 4645 | // Initialize chat visibility for all instances |
| 4291 | 4646 | $('.mxchat-chatbot-wrapper').each(function() { |
| 4292 | 4647 | var botId = $(this).data('bot-id') || 'default'; |
| 4293 | 4648 | initializeChatVisibility(botId); |
| 4649 | + // An embedded widget is on screen from the first paint; a floating one | |
| 4650 | + // reports mxchat:opened when its window is shown (plan 65c9b6). | |
| 4651 | + if (isEmbeddedBot(botId)) { | |
| 4652 | + mxchatFireOpened(botId); | |
| 4653 | + } else { | |
| 4654 | + mxchatWatchForOpen(botId); | |
| 4655 | + } | |
| 4294 | 4656 | }); |
| 4295 | 4657 | |
| 4296 | 4658 | // Make functions globally available for add-ons |
| 4297 | 4659 | window.hasQuickQuestions = hasQuickQuestions; |