1/* ww-chat-pin.js - pinned chat message for WaveDeck live chat. 2 * Draws a pin banner above the chat list, and a hover "..." menu on each 3 * message for the host, their moderators, and site admins. 4 * All permission and storage logic lives in /wavedeck_pin.php - this file 5 * only draws what that endpoint allows. Viewers get the banner, no controls. */ 6(function () { 7 var ROW = '.live-now-hero-chat-msg'; 8 var SID = 0; 9 /* The studio sets window.WW_WC_SID; the public watch page (livenow) does not, 10 but renders data-ww-stream-id on the chat container. Read whichever exists. 11 Never parse the URL slug - slugs are titles, not ids. */ 12 function getSID() { 13 if (SID) { return SID; } 14 var w = parseInt(window.WW_WC_SID || 0, 10); 15 if (w) { SID = w; return SID; } 16 var b = findBox(); 17 var host = (b && b.closest) ? b.closest('[data-ww-stream-id]') : null; 18 if (!host) { host = document.querySelector('[data-ww-stream-id]'); } 19 if (host) { SID = parseInt(host.getAttribute('data-ww-stream-id'), 10) || 0; } 20 return SID; 21 } 22 var CSRF = null, CAN = false, PIN = null, banner = null; 23 24 var css = '.wwpin-bar{display:flex;gap:8px;align-items:flex-start;padding:8px 12px;margin:6px 8px;' 25 + 'border-radius:6px;background:rgba(0,0,0,.5);border:1px solid rgba(255,45,45,.9);' 26 + 'box-shadow:0 0 10px rgba(255,45,45,.65),inset 0 0 10px rgba(255,45,45,.18);' 27 + 'font-size:13px;line-height:1.4;max-height:176px;overflow-y:auto;' 28 + 'scrollbar-width:thin;flex:0 0 auto;color:#e8e8e8}' 29 + '.wwpin-bar[hidden]{display:none}' 30 + '.wwpin-ico{flex:0 0 auto}' 31 + '.wwpin-body{flex:1 1 auto;min-width:0;overflow-wrap:anywhere;word-break:break-word}' 32 + '.wwpin-who{font-weight:600;color:#ff7b7b;margin-right:4px}' 33 + '.wwpin-x{margin-left:auto;cursor:pointer;opacity:.6;background:none;border:0;color:#fff;' 34 + 'font-size:16px;line-height:1;padding:0 2px}' 35 + '.wwpin-x:hover{opacity:1}' 36 + ROW + '{position:relative}' 37 + '.wwpin-dots{position:absolute;top:0;right:2px;opacity:0;cursor:pointer;' 38 + 'background:rgba(0,0,0,.55);border:0;color:#ddd;border-radius:4px;padding:0 6px;' 39 + 'font-size:14px;line-height:18px}' 40 + ROW + ':hover .wwpin-dots{opacity:1}' 41 + '.wwpin-menu{position:absolute;top:20px;right:2px;z-index:60;background:#15171c;' 42 + 'border:1px solid #333;border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,.6);' 43 + 'min-width:160px;overflow:hidden}' 44 + '.wwpin-menu button{display:block;width:100%;text-align:left;background:none;border:0;' 45 + 'color:#e8e8e8;padding:9px 12px;font-size:13px;cursor:pointer}' 46 + '.wwpin-menu button:hover{background:#22252c}'; 47 var st = document.createElement('style'); 48 st.textContent = css; 49 document.head.appendChild(st); 50 51 function readPin() { 52 return fetch('/wavedeck_pin.php?stream_id=' + getSID(), { credentials: 'same-origin' }) 53 .then(function (r) { return r.json(); }); 54 } 55 function writePin(params) { 56 params.stream_id = getSID(); 57 params.csrf = CSRF || ''; 58 return fetch('/wavedeck_pin.php', { 59 method: 'POST', credentials: 'same-origin', 60 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 61 body: new URLSearchParams(params) 62 }).then(function (r) { return r.json(); }); 63 } 64 function findBox() { 65 var r = document.querySelector(ROW); 66 if (r && r.parentElement) { return r.parentElement; } 67 return document.querySelector('#wch_msgs, .live-now-hero-chat-messages'); 68 } 69 function ensureBanner() { 70 var b = findBox(); 71 if (!b || !b.parentElement) { return null; } 72 if (banner && banner.parentElement) { return banner; } 73 banner = document.createElement('div'); 74 banner.className = 'wwpin-bar'; 75 banner.hidden = true; 76 banner.innerHTML = '<span class="wwpin-ico">📌</span>' 77 + '<span class="wwpin-body"><span class="wwpin-who"></span>' 78 + '<span class="wwpin-txt"></span></span>'; 79 b.parentElement.insertBefore(banner, b); 80 return banner; 81 } 82 function closeMenus() { 83 var m = document.querySelectorAll('.wwpin-menu'); 84 for (var i = 0; i < m.length; i++) { m[i].remove(); } 85 } 86 function afterWrite(res) { 87 closeMenus(); 88 if (!res || !res.ok) { alert('Pin failed: ' + ((res && res.error) || 'unknown error')); return; } 89 PIN = res.pin; 90 render(); 91 } 92 function decorate() { 93 if (!CAN) { return; } 94 var rows = document.querySelectorAll(ROW + ':not([data-wwpin])'); 95 for (var i = 0; i < rows.length; i++) { 96 var r = rows[i]; 97 var id = r.getAttribute('data-msg-id'); 98 if (!id || r.className.indexOf('is-sys') !== -1) { continue; } 99 r.setAttribute('data-wwpin', '1'); 100 var d = document.createElement('button'); 101 d.className = 'wwpin-dots'; 102 d.type = 'button'; 103 d.title = 'Message actions'; 104 d.textContent = '\u22EE'; 105 d.setAttribute('data-cid', id); 106 r.appendChild(d); 107 } 108 } 109 /* Cap the banner at 176px, but never more than 45% of the chat column, so a 110 long pin cannot swallow the message list. Measured rather than a CSS %: 111 the watch page's .live-now-hero-chat is sized by max-height, so its height 112 is indefinite and a percentage max-height there would compute to none. */ 113 function fitBanner(el) { 114 var p = el.parentElement; 115 var h = p ? p.clientHeight : 0; 116 el.style.maxHeight = (h > 0 ? Math.min(176, Math.round(h * 0.45)) : 176) + 'px'; 117 } 118 function render() { 119 var el = ensureBanner(); 120 if (el) { 121 if (!PIN) { 122 el.hidden = true; 123 } else { 124 el.querySelector('.wwpin-who').textContent = PIN.author_name + ':'; 125 el.querySelector('.wwpin-txt').textContent = ' ' + PIN.text; 126 var x = el.querySelector('.wwpin-x'); 127 if (CAN && !x) { 128 x = document.createElement('button'); 129 x.className = 'wwpin-x'; 130 x.type = 'button'; 131 x.title = 'Unpin'; 132 x.innerHTML = '×'; 133 x.addEventListener('click', function () { writePin({ action: 'clear' }).then(afterWrite); }); 134 el.appendChild(x); 135 } else if (!CAN && x) { x.remove(); } 136 el.hidden = false;
137 fitBanner(el); 138 } 139 } 140 decorate(); 141 } 142 document.addEventListener('click', function (ev) { 143 var t = ev.target; 144 var isDots = t && t.classList && t.classList.contains('wwpin-dots'); 145 if (!isDots) { closeMenus(); return; } 146 ev.preventDefault(); 147 ev.stopPropagation(); 148 var open = t.parentElement.querySelector('.wwpin-menu'); 149 closeMenus(); 150 if (open) { return; } 151 var cid = t.getAttribute('data-cid'); 152 var pinned = PIN && String(PIN.comment_id) === String(cid); 153 var menu = document.createElement('div'); 154 menu.className = 'wwpin-menu'; 155 var btn = document.createElement('button'); 156 btn.type = 'button'; 157 btn.textContent = pinned ? 'Unpin this message' : 'Pin this message'; 158 btn.addEventListener('click', function () { 159 if (pinned) { writePin({ action: 'clear' }).then(afterWrite); } 160 else { writePin({ action: 'set', comment_id: cid }).then(afterWrite); } 161 }); 162 menu.appendChild(btn); 163 var del = document.createElement('button'); 164 del.type = 'button'; 165 del.style.color = '#ff6b6b'; 166 del.textContent = 'Delete message';
167 del.addEventListener('click', function () { 168 closeMenus(); 169 if (!confirm('Permanently delete this message?\n\nIt disappears for everyone - web chat, popout, ' 170 + 'OBS overlay and the apps. This cannot be undone.')) { return; } 171 writePin({ action: 'delete_msg', comment_id: cid }).then(function (res) { 172 if (!res || !res.ok) { alert('Delete failed: ' + ((res && res.error) || 'unknown error')); return; } 173 var gone = document.querySelector(ROW + '[data-msg-id="' + cid + '"]'); 174 if (gone) { gone.remove(); } 175 PIN = res.pin; 176 render(); 177 }); 178 }); 179 menu.appendChild(del); 180 t.parentElement.appendChild(menu); 181 }); 182 function refresh() { 183 if (!getSID()) { return Promise.resolve(); } 184 return readPin().then(function (d) { 185 if (!d || !d.ok) { return; } 186 CAN = !!d.can_pin; 187 if (d.csrf) { CSRF = d.csrf; } 188 if (window.WWChatLinks) { WWChatLinks.setState(d); } 189 PIN = d.pin; 190 render(); 191 }).catch(function () {}); 192 } 193 window.addEventListener('resize', function () { 194 if (banner && !banner.hidden) { fitBanner(banner); } 195 }); 196 refresh(); 197 /* WW-PUSH 26 Sep 2026: the server says when the pin changes (ww:push, topic 'pin'); the timer is 198 only a safety net for a dropped socket. Was 10 s, before that 5 s. */ 199 setInterval(refresh, 60000); 200 window.addEventListener('ww:push', function (ev) { 201 var d = (ev && ev.detail) || {}; 202 if (d.topic !== 'pin') { return; } 203 var sid = parseInt(d.streamId, 10) || 0; 204 if (sid && getSID() && sid !== getSID()) { return; } 205 refresh(); 206 }); 207 setTimeout(function () { 208 var b = findBox(); 209 if (b && window.MutationObserver) { 210 new MutationObserver(decorate).observe(b, { childList: true }); 211 } 212 }, 1500); 213})();
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.