1/* 2 * TargetHit alert toaster (HQ #429, WO ALERTS_REBUILD_20260923 lane E). 3 * 4 * In-app toast and sound for ANY symbol on ANY TargetHit page, fed by the 5 * member's server-sent event stream GET /api/alerts/stream. One file serves 6 * both surfaces: 7 * - the Next.js site mounts it for signed-in members 8 * (src/components/alerts/alert-toaster.tsx calls TargetHitAlerts.start()); 9 * - the terminal at /trade loads it with 10 * <script src="/alerts/alert-toaster.js" data-autostart="1" defer></script> 11 * and listens to window 'targethit:alert' events to flash the chart. 12 * Every received alert is also dispatched as a window CustomEvent 13 * 'targethit:alert' whose detail is the stream event. 14 * Contract: docs/contracts/alert-delivery-v1.md §3. 15 */ 16(function (root, factory) { 17 var api = factory() 18 if (typeof module === 'object' && module && module.exports) { 19 module.exports = api 20 return 21 } 22 if (!root || !root.document || root.TargetHitAlerts) return 23 var toaster = api.createAlertToaster({ window: root }) 24 root.TargetHitAlerts = toaster 25 var script = root.document.currentScript 26 if (script && script.getAttribute('data-autostart') === '1') toaster.start() 27})(typeof window !== 'undefined' ? window : undefined, function () { 28 'use strict' 29 30 var VERSION = '1' 31 var STREAM_URL = '/api/alerts/stream' 32 var CURSOR_KEY = 'targethit.alerts.cursor' 33 var CURSOR = /^[1-9][0-9]{0,18}$/ 34 var TOAST_MS = 20000 35 var MAX_TOASTS = 4 36 var MAX_SEEN = 200 37 var RECONNECT_BASE_MS = 2000 38 var RECONNECT_MAX_MS = 60000 39 var REPEAT_EVERY_MS = 1500 40 var REPEAT_FOR_MS = 10000 41 var SOUND_LOCK_HOLD_MS = 8000 42 43 // Synthesized so no audio file has to load before the first alert: 44 // [frequency Hz, start s, duration s]. Unknown sound ids play 'default'. 45 var SOUNDS = { 46 'default': [[880, 0, 0.16], [1318.5, 0.14, 0.28]], 47 chime: [[880, 0, 0.16], [1318.5, 0.14, 0.28]], 48 bell: [[1046.5, 0, 0.7], [2093, 0, 0.4]], 49 beep: [[1000, 0, 0.1], [1000, 0.18, 0.1]], 50 alarm: [[880, 0, 0.14], [660, 0.17, 0.14], [880, 0.34, 0.14], [660, 0.51, 0.14]], 51 soft: [[523.25, 0, 0.35], [659.25, 0.12, 0.4]] 52 } 53 54 var CSS = [ 55 ':host{all:initial}', 56 '.stack{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;', 57 'width:min(360px,calc(100vw - 32px));font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}', 58 '.toast{background:#1e222d;color:#d1d4dc;border:1px solid #363a45;border-left:3px solid #dc2626;border-radius:6px;', 59 'box-shadow:0 8px 24px rgba(0,0,0,.45);padding:10px 12px;animation:thin .18s ease-out}', 60 '.head{display:flex;align-items:flex-start;gap:8px}', 61 '.dot{flex:none;width:8px;height:8px;margin-top:5px;border-radius:50%;background:#dc2626}', 62 '.title{flex:1;min-width:0;overflow-wrap:anywhere}', 63 '.symbol{font-weight:600;color:#fff;margin-right:6px}', 64 '.close{flex:none;background:none;border:0;color:#787b86;font-size:18px;line-height:1;cursor:pointer;padding:0 2px}', 65 '.close:hover,.close:focus-visible{color:#fff}', 66 '.name{margin-top:4px;font-weight:600;color:#fff;overflow-wrap:anywhere}', 67 '.message{margin-top:4px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:6.3em;overflow:hidden}', 68 '.foot{margin-top:8px;display:flex;justify-content:space-between;align-items:center;gap:8px;color:#787b86;font-size:12px}', 69 '.open{color:#60a5fa;text-decoration:none;font-weight:600;white-space:nowrap}', 70 '.open:hover,.open:focus-visible{text-decoration:underline}', 71 '.hint{margin-top:6px;color:#f0b90b;font-size:12px}', 72 '@keyframes thin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}', 73 '@media (prefers-reduced-motion:reduce){.toast{animation:none}}' 74 ].join('') 75 76 function cursorGreater(a, b) { 77 if (!b) return true 78 if (a.length !== b.length) return a.length > b.length 79 return a > b 80 } 81 82 function safePath(value, symbol) { 83 if (typeof value === 'string' && value.charAt(0) === '/' && value.charAt(1) !== '/' 84 && value.indexOf('\\') < 0 && !/[\u0000-\u001f]/.test(value)) { 85 return value 86 } 87 return '/trade?asset=' + encodeURIComponent(symbol) 88 } 89 90 /** Normalize one SSE `alert` frame; null for anything malformed. */ 91 function parseAlertEvent(raw, lastEventId) { 92 var data 93 try { data = JSON.parse(raw) } catch (_) { return null } 94 if (!data || typeof data !== 'object') return null 95 var id = String(lastEventId || data.id || '') 96 if (!CURSOR.test(id)) return null 97 if (typeof data.symbol !== 'string' || typeof data.condition_text !== 'string') return null 98 var sound = data.sound && typeof data.sound === 'object' 99 ? { name: typeof data.sound.name === 'string' ? data.sound.name : 'default', repeat: data.sound.repeat === true } 100 : null 101 return { 102 id: id, 103 alertId: String(data.alert_id || ''), 104 symbol: data.symbol, 105 conditionText: data.condition_text, 106 priceDisplay: typeof data.price_display === 'string' ? data.price_display 107 : (typeof data.price === 'string' ? data.price : ''), 108 message: typeof data.message === 'string' ? data.message : '', 109 name: typeof data.name === 'string' ? data.name : null, 110 triggeredAt: typeof data.triggered_at === 'string' ? data.triggered_at : '', 111 url: safePath(data.url, data.symbol), 112 toast: data.toast === true, 113 sound: sound, 114 detail: data 115 } 116 } 117 118 function safeStorage(win) { 119 try { return win.sessionStorage || null } catch (_) { return null } 120 } 121 122 function createAlertToaster(env) { 123 var win = env.window 124 var doc = env.document || win.document 125 var EventSourceImpl = env.EventSource !== undefined ? env.EventSource : win.EventSource 126 var storage = env.storage !== undefined ? env.storage : safeStorage(win) 127 var nav = win.navigator || {} 128 var locks = env.locks !== undefined ? env.locks : (nav.locks || null) 129 var AudioContextImpl = env.AudioContext !== undefined 130 ? env.AudioContext 131 : (win.AudioContext || win.webkitAudioContext || null) 132 var setT = env.setTimeout || function (fn, ms) { return win.setTimeout(fn, ms) } 133 var clearT = env.clearTimeout || function (id) { win.clearTimeout(id) } 134 var now = env.now || function () { return Date.now() } 135 var streamUrl = env.streamUrl || STREAM_URL 136 137 var options = {} 138 var running = false 139 var source = null 140 var attempt = 0 141 var reconnectTimer = null 142 var seen = [] 143 var seenSet = {} 144 var host = null 145 var stack = null 146 var toasts = [] 147 var audio = null 148 var unlockBound = false 149 var visibilityBound = false 150 151 function readCursor() {
152 try { 153 var value = storage && storage.getItem(CURSOR_KEY) 154 return value && CURSOR.test(value) ? value : null 155 } catch (_) { return null } 156 } 157 158 function writeCursor(id) { 159 try { 160 var current = readCursor() 161 if (storage && cursorGreater(id, current)) storage.setItem(CURSOR_KEY, id) 162 } catch (_) { /* storage full or blocked: replay just starts later */ } 163 } 164 165 function connect() { 166 if (!running || !EventSourceImpl) return 167 var cursor = readCursor() 168 var es 169 try { 170 es = new EventSourceImpl(streamUrl + (cursor ? '?after=' + encodeURIComponent(cursor) : '')) 171 } catch (_) { 172 scheduleReconnect() 173 return 174 } 175 source = es 176 es.addEventListener('ready', function () { attempt = 0 }) 177 es.addEventListener('alert', function (message) { receive(message) }) 178 es.addEventListener('unauthorized', function () { stop() }) 179 es.onerror = function () { 180 if (source !== es) return 181 // CONNECTING: the browser is already retrying with Last-Event-ID. 182 // CLOSED: it gave up (non-200, proxy error during a deploy) â retry here. 183 if (es.readyState === 2) { 184 try { es.close() } catch (_) { /* ignore */ } 185 source = null 186 scheduleReconnect() 187 } 188 } 189 } 190 191 function scheduleReconnect() { 192 if (!running || reconnectTimer) return 193 var delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * Math.pow(2, attempt)) 194 attempt = Math.min(attempt + 1, 10) 195 reconnectTimer = setT(function () { 196 reconnectTimer = null 197 connect() 198 }, delay + Math.floor(Math.random() * 500)) 199 } 200 201 function receive(message) { 202 var alert = parseAlertEvent(message && message.data, message && message.lastEventId) 203 if (!alert || seenSet[alert.id]) return 204 seenSet[alert.id] = true 205 seen.push(alert.id) 206 while (seen.length > MAX_SEEN) delete seenSet[seen.shift()] 207 writeCursor(alert.id) 208 try { 209 if (typeof win.CustomEvent === 'function') { 210 win.dispatchEvent(new win.CustomEvent('targethit:alert', { detail: alert.detail })) 211 } 212 } catch (_) { /* a listener's error must not stop the toast */ } 213 var entry = alert.toast ? showToast(alert) : null 214 if (alert.sound) playAlertSound(alert, entry) 215 } 216 217 function el(tag, className, text) { 218 var node = doc.createElement(tag) 219 if (className) node.className = className 220 if (text !== undefined && text !== null) node.textContent = String(text) 221 return node 222 } 223 224 function ensureStack() { 225 if (stack && host && host.parentNode) return stack 226 host = el('div') 227 host.setAttribute('data-targethit-alerts', VERSION) 228 var shadow = typeof host.attachShadow === 'function' ? host.attachShadow({ mode: 'open' }) : host 229 shadow.appendChild(el('style', null, CSS)) 230 stack = el('div', 'stack') 231 stack.setAttribute('role', 'status') 232 stack.setAttribute('aria-live', 'polite') 233 shadow.appendChild(stack) 234 ;(doc.body || doc.documentElement).appendChild(host) 235 return stack 236 } 237 238 function timeLabel(iso) { 239 var date = new Date(iso) 240 if (!isFinite(date.getTime())) return ''
241 try { 242 return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) 243 } catch (_) { 244 return iso.slice(11, 19) 245 } 246 } 247 248 function hidden() { 249 return doc.visibilityState === 'hidden' 250 } 251 252 function pause(entry) { 253 if (!entry.timer) return 254 clearT(entry.timer) 255 entry.timer = null 256 entry.remaining = Math.max(0, entry.remaining - (now() - entry.startedAt)) 257 } 258 259 function resume(entry) { 260 if (entry.timer || entry.dismissed || entry.hovered || hidden()) return 261 entry.startedAt = now() 262 entry.timer = setT(function () { dismiss(entry) }, entry.remaining) 263 } 264 265 function dismiss(entry) { 266 if (entry.dismissed) return 267 entry.dismissed = true 268 if (entry.timer) clearT(entry.timer) 269 entry.timer = null 270 if (entry.card.parentNode) entry.card.parentNode.removeChild(entry.card) 271 var index = toasts.indexOf(entry) 272 if (index >= 0) toasts.splice(index, 1) 273 } 274 275 function showToast(alert) { 276 var container = ensureStack() 277 var card = el('div', 'toast') 278 var entry = { card: card, alert: alert, timer: null, remaining: TOAST_MS, startedAt: 0, dismissed: false, hovered: false } 279 280 var head = el('div', 'head') 281 head.appendChild(el('span', 'dot')) 282 var title = el('div', 'title') 283 title.appendChild(el('span', 'symbol', alert.symbol)) 284 title.appendChild(el('span', 'condition', alert.conditionText)) 285 head.appendChild(title) 286 var close = el('button', 'close', 'Ã') 287 close.setAttribute('type', 'button') 288 close.setAttribute('aria-label', 'Dismiss alert') 289 close.addEventListener('click', function () { dismiss(entry) }) 290 head.appendChild(close) 291 card.appendChild(head) 292 293 if (alert.name) card.appendChild(el('div', 'name', alert.name)) 294 if (alert.message && alert.message !== alert.symbol + ' ' + alert.conditionText) { 295 card.appendChild(el('div', 'message', alert.message)) 296 } 297 var foot = el('div', 'foot') 298 var meta = [alert.priceDisplay, timeLabel(alert.triggeredAt)].filter(Boolean).join(' · ') 299 foot.appendChild(el('span', 'meta', meta)) 300 var open = el('a', 'open', 'Open chart') 301 open.setAttribute('href', alert.url) 302 open.addEventListener('click', function (event) { 303 if (typeof options.onOpen === 'function') { 304 if (event && typeof event.preventDefault === 'function') event.preventDefault() 305 try { options.onOpen(alert.detail) } catch (_) { /* ignore */ } 306 } 307 dismiss(entry) 308 }) 309 foot.appendChild(open) 310 card.appendChild(foot) 311 312 card.addEventListener('mouseenter', function () { entry.hovered = true; pause(entry) }) 313 card.addEventListener('mouseleave', function () { entry.hovered = false; resume(entry) }) 314 315 container.insertBefore(card, container.firstChild || null) 316 toasts.push(entry) 317 while (toasts.length > MAX_TOASTS) dismiss(toasts[0]) 318 resume(entry) 319 return entry 320 } 321 322 function audioContext() { 323 if (!AudioContextImpl) return null 324 if (!audio) { 325 try { audio = new AudioContextImpl() } catch (_) { audio = null } 326 } 327 return audio 328 } 329 330 function bindUnlock() { 331 if (unlockBound || typeof doc.addEventListener !== 'function') return 332 unlockBound = true 333 var unlock = function () { 334 var ctx = audioContext() 335 if (ctx && ctx.state === 'suspended' && typeof ctx.resume === 'function') { 336 try { ctx.resume() } catch (_) { /* stays suspended */ } 337 } 338 } 339 doc.addEventListener('pointerdown', unlock, true) 340 doc.addEventListener('keydown', unlock, true) 341 } 342 343 function playTones(name) { 344 var ctx = audioContext() 345 if (!ctx) return 346 var tones = Object.prototype.hasOwnProperty.call(SOUNDS, name) ? SOUNDS[name] : SOUNDS['default'] 347 var t0 = ctx.currentTime + 0.01 348 for (var i = 0; i < tones.length; i++) { 349 var tone = tones[i] 350 var osc = ctx.createOscillator() 351 var gain = ctx.createGain() 352 osc.type = 'sine' 353 osc.frequency.value = tone[0] 354 gain.gain.setValueAtTime(0.0001, t0 + tone[1]) 355 gain.gain.exponentialRampToValueAtTime(0.25, t0 + tone[1] + 0.012) 356 gain.gain.exponentialRampToValueAtTime(0.0001, t0 + tone[1] + tone[2]) 357 osc.connect(gain) 358 gain.connect(ctx.destination) 359 osc.start(t0 + tone[1]) 360 osc.stop(t0 + tone[1] + tone[2] + 0.02) 361 } 362 } 363 364 function playAlertSound(alert, entry) { 365 bindUnlock() 366 var ctx = audioContext() 367 if (!ctx || ctx.state !== 'running') { 368 // Browsers allow sound only after the member has interacted with the page. 369 if (entry && !entry.hinted) { 370 entry.hinted = true 371 entry.card.appendChild(el('div', 'hint', 'Sound is blocked until you click or type on this page.')) 372 } 373 return 374 } 375 var play = function () { 376 playTones(alert.sound.name) 377 if (!alert.sound.repeat) return 378 var until = now() + REPEAT_FOR_MS 379 var again = function () { 380 if (now() >= until || (entry && entry.dismissed)) return 381 playTones(alert.sound.name) 382 setT(again, REPEAT_EVERY_MS) 383 } 384 setT(again, REPEAT_EVERY_MS) 385 } 386 // One chime per alert across all of the member's tabs: the first tab to 387 // take this lock plays it. 388 if (!locks || typeof locks.request !== 'function') { 389 if (!hidden()) play() 390 return 391 }
392 try { 393 var pending = locks.request('targethit-alert-sound-' + alert.id, { ifAvailable: true }, function (lock) { 394 if (!lock) return undefined 395 play() 396 return new Promise(function (resolve) { setT(resolve, SOUND_LOCK_HOLD_MS) }) 397 }) 398 if (pending && typeof pending.catch === 'function') pending.catch(function () { /* ignore */ }) 399 } catch (_) { 400 if (!hidden()) play() 401 } 402 } 403 404 function onVisibility() { 405 for (var i = 0; i < toasts.length; i++) { 406 if (hidden()) pause(toasts[i]) 407 else resume(toasts[i]) 408 } 409 } 410 411 function start(startOptions) { 412 options = startOptions || {} 413 if (running) return 414 // Framed site pages (terminal embeds) leave toasts to the top page. 415 try { if (win.top && win.top !== win) return } catch (_) { return } 416 running = true 417 attempt = 0 418 if (!visibilityBound && typeof doc.addEventListener === 'function') { 419 visibilityBound = true 420 doc.addEventListener('visibilitychange', onVisibility) 421 } 422 connect() 423 } 424 425 function stop() { 426 running = false 427 if (reconnectTimer) clearT(reconnectTimer) 428 reconnectTimer = null 429 if (source) { 430 try { source.close() } catch (_) { /* ignore */ } 431 } 432 source = null 433 // Signing out must not leave a member's alert text on screen. 434 while (toasts.length) dismiss(toasts[0]) 435 } 436 437 return { 438 version: VERSION, 439 start: start, 440 stop: stop, 441 isRunning: function () { return running }, 442 toastCount: function () { return toasts.length } 443 } 444 } 445 446 return { 447 VERSION: VERSION, 448 SOUNDS: SOUNDS, 449 createAlertToaster: createAlertToaster, 450 parseAlertEvent: parseAlertEvent 451 } 452})
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.