| @@ -847,9 +847,12 @@ | ||
| 847 | 847 | if (typeof customMxChatFilter === 'function') { |
| 848 | 848 | message = customMxChatFilter(message, "prompt"); |
| 849 | 849 | } |
| 850 | 850 | |
| 851 | - 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() !== '') { | |
| 852 | 855 | // Don't disable input in live agent mode - let users chat freely |
| 853 | 856 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 854 | 857 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 855 | 858 | if (!isAgentMode) { |
| @@ -893,8 +896,14 @@ | ||
| 893 | 896 | if (typeof customMxChatFilter === 'function') { |
| 894 | 897 | message = customMxChatFilter(message, "prompt"); |
| 895 | 898 | } |
| 896 | 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 | + | |
| 897 | 906 | // Don't disable input in live agent mode - let users chat freely |
| 898 | 907 | var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); |
| 899 | 908 | var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent'; |
| 900 | 909 | if (!isAgentMode) { |
| @@ -1297,11 +1306,26 @@ | ||
| 1297 | 1306 | let testingDataReceived = false; |
| 1298 | 1307 | let streamingStarted = false; |
| 1299 | 1308 | // Server-pushed html to append as its OWN bot bubble once the stream |
| 1300 | 1309 | // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is |
| 1301 | - // 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. | |
| 1302 | 1312 | let pendingAppendHtml = ''; |
| 1303 | 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 | + | |
| 1304 | 1328 | // Abortable stream: a fresh controller per turn, keyed by bot instance. |
| 1305 | 1329 | // The Stop control (send button swapped in place) aborts both the read |
| 1306 | 1330 | // loop and the underlying request. |
| 1307 | 1331 | var streamControl = { controller: new AbortController(), aborted: false }; |
| @@ -1382,8 +1406,10 @@ | ||
| 1382 | 1406 | |
| 1383 | 1407 | // Re-enable chat input when stream ends with content |
| 1384 | 1408 | enableChatInput(botId); |
| 1385 | 1409 | |
| 1410 | + flushAppendHtml(); | |
| 1411 | + | |
| 1386 | 1412 | // Scroll the user's last message to the top now that the |
| 1387 | 1413 | // bot's full reply has rendered (gives max reading room). |
| 1388 | 1414 | var $chatBoxDone = getElement(botId, 'chat-box'); |
| 1389 | 1415 | var $lastUserMsgDone = $chatBoxDone.find('.user-message').last(); |
| @@ -1414,16 +1440,9 @@ | ||
| 1414 | 1440 | |
| 1415 | 1441 | // Re-enable chat input after streaming completes |
| 1416 | 1442 | enableChatInput(botId); |
| 1417 | 1443 | |
| 1418 | - // Render any server-pushed appendix html (e.g. the | |
| 1419 | - // YouTube embed) as its own bot bubble below the | |
| 1420 | - // streamed text — mirrors how it is saved in the | |
| 1421 | - // transcript, so history replays identically. | |
| 1422 | - if (pendingAppendHtml) { | |
| 1423 | - appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1424 | - pendingAppendHtml = ''; | |
| 1425 | - } | |
| 1444 | + flushAppendHtml(); | |
| 1426 | 1445 | |
| 1427 | 1446 | // Scroll the user's last message to the top now |
| 1428 | 1447 | // that the bot's full reply has rendered. |
| 1429 | 1448 | var $chatBoxStreamDone = getElement(botId, 'chat-box'); |
| @@ -1753,25 +1772,21 @@ | ||
| 1753 | 1772 | if (this.__mxchatStopJustShown) return; |
| 1754 | 1773 | mxchatStopStreaming(botId); |
| 1755 | 1774 | return; |
| 1756 | 1775 | } |
| 1757 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1758 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1759 | - disableChatInput(botId); | |
| 1760 | - } | |
| 1776 | + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5). | |
| 1761 | 1777 | sendMessage(botId); |
| 1762 | 1778 | }); |
| 1763 | 1779 | |
| 1764 | -// 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). | |
| 1765 | 1785 | $(document).on('keypress', '.chat-input', function(e) { |
| 1766 | 1786 | if (e.which == 13 && !e.shiftKey) { |
| 1767 | 1787 | e.preventDefault(); |
| 1768 | - var botId = getBotIdFromElement(this); | |
| 1769 | - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator'); | |
| 1770 | - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) { | |
| 1771 | - disableChatInput(botId); | |
| 1772 | - } | |
| 1773 | - sendMessage(botId); | |
| 1788 | + sendMessage(getBotIdFromElement(this)); | |
| 1774 | 1789 | } |
| 1775 | 1790 | }); |
| 1776 | 1791 | |
| 1777 | 1792 | // Chat input character counter + soft limit feedback (plan 7091a2). |
| @@ -2246,8 +2261,56 @@ | ||
| 2246 | 2261 | // Error rendering message - silently continue |
| 2247 | 2262 | } |
| 2248 | 2263 | } |
| 2249 | 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 | + | |
| 2250 | 2313 | // Helper function to attach link tracking with proper event handling |
| 2251 | 2314 | function attachLinkTracking(messageDiv, messageText, botId) { |
| 2252 | 2315 | botId = botId || 'default'; |
| 2253 | 2316 | // Use a slight delay to ensure DOM is ready |
| @@ -2265,11 +2328,13 @@ | ||
| 2265 | 2328 | if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { |
| 2266 | 2329 | // Remove any existing click handlers first |
| 2267 | 2330 | $link.off('click.tracking'); |
| 2268 | 2331 | |
| 2332 | + // The anchor carries its own target: the browser follows it | |
| 2333 | + prepareTrackedLink($link); | |
| 2334 | + | |
| 2269 | 2335 | // Add new click handler with namespace |
| 2270 | 2336 | $link.on('click.tracking', function(e) { |
| 2271 | - e.preventDefault(); | |
| 2272 | 2337 | e.stopPropagation(); |
| 2273 | 2338 | |
| 2274 | 2339 | const messageContext = typeof messageText === 'string' |
| 2275 | 2340 | ? messageText.substring(0, 200) |
| @@ -2274,30 +2339,10 @@ | ||
| 2274 | 2339 | const messageContext = typeof messageText === 'string' |
| 2275 | 2340 | ? messageText.substring(0, 200) |
| 2276 | 2341 | : ''; |
| 2277 | 2342 | |
| 2278 | - // Track the click | |
| 2279 | - $.ajax({ | |
| 2280 | - url: mxchatChat.ajax_url, | |
| 2281 | - type: 'POST', | |
| 2282 | - data: { | |
| 2283 | - action: 'mxchat_track_url_click', | |
| 2284 | - session_id: getChatSession(botId), | |
| 2285 | - url: originalHref, | |
| 2286 | - message_context: messageContext, | |
| 2287 | - nonce: mxchatChat.nonce | |
| 2288 | - }, | |
| 2289 | - complete: function() { | |
| 2290 | - // Always redirect, even if tracking fails | |
| 2291 | - if ($link.attr('target') === '_blank' || linkTarget === '_blank') { | |
| 2292 | - window.open(originalHref, '_blank'); | |
| 2293 | - } else { | |
| 2294 | - window.location.href = originalHref; | |
| 2295 | - } | |
| 2296 | - } | |
| 2297 | - }); | |
| 2298 | - | |
| 2299 | - return false; // Extra insurance to prevent default | |
| 2343 | + prepareTrackedLink($link); | |
| 2344 | + trackLinkClick(originalHref, messageContext, botId); | |
| 2300 | 2345 | }); |
| 2301 | 2346 | } |
| 2302 | 2347 | }); |
| 2303 | 2348 | }, 100); // Small delay to ensure DOM is ready |
| @@ -4545,14 +4590,8 @@ | ||
| 4545 | 4590 | if (messageDiv.length > 0) { |
| 4546 | 4591 | const originalHref = $link.attr('href'); |
| 4547 | 4592 | |
| 4548 | 4593 | if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { |
| 4549 | - e.preventDefault(); | |
| 4550 | - e.stopPropagation(); | |
| 4551 | - | |
| 4552 | - // Mark as tracked | |
| 4553 | - $link.attr('data-tracked', 'true'); | |
| 4554 | - | |
| 4555 | 4594 | // Get bot ID from the chat box context |
| 4556 | 4595 | var botId = getBotIdFromElement(this); |
| 4557 | 4596 | |
| 4558 | 4597 | // Get message context from the message div |
| @@ -4557,28 +4596,12 @@ | ||
| 4557 | 4596 | |
| 4558 | 4597 | // Get message context from the message div |
| 4559 | 4598 | const messageText = messageDiv.text().substring(0, 200); |
| 4560 | 4599 | |
| 4561 | - $.ajax({ | |
| 4562 | - url: mxchatChat.ajax_url, | |
| 4563 | - type: 'POST', | |
| 4564 | - data: { | |
| 4565 | - action: 'mxchat_track_url_click', | |
| 4566 | - session_id: getChatSession(botId), | |
| 4567 | - url: originalHref, | |
| 4568 | - message_context: messageText, | |
| 4569 | - nonce: mxchatChat.nonce | |
| 4570 | - }, | |
| 4571 | - complete: function() { | |
| 4572 | - if ($link.attr('target') === '_blank' || linkTarget === '_blank') { | |
| 4573 | - window.open(originalHref, '_blank'); | |
| 4574 | - } else { | |
| 4575 | - window.location.href = originalHref; | |
| 4576 | - } | |
| 4577 | - } | |
| 4578 | - }); | |
| 4579 | - | |
| 4580 | - 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); | |
| 4581 | 4604 | } |
| 4582 | 4605 | } |
| 4583 | 4606 | }); |
| 4584 | 4607 | |