added on local at 2026-07-01 15:02:38
| 1 | /* ===================================================================== | |
| 2 | ContactForge - Visitor tracking + support chat widget. | |
| 3 | Loaded on every public dashboard page. No third-party deps. | |
| 4 | ||
| 5 | What it does: | |
| 6 | 1. Generates / reads a UUID session token cookie | |
| 7 | 2. POSTs an "init" event with full client fingerprint | |
| 8 | (OS, browser, screen, viewport, language, timezone, touch, ...) | |
| 9 | 3. Tracks page views, clicks (with x/y%), max scroll depth | |
| 10 | 4. Long-polls /_track_poll.cgi every 6s for admin chat msgs + | |
| 11 | pushed links | |
| 12 | 5. Renders a small chat bubble bottom-right (when admin is online) | |
| 13 | 6. Opens pushed links automatically when admin sends them | |
| 14 | ||
| 15 | Inject by adding /assets/javascript/track.js to extra_js on any | |
| 16 | public-facing page. Auth-required dashboard pages do not include | |
| 17 | this -- only contact-facing dashboards. | |
| 18 | ===================================================================== */ | |
| 19 | (function () { | |
| 20 | 'use strict'; | |
| 21 | ||
| 22 | /* ---- Admin / dashboard page guard ------------------------------- */ | |
| 23 | // Heatmaps + visitor analytics are about the public-facing site. | |
| 24 | // If this script ever loads on an admin or worker URL (a future | |
| 25 | // template wiring this in by mistake), bail before doing anything. | |
| 26 | // The server-side ingest enforces the same rule; this just saves | |
| 27 | // the round-trip. | |
| 28 | var __path = (window.location && window.location.pathname) || ''; | |
| 29 | if (/^\/(?:admin|admin_|_)/.test(__path)) return; | |
| 30 | ||
| 31 | /* ---- Configuration ---------------------------------------------- */ | |
| 32 | var INGEST_URL = '/track.cgi'; | |
| 33 | var POLL_URL = '/track_poll.cgi'; | |
| 34 | var POLL_MS = 6000; | |
| 35 | var HEATMAP_SAMPLE_RATE = 1.0; // 1.0 = log every click; lower to sample | |
| 36 | ||
| 37 | /* ---- UUID + cookie ---------------------------------------------- */ | |
| 38 | function uuid4() { | |
| 39 | // RFC 4122 v4 from Math.random; collision risk is negligible at | |
| 40 | // platform scale and we don't need cryptographic strength. | |
| 41 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { | |
| 42 | var r = Math.random() * 16 | 0; | |
| 43 | var v = c === 'x' ? r : (r & 0x3 | 0x8); | |
| 44 | return v.toString(16); | |
| 45 | }); | |
| 46 | } | |
| 47 | function getCookie(name) { | |
| 48 | var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); | |
| 49 | return m ? decodeURIComponent(m[1]) : ''; | |
| 50 | } | |
| 51 | function setCookie(name, value, days) { | |
| 52 | var expires = ''; | |
| 53 | if (days) { | |
| 54 | var d = new Date(); | |
| 55 | d.setTime(d.getTime() + days * 86400000); | |
| 56 | expires = '; expires=' + d.toUTCString(); | |
| 57 | } | |
| 58 | document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax'; | |
| 59 | } | |
| 60 | var TOKEN = getCookie('contactforge_track'); | |
| 61 | if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(TOKEN)) { | |
| 62 | TOKEN = uuid4(); | |
| 63 | setCookie('contactforge_track', TOKEN, 365); | |
| 64 | } | |
| 65 | ||
| 66 | /* ---- Client fingerprint ----------------------------------------- */ | |
| 67 | function detectDevice() { | |
| 68 | var ua = navigator.userAgent || ''; | |
| 69 | if (/iPad|Tablet/i.test(ua)) return 'tablet'; | |
| 70 | if (/Mobi/i.test(ua) || /iPhone|Android/i.test(ua)) return 'mobile'; | |
| 71 | return 'desktop'; | |
| 72 | } | |
| 73 | function detectOS() { | |
| 74 | var ua = navigator.userAgent || ''; | |
| 75 | if (/Windows NT 10/i.test(ua)) return { name: 'Windows', version: '10/11' }; | |
| 76 | if (/Windows NT ([\d.]+)/i.test(ua)) return { name: 'Windows', version: RegExp.$1 }; | |
| 77 | if (/Mac OS X ([\d_]+)/i.test(ua)) return { name: 'macOS', version: RegExp.$1.replace(/_/g, '.') }; | |
| 78 | if (/iPhone|iPad|iPod/i.test(ua)) return { name: 'iOS', version: (ua.match(/OS ([\d_]+)/) || [,''])[1].replace(/_/g, '.') }; | |
| 79 | if (/Android ([\d.]+)/i.test(ua)) return { name: 'Android', version: RegExp.$1 }; | |
| 80 | if (/Linux/i.test(ua)) return { name: 'Linux', version: '' }; | |
| 81 | return { name: '', version: '' }; | |
| 82 | } | |
| 83 | function detectBrowser() { | |
| 84 | var ua = navigator.userAgent || ''; | |
| 85 | if (/Edg\/([\d.]+)/.test(ua)) return { name: 'Edge', version: RegExp.$1 }; | |
| 86 | if (/OPR\/([\d.]+)/.test(ua)) return { name: 'Opera', version: RegExp.$1 }; | |
| 87 | if (/Firefox\/([\d.]+)/.test(ua)) return { name: 'Firefox', version: RegExp.$1 }; | |
| 88 | if (/Chrome\/([\d.]+)/.test(ua)) return { name: 'Chrome', version: RegExp.$1 }; | |
| 89 | if (/Safari\/.*Version\/([\d.]+)/.test(ua)) return { name: 'Safari', version: RegExp.$1 }; | |
| 90 | if (/Safari/.test(ua)) return { name: 'Safari', version: '' }; | |
| 91 | return { name: '', version: '' }; | |
| 92 | } | |
| 93 | ||
| 94 | function buildFingerprint() { | |
| 95 | var os = detectOS(); | |
| 96 | var br = detectBrowser(); | |
| 97 | var tz = ''; | |
| 98 | try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {} | |
| 99 | return { | |
| 100 | os_name: os.name, | |
| 101 | os_version: os.version, | |
| 102 | browser_name: br.name, | |
| 103 | browser_version: br.version, | |
| 104 | device_type: detectDevice(), | |
| 105 | screen_w: (window.screen && window.screen.width) || 0, | |
| 106 | screen_h: (window.screen && window.screen.height) || 0, | |
| 107 | viewport_w: window.innerWidth || 0, | |
| 108 | viewport_h: window.innerHeight || 0, | |
| 109 | color_depth: (window.screen && window.screen.colorDepth) || 0, | |
| 110 | pixel_ratio: window.devicePixelRatio || 1, | |
| 111 | touch_capable: ('ontouchstart' in window || (navigator.maxTouchPoints > 0)) ? 1 : 0, | |
| 112 | language: navigator.language || '', | |
| 113 | timezone: tz, | |
| 114 | referrer: document.referrer || '', | |
| 115 | current_page_path: location.pathname + location.search | |
| 116 | }; | |
| 117 | } | |
| 118 | ||
| 119 | /* ---- Tiny JSON POST helper -------------------------------------- */ | |
| 120 | function post(url, obj) { | |
| 121 | return fetch(url, { | |
| 122 | method: 'POST', | |
| 123 | headers: { 'Content-Type': 'application/json' }, | |
| 124 | body: JSON.stringify(obj), | |
| 125 | credentials: 'same-origin', | |
| 126 | keepalive: true | |
| 127 | }).then(function (r) { return r.json(); }).catch(function () { return null; }); | |
| 128 | } | |
| 129 | ||
| 130 | /* ---- Session id (set by init response) -------------------------- */ | |
| 131 | var SESSION_ID = null; | |
| 132 | ||
| 133 | /* ---- 1. Init ---------------------------------------------------- */ | |
| 134 | var fp = buildFingerprint(); | |
| 135 | fp.kind = 'init'; | |
| 136 | fp.token = TOKEN; | |
| 137 | fp.page = location.pathname + location.search; | |
| 138 | post(INGEST_URL, fp).then(function (res) { | |
| 139 | if (res && res.session_id) SESSION_ID = res.session_id; | |
| 140 | sendEvent('page_view', {}); | |
| 141 | startPolling(); | |
| 142 | startChatWidget(); | |
| 143 | }); | |
| 144 | ||
| 145 | /* ---- 2. Event helper ------------------------------------------- */ | |
| 146 | function sendEvent(type, extra) { | |
| 147 | var body = { | |
| 148 | kind: 'event', | |
| 149 | token: TOKEN, | |
| 150 | type: type, | |
| 151 | page: location.pathname + location.search | |
| 152 | }; | |
| 153 | if (extra) for (var k in extra) if (extra.hasOwnProperty(k)) body[k] = extra[k]; | |
| 154 | post(INGEST_URL, body); | |
| 155 | } | |
| 156 | ||
| 157 | /* ---- 3. Click + heatmap tracking ------------------------------- | |
| 158 | We record TWO coord systems per click so the admin overlay can | |
| 159 | stay accurate even after the page reflows for a different viewport: | |
| 160 | - selector + offset_x/y: a stable DOM path to the clicked | |
| 161 | element, plus the click position as a fraction of that | |
| 162 | element's bounding box. Re-rendered against the current DOM, | |
| 163 | the dot stays glued to the button/word it landed on. | |
| 164 | - x_pct / y_pct (viewport-relative): legacy fallback used when | |
| 165 | the element no longer exists (page was edited, A/B variant | |
| 166 | swapped out, etc.) or sits inside <body> directly. | |
| 167 | -------------------------------------------------------------- */ | |
| 168 | function selectorPath(el) { | |
| 169 | // Walk up to nearest id (or body) building tag:nth-of-type steps. | |
| 170 | // Capped at 8 segments / 500 chars to match DB column width. | |
| 171 | if (!el || el.nodeType !== 1) return ''; | |
| 172 | var parts = []; | |
| 173 | var depth = 0; | |
| 174 | while (el && el.nodeType === 1 && depth < 8) { | |
| 175 | var tag = (el.tagName || '').toLowerCase(); | |
| 176 | if (!tag || tag === 'html') break; | |
| 177 | if (el.id && /^[A-Za-z][\w\-:.]*$/.test(el.id)) { | |
| 178 | parts.unshift('#' + el.id); | |
| 179 | break; | |
| 180 | } | |
| 181 | var part = tag; | |
| 182 | var parent = el.parentNode; | |
| 183 | if (parent && parent.children && parent.children.length > 1) { | |
| 184 | var n = 1; | |
| 185 | for (var i = 0; i < parent.children.length; i++) { | |
| 186 | var sib = parent.children[i]; | |
| 187 | if (sib === el) break; | |
| 188 | if (sib.tagName === el.tagName) n++; | |
| 189 | } | |
| 190 | part += ':nth-of-type(' + n + ')'; | |
| 191 | } | |
| 192 | parts.unshift(part); | |
| 193 | if (tag === 'body') break; | |
| 194 | el = parent; | |
| 195 | depth++; | |
| 196 | } | |
| 197 | return parts.join('>').slice(0, 500); | |
| 198 | } | |
| 199 | ||
| 200 | document.addEventListener('click', function (e) { | |
| 201 | if (Math.random() > HEATMAP_SAMPLE_RATE) return; | |
| 202 | var t = e.target; | |
| 203 | var sel = selectorPath(t); | |
| 204 | ||
| 205 | // Element-relative offsets: fraction of the target's bounding rect. | |
| 206 | // Clamp to [0,1] -- a click on the very edge can land marginally | |
| 207 | // outside the rect due to subpixel rounding. | |
| 208 | var ox = 0.5, oy = 0.5, haveOffsets = 0; | |
| 209 | try { | |
| 210 | var r = t.getBoundingClientRect(); | |
| 211 | if (r && r.width > 0 && r.height > 0) { | |
| 212 | ox = (e.clientX - r.left) / r.width; | |
| 213 | oy = (e.clientY - r.top) / r.height; | |
| 214 | if (ox < 0) ox = 0; else if (ox > 1) ox = 1; | |
| 215 | if (oy < 0) oy = 0; else if (oy > 1) oy = 1; | |
| 216 | haveOffsets = 1; | |
| 217 | } | |
| 218 | } catch (err) {} | |
| 219 | ||
| 220 | sendEvent('click', { | |
| 221 | selector: sel, | |
| 222 | x_pct: ((e.clientX / Math.max(window.innerWidth, 1)) * 100).toFixed(2), | |
| 223 | y_pct: (((e.clientY + window.scrollY) / Math.max(document.body.scrollHeight, 1)) * 100).toFixed(2), | |
| 224 | offset_x_pct: haveOffsets ? ox.toFixed(4) : '', | |
| 225 | offset_y_pct: haveOffsets ? oy.toFixed(4) : '' | |
| 226 | }); | |
| 227 | }, true); | |
| 228 | ||
| 229 | /* ---- 4. Scroll depth tracking ---------------------------------- */ | |
| 230 | var maxScrollPct = 0; | |
| 231 | var scrollTimer = null; | |
| 232 | window.addEventListener('scroll', function () { | |
| 233 | var h = Math.max(document.body.scrollHeight - window.innerHeight, 1); | |
| 234 | var pct = Math.min(100, Math.round((window.scrollY / h) * 100)); | |
| 235 | if (pct > maxScrollPct) maxScrollPct = pct; | |
| 236 | clearTimeout(scrollTimer); | |
| 237 | scrollTimer = setTimeout(function () { | |
| 238 | sendEvent('scroll', { scroll_pct: maxScrollPct }); | |
| 239 | }, 1500); | |
| 240 | }, { passive: true }); | |
| 241 | ||
| 242 | /* ---- 5. Poll for admin chat msgs + pushed links --------------- */ | |
| 243 | function startPolling() { | |
| 244 | setInterval(function () { | |
| 245 | fetch(POLL_URL + '?token=' + encodeURIComponent(TOKEN) + '&page=' + encodeURIComponent(location.pathname), { | |
| 246 | credentials: 'same-origin' | |
| 247 | }).then(function (r) { return r.json(); }).then(function (res) { | |
| 248 | if (!res || !res.ok) return; | |
| 249 | // Toggle chat widget visibility based on admin online state | |
| 250 | toggleWidget(!!res.admin_online); | |
| 251 | // Admin pushed messages | |
| 252 | if (res.messages && res.messages.length) { | |
| 253 | res.messages.forEach(function (m) { appendMessage('admin', m.body, m.sent_at); }); | |
| 254 | openChat(); | |
| 255 | } | |
| 256 | // Admin pushed links -- open them | |
| 257 | if (res.pushes && res.pushes.length) { | |
| 258 | res.pushes.forEach(function (p) { | |
| 259 | if (p.open_mode === 'same_tab') { | |
| 260 | window.location.href = p.url; | |
| 261 | } else if (p.open_mode === 'popup') { | |
| 262 | window.open(p.url, 'contactforge_push', 'width=900,height=600,resizable=yes,scrollbars=yes'); | |
| 263 | } else { | |
| 264 | window.open(p.url, '_blank', 'noopener'); | |
| 265 | } | |
| 266 | }); | |
| 267 | } | |
| 268 | }).catch(function () {}); | |
| 269 | }, POLL_MS); | |
| 270 | } | |
| 271 | ||
| 272 | /* ---- 6. Chat widget -------------------------------------------- */ | |
| 273 | // Injected lazily; CSS is inline so the dashboard's site.css does | |
| 274 | // not have to know about it. Keeps the widget plug-and-play. | |
| 275 | var widget, msgList, inputEl, bubbleEl, panelEl, openState = false, adminOnline = false; | |
| 276 | ||
| 277 | function startChatWidget() { | |
| 278 | if (widget) return; | |
| 279 | widget = document.createElement('div'); | |
| 280 | widget.id = 'wsChatWidget'; | |
| 281 | // Hide inline so there's no paint window before the embedded | |
| 282 | // <style> tag's display:none rule applies. toggleWidget() flips | |
| 283 | // this when admin online state changes. | |
| 284 | widget.style.display = 'none'; | |
| 285 | widget.innerHTML = | |
| 286 | '<style>' + | |
| 287 | ' #wsChatWidget{position:fixed;right:20px;bottom:20px;z-index:99000;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:none}' + | |
| 288 | ' #wsChatWidget.is-online{display:block}' + | |
| 289 | ' #wsChatBubble{width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#6366f1);color:#fff;display:grid;place-items:center;cursor:pointer;box-shadow:0 8px 24px rgba(59,130,246,0.45);transition:transform .15s}' + | |
| 290 | ' #wsChatBubble:hover{transform:scale(1.06)}' + | |
| 291 | ' #wsChatBubble svg{width:24px;height:24px}' + | |
| 292 | ' #wsChatPanel{position:absolute;bottom:70px;right:0;width:400px;height:520px;background:#0a0e1c;border:1px solid #1e293b;border-radius:14px;box-shadow:0 18px 50px rgba(0,0,0,0.6);display:none;flex-direction:column;overflow:hidden}' + | |
| 293 | ' #wsChatPanel.open{display:flex}' + | |
| 294 | ' @media (max-width:480px){#wsChatPanel{width:calc(100vw - 30px);right:-5px;height:70vh}}' + | |
| 295 | ' #wsChatHead{padding:12px 14px;background:linear-gradient(135deg,#1e3a8a,#1e40af);color:#fff;font-weight:700;font-size:14px;display:flex;align-items:center;gap:8px}' + | |
| 296 | ' #wsChatHead .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 6px #22c55e}' + | |
| 297 | ' #wsChatHead .title{flex:1}' + | |
| 298 | ' #wsChatEnd{background:rgba(255,255,255,0.12);border:0;color:#fff;padding:5px 12px;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer;letter-spacing:0.4px;text-transform:uppercase}' + | |
| 299 | ' #wsChatEnd:hover{background:rgba(255,255,255,0.22)}' + | |
| 300 | ' #wsChatList{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:8px;font-size:13px}' + | |
| 301 | ' .wsm{max-width:88%;padding:8px 12px;border-radius:14px;line-height:1.45;word-wrap:break-word}' + | |
| 302 | ' .wsm.admin{background:#1e293b;color:#e2e8f0;border-bottom-left-radius:4px;align-self:flex-start}' + | |
| 303 | ' .wsm.visitor{background:#2563eb;color:#fff;border-bottom-right-radius:4px;align-self:flex-end}' + | |
| 304 | ' .wsm.system{background:rgba(124,58,237,0.18);color:#c4b5fd;align-self:center;font-size:11px;padding:4px 10px}' + | |
| 305 | ' .wsm time{display:block;font-size:10px;opacity:0.65;margin-top:3px}' + | |
| 306 | ' #wsChatForm{display:flex;flex-direction:column;border-top:1px solid #1e293b;background:#0d1224;padding:10px 12px;gap:8px}' + | |
| 307 | ' #wsChatInput{width:100%;min-height:64px;max-height:160px;resize:vertical;background:#0a0e1c;border:1px solid #1e293b;border-radius:8px;color:#fff;padding:8px 10px;font-size:13px;font-family:inherit;outline:none;box-sizing:border-box}' + | |
| 308 | ' #wsChatInput:focus{border-color:#3b82f6}' + | |
| 309 | ' #wsChatSend{align-self:flex-end;background:#2563eb;border:0;color:#fff;padding:8px 18px;border-radius:8px;font-weight:700;cursor:pointer;font-size:13px}' + | |
| 310 | ' #wsChatSend:hover{background:#3b82f6}' + | |
| 311 | ' #wsChatHint{font-size:10px;color:#64748b;align-self:flex-start;margin-top:-4px}' + | |
| 312 | '</style>' + | |
| 313 | '<div id="wsChatPanel" role="dialog" aria-label="Support chat">' + | |
| 314 | ' <div id="wsChatHead"><span class="dot"></span><span class="title">Live support</span>' + | |
| 315 | ' <button id="wsChatEnd" type="button" title="End this conversation">End</button>' + | |
| 316 | ' </div>' + | |
| 317 | ' <div id="wsChatList"></div>' + | |
| 318 | ' <form id="wsChatForm">' + | |
| 319 | ' <textarea id="wsChatInput" placeholder="Type a message... (Enter to send, Shift+Enter for new line)" maxlength="2000" rows="3"></textarea>' + | |
| 320 | ' <span id="wsChatHint">Enter to send · Shift+Enter for a new line</span>' + | |
| 321 | ' <button id="wsChatSend" type="submit">Send</button>' + | |
| 322 | ' </form>' + | |
| 323 | '</div>' + | |
| 324 | '<div id="wsChatBubble" role="button" aria-label="Open chat">' + | |
| 325 | ' <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>' + | |
| 326 | '</div>'; | |
| 327 | document.body.appendChild(widget); | |
| 328 | ||
| 329 | bubbleEl = document.getElementById('wsChatBubble'); | |
| 330 | panelEl = document.getElementById('wsChatPanel'); | |
| 331 | msgList = document.getElementById('wsChatList'); | |
| 332 | inputEl = document.getElementById('wsChatInput'); | |
| 333 | ||
| 334 | bubbleEl.addEventListener('click', function () { openState ? closeChat() : openChat(); }); | |
| 335 | ||
| 336 | function sendCurrent() { | |
| 337 | var text = inputEl.value.trim(); | |
| 338 | if (!text) return; | |
| 339 | appendMessage('visitor', text); | |
| 340 | inputEl.value = ''; | |
| 341 | post(INGEST_URL, { kind: 'chat_send', token: TOKEN, body: text }); | |
| 342 | } | |
| 343 | ||
| 344 | document.getElementById('wsChatForm').addEventListener('submit', function (e) { | |
| 345 | e.preventDefault(); | |
| 346 | sendCurrent(); | |
| 347 | }); | |
| 348 | ||
| 349 | // Enter sends, Shift+Enter inserts a newline. Standard chat ergonomics. | |
| 350 | inputEl.addEventListener('keydown', function (e) { | |
| 351 | if (e.key === 'Enter' && !e.shiftKey) { | |
| 352 | e.preventDefault(); | |
| 353 | sendCurrent(); | |
| 354 | } | |
| 355 | }); | |
| 356 | ||
| 357 | // End conversation -- notifies the admin server-side, drops a system | |
| 358 | // message in the visitor's thread, then hides the widget for this | |
| 359 | // page view. Refreshing the page brings it back if admin is online. | |
| 360 | document.getElementById('wsChatEnd').addEventListener('click', function () { | |
| 361 | post(INGEST_URL, { kind: 'chat_end', token: TOKEN }); | |
| 362 | appendMessage('system', 'You ended the conversation. Thanks!'); | |
| 363 | // Hide widget after a short beat so the user sees the confirmation. | |
| 364 | setTimeout(function () { | |
| 365 | if (widget) widget.style.display = 'none'; | |
| 366 | }, 1500); | |
| 367 | }); | |
| 368 | } | |
| 369 | ||
| 370 | function toggleWidget(on) { | |
| 371 | if (!widget) return; | |
| 372 | adminOnline = on; | |
| 373 | // Inline style wins over the CSS class, so flip it directly. | |
| 374 | widget.style.display = on ? 'block' : 'none'; | |
| 375 | if (on) widget.classList.add('is-online'); | |
| 376 | else widget.classList.remove('is-online'); | |
| 377 | } | |
| 378 | ||
| 379 | function openChat() { | |
| 380 | if (!panelEl) return; | |
| 381 | panelEl.classList.add('open'); | |
| 382 | openState = true; | |
| 383 | if (msgList) msgList.scrollTop = msgList.scrollHeight; | |
| 384 | if (inputEl) inputEl.focus(); | |
| 385 | } | |
| 386 | function closeChat() { | |
| 387 | if (!panelEl) return; | |
| 388 | panelEl.classList.remove('open'); | |
| 389 | openState = false; | |
| 390 | } | |
| 391 | ||
| 392 | function appendMessage(role, body, ts) { | |
| 393 | if (!msgList) return; | |
| 394 | var d = document.createElement('div'); | |
| 395 | d.className = 'wsm ' + role; | |
| 396 | var bodyEl = document.createElement('div'); | |
| 397 | // Linkify: split the body on URL boundaries and rebuild as a | |
| 398 | // mix of text + <a> nodes. Done with createElement (not innerHTML) | |
| 399 | // so the message body still can't smuggle HTML in. | |
| 400 | var urlRe = /(https?:\/\/[^\s<>]+)/g; | |
| 401 | var parts = body.split(urlRe); | |
| 402 | parts.forEach(function (part, i) { | |
| 403 | if (i % 2 === 1) { | |
| 404 | var a = document.createElement('a'); | |
| 405 | a.href = part; | |
| 406 | a.target = '_blank'; | |
| 407 | a.rel = 'noopener'; | |
| 408 | a.textContent = part; | |
| 409 | a.style.color = '#93c5fd'; | |
| 410 | a.style.textDecoration = 'underline'; | |
| 411 | bodyEl.appendChild(a); | |
| 412 | } else if (part) { | |
| 413 | bodyEl.appendChild(document.createTextNode(part)); | |
| 414 | } | |
| 415 | }); | |
| 416 | d.appendChild(bodyEl); | |
| 417 | if (ts) { | |
| 418 | var t = document.createElement('time'); | |
| 419 | t.textContent = ts; | |
| 420 | d.appendChild(t); | |
| 421 | } | |
| 422 | msgList.appendChild(d); | |
| 423 | msgList.scrollTop = msgList.scrollHeight; | |
| 424 | } | |
| 425 | })(); |