PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | js/chat-script.js +305 -68 3.2.20 → 3.2.23 View file →
@@ -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, '&lt;').replace(/>/g, '&gt;');
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('&lt;') || unsafe.includes('&gt;') ||
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;