| @@ -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() { |
| @@ -759,9 +847,12 @@ | ||
| 759 | 847 | if (typeof customMxChatFilter === 'function') { |
| 760 | 848 | message = customMxChatFilter(message, "prompt"); |
| 761 | 849 | } |
| 762 | 850 | |
| 763 | - 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() !== '') { | |
| 764 | 855 | // Don't disable input in live agent mode - let users chat freely |
| 765 | 856 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 766 | 857 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 767 | 858 | if (!isAgentMode) { |
| @@ -805,8 +896,14 @@ | ||
| 805 | 896 | if (typeof customMxChatFilter === 'function') { |
| 806 | 897 | message = customMxChatFilter(message, "prompt"); |
| 807 | 898 | } |
| 808 | 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 | + | |
| 809 | 906 | // Don't disable input in live agent mode - let users chat freely |
| 810 | 907 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 811 | 908 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 812 | 909 | if (!isAgentMode) { |
| @@ -1665,25 +1762,21 @@ | ||
| 1665 | 1762 | if (this.__mxchatStopJustShown) return; |
| 1666 | 1763 | mxchatStopStreaming(botId); |
| 1667 | 1764 | return; |
| 1668 | 1765 | } |
| 1669 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1670 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1671 | - disableChatInput(botId); | |
| 1672 | - } | |
| 1766 | + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5). | |
| 1673 | 1767 | sendMessage(botId); |
| 1674 | 1768 | }); |
| 1675 | 1769 | |
| 1676 | -// 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). | |
| 1677 | 1775 | $(document).on('keypress', '.chat-input', function(e) { |
| 1678 | 1776 | if (e.which == 13 && !e.shiftKey) { |
| 1679 | 1777 | e.preventDefault(); |
| 1680 | - var botId = getBotIdFromElement(this); | |
| 1681 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1682 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1683 | - disableChatInput(botId); | |
| 1684 | - } | |
| 1685 | - sendMessage(botId); | |
| 1778 | + sendMessage(getBotIdFromElement(this)); | |
| 1686 | 1779 | } |
| 1687 | 1780 | }); |
| 1688 | 1781 | |
| 1689 | 1782 | // Chat input character counter + soft limit feedback (plan 7091a2). |
| @@ -2249,13 +2342,16 @@ | ||
| 2249 | 2342 | |
| 2250 | 2343 | if (images.length > 0) { |
| 2251 | 2344 | fullMessage += '<div class="image-gallery" dir="auto">'; |
| 2252 | 2345 | images.forEach(img => { |
| 2346 | + const safeTitle = sanitizeUserInput(img.title); | |
| 2347 | + const safeUrl = escapeAttr(img.image_url); | |
| 2348 | + const safeThumbnail = escapeAttr(img.thumbnail_url); | |
| 2253 | 2349 | fullMessage += ` |
| 2254 | 2350 | <div style="margin-bottom: 10px;"> |
| 2255 | - <strong>${img.title}</strong><br> | |
| 2256 | - <a href="${img.image_url}" target="_blank"> | |
| 2257 | - <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;" /> | |
| 2258 | 2354 | </a> |
| 2259 | 2355 | </div>`; |
| 2260 | 2356 | }); |
| 2261 | 2357 | fullMessage += '</div>'; |
| @@ -2374,27 +2470,42 @@ | ||
| 2374 | 2470 | // Check for % followed by exactly 2 hex digits |
| 2375 | 2471 | return /%[0-9a-fA-F]{2}/.test(url); |
| 2376 | 2472 | } |
| 2377 | 2473 | |
| 2378 | - // 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. | |
| 2379 | 2480 | function safeEncodeUrl(url) { |
| 2380 | - // If URL already contains encoded characters, return as-is | |
| 2381 | - if (isUrlEncoded(url)) { | |
| 2382 | - return url; | |
| 2383 | - } | |
| 2384 | - // Otherwise, encode it | |
| 2385 | - return encodeURI(url); | |
| 2481 | + const encoded = isUrlEncoded(url) ? url : encodeURI(url); | |
| 2482 | + return escapeAttr(encoded); | |
| 2386 | 2483 | } |
| 2387 | 2484 | |
| 2388 | - // Process markdown headers FIRST | |
| 2389 | - let processedText = formatMarkdownHeaders(inputText); | |
| 2390 | - | |
| 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 | + | |
| 2391 | 2498 | // Process text styling (bold, italic, strikethrough) |
| 2392 | 2499 | processedText = formatTextStyling(processedText); |
| 2393 | - | |
| 2394 | - // 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 | |
| 2395 | 2503 | processedText = formatCodeBlocks(processedText); |
| 2396 | 2504 | |
| 2505 | + // Put the rendered code and the escaped characters back | |
| 2506 | + processedText = literals.restore(processedText); | |
| 2507 | + | |
| 2397 | 2508 | // Process markdown tables BEFORE converting newlines to paragraphs |
| 2398 | 2509 | processedText = formatMarkdownTables(processedText); |
| 2399 | 2510 | |
| 2400 | 2511 | // NOW convert to paragraphs |
| @@ -2406,10 +2517,13 @@ | ||
| 2406 | 2517 | processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => { |
| 2407 | 2518 | // Clean the URL of any trailing punctuation |
| 2408 | 2519 | let cleanUrl = url.replace(/[.,;!?]+$/, ''); |
| 2409 | 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, '>'); | |
| 2410 | 2524 | // Return as a proper link without the brackets |
| 2411 | - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`; | |
| 2525 | + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`; | |
| 2412 | 2526 | }); |
| 2413 | 2527 | |
| 2414 | 2528 | // Process markdown links: [text](url) and [](url) |
| 2415 | 2529 | // Uses balanced parenthesis matching to handle URLs containing parens |
| @@ -2513,8 +2627,30 @@ | ||
| 2513 | 2627 | |
| 2514 | 2628 | return processedText; |
| 2515 | 2629 | } |
| 2516 | 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 | + | |
| 2517 | 2653 | function formatMarkdownHeaders(text) { |
| 2518 | 2654 | // Handle h1 to h6 headers |
| 2519 | 2655 | return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) { |
| 2520 | 2656 | const level = hashes.length; |
| @@ -2556,11 +2692,24 @@ | ||
| 2556 | 2692 | // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind) |
| 2557 | 2693 | // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another * |
| 2558 | 2694 | protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); |
| 2559 | 2695 | |
| 2560 | - // Handle underscores for italic - Safari-compatible (no lookbehind) | |
| 2561 | - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED | |
| 2562 | - 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 | + }); | |
| 2563 | 2712 | |
| 2564 | 2713 | // Handle strikethrough (~~text~~) |
| 2565 | 2714 | protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>'); |
| 2566 | 2715 | |
| @@ -2694,8 +2843,24 @@ | ||
| 2694 | 2843 | div.textContent = text; |
| 2695 | 2844 | return div.innerHTML; |
| 2696 | 2845 | } |
| 2697 | 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 | + | |
| 2698 | 2863 | function escapeHtml(unsafe) { |
| 2699 | 2864 | // Skip escaping if it's already escaped or contains HTML code block markup |
| 2700 | 2865 | if (unsafe.includes('<') || unsafe.includes('>') || |
| 2701 | 2866 | unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) { |
| @@ -3051,9 +3216,18 @@ | ||
| 3051 | 3216 | if (mxLiveRegionEl) { |
| 3052 | 3217 | mxLiveRegionEl.setAttribute('aria-live', 'off'); |
| 3053 | 3218 | } |
| 3054 | 3219 | |
| 3055 | - // IMPORTANT: Clear existing messages before loading history | |
| 3220 | + // IMPORTANT: Clear existing messages before loading history. | |
| 3221 | + // Detach the greeting first and put it back below — | |
| 3222 | + // it is server-rendered and never stored in the | |
| 3223 | + // transcript, so the old unconditional .empty() | |
| 3224 | + // deleted it for the rest of the page life (plan | |
| 3225 | + // a1a79b). Detach rather than rebuild: intro_message | |
| 3226 | + // is not localized to JS, and this node already | |
| 3227 | + // carries the per-bot inline colors and any | |
| 3228 | + // {visitor_name} substitution already applied to it. | |
| 3229 | + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach(); | |
| 3056 | 3230 | $chatBox.empty(); |
| 3057 | 3231 | |
| 3058 | 3232 | $.each(response.data.conversation, function(index, message) { |
| 3059 | 3233 | // Skip agent messages if persistence is off |
| @@ -3090,8 +3264,17 @@ | ||
| 3090 | 3264 | var content = message.content; |
| 3091 | 3265 | content = content.replace(/\\'/g, "'").replace(/\\"/g, '"'); |
| 3092 | 3266 | content = decodeHTMLEntities(content); |
| 3093 | 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 | + | |
| 3094 | 3277 | // Skip linkify for messages containing structured HTML |
| 3095 | 3278 | // (forms, product cards, galleries, etc.) to avoid |
| 3096 | 3279 | // markdown formatting corrupting HTML attributes |
| 3097 | 3280 | // (e.g. underscores in name="field_name" becoming <em> tags). |
| @@ -3120,9 +3303,16 @@ | ||
| 3120 | 3303 | instance.processedMessageIds.add(message.id); |
| 3121 | 3304 | } |
| 3122 | 3305 | }); |
| 3123 | 3306 | |
| 3124 | - // Only append messages and scroll if we have content | |
| 3307 | + // Only append messages and scroll if we have content. | |
| 3308 | + // Greeting goes back FIRST, above the restored | |
| 3309 | + // history: "Hello — [earlier conversation]" is the | |
| 3310 | + // natural reading and matches the order a fresh | |
| 3311 | + // visitor sees (plan a1a79b). | |
| 3312 | + if ($mxIntro && $mxIntro.length) { | |
| 3313 | + $chatBox.append($mxIntro); | |
| 3314 | + } | |
| 3125 | 3315 | $chatBox.append($fragment); |
| 3126 | 3316 | scrollToBottom(botId, true); |
| 3127 | 3317 | |
| 3128 | 3318 | // Re-attach live semantics AFTER the rehydration |
| @@ -3467,8 +3657,9 @@ | ||
| 3467 | 3657 | $(this).addClass('hidden').attr('aria-expanded', 'true'); |
| 3468 | 3658 | $badge.hide(); // Hide notification when opening chat |
| 3469 | 3659 | disableScroll(); |
| 3470 | 3660 | $preChat.fadeOut(250); |
| 3661 | + mxchatFireOpened(botId); | |
| 3471 | 3662 | |
| 3472 | 3663 | // First open per page load: re-fetch behavior settings in case |
| 3473 | 3664 | // this page's inline values came from a stale full-page cache |
| 3474 | 3665 | // (plan-32db95). Idempotent — later opens are a no-op. |
| @@ -3737,9 +3928,12 @@ | ||
| 3737 | 3928 | $('.chat-toolbar').show(); |
| 3738 | 3929 | } else { |
| 3739 | 3930 | $('.chat-toolbar').hide(); |
| 3740 | 3931 | } |
| 3741 | - | |
| 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 | + | |
| 3742 | 3936 | // Apply toolbar icon colors |
| 3743 | 3937 | const toolbarElements = [ |
| 3744 | 3938 | '#mxchat-chatbot .toolbar-btn svg', |
| 3745 | 3939 | '#mxchat-chatbot .active-pdf-name', |
| @@ -3864,10 +4058,13 @@ | ||
| 3864 | 4058 | function replaceVisitorNamePlaceholder(botId, visitorName) { |
| 3865 | 4059 | var chatBox = getElementDOM(botId, 'chat-box'); |
| 3866 | 4060 | if (!chatBox) return; |
| 3867 | 4061 | |
| 3868 | - // Find the first bot message (intro message) | |
| 3869 | - var introMessage = chatBox.querySelector('.bot-message'); | |
| 4062 | + // Find the greeting by its marker, not by position (plan a1a79b) — | |
| 4063 | + // after a persistence restore the first .bot-message is a restored | |
| 4064 | + // reply, and {visitor_name} was being substituted into that instead. | |
| 4065 | + // Positional fallback for HTML cached before this release only. | |
| 4066 | + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message'); | |
| 3870 | 4067 | if (!introMessage) return; |
| 3871 | 4068 | |
| 3872 | 4069 | var messageContent = introMessage.querySelector('div[dir="auto"]'); |
| 3873 | 4070 | if (!messageContent) return; |
| @@ -4011,14 +4208,25 @@ | ||
| 4011 | 4208 | return response.json(); |
| 4012 | 4209 | }) |
| 4013 | 4210 | .then((data) => { |
| 4014 | 4211 | if (data.success) { |
| 4015 | - 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) { | |
| 4016 | 4217 | showChatContainerForBot(botId); |
| 4017 | 4218 | } else { |
| 4018 | 4219 | showEmailFormForBot(botId); |
| 4019 | 4220 | } |
| 4020 | 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 | + } | |
| 4021 | 4229 | showEmailFormForBot(botId); |
| 4022 | 4230 | } |
| 4023 | 4231 | }) |
| 4024 | 4232 | .catch((error) => { |
| @@ -4025,8 +4233,19 @@ | ||
| 4025 | 4233 | showEmailFormForBot(botId); |
| 4026 | 4234 | }); |
| 4027 | 4235 | } |
| 4028 | 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 | + | |
| 4029 | 4248 | // Event delegation for email form submission |
| 4030 | 4249 | $(document).on('submit', '.email-collection-form', function(e) { |
| 4031 | 4250 | e.preventDefault(); |
| 4032 | 4251 | e.stopPropagation(); |
| @@ -4044,19 +4263,29 @@ | ||
| 4044 | 4263 | var userEmail = emailInput ? emailInput.value.trim() : ''; |
| 4045 | 4264 | var userName = nameInput ? nameInput.value.trim() : ''; |
| 4046 | 4265 | var sessionId = MxChatInstances.ensureSession(botId); |
| 4047 | 4266 | |
| 4048 | - // Validate email | |
| 4049 | - 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) { | |
| 4050 | 4274 | showEmailError(botId, 'Please enter your email address.'); |
| 4051 | 4275 | return false; |
| 4052 | 4276 | } |
| 4053 | 4277 | |
| 4054 | - if (!isValidEmailAddress(userEmail)) { | |
| 4278 | + if (userEmail && !isValidEmailAddress(userEmail)) { | |
| 4055 | 4279 | showEmailError(botId, 'Please enter a valid email address.'); |
| 4056 | 4280 | return false; |
| 4057 | 4281 | } |
| 4058 | 4282 | |
| 4283 | + if (!emailRequired && !userEmail && !userName) { | |
| 4284 | + showEmailError(botId, 'Please enter your name or email address.'); | |
| 4285 | + return false; | |
| 4286 | + } | |
| 4287 | + | |
| 4059 | 4288 | // Validate name if field exists and has content |
| 4060 | 4289 | if (nameInput && userName && !isValidNameInput(userName)) { |
| 4061 | 4290 | showEmailError(botId, 'Please enter a valid name (2-100 characters).'); |
| 4062 | 4291 | return false; |
| @@ -4212,8 +4441,9 @@ | ||
| 4212 | 4441 | $chatbot.removeClass('hidden').addClass('visible'); |
| 4213 | 4442 | getElement(botId, 'floating-chatbot-button').addClass('hidden'); |
| 4214 | 4443 | handlePreChatDismissal(botId); |
| 4215 | 4444 | disableScroll(); // Disable scroll when chatbot opens |
| 4445 | + mxchatFireOpened(botId); | |
| 4216 | 4446 | |
| 4217 | 4447 | // Load chat history for returning visitors (persistence) |
| 4218 | 4448 | var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; |
| 4219 | 4449 | if (chatPersistenceEnabled) { |
| @@ -4379,8 +4609,15 @@ | ||
| 4379 | 4609 | // Initialize chat visibility for all instances |
| 4380 | 4610 | $('.mxchat-chatbot-wrapper').each(function() { |
| 4381 | 4611 | var botId = $(this).data('bot-id') || 'default'; |
| 4382 | 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 | + } | |
| 4383 | 4620 | }); |
| 4384 | 4621 | |
| 4385 | 4622 | // Make functions globally available for add-ons |
| 4386 | 4623 | window.hasQuickQuestions = hasQuickQuestions; |