| @@ -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) { |
| @@ -1220,11 +1306,26 @@ | ||
| 1220 | 1306 | let testingDataReceived = false; |
| 1221 | 1307 | let streamingStarted = false; |
| 1222 | 1308 | // Server-pushed html to append as its OWN bot bubble once the stream |
| 1223 | 1309 | // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is |
| 1224 | - // deferred to [DONE] so the embed always lands BELOW the streamed text. | |
| 1310 | + // deferred to the end of the stream so the embed always lands BELOW the | |
| 1311 | + // streamed text. | |
| 1225 | 1312 | let pendingAppendHtml = ''; |
| 1226 | 1313 | |
| 1314 | + // Render the stashed appendix as its own bot bubble below the streamed | |
| 1315 | + // text — mirrors how it is saved in the transcript, so history replays | |
| 1316 | + // identically. Called from BOTH ends of a stream: the [DONE] line and | |
| 1317 | + // the stream simply closing (the OpenAI Responses path and Gemini end | |
| 1318 | + // without a [DONE] line). The stash is cleared, so a stream that sends | |
| 1319 | + // [DONE] and then closes renders it once. Any future appendix-type | |
| 1320 | + // event belongs in here, not in one of the two branches. | |
| 1321 | + function flushAppendHtml() { | |
| 1322 | + if (pendingAppendHtml) { | |
| 1323 | + appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1324 | + pendingAppendHtml = ''; | |
| 1325 | + } | |
| 1326 | + } | |
| 1327 | + | |
| 1227 | 1328 | // Abortable stream: a fresh controller per turn, keyed by bot instance. |
| 1228 | 1329 | // The Stop control (send button swapped in place) aborts both the read |
| 1229 | 1330 | // loop and the underlying request. |
| 1230 | 1331 | var streamControl = { controller: new AbortController(), aborted: false }; |
| @@ -1305,8 +1406,10 @@ | ||
| 1305 | 1406 | |
| 1306 | 1407 | // Re-enable chat input when stream ends with content |
| 1307 | 1408 | enableChatInput(botId); |
| 1308 | 1409 | |
| 1410 | + flushAppendHtml(); | |
| 1411 | + | |
| 1309 | 1412 | // Scroll the user's last message to the top now that the |
| 1310 | 1413 | // bot's full reply has rendered (gives max reading room). |
| 1311 | 1414 | var $chatBoxDone = getElement(botId, 'chat-box'); |
| 1312 | 1415 | var $lastUserMsgDone = $chatBoxDone.find('.user-message').last(); |
| @@ -1337,16 +1440,9 @@ | ||
| 1337 | 1440 | |
| 1338 | 1441 | // Re-enable chat input after streaming completes |
| 1339 | 1442 | enableChatInput(botId); |
| 1340 | 1443 | |
| 1341 | - // Render any server-pushed appendix html (e.g. the | |
| 1342 | - // YouTube embed) as its own bot bubble below the | |
| 1343 | - // streamed text — mirrors how it is saved in the | |
| 1344 | - // transcript, so history replays identically. | |
| 1345 | - if (pendingAppendHtml) { | |
| 1346 | - appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1347 | - pendingAppendHtml = ''; | |
| 1348 | - } | |
| 1444 | + flushAppendHtml(); | |
| 1349 | 1445 | |
| 1350 | 1446 | // Scroll the user's last message to the top now |
| 1351 | 1447 | // that the bot's full reply has rendered. |
| 1352 | 1448 | var $chatBoxStreamDone = getElement(botId, 'chat-box'); |
| @@ -1676,25 +1772,21 @@ | ||
| 1676 | 1772 | if (this.__mxchatStopJustShown) return; |
| 1677 | 1773 | mxchatStopStreaming(botId); |
| 1678 | 1774 | return; |
| 1679 | 1775 | } |
| 1680 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1681 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1682 | - disableChatInput(botId); | |
| 1683 | - } | |
| 1776 | + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5). | |
| 1684 | 1777 | sendMessage(botId); |
| 1685 | 1778 | }); |
| 1686 | 1779 | |
| 1687 | -// Override enter key handler (using event delegation) | |
| 1780 | +// Enter sends, Shift+Enter inserts a newline (using event delegation). | |
| 1781 | +// sendMessage() owns the disable: it greys the box only when a real message is | |
| 1782 | +// about to go out. Disabling here first left the widget locked for good after | |
| 1783 | +// Enter on an empty box - the send returned early and no reply path ever | |
| 1784 | +// re-enabled it (plan b67fd5). | |
| 1688 | 1785 | $(document).on('keypress', '.chat-input', function(e) { |
| 1689 | 1786 | if (e.which == 13 && !e.shiftKey) { |
| 1690 | 1787 | 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); | |
| 1788 | + sendMessage(getBotIdFromElement(this)); | |
| 1697 | 1789 | } |
| 1698 | 1790 | }); |
| 1699 | 1791 | |
| 1700 | 1792 | // Chat input character counter + soft limit feedback (plan 7091a2). |
| @@ -2169,8 +2261,56 @@ | ||
| 2169 | 2261 | // Error rendering message - silently continue |
| 2170 | 2262 | } |
| 2171 | 2263 | } |
| 2172 | 2264 | |
| 2265 | +// Link clicks in bot and agent messages: the click is recorded, the browser | |
| 2266 | +// follows the anchor itself. Navigation must not wait for the tracking | |
| 2267 | +// request: a window.open() made from that request's callback is outside the | |
| 2268 | +// visitor's tap, and Safari's pop-up blocker drops it without a message. | |
| 2269 | + | |
| 2270 | +// Give a tracked anchor the target it should open in, and noopener on a new tab. | |
| 2271 | +function prepareTrackedLink($link) { | |
| 2272 | + if (linkTarget === '_blank' && $link.attr('target') !== '_blank') { | |
| 2273 | + $link.attr('target', '_blank'); | |
| 2274 | + } | |
| 2275 | + if ($link.attr('target') === '_blank') { | |
| 2276 | + var rel = $link.attr('rel') || ''; | |
| 2277 | + if (!/(^|\s)noopener(\s|$)/.test(rel)) { | |
| 2278 | + $link.attr('rel', $.trim(rel + ' noopener')); | |
| 2279 | + } | |
| 2280 | + } | |
| 2281 | +} | |
| 2282 | + | |
| 2283 | +// Record one click, fire-and-forget. sendBeacon survives the page unloading | |
| 2284 | +// when the link opens in the same tab. | |
| 2285 | +function trackLinkClick(href, messageContext, botId) { | |
| 2286 | + var fields = { | |
| 2287 | + action: 'mxchat_track_url_click', | |
| 2288 | + session_id: getChatSession(botId), | |
| 2289 | + url: href, | |
| 2290 | + message_context: messageContext, | |
| 2291 | + nonce: mxchatChat.nonce | |
| 2292 | + }; | |
| 2293 | + try { | |
| 2294 | + if (navigator.sendBeacon && typeof FormData !== 'undefined') { | |
| 2295 | + var body = new FormData(); | |
| 2296 | + $.each(fields, function(name, value) { | |
| 2297 | + body.append(name, value == null ? '' : value); | |
| 2298 | + }); | |
| 2299 | + if (navigator.sendBeacon(mxchatChat.ajax_url, body)) { | |
| 2300 | + return; | |
| 2301 | + } | |
| 2302 | + } | |
| 2303 | + } catch (error) { | |
| 2304 | + // Fall through to the plain request | |
| 2305 | + } | |
| 2306 | + $.ajax({ | |
| 2307 | + url: mxchatChat.ajax_url, | |
| 2308 | + type: 'POST', | |
| 2309 | + data: fields | |
| 2310 | + }); | |
| 2311 | +} | |
| 2312 | + | |
| 2173 | 2313 | // Helper function to attach link tracking with proper event handling |
| 2174 | 2314 | function attachLinkTracking(messageDiv, messageText, botId) { |
| 2175 | 2315 | botId = botId || 'default'; |
| 2176 | 2316 | // Use a slight delay to ensure DOM is ready |
| @@ -2188,11 +2328,13 @@ | ||
| 2188 | 2328 | if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { |
| 2189 | 2329 | // Remove any existing click handlers first |
| 2190 | 2330 | $link.off('click.tracking'); |
| 2191 | 2331 | |
| 2332 | + // The anchor carries its own target: the browser follows it | |
| 2333 | + prepareTrackedLink($link); | |
| 2334 | + | |
| 2192 | 2335 | // Add new click handler with namespace |
| 2193 | 2336 | $link.on('click.tracking', function(e) { |
| 2194 | - e.preventDefault(); | |
| 2195 | 2337 | e.stopPropagation(); |
| 2196 | 2338 | |
| 2197 | 2339 | const messageContext = typeof messageText === 'string' |
| 2198 | 2340 | ? messageText.substring(0, 200) |
| @@ -2197,30 +2339,10 @@ | ||
| 2197 | 2339 | const messageContext = typeof messageText === 'string' |
| 2198 | 2340 | ? messageText.substring(0, 200) |
| 2199 | 2341 | : ''; |
| 2200 | 2342 | |
| 2201 | - // Track the click | |
| 2202 | - $.ajax({ | |
| 2203 | - url: mxchatChat.ajax_url, | |
| 2204 | - type: 'POST', | |
| 2205 | - data: { | |
| 2206 | - action: 'mxchat_track_url_click', | |
| 2207 | - session_id: getChatSession(botId), | |
| 2208 | - url: originalHref, | |
| 2209 | - message_context: messageContext, | |
| 2210 | - nonce: mxchatChat.nonce | |
| 2211 | - }, | |
| 2212 | - complete: function() { | |
| 2213 | - // Always redirect, even if tracking fails | |
| 2214 | - if ($link.attr('target') === '_blank' || linkTarget === '_blank') { | |
| 2215 | - window.open(originalHref, '_blank'); | |
| 2216 | - } else { | |
| 2217 | - window.location.href = originalHref; | |
| 2218 | - } | |
| 2219 | - } | |
| 2220 | - }); | |
| 2221 | - | |
| 2222 | - return false; // Extra insurance to prevent default | |
| 2343 | + prepareTrackedLink($link); | |
| 2344 | + trackLinkClick(originalHref, messageContext, botId); | |
| 2223 | 2345 | }); |
| 2224 | 2346 | } |
| 2225 | 2347 | }); |
| 2226 | 2348 | }, 100); // Small delay to ensure DOM is ready |
| @@ -2260,13 +2382,16 @@ | ||
| 2260 | 2382 | |
| 2261 | 2383 | if (images.length > 0) { |
| 2262 | 2384 | fullMessage += '<div class="image-gallery" dir="auto">'; |
| 2263 | 2385 | images.forEach(img => { |
| 2386 | + const safeTitle = sanitizeUserInput(img.title); | |
| 2387 | + const safeUrl = escapeAttr(img.image_url); | |
| 2388 | + const safeThumbnail = escapeAttr(img.thumbnail_url); | |
| 2264 | 2389 | fullMessage += ` |
| 2265 | 2390 | <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;" /> | |
| 2391 | + <strong>${safeTitle}</strong><br> | |
| 2392 | + <a href="${safeUrl}" target="_blank"> | |
| 2393 | + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" /> | |
| 2269 | 2394 | </a> |
| 2270 | 2395 | </div>`; |
| 2271 | 2396 | }); |
| 2272 | 2397 | fullMessage += '</div>'; |
| @@ -2385,27 +2510,42 @@ | ||
| 2385 | 2510 | // Check for % followed by exactly 2 hex digits |
| 2386 | 2511 | return /%[0-9a-fA-F]{2}/.test(url); |
| 2387 | 2512 | } |
| 2388 | 2513 | |
| 2389 | - // Helper function to safely encode URLs only if needed | |
| 2514 | + // Helper function to safely encode URLs only if needed, then make the | |
| 2515 | + // result attribute-safe. "Don't double-encode an already-encoded URL" | |
| 2516 | + // used to mean "return it untouched" — so a literal quote riding next | |
| 2517 | + // to a %20 reached href="…" intact and closed the attribute | |
| 2518 | + // (plan 2db182). Every href this function feeds now goes through | |
| 2519 | + // escapeAttr(), whether or not the URL was already percent-encoded. | |
| 2390 | 2520 | 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); | |
| 2521 | + const encoded = isUrlEncoded(url) ? url : encodeURI(url); | |
| 2522 | + return escapeAttr(encoded); | |
| 2397 | 2523 | } |
| 2398 | 2524 | |
| 2399 | - // Process markdown headers FIRST | |
| 2400 | - let processedText = formatMarkdownHeaders(inputText); | |
| 2401 | - | |
| 2525 | + // Take backslash escapes and code (fenced + inline) out of the text FIRST | |
| 2526 | + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a | |
| 2527 | + // backtick could not protect an underscore and \_ was not an escape at | |
| 2528 | + // all; the header rule also ran over fenced code. Each escape becomes a | |
| 2529 | + // placeholder carrying its literal character, each code span a | |
| 2530 | + // placeholder carrying its rendered HTML; both are restored below, after | |
| 2531 | + // the styling rules have run, so nothing downstream changes. | |
| 2532 | + const literals = protectLiterals(inputText); | |
| 2533 | + let processedText = literals.text; | |
| 2534 | + | |
| 2535 | + // Process markdown headers | |
| 2536 | + processedText = formatMarkdownHeaders(processedText); | |
| 2537 | + | |
| 2402 | 2538 | // Process text styling (bold, italic, strikethrough) |
| 2403 | 2539 | processedText = formatTextStyling(processedText); |
| 2404 | - | |
| 2405 | - // Process code blocks BEFORE processing links | |
| 2540 | + | |
| 2541 | + // Legacy raw <?php ... ?> blocks (fenced + inline code were already | |
| 2542 | + // rendered inside protectLiterals) — still BEFORE processing links | |
| 2406 | 2543 | processedText = formatCodeBlocks(processedText); |
| 2407 | 2544 | |
| 2545 | + // Put the rendered code and the escaped characters back | |
| 2546 | + processedText = literals.restore(processedText); | |
| 2547 | + | |
| 2408 | 2548 | // Process markdown tables BEFORE converting newlines to paragraphs |
| 2409 | 2549 | processedText = formatMarkdownTables(processedText); |
| 2410 | 2550 | |
| 2411 | 2551 | // NOW convert to paragraphs |
| @@ -2417,10 +2557,13 @@ | ||
| 2417 | 2557 | processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => { |
| 2418 | 2558 | // Clean the URL of any trailing punctuation |
| 2419 | 2559 | let cleanUrl = url.replace(/[.,;!?]+$/, ''); |
| 2420 | 2560 | const safeUrl = safeEncodeUrl(cleanUrl); |
| 2561 | + // The bracket body is the only link text that can carry angle | |
| 2562 | + // brackets (the standalone matcher excludes them) — keep it text. | |
| 2563 | + const safeText = cleanUrl.replace(/</g, '<').replace(/>/g, '>'); | |
| 2421 | 2564 | // Return as a proper link without the brackets |
| 2422 | - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`; | |
| 2565 | + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`; | |
| 2423 | 2566 | }); |
| 2424 | 2567 | |
| 2425 | 2568 | // Process markdown links: [text](url) and [](url) |
| 2426 | 2569 | // Uses balanced parenthesis matching to handle URLs containing parens |
| @@ -2524,8 +2667,30 @@ | ||
| 2524 | 2667 | |
| 2525 | 2668 | return processedText; |
| 2526 | 2669 | } |
| 2527 | 2670 | |
| 2671 | + // One left-to-right pass over backslash escapes and code (plan 9dc34f). | |
| 2672 | + // A single alternation keeps the two honest with each other: an escaped | |
| 2673 | + // backtick is consumed as an escape before it can open a code span, and a | |
| 2674 | + // code span is consumed whole so the escapes inside it stay verbatim. | |
| 2675 | + // Placeholders are private-use characters — no underscore, asterisk, | |
| 2676 | + // backtick, hash, pipe or bracket for a later rule to match. | |
| 2677 | + function protectLiterals(text) { | |
| 2678 | + const store = []; | |
| 2679 | + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) { | |
| 2680 | + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match)); | |
| 2681 | + return '\uE000' + (store.length - 1) + '\uE001'; | |
| 2682 | + }); | |
| 2683 | + return { | |
| 2684 | + text: out, | |
| 2685 | + restore: function(t) { | |
| 2686 | + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) { | |
| 2687 | + return store[Number(i)]; | |
| 2688 | + }); | |
| 2689 | + } | |
| 2690 | + }; | |
| 2691 | + } | |
| 2692 | + | |
| 2528 | 2693 | function formatMarkdownHeaders(text) { |
| 2529 | 2694 | // Handle h1 to h6 headers |
| 2530 | 2695 | return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) { |
| 2531 | 2696 | const level = hashes.length; |
| @@ -2567,11 +2732,24 @@ | ||
| 2567 | 2732 | // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind) |
| 2568 | 2733 | // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another * |
| 2569 | 2734 | protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); |
| 2570 | 2735 | |
| 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>'); | |
| 2736 | + // Handle underscores for italic - Safari-compatible (no lookbehind), and | |
| 2737 | + // word-bounded (plan 9dc34f): the opener must follow the start of the | |
| 2738 | + // text or a non-word character, the closer must precede the end or a | |
| 2739 | + // non-word character, and the content may not start or end with a | |
| 2740 | + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed | |
| 2741 | + // while "use _this_ word" still italicises. A token whose content is | |
| 2742 | + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder | |
| 2743 | + // convention, not prose emphasis — it is left verbatim too (ticket 440). | |
| 2744 | + // __PROTECTED_N__ placeholders cannot match: their underscores are all | |
| 2745 | + // adjacent to another underscore or a word character. | |
| 2746 | + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) { | |
| 2747 | + if (/^[\p{Lu}\p{N}]+$/u.test(content)) { | |
| 2748 | + return match; | |
| 2749 | + } | |
| 2750 | + return before + '<em>' + content + '</em>'; | |
| 2751 | + }); | |
| 2574 | 2752 | |
| 2575 | 2753 | // Handle strikethrough (~~text~~) |
| 2576 | 2754 | protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>'); |
| 2577 | 2755 | |
| @@ -2705,8 +2883,24 @@ | ||
| 2705 | 2883 | div.textContent = text; |
| 2706 | 2884 | return div.innerHTML; |
| 2707 | 2885 | } |
| 2708 | 2886 | |
| 2887 | + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182). | |
| 2888 | + // Percent-encodes the characters that can end or extend an attribute. | |
| 2889 | + // Valid in every scheme the widget emits (http, https, tel, mailto) and | |
| 2890 | + // never changes where a legitimate link resolves — encodeURI() already | |
| 2891 | + // produces most of these, this just guarantees them for URLs that skip | |
| 2892 | + // encodeURI() because they were percent-encoded on arrival. | |
| 2893 | + function escapeAttr(url) { | |
| 2894 | + return String(url) | |
| 2895 | + .replace(/"/g, '%22') | |
| 2896 | + .replace(/'/g, '%27') | |
| 2897 | + .replace(/</g, '%3C') | |
| 2898 | + .replace(/>/g, '%3E') | |
| 2899 | + .replace(/`/g, '%60') | |
| 2900 | + .replace(/ /g, '%20'); | |
| 2901 | + } | |
| 2902 | + | |
| 2709 | 2903 | function escapeHtml(unsafe) { |
| 2710 | 2904 | // Skip escaping if it's already escaped or contains HTML code block markup |
| 2711 | 2905 | if (unsafe.includes('<') || unsafe.includes('>') || |
| 2712 | 2906 | unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) { |
| @@ -3110,8 +3304,17 @@ | ||
| 3110 | 3304 | var content = message.content; |
| 3111 | 3305 | content = content.replace(/\\'/g, "'").replace(/\\"/g, '"'); |
| 3112 | 3306 | content = decodeHTMLEntities(content); |
| 3113 | 3307 | |
| 3308 | + // A visitor's own rows get the same treatment as the | |
| 3309 | + // live turn (appendMessage escapes user text before | |
| 3310 | + // linkify). Without this a replayed user message came | |
| 3311 | + // back as live markup after a refresh (plan 2db182). | |
| 3312 | + // Bot/agent rows are trusted HTML and stay as they were. | |
| 3313 | + if (message.role === 'user') { | |
| 3314 | + content = sanitizeUserInput(content); | |
| 3315 | + } | |
| 3316 | + | |
| 3114 | 3317 | // Skip linkify for messages containing structured HTML |
| 3115 | 3318 | // (forms, product cards, galleries, etc.) to avoid |
| 3116 | 3319 | // markdown formatting corrupting HTML attributes |
| 3117 | 3320 | // (e.g. underscores in name="field_name" becoming <em> tags). |
| @@ -3494,8 +3697,9 @@ | ||
| 3494 | 3697 | $(this).addClass('hidden').attr('aria-expanded', 'true'); |
| 3495 | 3698 | $badge.hide(); // Hide notification when opening chat |
| 3496 | 3699 | disableScroll(); |
| 3497 | 3700 | $preChat.fadeOut(250); |
| 3701 | + mxchatFireOpened(botId); | |
| 3498 | 3702 | |
| 3499 | 3703 | // First open per page load: re-fetch behavior settings in case |
| 3500 | 3704 | // this page's inline values came from a stale full-page cache |
| 3501 | 3705 | // (plan-32db95). Idempotent — later opens are a no-op. |
| @@ -3764,9 +3968,12 @@ | ||
| 3764 | 3968 | $('.chat-toolbar').show(); |
| 3765 | 3969 | } else { |
| 3766 | 3970 | $('.chat-toolbar').hide(); |
| 3767 | 3971 | } |
| 3768 | - | |
| 3972 | + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6). | |
| 3973 | + MxChatInstances.toolbarReady = true; | |
| 3974 | + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' }); | |
| 3975 | + | |
| 3769 | 3976 | // Apply toolbar icon colors |
| 3770 | 3977 | const toolbarElements = [ |
| 3771 | 3978 | '#mxchat-chatbot .toolbar-btn svg', |
| 3772 | 3979 | '#mxchat-chatbot .active-pdf-name', |
| @@ -4041,14 +4248,25 @@ | ||
| 4041 | 4248 | return response.json(); |
| 4042 | 4249 | }) |
| 4043 | 4250 | .then((data) => { |
| 4044 | 4251 | if (data.success) { |
| 4045 | - if (data.data.logged_in || data.data.email) { | |
| 4252 | + // A success response means the server has already decided the | |
| 4253 | + // required info is on file. With email optional (c0cfaf) that | |
| 4254 | + // info can be a name and no address, so keying on data.email | |
| 4255 | + // alone re-prompted a name-only visitor on every page load. | |
| 4256 | + if (data.data.logged_in || data.data.email || data.data.name) { | |
| 4046 | 4257 | showChatContainerForBot(botId); |
| 4047 | 4258 | } else { |
| 4048 | 4259 | showEmailFormForBot(botId); |
| 4049 | 4260 | } |
| 4050 | 4261 | } else { |
| 4262 | + // Pre-fill for a signed-in visitor being shown the form | |
| 4263 | + // (c0cfaf). It arrives on this nocache'd response rather than | |
| 4264 | + // in the page HTML, which is cacheable. Never overwrite what | |
| 4265 | + // the visitor has already typed. | |
| 4266 | + if (data.data) { | |
| 4267 | + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email); | |
| 4268 | + } | |
| 4051 | 4269 | showEmailFormForBot(botId); |
| 4052 | 4270 | } |
| 4053 | 4271 | }) |
| 4054 | 4272 | .catch((error) => { |
| @@ -4055,8 +4273,19 @@ | ||
| 4055 | 4273 | showEmailFormForBot(botId); |
| 4056 | 4274 | }); |
| 4057 | 4275 | } |
| 4058 | 4276 | |
| 4277 | + function prefillEmailForm(botId, name, email) { | |
| 4278 | + var nameInput = getElementDOM(botId, 'user-name'); | |
| 4279 | + var emailInput = getElementDOM(botId, 'user-email'); | |
| 4280 | + if (nameInput && name && !nameInput.value) { | |
| 4281 | + nameInput.value = name; | |
| 4282 | + } | |
| 4283 | + if (emailInput && email && !emailInput.value) { | |
| 4284 | + emailInput.value = email; | |
| 4285 | + } | |
| 4286 | + } | |
| 4287 | + | |
| 4059 | 4288 | // Event delegation for email form submission |
| 4060 | 4289 | $(document).on('submit', '.email-collection-form', function(e) { |
| 4061 | 4290 | e.preventDefault(); |
| 4062 | 4291 | e.stopPropagation(); |
| @@ -4074,19 +4303,29 @@ | ||
| 4074 | 4303 | var userEmail = emailInput ? emailInput.value.trim() : ''; |
| 4075 | 4304 | var userName = nameInput ? nameInput.value.trim() : ''; |
| 4076 | 4305 | var sessionId = MxChatInstances.ensureSession(botId); |
| 4077 | 4306 | |
| 4078 | - // Validate email | |
| 4079 | - if (!userEmail) { | |
| 4307 | + // Validate email. The address is optional when the owner turned | |
| 4308 | + // "Require Email Address" off (c0cfaf) — but anything actually typed | |
| 4309 | + // still has to be a valid address. The server enforces both rules | |
| 4310 | + // independently. | |
| 4311 | + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off'; | |
| 4312 | + | |
| 4313 | + if (emailRequired && !userEmail) { | |
| 4080 | 4314 | showEmailError(botId, 'Please enter your email address.'); |
| 4081 | 4315 | return false; |
| 4082 | 4316 | } |
| 4083 | 4317 | |
| 4084 | - if (!isValidEmailAddress(userEmail)) { | |
| 4318 | + if (userEmail && !isValidEmailAddress(userEmail)) { | |
| 4085 | 4319 | showEmailError(botId, 'Please enter a valid email address.'); |
| 4086 | 4320 | return false; |
| 4087 | 4321 | } |
| 4088 | 4322 | |
| 4323 | + if (!emailRequired && !userEmail && !userName) { | |
| 4324 | + showEmailError(botId, 'Please enter your name or email address.'); | |
| 4325 | + return false; | |
| 4326 | + } | |
| 4327 | + | |
| 4089 | 4328 | // Validate name if field exists and has content |
| 4090 | 4329 | if (nameInput && userName && !isValidNameInput(userName)) { |
| 4091 | 4330 | showEmailError(botId, 'Please enter a valid name (2-100 characters).'); |
| 4092 | 4331 | return false; |
| @@ -4242,8 +4481,9 @@ | ||
| 4242 | 4481 | $chatbot.removeClass('hidden').addClass('visible'); |
| 4243 | 4482 | getElement(botId, 'floating-chatbot-button').addClass('hidden'); |
| 4244 | 4483 | handlePreChatDismissal(botId); |
| 4245 | 4484 | disableScroll(); // Disable scroll when chatbot opens |
| 4485 | + mxchatFireOpened(botId); | |
| 4246 | 4486 | |
| 4247 | 4487 | // Load chat history for returning visitors (persistence) |
| 4248 | 4488 | var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; |
| 4249 | 4489 | if (chatPersistenceEnabled) { |
| @@ -4350,14 +4590,8 @@ | ||
| 4350 | 4590 | if (messageDiv.length > 0) { |
| 4351 | 4591 | const originalHref = $link.attr('href'); |
| 4352 | 4592 | |
| 4353 | 4593 | if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { |
| 4354 | - e.preventDefault(); | |
| 4355 | - e.stopPropagation(); | |
| 4356 | - | |
| 4357 | - // Mark as tracked | |
| 4358 | - $link.attr('data-tracked', 'true'); | |
| 4359 | - | |
| 4360 | 4594 | // Get bot ID from the chat box context |
| 4361 | 4595 | var botId = getBotIdFromElement(this); |
| 4362 | 4596 | |
| 4363 | 4597 | // Get message context from the message div |
| @@ -4362,28 +4596,12 @@ | ||
| 4362 | 4596 | |
| 4363 | 4597 | // Get message context from the message div |
| 4364 | 4598 | const messageText = messageDiv.text().substring(0, 200); |
| 4365 | 4599 | |
| 4366 | - $.ajax({ | |
| 4367 | - url: mxchatChat.ajax_url, | |
| 4368 | - type: 'POST', | |
| 4369 | - data: { | |
| 4370 | - action: 'mxchat_track_url_click', | |
| 4371 | - session_id: getChatSession(botId), | |
| 4372 | - url: originalHref, | |
| 4373 | - message_context: messageText, | |
| 4374 | - nonce: mxchatChat.nonce | |
| 4375 | - }, | |
| 4376 | - complete: function() { | |
| 4377 | - if ($link.attr('target') === '_blank' || linkTarget === '_blank') { | |
| 4378 | - window.open(originalHref, '_blank'); | |
| 4379 | - } else { | |
| 4380 | - window.location.href = originalHref; | |
| 4381 | - } | |
| 4382 | - } | |
| 4383 | - }); | |
| 4384 | - | |
| 4385 | - return false; | |
| 4600 | + // Same path as attachLinkTracking(): set the target, record the | |
| 4601 | + // click, and let the browser follow the anchor. | |
| 4602 | + prepareTrackedLink($link); | |
| 4603 | + trackLinkClick(originalHref, messageText, botId); | |
| 4386 | 4604 | } |
| 4387 | 4605 | } |
| 4388 | 4606 | }); |
| 4389 | 4607 | |
| @@ -4409,8 +4627,15 @@ | ||
| 4409 | 4627 | // Initialize chat visibility for all instances |
| 4410 | 4628 | $('.mxchat-chatbot-wrapper').each(function() { |
| 4411 | 4629 | var botId = $(this).data('bot-id') || 'default'; |
| 4412 | 4630 | initializeChatVisibility(botId); |
| 4631 | + // An embedded widget is on screen from the first paint; a floating one | |
| 4632 | + // reports mxchat:opened when its window is shown (plan 65c9b6). | |
| 4633 | + if (isEmbeddedBot(botId)) { | |
| 4634 | + mxchatFireOpened(botId); | |
| 4635 | + } else { | |
| 4636 | + mxchatWatchForOpen(botId); | |
| 4637 | + } | |
| 4413 | 4638 | }); |
| 4414 | 4639 | |
| 4415 | 4640 | // Make functions globally available for add-ons |
| 4416 | 4641 | window.hasQuickQuestions = hasQuickQuestions; |