| @@ -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 |
| @@ -317,8 +325,50 @@ | ||
| 317 | 325 | } |
| 318 | 326 | }; |
| 319 | 327 | |
| 320 | 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 | + // ==================================== | |
| 321 | 371 | // ELEMENT SELECTOR HELPERS |
| 322 | 372 | // ==================================== |
| 323 | 373 | |
| 324 | 374 | // Check if a specific bot has an AI theme assigned (skip inline colors) |
| @@ -471,23 +521,28 @@ | ||
| 471 | 521 | // ==================================== |
| 472 | 522 | // CONTEXTUAL AWARENESS FUNCTIONALITY |
| 473 | 523 | // ==================================== |
| 474 | 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 | + | |
| 475 | 530 | function getPageContext() { |
| 476 | 531 | // Check if contextual awareness is enabled |
| 477 | 532 | if (mxchatChat.contextual_awareness_toggle !== 'on') { |
| 478 | 533 | return null; |
| 479 | 534 | } |
| 480 | - | |
| 535 | + | |
| 481 | 536 | // Get page URL |
| 482 | 537 | const pageUrl = window.location.href; |
| 483 | - | |
| 538 | + | |
| 484 | 539 | // Get page title |
| 485 | 540 | const pageTitle = document.title || ''; |
| 486 | - | |
| 541 | + | |
| 487 | 542 | // Get main content from the page |
| 488 | 543 | let pageContent = ''; |
| 489 | - | |
| 544 | + | |
| 490 | 545 | // Try to get content from common content areas |
| 491 | 546 | const contentSelectors = [ |
| 492 | 547 | 'main', |
| 493 | 548 | '[role="main"]', |
| @@ -499,9 +554,9 @@ | ||
| 499 | 554 | 'article', |
| 500 | 555 | '#content', |
| 501 | 556 | '#main' |
| 502 | 557 | ]; |
| 503 | - | |
| 558 | + | |
| 504 | 559 | let contentElement = null; |
| 505 | 560 | for (const selector of contentSelectors) { |
| 506 | 561 | contentElement = document.querySelector(selector); |
| 507 | 562 | if (contentElement) { |
| @@ -507,18 +562,31 @@ | ||
| 507 | 562 | if (contentElement) { |
| 508 | 563 | break; |
| 509 | 564 | } |
| 510 | 565 | } |
| 511 | - | |
| 566 | + | |
| 512 | 567 | // If no specific content area found, use body but exclude header, footer, nav, sidebar |
| 513 | 568 | if (!contentElement) { |
| 514 | 569 | contentElement = document.body; |
| 515 | 570 | } |
| 516 | - | |
| 571 | + | |
| 517 | 572 | if (contentElement) { |
| 518 | - // Clone the element to avoid modifying the original | |
| 519 | - const clone = contentElement.cloneNode(true); | |
| 520 | - | |
| 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 | + | |
| 521 | 589 | // Remove unwanted elements |
| 522 | 590 | const unwantedSelectors = [ |
| 523 | 591 | 'header', |
| 524 | 592 | 'footer', |
| @@ -536,16 +604,25 @@ | ||
| 536 | 604 | '#floating-chatbot', |
| 537 | 605 | '#floating-chatbot-button', |
| 538 | 606 | '.mxchat', |
| 539 | 607 | '[class*="chat"]', |
| 540 | - '[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' | |
| 541 | 618 | ]; |
| 542 | - | |
| 619 | + | |
| 543 | 620 | unwantedSelectors.forEach(selector => { |
| 544 | 621 | const elements = clone.querySelectorAll(selector); |
| 545 | 622 | elements.forEach(el => el.remove()); |
| 546 | 623 | }); |
| 547 | - | |
| 624 | + | |
| 548 | 625 | // Extract MxChat context data attributes before getting text content |
| 549 | 626 | const contextData = []; |
| 550 | 627 | clone.querySelectorAll('[data-mxchat-context]').forEach(el => { |
| 551 | 628 | const contextValue = el.dataset.mxchatContext; |
| @@ -552,34 +629,34 @@ | ||
| 552 | 629 | if (contextValue && contextValue.trim()) { |
| 553 | 630 | contextData.push(contextValue); |
| 554 | 631 | } |
| 555 | 632 | }); |
| 556 | - | |
| 633 | + | |
| 557 | 634 | // Get text content and clean it up |
| 558 | 635 | pageContent = clone.textContent || clone.innerText || ''; |
| 559 | - | |
| 636 | + | |
| 560 | 637 | // Add context data to page content if any were found |
| 561 | 638 | if (contextData.length > 0) { |
| 562 | 639 | pageContent += '\n\nAdditional Context:\n' + contextData.join('\n'); |
| 563 | 640 | } |
| 564 | - | |
| 641 | + | |
| 565 | 642 | // Clean up whitespace and limit length |
| 566 | 643 | pageContent = pageContent |
| 567 | 644 | .replace(/\s+/g, ' ') |
| 568 | 645 | .trim() |
| 569 | 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; | |
| 570 | 656 | } |
| 571 | - | |
| 572 | - // Only return context if we have meaningful content | |
| 573 | - if (!pageContent || pageContent.length < 50) { | |
| 574 | - return null; | |
| 575 | - } | |
| 576 | - | |
| 577 | - return { | |
| 578 | - url: pageUrl, | |
| 579 | - title: pageTitle, | |
| 580 | - content: pageContent | |
| 581 | - }; | |
| 657 | + | |
| 658 | + return null; | |
| 582 | 659 | } |
| 583 | 660 | |
| 584 | 661 | // Track originating page when chat starts |
| 585 | 662 | function trackOriginatingPage() { |
| @@ -770,9 +847,12 @@ | ||
| 770 | 847 | if (typeof customMxChatFilter === 'function') { |
| 771 | 848 | message = customMxChatFilter(message, "prompt"); |
| 772 | 849 | } |
| 773 | 850 | |
| 774 | - 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() !== '') { | |
| 775 | 855 | // Don't disable input in live agent mode - let users chat freely |
| 776 | 856 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 777 | 857 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 778 | 858 | if (!isAgentMode) { |
| @@ -816,8 +896,14 @@ | ||
| 816 | 896 | if (typeof customMxChatFilter === 'function') { |
| 817 | 897 | message = customMxChatFilter(message, "prompt"); |
| 818 | 898 | } |
| 819 | 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 | + | |
| 820 | 906 | // Don't disable input in live agent mode - let users chat freely |
| 821 | 907 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 822 | 908 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 823 | 909 | if (!isAgentMode) { |
| @@ -1676,25 +1762,21 @@ | ||
| 1676 | 1762 | if (this.__mxchatStopJustShown) return; |
| 1677 | 1763 | mxchatStopStreaming(botId); |
| 1678 | 1764 | return; |
| 1679 | 1765 | } |
| 1680 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1681 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1682 | - disableChatInput(botId); | |
| 1683 | - } | |
| 1766 | + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5). | |
| 1684 | 1767 | sendMessage(botId); |
| 1685 | 1768 | }); |
| 1686 | 1769 | |
| 1687 | -// 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). | |
| 1688 | 1775 | $(document).on('keypress', '.chat-input', function(e) { |
| 1689 | 1776 | if (e.which == 13 && !e.shiftKey) { |
| 1690 | 1777 | e.preventDefault(); |
| 1691 | - var botId = getBotIdFromElement(this); | |
| 1692 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1693 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1694 | - disableChatInput(botId); | |
| 1695 | - } | |
| 1696 | - sendMessage(botId); | |
| 1778 | + sendMessage(getBotIdFromElement(this)); | |
| 1697 | 1779 | } |
| 1698 | 1780 | }); |
| 1699 | 1781 | |
| 1700 | 1782 | // Chat input character counter + soft limit feedback (plan 7091a2). |
| @@ -2260,13 +2342,16 @@ | ||
| 2260 | 2342 | |
| 2261 | 2343 | if (images.length > 0) { |
| 2262 | 2344 | fullMessage += '<div class="image-gallery" dir="auto">'; |
| 2263 | 2345 | images.forEach(img => { |
| 2346 | + const safeTitle = sanitizeUserInput(img.title); | |
| 2347 | + const safeUrl = escapeAttr(img.image_url); | |
| 2348 | + const safeThumbnail = escapeAttr(img.thumbnail_url); | |
| 2264 | 2349 | fullMessage += ` |
| 2265 | 2350 | <div style="margin-bottom: 10px;"> |
| 2266 | - <strong>${img.title}</strong><br> | |
| 2267 | - <a href="${img.image_url}" target="_blank"> | |
| 2268 | - <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;" /> | |
| 2269 | 2354 | </a> |
| 2270 | 2355 | </div>`; |
| 2271 | 2356 | }); |
| 2272 | 2357 | fullMessage += '</div>'; |
| @@ -2385,27 +2470,42 @@ | ||
| 2385 | 2470 | // Check for % followed by exactly 2 hex digits |
| 2386 | 2471 | return /%[0-9a-fA-F]{2}/.test(url); |
| 2387 | 2472 | } |
| 2388 | 2473 | |
| 2389 | - // 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. | |
| 2390 | 2480 | function safeEncodeUrl(url) { |
| 2391 | - // If URL already contains encoded characters, return as-is | |
| 2392 | - if (isUrlEncoded(url)) { | |
| 2393 | - return url; | |
| 2394 | - } | |
| 2395 | - // Otherwise, encode it | |
| 2396 | - return encodeURI(url); | |
| 2481 | + const encoded = isUrlEncoded(url) ? url : encodeURI(url); | |
| 2482 | + return escapeAttr(encoded); | |
| 2397 | 2483 | } |
| 2398 | 2484 | |
| 2399 | - // Process markdown headers FIRST | |
| 2400 | - let processedText = formatMarkdownHeaders(inputText); | |
| 2401 | - | |
| 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 | + | |
| 2402 | 2498 | // Process text styling (bold, italic, strikethrough) |
| 2403 | 2499 | processedText = formatTextStyling(processedText); |
| 2404 | - | |
| 2405 | - // 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 | |
| 2406 | 2503 | processedText = formatCodeBlocks(processedText); |
| 2407 | 2504 | |
| 2505 | + // Put the rendered code and the escaped characters back | |
| 2506 | + processedText = literals.restore(processedText); | |
| 2507 | + | |
| 2408 | 2508 | // Process markdown tables BEFORE converting newlines to paragraphs |
| 2409 | 2509 | processedText = formatMarkdownTables(processedText); |
| 2410 | 2510 | |
| 2411 | 2511 | // NOW convert to paragraphs |
| @@ -2417,10 +2517,13 @@ | ||
| 2417 | 2517 | processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => { |
| 2418 | 2518 | // Clean the URL of any trailing punctuation |
| 2419 | 2519 | let cleanUrl = url.replace(/[.,;!?]+$/, ''); |
| 2420 | 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, '>'); | |
| 2421 | 2524 | // Return as a proper link without the brackets |
| 2422 | - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`; | |
| 2525 | + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`; | |
| 2423 | 2526 | }); |
| 2424 | 2527 | |
| 2425 | 2528 | // Process markdown links: [text](url) and [](url) |
| 2426 | 2529 | // Uses balanced parenthesis matching to handle URLs containing parens |
| @@ -2524,8 +2627,30 @@ | ||
| 2524 | 2627 | |
| 2525 | 2628 | return processedText; |
| 2526 | 2629 | } |
| 2527 | 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 | + | |
| 2528 | 2653 | function formatMarkdownHeaders(text) { |
| 2529 | 2654 | // Handle h1 to h6 headers |
| 2530 | 2655 | return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) { |
| 2531 | 2656 | const level = hashes.length; |
| @@ -2567,11 +2692,24 @@ | ||
| 2567 | 2692 | // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind) |
| 2568 | 2693 | // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another * |
| 2569 | 2694 | protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); |
| 2570 | 2695 | |
| 2571 | - // Handle underscores for italic - Safari-compatible (no lookbehind) | |
| 2572 | - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED | |
| 2573 | - 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 | + }); | |
| 2574 | 2712 | |
| 2575 | 2713 | // Handle strikethrough (~~text~~) |
| 2576 | 2714 | protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>'); |
| 2577 | 2715 | |
| @@ -2705,8 +2843,24 @@ | ||
| 2705 | 2843 | div.textContent = text; |
| 2706 | 2844 | return div.innerHTML; |
| 2707 | 2845 | } |
| 2708 | 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 | + | |
| 2709 | 2863 | function escapeHtml(unsafe) { |
| 2710 | 2864 | // Skip escaping if it's already escaped or contains HTML code block markup |
| 2711 | 2865 | if (unsafe.includes('<') || unsafe.includes('>') || |
| 2712 | 2866 | unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) { |
| @@ -3110,8 +3264,17 @@ | ||
| 3110 | 3264 | var content = message.content; |
| 3111 | 3265 | content = content.replace(/\\'/g, "'").replace(/\\"/g, '"'); |
| 3112 | 3266 | content = decodeHTMLEntities(content); |
| 3113 | 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 | + | |
| 3114 | 3277 | // Skip linkify for messages containing structured HTML |
| 3115 | 3278 | // (forms, product cards, galleries, etc.) to avoid |
| 3116 | 3279 | // markdown formatting corrupting HTML attributes |
| 3117 | 3280 | // (e.g. underscores in name="field_name" becoming <em> tags). |
| @@ -3494,8 +3657,9 @@ | ||
| 3494 | 3657 | $(this).addClass('hidden').attr('aria-expanded', 'true'); |
| 3495 | 3658 | $badge.hide(); // Hide notification when opening chat |
| 3496 | 3659 | disableScroll(); |
| 3497 | 3660 | $preChat.fadeOut(250); |
| 3661 | + mxchatFireOpened(botId); | |
| 3498 | 3662 | |
| 3499 | 3663 | // First open per page load: re-fetch behavior settings in case |
| 3500 | 3664 | // this page's inline values came from a stale full-page cache |
| 3501 | 3665 | // (plan-32db95). Idempotent — later opens are a no-op. |
| @@ -3764,9 +3928,12 @@ | ||
| 3764 | 3928 | $('.chat-toolbar').show(); |
| 3765 | 3929 | } else { |
| 3766 | 3930 | $('.chat-toolbar').hide(); |
| 3767 | 3931 | } |
| 3768 | - | |
| 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 | + | |
| 3769 | 3936 | // Apply toolbar icon colors |
| 3770 | 3937 | const toolbarElements = [ |
| 3771 | 3938 | '#mxchat-chatbot .toolbar-btn svg', |
| 3772 | 3939 | '#mxchat-chatbot .active-pdf-name', |
| @@ -4041,14 +4208,25 @@ | ||
| 4041 | 4208 | return response.json(); |
| 4042 | 4209 | }) |
| 4043 | 4210 | .then((data) => { |
| 4044 | 4211 | if (data.success) { |
| 4045 | - 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) { | |
| 4046 | 4217 | showChatContainerForBot(botId); |
| 4047 | 4218 | } else { |
| 4048 | 4219 | showEmailFormForBot(botId); |
| 4049 | 4220 | } |
| 4050 | 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 | + } | |
| 4051 | 4229 | showEmailFormForBot(botId); |
| 4052 | 4230 | } |
| 4053 | 4231 | }) |
| 4054 | 4232 | .catch((error) => { |
| @@ -4055,8 +4233,19 @@ | ||
| 4055 | 4233 | showEmailFormForBot(botId); |
| 4056 | 4234 | }); |
| 4057 | 4235 | } |
| 4058 | 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 | + | |
| 4059 | 4248 | // Event delegation for email form submission |
| 4060 | 4249 | $(document).on('submit', '.email-collection-form', function(e) { |
| 4061 | 4250 | e.preventDefault(); |
| 4062 | 4251 | e.stopPropagation(); |
| @@ -4074,19 +4263,29 @@ | ||
| 4074 | 4263 | var userEmail = emailInput ? emailInput.value.trim() : ''; |
| 4075 | 4264 | var userName = nameInput ? nameInput.value.trim() : ''; |
| 4076 | 4265 | var sessionId = MxChatInstances.ensureSession(botId); |
| 4077 | 4266 | |
| 4078 | - // Validate email | |
| 4079 | - 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) { | |
| 4080 | 4274 | showEmailError(botId, 'Please enter your email address.'); |
| 4081 | 4275 | return false; |
| 4082 | 4276 | } |
| 4083 | 4277 | |
| 4084 | - if (!isValidEmailAddress(userEmail)) { | |
| 4278 | + if (userEmail && !isValidEmailAddress(userEmail)) { | |
| 4085 | 4279 | showEmailError(botId, 'Please enter a valid email address.'); |
| 4086 | 4280 | return false; |
| 4087 | 4281 | } |
| 4088 | 4282 | |
| 4283 | + if (!emailRequired && !userEmail && !userName) { | |
| 4284 | + showEmailError(botId, 'Please enter your name or email address.'); | |
| 4285 | + return false; | |
| 4286 | + } | |
| 4287 | + | |
| 4089 | 4288 | // Validate name if field exists and has content |
| 4090 | 4289 | if (nameInput && userName && !isValidNameInput(userName)) { |
| 4091 | 4290 | showEmailError(botId, 'Please enter a valid name (2-100 characters).'); |
| 4092 | 4291 | return false; |
| @@ -4242,8 +4441,9 @@ | ||
| 4242 | 4441 | $chatbot.removeClass('hidden').addClass('visible'); |
| 4243 | 4442 | getElement(botId, 'floating-chatbot-button').addClass('hidden'); |
| 4244 | 4443 | handlePreChatDismissal(botId); |
| 4245 | 4444 | disableScroll(); // Disable scroll when chatbot opens |
| 4445 | + mxchatFireOpened(botId); | |
| 4246 | 4446 | |
| 4247 | 4447 | // Load chat history for returning visitors (persistence) |
| 4248 | 4448 | var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; |
| 4249 | 4449 | if (chatPersistenceEnabled) { |
| @@ -4409,8 +4609,15 @@ | ||
| 4409 | 4609 | // Initialize chat visibility for all instances |
| 4410 | 4610 | $('.mxchat-chatbot-wrapper').each(function() { |
| 4411 | 4611 | var botId = $(this).data('bot-id') || 'default'; |
| 4412 | 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 | + } | |
| 4413 | 4620 | }); |
| 4414 | 4621 | |
| 4415 | 4622 | // Make functions globally available for add-ons |
| 4416 | 4623 | window.hasQuickQuestions = hasQuickQuestions; |