Files
libredesk/static/widget.js
T
Abhinav Raut 7fee393cce fix issues found during release testing
- Save recipient edits and sender choices with reply drafts.
- Keep CC and BCC open when an address is cleared.
- Allow SMTP without credentials when authentication is off.
- Use the workspace timezone for reports and campaign dates.
- Show CSAT responses without reloading the conversation.
- Block access to attachments from deleted messages.
- Accept conditional email templates and catch missing or repeated replies.
- Explain why choosing another default email template failed.
- Reload stale routes and keep widget image fallbacks working.
- Keep the launcher usable by keyboard and reset host button styles.
2026-10-20 00:58:33 +05:30

1033 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Libredesk Chat Widget
* Embeddable chat widget for websites
*/
(function () {
'use strict';
if (window.__libredeskWidgetLoaded) {
return;
}
window.__libredeskWidgetLoaded = true;
const DEFAULT_LAUNCHER_LOGO_PATH = '/static/public/launcher-logo.png';
class Libredesk {
constructor(config = {}) {
if (!config.baseURL) {
throw new Error('baseURL is required');
}
if (!config.inboxID) {
throw new Error('inboxID is required');
}
this.IFRAME_BORDER_RADIUS = '20px';
this.IFRAME_BOX_SHADOW = 'rgba(9, 14, 21, 0.9) 0px 5px 40px 0px';
this.LAUNCHER_DROP_SHADOW = 'drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px)';
this.IFRAME_WIDTH = '400px';
this.IFRAME_HEIGHT = '700px';
this.EXPANDED_WIDTH = '750px';
this.MOBILE_BREAKPOINT = 600;
this.LAUNCHER_SIZE = 60;
this.LAUNCHER_IFRAME_GAP = 20;
this.DEFAULT_LAUNCHER_ICON_SCALE = 100;
this.LAUNCHER_HOVER_SCALE = 1.08;
this.LAUNCHER_OPEN_SCALE = 0.9;
this.EASE_OUT = 'cubic-bezier(0.23, 1, 0.32, 1)';
this.EASE_POP = 'cubic-bezier(0.34, 1.56, 0.64, 1)';
this.OPEN_MS = 200;
this.CLOSE_MS = 150;
this.reducedMotion = Boolean(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
this.MOBILE_LAUNCHER_SIZE = 60;
this.CAMPAIGN_POLL_MIN = 2;
this.CAMPAIGN_POLL_MAX = 60;
this.config = config;
this.iframe = null;
this.iframeHideTimer = null;
this.toggleButton = null;
this.isLauncherHovered = false;
this.widgetButtonWrapper = null;
this.unreadBadge = null;
this.isChatVisible = false;
this.widgetSettings = null;
this.unreadCount = 0;
this.previewData = null;
this.previewHost = null;
this.campaignData = null;
this.campaignActiveSeconds = 0;
this.campaignURL = location.href;
this.campaignEvent = "";
this.campaignDelay = 0;
this.campaignBrowserKey = this.getCookie(this.getCookieName('campaign')) || this.randomKey();
this.setCookie(this.getCookieName('campaign'), this.campaignBrowserKey);
this.campaignSessionKey = this.getCookie(this.getCookieName('campaign-session')) || this.randomKey();
this.setCampaignSessionCookie();
this.isMobile = window.innerWidth <= this.MOBILE_BREAKPOINT;
this.isExpanded = false;
this.hideLauncher = config.hideLauncher || false;
this.language = config.language || '';
this.widgetLoaded = false;
this._onShowCallback = null;
this._onHideCallback = null;
this._onUnreadCountChangeCallback = null;
this._boundHandleMessage = (e) => this.handleMessage(e);
this._boundHandleResize = () => this.handleResize();
this.init();
}
// crypto.randomUUID is unavailable on plain-http host pages; getRandomValues is not.
randomKey () {
if (crypto.randomUUID) return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
dropCampaign (id) {
this.campaignData = null;
this.postToIframe({ type: 'CAMPAIGN_EVENT', event: 'dropped', id });
}
postToIframe (data) {
if (this.iframe && this.iframe.contentWindow) {
this.iframe.contentWindow.postMessage(data, new URL(this.config.baseURL).origin);
}
}
formatBadgeCount (count) {
return count > 99 ? '99+' : count.toString();
}
getCookieName (type) {
return 'libredesk-' + type + '-' + this.config.inboxID;
}
getCookieDomain () {
if (this.config.cookieDomain) return this.config.cookieDomain;
if (this._cookieDomain !== undefined) return this._cookieDomain;
var hostname = window.location.hostname;
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname === 'localhost') {
this._cookieDomain = '';
return '';
}
var parts = hostname.split('.');
for (var i = parts.length - 1; i >= 0; i--) {
var domain = '.' + parts.slice(i).join('.');
document.cookie = '__ld_test__=1;domain=' + domain + ';path=/';
if (document.cookie.indexOf('__ld_test__') !== -1) {
document.cookie = '__ld_test__=;domain=' + domain + ';path=/;max-age=0';
this._cookieDomain = domain;
return domain;
}
}
this._cookieDomain = '';
return '';
}
setCookie (name, value) {
var domain = this.getCookieDomain();
var maxAge = 365 * 24 * 60 * 60;
var cookie = name + '=' + encodeURIComponent(value) + ';path=/;max-age=' + maxAge + ';SameSite=Lax';
if (domain) {
cookie += ';domain=' + domain;
}
if (window.location.protocol === 'https:') {
cookie += ';Secure';
}
document.cookie = cookie;
}
setCampaignSessionCookie () {
const domain = this.getCookieDomain();
document.cookie = this.getCookieName('campaign-session') + '=' + this.campaignSessionKey + ';path=/;SameSite=Lax' + (domain ? ';domain=' + domain : '') + (location.protocol === 'https:' ? ';Secure' : '');
}
getCookie (name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
}
deleteCookie (name) {
var domain = this.getCookieDomain();
var cookie = name + '=;path=/;max-age=0;SameSite=Lax';
if (domain) {
cookie += ';domain=' + domain;
}
document.cookie = cookie;
}
async init () {
try {
await this.fetchWidgetSettings();
if (!document.body) {
await new Promise((resolve) => {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
});
}
this.createElements();
this.setLauncherPosition();
this.watchColorScheme();
this.widgetButtonWrapper.style.display = 'none';
this.iframe.addEventListener('load', () => {
this.sendMobileState();
});
this.setupMobileDetection();
this.setupEventListeners();
} catch (error) {
console.error('Failed to initialize Libredesk Widget:', error);
}
}
async fetchWidgetSettings () {
try {
const response = await fetch(`${this.config.baseURL}/api/v1/widget/chat/settings/launcher?inbox_id=${this.config.inboxID}`);
if (!response.ok) {
throw new Error(`Error fetching widget settings. Status: ${response.status}`);
}
const result = await response.json();
if (result.status !== 'success') {
throw new Error('Failed to fetch widget settings');
}
this.widgetSettings = result.data;
} catch (error) {
console.error('Error fetching widget settings:', error);
throw error;
}
}
contrastColor (hex) {
try {
hex = hex.replace(/^#/, '');
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
var toLinear = function (channel) {
var c = parseInt(channel, 16) / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
var r = toLinear(hex.substring(0, 2));
var g = toLinear(hex.substring(2, 4));
var b = toLinear(hex.substring(4, 6));
// Relative luminance per WCAG, gamma-corrected so the 0.179 threshold
// reflects perceived brightness instead of raw sRGB values.
var L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return L > 0.179 ? '#000000' : '#ffffff';
} catch (e) {
return '#ffffff';
}
}
createElements () {
const branding = this.branding();
this.toggleButton = document.createElement('button');
this.toggleButton.type = 'button';
this.toggleButton.style.cssText = `
all: revert;
border: 0;
padding: 0;
position: fixed;
cursor: pointer;
z-index: 9999;
width: ${this.launcherSize()}px;
height: ${this.launcherSize()}px;
background-color: ${this.launcherColor()};
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
filter: ${this.LAUNCHER_DROP_SHADOW};
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
`;
this.iconContainer = document.createElement('div');
this.iconContainer.setAttribute('aria-hidden', 'true');
this.iconContainer.style.cssText = `
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
`;
const iconTransition = `opacity ${this.OPEN_MS}ms ${this.EASE_OUT}, transform ${this.OPEN_MS}ms ${this.EASE_OUT}`;
this.defaultIcon = document.createElement('img');
this.defaultIcon.alt = '';
this.defaultIcon.src = branding.launcher?.logo_url || (this.config.baseURL + DEFAULT_LAUNCHER_LOGO_PATH);
this.defaultIcon.style.transition = iconTransition;
this.styleLauncherIcon();
this.iconContainer.appendChild(this.defaultIcon);
this.arrowIcon = document.createElement('div');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '24');
svg.setAttribute('height', '24');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M7 10L12 15L17 10');
path.setAttribute('stroke', this.contrastColor(this.launcherColor()));
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.appendChild(path);
this.arrowIcon.appendChild(svg);
this.arrowIcon.style.cssText = `
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
transition: ${iconTransition};
`;
this.iconContainer.appendChild(this.arrowIcon);
this.setLauncherIcon(false /** open **/);
this.toggleButton.appendChild(this.iconContainer);
this.unreadBadge = document.createElement('div');
this.unreadBadge.style.cssText = `
position: absolute;
top: -5px;
right: -5px;
background-color: #ef4444;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: none;
justify-content: center;
align-items: center;
font-size: 12px;
font-weight: bold;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
border: 2px solid white;
box-sizing: border-box;
z-index: 10000;
`;
const widgetButtonWrapper = document.createElement('div');
widgetButtonWrapper.style.cssText = `
position: fixed;
z-index: 9999;
`;
widgetButtonWrapper.appendChild(this.toggleButton);
widgetButtonWrapper.appendChild(this.unreadBadge);
this.toggleButton.style.position = 'relative';
this.widgetButtonWrapper = widgetButtonWrapper;
this.iframe = document.createElement('iframe');
this.iframe.id = `libredesk-widget-${this.config.inboxID}`;
this.toggleButton.setAttribute('aria-controls', this.iframe.id);
this.iframe.src = `${this.config.baseURL}/widget?inbox_id=${encodeURIComponent(this.config.inboxID)}&parent_origin=${encodeURIComponent(window.location.origin)}` +
(this.language ? `&lang=${encodeURIComponent(this.language)}` : '');
this.iframeLanguage = this.language;
this.iframe.title = 'libredesk';
this.iframe.style.cssText = `
position: fixed;
border: none;
border-radius: ${this.IFRAME_BORDER_RADIUS};
box-shadow: ${this.IFRAME_BOX_SHADOW};
z-index: 9999;
width: ${this.IFRAME_WIDTH};
height: ${this.IFRAME_HEIGHT};
transition: ${this.iframeTransition(this.OPEN_MS)};
display: none;
`;
document.body.appendChild(this.widgetButtonWrapper);
document.body.appendChild(this.iframe);
}
sendMobileState () {
this.isMobile = window.innerWidth <= this.MOBILE_BREAKPOINT;
this.postToIframe({
type: 'SET_MOBILE_STATE',
isMobile: this.isMobile
});
if (this.toggleButton) {
this.toggleButton.style.width = this.launcherSize() + 'px';
this.toggleButton.style.height = this.launcherSize() + 'px';
}
}
launcherSize () {
return this.isMobile ? this.MOBILE_LAUNCHER_SIZE : this.LAUNCHER_SIZE;
}
launcherIconScale () {
return this.widgetSettings?.launcher?.icon_scale || this.DEFAULT_LAUNCHER_ICON_SCALE;
}
styleLauncherIcon () {
if (!this.defaultIcon) return;
const scale = this.launcherIconScale();
const full = scale >= 100;
const style = this.defaultIcon.style;
style.width = `${scale}%`;
style.height = `${scale}%`;
style.borderRadius = full ? '50%' : '0';
style.objectFit = full ? 'cover' : 'contain';
}
prefersDark () {
return Boolean(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
branding () {
const theme = this.widgetSettings.theme;
const isDark = theme === 'dark' || (theme === 'system' && this.prefersDark());
return this.widgetSettings.branding?.[isDark ? 'dark' : 'light'] || {};
}
launcherColor () {
const branding = this.branding();
return branding.launcher?.color || branding.colors?.primary || '#000000';
}
applyLauncherTheme () {
if (!this.toggleButton) return;
const branding = this.branding();
this.toggleButton.style.backgroundColor = this.launcherColor();
this.defaultIcon.src = branding.launcher?.logo_url || (this.config.baseURL + DEFAULT_LAUNCHER_LOGO_PATH);
this.styleLauncherIcon();
const path = this.arrowIcon.querySelector('path');
if (path) path.setAttribute('stroke', this.contrastColor(this.launcherColor()));
}
watchColorScheme () {
if (!window.matchMedia) return;
const query = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => {
if (this.widgetSettings.theme === 'system') this.applyLauncherTheme();
};
// Safari 13.1 and older only have the deprecated addListener.
if (query.addEventListener) query.addEventListener('change', onChange);
else if (query.addListener) query.addListener(onChange);
}
iframeBottomOffset () {
return this.widgetSettings.launcher.spacing.bottom + this.launcherSize() + this.LAUNCHER_IFRAME_GAP;
}
getNormalIframeHeight () {
return `min(${this.IFRAME_HEIGHT}, calc(100dvh - ${this.iframeBottomOffset() + this.LAUNCHER_IFRAME_GAP}px))`;
}
sendPageInfo () {
this.postToIframe({
type: 'PAGE_VISIT',
url: window.location.href,
title: document.title || ''
});
}
setLauncherPosition () {
const spacing = this.widgetSettings.launcher.spacing;
const side = this.widgetSettings.launcher.position === 'right' ? 'right' : 'left';
this.widgetButtonWrapper.style.bottom = `${spacing.bottom}px`;
this.widgetButtonWrapper.style[side] = `${spacing.side}px`;
}
applyIframeLayout () {
if (!this.iframe) return;
const iframe = this.iframe;
if (this.isMobile) {
iframe.style.transformOrigin = 'bottom center';
iframe.style.top = '0';
iframe.style.left = '0';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '100vw';
iframe.style.height = '100dvh';
iframe.style.borderRadius = '0';
iframe.style.boxShadow = 'none';
return;
}
const spacing = this.widgetSettings.launcher.spacing;
const side = this.widgetSettings.launcher.position === 'right' ? 'right' : 'left';
iframe.style.top = '';
iframe.style.left = '';
iframe.style.right = '';
iframe.style.borderRadius = this.IFRAME_BORDER_RADIUS;
iframe.style.boxShadow = this.IFRAME_BOX_SHADOW;
iframe.style.transformOrigin = `bottom ${side}`;
iframe.style[side] = `${spacing.side}px`;
if (this.isExpanded) {
iframe.style.width = this.EXPANDED_WIDTH;
iframe.style.height = 'calc(100dvh - 40px)';
iframe.style.bottom = '20px';
} else {
iframe.style.width = this.IFRAME_WIDTH;
iframe.style.height = this.getNormalIframeHeight();
iframe.style.bottom = `${this.iframeBottomOffset()}px`;
}
}
updateLauncherVisibility () {
if (!this.widgetButtonWrapper) return;
const shouldShow = this.widgetLoaded
&& !this.hideLauncher
&& !(this.isChatVisible && this.isMobile);
this.widgetButtonWrapper.style.display = shouldShow ? '' : 'none';
}
handleMessage (event) {
if (event.source !== this.iframe?.contentWindow || event.origin !== new URL(this.config.baseURL).origin || !event.data) return;
switch (event.data.type) {
case 'SHOW_CAMPAIGN':
if (this.isChatVisible || this.unreadCount > 0 || this.hideLauncher) {
this.dropCampaign(event.data.delivery.id);
break;
}
this.campaignData = event.data.delivery;
this.renderPreviews();
if (this.previewHost) this.postToIframe({ type: 'CAMPAIGN_EVENT', event: 'displayed', id: this.campaignData.id });
else this.dropCampaign(event.data.delivery.id);
break;
case 'CLEAR_CAMPAIGN':
this.campaignData = null;
this.renderPreviews();
break;
case 'REPLY_PREVIEWS':
if (event.data.previews?.length && this.campaignData) this.dropCampaign(this.campaignData.id);
this.previewData = event.data;
this.setLauncherIcon(this.isChatVisible);
this.renderPreviews();
break;
case 'VUE_APP_READY':
this.handleVueAppReady();
break;
case 'CLOSE_WIDGET':
this.hideChat();
break;
case 'UPDATE_UNREAD_COUNT':
this.updateUnreadCount(event.data.count);
break;
case 'WIDGET_LOADED':
this.handleWidgetLoaded();
if (event.data.campaigns) {
this.campaignDelay = Math.min(Math.max(Number(event.data.campaignDelay) || 0, 0), 86400);
this.startCampaignTracking();
}
break;
case 'EXPAND_WIDGET':
this.expandWidget();
break;
case 'COLLAPSE_WIDGET':
this.collapseWidget();
break;
case 'REQUEST_PAGE_INFO':
this.sendPageInfo();
break;
case 'STORE_SESSION':
this.setCookie(this.getCookieName('session'), event.data.token);
break;
case 'STORE_VISITOR_TOKEN':
this.setCookie(this.getCookieName('visitor'), event.data.token);
break;
case 'CLEAR_VISITOR_TOKEN':
this.deleteCookie(this.getCookieName('visitor'));
break;
case 'CLEAR_SESSION_TOKEN':
this.deleteCookie(this.getCookieName('session'));
break;
}
}
setupEventListeners () {
this.toggleButton.addEventListener('click', () => this.toggle());
this.toggleButton.addEventListener('mouseenter', () => {
const canHover = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (!canHover) return;
this.isLauncherHovered = true;
this.applyLauncherScale();
});
this.toggleButton.addEventListener('mouseleave', () => {
this.isLauncherHovered = false;
this.applyLauncherScale();
});
window.addEventListener('message', this._boundHandleMessage);
}
handleResize () {
const wasMobile = this.isMobile;
this.sendMobileState();
this.renderPreviews();
if (this.isChatVisible && wasMobile !== this.isMobile) {
this.applyIframeLayout();
this.updateLauncherVisibility();
}
}
setupMobileDetection () {
window.addEventListener('resize', this._boundHandleResize);
window.addEventListener('orientationchange', this._boundHandleResize);
}
handleVueAppReady () {
this.startPageTracking();
this.sendMobileState();
var visitorToken = this.getCookie(this.getCookieName('visitor'));
if (this.config.userJWT) {
this.postToIframe({
type: 'SET_JWT_TOKEN',
jwt: this.config.userJWT,
visitorToken: visitorToken || '',
campaignBrowserKey: this.campaignBrowserKey,
campaignSessionKey: this.campaignSessionKey
});
return;
}
var sessionToken = this.getCookie(this.getCookieName('session'));
this.postToIframe({
type: 'SESSION_DATA',
sessionToken: sessionToken || '',
visitorToken: visitorToken || '',
campaignBrowserKey: this.campaignBrowserKey,
campaignSessionKey: this.campaignSessionKey
});
}
handleWidgetLoaded () {
this.widgetLoaded = true;
this.updateLauncherVisibility();
this.renderPreviews();
// setLanguage() may have been called before the iframe loaded.
if (this.language !== this.iframeLanguage) {
this.postToIframe({ type: 'SET_LANGUAGE', language: this.language });
}
}
toggle () {
if (this.isChatVisible) {
this.hideChat();
} else {
this.showChat();
}
}
showChat () {
if (!this.iframe) return;
this.isMobile = window.innerWidth <= this.MOBILE_BREAKPOINT;
this.isChatVisible = true;
this.renderPreviews();
const iframe = this.iframe;
clearTimeout(this.iframeHideTimer);
if (iframe.style.display === 'none') {
iframe.style.opacity = '0';
iframe.style.transform = this.iframeHiddenTransform();
iframe.style.display = 'block';
}
this.applyIframeLayout();
iframe.style.transition = this.iframeTransition(this.OPEN_MS);
// Read layout so the browser commits the hidden start state, else the open transition is skipped.
void iframe.offsetWidth;
iframe.style.pointerEvents = '';
iframe.style.opacity = '1';
iframe.style.transform = 'none';
this.updateLauncherVisibility();
this.applyLauncherScale();
this.unreadBadge.style.display = 'none';
this.setLauncherIcon(true /** open **/);
this.postToIframe({ type: 'WIDGET_OPENED' });
if (this._onShowCallback) this._onShowCallback();
}
hideChat () {
if (!this.iframe) return;
const iframe = this.iframe;
iframe.style.transition = this.iframeTransition(this.CLOSE_MS);
iframe.style.pointerEvents = 'none';
iframe.style.opacity = '0';
iframe.style.transform = this.iframeHiddenTransform();
clearTimeout(this.iframeHideTimer);
this.iframeHideTimer = setTimeout(() => {
if (this.iframe && !this.isChatVisible) this.iframe.style.display = 'none';
}, this.CLOSE_MS);
this.isChatVisible = false;
this.renderPreviews();
this.applyLauncherScale();
this.updateLauncherVisibility();
this.setLauncherIcon(false /** open **/);
if (this.unreadCount > 0) this.showUnreadBadge(this.unreadCount);
this.postToIframe({ type: 'WIDGET_CLOSED' });
if (document.activeElement === iframe) this.toggleButton.focus();
if (this._onHideCallback) this._onHideCallback();
}
applyLauncherScale () {
if (!this.toggleButton) return;
const base = !this.reducedMotion && this.isChatVisible ? this.LAUNCHER_OPEN_SCALE : 1;
const scale = !this.reducedMotion && this.isLauncherHovered ? base * this.LAUNCHER_HOVER_SCALE : base;
this.toggleButton.style.transform = `scale(${scale})`;
}
setLauncherIcon (open) {
this.toggleButton.setAttribute('aria-expanded', String(open));
this.toggleButton.setAttribute('aria-label', this.previewData?.labels?.[open ? 'closeChat' : 'openChat'] || 'libredesk');
const hidden = (deg) => this.reducedMotion ? 'none' : `rotate(${deg}deg) scale(0.9)`;
if (this.defaultIcon) {
this.defaultIcon.style.opacity = open ? '0' : '1';
this.defaultIcon.style.transform = open ? hidden(45) : 'none';
}
this.arrowIcon.style.opacity = open ? '1' : '0';
this.arrowIcon.style.transform = open ? 'none' : hidden(-45);
}
iframeTransition (durationMs) {
const fade = `opacity ${durationMs}ms ${this.EASE_OUT}`;
if (this.reducedMotion) return fade;
return `width 0.18s ease, height 0.18s ease, bottom 0.18s ease, border-radius 0.18s ease, box-shadow 0.18s ease, ${fade}, transform ${durationMs}ms ${this.EASE_OUT}`;
}
iframeHiddenTransform () {
return this.reducedMotion ? 'none' : 'translateY(8px) scale(0.95)';
}
showUnreadBadge (count) {
const badge = this.unreadBadge;
const wasHidden = badge.style.display === 'none';
badge.textContent = this.formatBadgeCount(count);
badge.style.display = 'flex';
if (!wasHidden || typeof badge.animate !== 'function') return;
const from = this.reducedMotion ? { opacity: 0 } : { opacity: 0, transform: 'scale(0.9)' };
const to = this.reducedMotion ? { opacity: 1 } : { opacity: 1, transform: 'none' };
badge.animate([from, to], { duration: this.OPEN_MS, easing: this.EASE_POP });
}
updateUnreadCount (count) {
this.unreadCount = count;
if (this._onUnreadCountChangeCallback) this._onUnreadCountChangeCallback(count);
if (count > 0 && !this.isChatVisible) {
this.showUnreadBadge(count);
} else {
this.unreadBadge.style.display = 'none';
}
}
expandWidget () {
if (!this.iframe || !this.isChatVisible || this.isMobile) return;
this.isExpanded = true;
this.applyIframeLayout();
this.postToIframe({ type: 'WIDGET_EXPANDED', isExpanded: true });
}
collapseWidget () {
if (!this.iframe || !this.isChatVisible || this.isMobile) return;
this.isExpanded = false;
this.applyIframeLayout();
this.postToIframe({ type: 'WIDGET_EXPANDED', isExpanded: false });
}
startPageTracking () {
this._lastPageURL = '';
this._origPushState = history.pushState;
this._origReplaceState = history.replaceState;
const self = this;
const onPageChange = () => {
const url = window.location.href;
if (url === self._lastPageURL) return;
self._lastPageURL = url;
// Defer to let SPA frameworks update document.title after route change.
setTimeout(() => { self.sendPageInfo(); }, 100);
};
history.pushState = function () {
self._origPushState.apply(this, arguments);
onPageChange();
};
history.replaceState = function () {
self._origReplaceState.apply(this, arguments);
onPageChange();
};
this._onPopState = onPageChange;
this._onHashChange = onPageChange;
window.addEventListener('popstate', this._onPopState);
window.addEventListener('hashchange', this._onHashChange);
this._pageTrackInterval = setInterval(onPageChange, 7000);
onPageChange();
}
stopPageTracking () {
if (this._origPushState) history.pushState = this._origPushState;
if (this._origReplaceState) history.replaceState = this._origReplaceState;
if (this._onPopState) window.removeEventListener('popstate', this._onPopState);
if (this._onHashChange) window.removeEventListener('hashchange', this._onHashChange);
if (this._pageTrackInterval) clearInterval(this._pageTrackInterval);
}
clearPreviews () {
this.previewData = null;
this.campaignData = null;
this.dismissedPreviews = [];
this.previewHost?.remove();
this.previewHost = null;
this.previewSignature = '';
}
dismissPreview (key) {
if (this.campaignData?.id === key) {
this.postToIframe({ type: 'CAMPAIGN_EVENT', event: 'dismissed', id: key });
this.campaignData = null;
this.renderPreviews();
return;
}
const storageKey = `libredesk-previews-${this.config.inboxID}-${this.previewData?.identity || 'visitor'}`;
let dismissed = this.dismissedPreviews || [];
try { dismissed = JSON.parse(localStorage.getItem(storageKey) || '[]'); } catch {}
dismissed = [...new Set([...dismissed, key])].slice(-200);
this.dismissedPreviews = dismissed;
try { localStorage.setItem(storageKey, JSON.stringify(dismissed)); } catch {}
this.renderPreviews();
}
renderPreviews () {
let data = this.previewData;
const invitation = this.campaignData;
if (invitation && this.unreadCount === 0 && data) {
const snapshot = invitation.snapshot;
data = { ...data, identity: 'campaign', previews: [{
key: invitation.id, campaign: true, name: snapshot.sender, avatar: snapshot.avatar,
text: snapshot.message.slice(0, 240),
}] };
}
if (!data || !this.widgetLoaded || this.isChatVisible || this.hideLauncher) {
this.previewHost?.remove();
this.previewHost = null;
this.previewSignature = '';
return;
}
const storageKey = `libredesk-previews-${this.config.inboxID}-${data.identity || 'visitor'}`;
let dismissed = this.dismissedPreviews || [];
try { dismissed = JSON.parse(localStorage.getItem(storageKey) || '[]'); } catch {}
const previews = data.previews.filter(item => !dismissed.includes(item.key)).slice(0, 3);
const signature = JSON.stringify([previews, data.theme, data.labels]);
if (signature === this.previewSignature) return;
this.previewSignature = signature;
this.previewHost?.remove();
this.previewHost = null;
if (!previews.length) return;
const host = document.createElement('div');
const side = this.widgetSettings.launcher.position === 'left' ? 'left' : 'right';
const spacing = this.widgetSettings.launcher.spacing;
Object.assign(host.style, { position: 'fixed', zIndex: '9998', bottom: `${spacing.bottom + this.launcherSize() + 12}px`, [side]: `${spacing.side}px`, width: `min(340px, calc(100vw - ${spacing.side * 2}px))` });
host.style.setProperty('--align', side === 'left' ? 'flex-start' : 'flex-end');
host.style.setProperty('--muted', data.theme.muted || data.theme.foreground);
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{font:14px/1.45 system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}button{font:inherit;color:inherit;cursor:pointer;border:0;background:transparent;padding:0}button:focus-visible{outline:2px solid;outline-offset:2px}.stack{display:flex;flex-direction:column;gap:10px;align-items:var(--align)}.card{position:relative;display:flex;max-width:100%;border-radius:18px;box-shadow:0 1px 3px rgba(9,14,21,.12),0 8px 28px rgba(9,14,21,.16);animation:rise .22s ease-out}.open{display:flex;gap:10px;align-items:flex-start;min-width:0;padding:12px 14px;text-align:left;border-radius:inherit}.body{display:flex;flex-direction:column;min-width:0;gap:2px}.name{font-size:12px;line-height:1.3;color:var(--muted)}.text{display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-line}.avatar{flex:none;width:32px;height:32px;border-radius:50%;object-fit:cover}.image{margin-top:6px;max-width:160px;max-height:96px;border-radius:10px;object-fit:cover}.close{position:absolute;top:-8px;inset-inline-end:-8px;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;line-height:1;color:var(--muted);box-shadow:0 1px 3px rgba(9,14,21,.18);opacity:0;transition:opacity .15s}.card:hover .close,.card:focus-within .close{opacity:1}@media(hover:none){.close{opacity:1}}.all{font-size:12px;color:var(--muted);padding:4px 6px;border-radius:6px}.all:hover{text-decoration:underline}@keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.card{animation:none}}';
root.append(style);
const stack = document.createElement('div');
stack.className = 'stack';
const notice = document.createElement('span');
notice.setAttribute('role', 'status');
notice.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)';
notice.textContent = previews[0].text;
stack.append(notice);
const safeImage = (src, className) => {
if (!src) return null;
let url;
try { url = new URL(src, this.config.baseURL); } catch { return null; }
if (!['https:', 'http:'].includes(url.protocol)) return null;
const img = document.createElement('img');
img.src = url.href;
img.alt = '';
img.className = className;
return img;
};
for (const item of previews) {
const card = document.createElement('div');
card.className = 'card';
Object.assign(card.style, { background: data.theme.background, color: data.theme.foreground });
const open = document.createElement('button');
open.type = 'button';
open.className = 'open';
open.setAttribute('aria-label', `${data.labels.open}: ${item.name}. ${item.text}`);
open.addEventListener('click', () => {
if (item.campaign) {
this.postToIframe({ type: 'CAMPAIGN_EVENT', event: 'opened', id: item.key });
this.campaignData = null;
} else this.postToIframe({ type: 'OPEN_CONVERSATION', uuid: item.conversation });
this.showChat();
});
const avatar = safeImage(item.avatar, 'avatar');
if (avatar) open.append(avatar);
const body = document.createElement('span');
body.className = 'body';
const name = document.createElement('span');
name.className = 'name';
name.textContent = item.name;
const content = document.createElement('span');
content.className = 'text';
content.textContent = item.text;
body.append(name, content);
const image = safeImage(item.image, 'image');
if (image) body.append(image);
open.append(body);
const close = document.createElement('button');
close.type = 'button';
close.className = 'close';
close.textContent = '×';
close.setAttribute('aria-label', data.labels.dismiss);
Object.assign(close.style, { background: data.theme.background, border: `1px solid ${data.theme.border}` });
close.addEventListener('click', () => { this.dismissPreview(item.key); this.toggleButton.focus(); });
card.append(open, close);
stack.append(card);
}
if (previews.length > 1) {
const all = document.createElement('button');
all.type = 'button';
all.className = 'all';
all.textContent = data.labels.dismissAll;
all.addEventListener('click', () => { for (const item of previews) this.dismissPreview(item.key); this.toggleButton.focus(); });
stack.append(all);
}
root.append(stack);
document.body.append(host);
this.previewHost = host;
}
startCampaignTracking () {
if (this.campaignInterval) return;
let last = performance.now();
let elapsed = 0;
let asked = false;
const firstGap = () => Math.max(this.CAMPAIGN_POLL_MIN, this.campaignDelay);
let gap = firstGap();
this.campaignInterval = setInterval(() => {
const now = performance.now();
const delta = Math.min((now - last) / 1000, 2);
last = now;
if (location.href !== this.campaignURL) {
this.campaignURL = location.href;
this.campaignActiveSeconds = 0;
this.campaignEvent = '';
elapsed = 0;
asked = false;
gap = firstGap();
if (this.campaignData) this.dropCampaign(this.campaignData.id);
this.renderPreviews();
}
if (document.hidden || this.isChatVisible || this.hideLauncher) return;
this.campaignActiveSeconds += delta;
elapsed += delta;
if (elapsed < gap || this.unreadCount > 0 || this.campaignData) return;
elapsed = 0;
gap = asked ? Math.min(gap * 2, this.CAMPAIGN_POLL_MAX) : this.CAMPAIGN_POLL_MIN;
asked = true;
this.postToIframe({ type: 'CAMPAIGN_CONTEXT', context: { url: location.href, mobile: this.isMobile, active_seconds: Math.floor(this.campaignActiveSeconds), event: this.campaignEvent } });
}, 1000);
}
trackEvent (name) {
if (typeof name === 'string' && name.length <= 128) this.campaignEvent = name;
}
setUser (jwt) {
this.clearPreviews();
this.postToIframe({ type: 'SET_JWT_TOKEN', jwt: jwt });
}
setLanguage (language) {
this.language = language || '';
if (this.widgetLoaded) {
this.postToIframe({ type: 'SET_LANGUAGE', language: this.language });
}
}
logout () {
this.clearPreviews();
this.deleteCookie(this.getCookieName('session'));
this.deleteCookie(this.getCookieName('visitor'));
this.campaignBrowserKey = this.randomKey();
this.setCookie(this.getCookieName('campaign'), this.campaignBrowserKey);
this.postToIframe({ type: 'CLEAR_SESSION', campaignBrowserKey: this.campaignBrowserKey });
}
destroy () {
this.clearPreviews();
clearInterval(this.campaignInterval);
this.stopPageTracking();
window.removeEventListener('message', this._boundHandleMessage);
window.removeEventListener('resize', this._boundHandleResize);
window.removeEventListener('orientationchange', this._boundHandleResize);
if (this.widgetButtonWrapper) {
document.body.removeChild(this.widgetButtonWrapper);
this.widgetButtonWrapper = null;
this.toggleButton = null;
this.unreadBadge = null;
}
if (this.iframe) {
document.body.removeChild(this.iframe);
this.iframe = null;
}
this.isChatVisible = false;
this._onShowCallback = null;
this._onHideCallback = null;
this._onUnreadCountChangeCallback = null;
}
}
Libredesk.prototype.show = Libredesk.prototype.showChat;
Libredesk.prototype.hide = Libredesk.prototype.hideChat;
Libredesk.prototype.isVisible = function () { return this.isChatVisible; };
Libredesk.prototype.onShow = function (fn) { this._onShowCallback = fn; };
Libredesk.prototype.onHide = function (fn) { this._onHideCallback = fn; };
Libredesk.prototype.onUnreadCountChange = function (fn) { this._onUnreadCountChangeCallback = fn; fn(this.unreadCount); };
window.Libredesk = Libredesk;
window.initLibredesk = function (config = {}) {
if (window.Libredesk && window.Libredesk instanceof Libredesk) {
console.warn('Libredesk Widget is already initialized');
return window.Libredesk;
}
window.Libredesk = new Libredesk(config);
return window.Libredesk;
};
function autoInit () {
if (window.LibredeskSettings) {
window.initLibredesk(window.LibredeskSettings);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit, { once: true });
} else {
autoInit();
}
})();