├── img ├── favicon-16x16.png └── favicon-32x32.png ├── style.css ├── index.html ├── tmi.min.js └── main.js /img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Glodenox/twitch-chat-monitor/HEAD/img/favicon-16x16.png -------------------------------------------------------------------------------- /img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Glodenox/twitch-chat-monitor/HEAD/img/favicon-32x32.png -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: black; 3 | background-color: var(--background-color); 4 | margin: 0; 5 | color: #eee; 6 | color: var(--text-color); 7 | font-size: 32px; 8 | font-size: var(--font-size); 9 | font-family: "Open Sans Condensed", sans-serif; 10 | font-weight: 300; 11 | } 12 | 13 | body.hide-cursor { 14 | cursor: none; 15 | } 16 | 17 | .hidden { 18 | display: none !important; 19 | } 20 | 21 | #chat-container { 22 | overflow: hidden; 23 | height: 100vh; 24 | } 25 | 26 | #chat { 27 | display: flex; 28 | flex-direction: column-reverse; 29 | margin-top: 0; 30 | margin-bottom: 100vh; 31 | } 32 | 33 | body.reverse-order #chat { 34 | flex-direction: column; 35 | margin-top: 100vh; 36 | margin-bottom: 0; 37 | } 38 | 39 | #chat > div { 40 | padding: 3px 1px 3px 6px; 41 | line-height: 35px; 42 | line-height: calc(var(--font-size) + 3px); 43 | border-bottom: #444 solid 1px; 44 | border-bottom-color: var(--separator-color); 45 | } 46 | #chat > div:nth-child(odd) { 47 | background-color: #111; 48 | background-color: var(--odd-background-color); 49 | } 50 | 51 | #chat .deleted { 52 | font-style: italic; 53 | } 54 | 55 | #chat:not(.hide-timestamps) .timestamp { 56 | margin-right: 5px; 57 | } 58 | 59 | #chat div.emoticon { 60 | display: inline-block; 61 | height: 28px; 62 | width: 28px; 63 | background-repeat: no-repeat; 64 | background-position: center; 65 | vertical-align: middle; 66 | } 67 | 68 | #chat img { 69 | max-width: 100%; 70 | max-height: 50vh; 71 | max-height: var(--inline-images-height); 72 | color: inherit; 73 | margin: 0; 74 | vertical-align: baseline; 75 | } 76 | 77 | #chat img.user-image { 78 | vertical-align: top; 79 | padding: 4px; 80 | } 81 | 82 | #chat img.user-image:not(.loaded) { 83 | display: none; 84 | } 85 | 86 | #chat .image-loading { 87 | font-style: italic; 88 | font-size: 80%; 89 | } 90 | 91 | #chat div.tweet-embed { 92 | display: inline-block; 93 | width: 550px; 94 | } 95 | 96 | #chat .chat-user::after { 97 | color: #eee; 98 | color: var(--text-color); 99 | font-weight: 300; 100 | } 101 | 102 | #chat .chat-user:not(.action)::after { 103 | content: ': '; 104 | } 105 | 106 | #chat .chat-user.action::after { 107 | content: ' '; 108 | } 109 | 110 | #chat .chat-user { 111 | color: green; 112 | color: var(--user-color); 113 | font-weight: 700; 114 | } 115 | 116 | #chat .chat-user.vip { 117 | color: #4686f8; 118 | color: var(--vip-color); 119 | } 120 | 121 | #chat .chat-user.admin { 122 | color: #a970ff; 123 | color: var(--admin-color); 124 | } 125 | 126 | #chat .chat-user.staff { 127 | color: #a970ff; 128 | color: var(--staff-color); 129 | } 130 | 131 | #chat .chat-user.moderator { 132 | color: #8383f9; 133 | color: var(--moderator-color); 134 | } 135 | 136 | #chat.align-messages .chat-user { 137 | flex-basis: 270px; 138 | flex-shrink: 0; 139 | text-overflow: ellipsis; 140 | overflow: hidden; 141 | text-align: right; 142 | padding-right: 5px; 143 | } 144 | 145 | #chat.align-messages > div.notice .chat-user, #chat.align-messages > div.highlight .chat-user { 146 | flex-basis: 258px; 147 | } 148 | 149 | #chat.align-messages > div { 150 | display: flex; 151 | } 152 | #chat.align-messages > div.subscription { 153 | display: block; 154 | } 155 | 156 | #chat > div.highlight, #chat > div.notice { 157 | padding-left: 12px; 158 | border-left: 6px solid; 159 | } 160 | 161 | #chat > div.highlight { 162 | border-left-color: #731180; 163 | border-left-color: var(--highlight-color); 164 | background-color: #73118030; 165 | background-color: var(--highlight-background-color); 166 | } 167 | 168 | #chat > div.notice { 169 | border-left-color: #eee; 170 | border-left-color: var(--notice-color); 171 | background-color: #eeeeee30; 172 | background-color: var(--notice-background-color); 173 | } 174 | 175 | #chat > div.channel { 176 | border-left-color: #0d86ff; 177 | border-left-color: var(--channel-color); 178 | background-color: #0d86ff30; 179 | background-color: var(--channel-background-color); 180 | } 181 | 182 | #chat a, #settings a { 183 | color: inherit; 184 | } 185 | 186 | #chat .cheer, #chat .cheer img { 187 | margin-right: 5px; 188 | font-weight: 700; 189 | } 190 | 191 | #chat .cheer-1 { 192 | color: gray; 193 | } 194 | #chat .cheer-100 { 195 | color: cyan; 196 | } 197 | #chat .cheer-500 { 198 | color: blue; 199 | } 200 | #chat .cheer-1000 { 201 | color: red; 202 | } 203 | #chat .cheer-10000 { 204 | color: yellow; 205 | } 206 | 207 | #chat .counter { 208 | font-weight: bold; 209 | float: right; 210 | font-size: 80%; 211 | transition: 1s; 212 | padding-right: 10px; 213 | } 214 | #chat .counter.bump { 215 | transition: 0.1s; 216 | font-size: 150%; 217 | } 218 | 219 | #chat.hide-timestamps .timestamps { 220 | display: none; 221 | } 222 | 223 | #commands { 224 | position: absolute; 225 | right: 5px; 226 | bottom: 5px; 227 | display: flex; 228 | } 229 | 230 | body.reverse-order #commands { 231 | bottom: auto; 232 | top: 5px; 233 | } 234 | 235 | #fullscreen, #settings-toggle { 236 | z-index: 10; 237 | opacity: 0.3; 238 | margin-left: 25px; 239 | cursor: pointer; 240 | } 241 | #fullscreen:hover, #settings-toggle:hover, #settings-toggle.open { 242 | opacity: 1; 243 | } 244 | 245 | body.reverse-order #settings-toggle { 246 | top: 5px; 247 | bottom: auto; 248 | } 249 | 250 | #fps { 251 | position: absolute; 252 | right: 15px; 253 | top: 15px; 254 | border: 2px solid #eee; 255 | border-color: var(--text-color); 256 | border-radius: 10px; 257 | width: 3em; 258 | text-align: center; 259 | background-color: black; 260 | background-color: var(--background-color); 261 | opacity: 0.9; 262 | } 263 | 264 | body.show-message-entry #fps { 265 | top: calc(15px + var(--font-size)); 266 | } 267 | 268 | body.reverse-order #fps { 269 | top: auto; 270 | bottom: 15px; 271 | } 272 | 273 | body.show-message-entry.reverse-order #fps { 274 | bottom: calc(15px + var(--font-size)); 275 | } 276 | 277 | .notifications { 278 | position: absolute; 279 | bottom: 10px; 280 | left: 0; 281 | right: 0; 282 | display: flex; 283 | flex-direction: column; 284 | align-items: center; 285 | } 286 | 287 | body.reverse-order .notifications { 288 | bottom: auto; 289 | top: 10px; 290 | } 291 | 292 | body.show-message-entry:not(.reverse-order) .notifications { 293 | bottom: calc(14px + var(--font-size)); 294 | } 295 | body.show-message-entry.reverse-order .notifications { 296 | top: calc(14px + var(--font-size)); 297 | } 298 | 299 | .notifications div { 300 | border: 2px solid #eee; 301 | border-color: var(--text-color); 302 | padding: 5px; 303 | margin: 5px; 304 | border-radius: 10px; 305 | background-color: black; 306 | background-color: var(--background-color); 307 | opacity: 0.9; 308 | } 309 | 310 | #settings { 311 | position: absolute; 312 | right: 20px; 313 | bottom: var(--font-size); 314 | left: 20px; 315 | margin-bottom: 15px; 316 | padding: 20px; 317 | max-height: 50vh; 318 | overflow-y: auto; 319 | border: 3px solid #444; 320 | border-color: var(--separator-color); 321 | background-color: black; 322 | background-color: var(--background-color); 323 | z-index: 9999; 324 | } 325 | 326 | body.reverse-order #settings { 327 | top: 60px; 328 | bottom: auto; 329 | } 330 | 331 | #settings h2 { 332 | margin-top: 0; 333 | text-align: center; 334 | } 335 | 336 | #settings h3 { 337 | padding: 5px 20px; 338 | background-color: var(--background-color); 339 | margin: 0; 340 | } 341 | 342 | #settings > div { 343 | background-color: #111; 344 | background-color: var(--odd-background-color); 345 | padding: 10px; 346 | } 347 | 348 | #settings > div > div { 349 | margin: 10px 0; 350 | } 351 | 352 | #settings .fields { 353 | display: grid; 354 | grid-gap: 10px; 355 | grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 356 | } 357 | 358 | #settings .fields.centered > div { 359 | text-align: center; 360 | } 361 | 362 | #settings .fields .style-preview { 363 | border: 2px solid var(--text-color); 364 | border-radius: 10px; 365 | padding: 10px; 366 | background-color: var(--background-color); 367 | cursor: pointer; 368 | opacity: 0.8; 369 | display: flex; 370 | flex-direction: column; 371 | align-items: center; 372 | } 373 | 374 | #settings .fields .style-preview.active { 375 | opacity: 1; 376 | border-color: var(--highlight-color); 377 | background-color: var(--highlight-background-color); 378 | } 379 | 380 | #settings .fields .style-preview:not(.active):hover { 381 | opacity: 1; 382 | } 383 | 384 | #settings .subfield { 385 | margin-top: 5px; 386 | margin-left: 40px; 387 | border-left: 5px solid #eee; 388 | border-color: var(--text-color); 389 | padding-left: 10px; 390 | } 391 | 392 | #settings label.disabled { 393 | opacity: 0.5; 394 | cursor: not-allowed; 395 | } 396 | 397 | #settings input, #settings button, #message-entry input { 398 | font-size: 0.625em; 399 | vertical-align: bottom; 400 | color: inherit; 401 | } 402 | 403 | #settings button:disabled { 404 | opacity: 0.5; 405 | cursor: default; 406 | } 407 | 408 | #settings input[type="checkbox"] { 409 | appearance: none; 410 | border: 2px solid #eee; 411 | border-color: var(--text-color); 412 | text-align: center; 413 | padding-top: 2px; 414 | width: 32px; 415 | width: calc(var(--font-size) + 4px); 416 | height: 32px; 417 | height: calc(var(--font-size) + 4px); 418 | cursor: pointer; 419 | } 420 | 421 | #settings input[type="checkbox"]:checked::after { 422 | content: "\2713"; 423 | font-weight: 700; 424 | } 425 | 426 | #settings input[type="number"], #settings input[type="text"], #settings input[type="password"], #settings input[type="submit"], #settings button, #message-entry input { 427 | height: 32px; 428 | height: var(--font-size); 429 | padding: 0 5px; 430 | border: 2px solid #eee; 431 | border-color: var(--text-color); 432 | background-color: black; 433 | background-color: var(--background-color); 434 | max-width: 95%; 435 | } 436 | 437 | #settings input[type="submit"], #settings button, #message-entry input[type="submit"] { 438 | height: 38px; 439 | height: calc(var(--font-size) + 4px); 440 | padding: 0 15px; 441 | border-radius: 8px; 442 | cursor: pointer; 443 | } 444 | 445 | #settings input[type="submit"]:hover, #settings input[type="submit"]:active, #settings button:hover, #settings button:active, #message-entry input[type="submit"]:hover, #message-entry input[type="submit"]:active { 446 | background-color: #111; 447 | background-color: var(--odd-background-color); 448 | } 449 | 450 | #settings input[type="number"]:invalid, #settings input[type="text"]:invalid, #settings input[type="password"]:invalid { 451 | border-color: red; 452 | } 453 | 454 | #settings input[type="color"] { 455 | appearance: none; 456 | border: 2px solid #eee; 457 | border-color: var(--text-color); 458 | background-color: black; 459 | background-color: var(--background-color); 460 | padding: 5px; 461 | height: 32px; 462 | cursor: pointer; 463 | } 464 | 465 | #settings select { 466 | font-size: 0.625em; 467 | color: #eee; 468 | border-color: var(--text-color); 469 | background-color: black; 470 | background-color: var(--background-color); 471 | } 472 | 473 | #curtain { 474 | position: fixed; 475 | top: 0; 476 | right: 0; 477 | bottom: 0; 478 | left: 0; 479 | background-color: inherit; 480 | display: flex; 481 | align-items: center; 482 | justify-content: center; 483 | text-align: center; 484 | opacity: 1; 485 | animation: curtain 1s; 486 | } 487 | #curtain.hidden { 488 | opacity: 0; 489 | } 490 | 491 | @keyframes curtain { 492 | from { opacity: 0; } 493 | to { opacity: 1; } 494 | } 495 | 496 | body.show-message-entry:not(.reverse-order) #commands { 497 | bottom: calc(9px + var(--font-size)); 498 | } 499 | 500 | body.show-message-entry.reverse-order #commands { 501 | top: calc(9px + var(--font-size)); 502 | } 503 | 504 | body.show-message-entry:not(.reverse-order) #settings { 505 | bottom: calc(var(--font-size) * 2); 506 | } 507 | 508 | body.show-message-entry.reverse-order #settings { 509 | top: calc(60px + var(--font-size)); 510 | } 511 | 512 | body:not(.show-message-entry) #message-entry { 513 | display: none; 514 | } 515 | 516 | #message-entry { 517 | position: fixed; 518 | bottom: 0; 519 | width: 100%; 520 | display: flex; 521 | height: calc(4px + var(--font-size)); 522 | background-color: black; 523 | background-color: var(--background-color); 524 | } 525 | 526 | body.reverse-order #message-entry { 527 | bottom: auto; 528 | top: 0; 529 | } 530 | 531 | #message-entry span { 532 | font-size: 0.8em; 533 | } 534 | 535 | #message-entry input[type="text"] { 536 | flex-grow: 1; 537 | margin: 0 5px; 538 | } 539 | 540 | #message-username::after { 541 | content: ': '; 542 | } 543 | 544 | .help:before { 545 | content: '?'; 546 | border-radius: 50%; 547 | background-color: #eee; 548 | background-color: var(--text-color); 549 | color: black; 550 | color: var(--background-color); 551 | font-weight: bold; 552 | padding: 0 0.5em; 553 | font-size: 0.5em; 554 | cursor: pointer; 555 | vertical-align: super; 556 | } 557 | .help::after { 558 | content: attr(data-title); 559 | display: none; 560 | border: 1px solid #eee; 561 | border-color: var(--text-color); 562 | margin: 15px; 563 | color: #eee; 564 | color: var(--text-color); 565 | background-color: black; 566 | background-color: var(--background-color); 567 | padding: 4px; 568 | } 569 | .help.visible::after { 570 | display: block; 571 | } 572 | 573 | .help:hover::after { 574 | opacity: 1; 575 | transition: all 0.1s ease 0.3s; 576 | visibility: visible; 577 | } 578 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Twitch Chat Monitor 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 21 | 22 |
23 | 24 | 25 |
26 | 161 |
162 |
163 |
164 |
165 | 166 | 167 | 168 |
169 | 170 | 215 | 216 | 217 | 218 | 269 | 270 | 271 | 272 | -------------------------------------------------------------------------------- /tmi.min.js: -------------------------------------------------------------------------------- 1 | !function s(o,i,r){function a(t,e){if(!i[t]){if(!o[t]){var n="function"==typeof require&&require;if(!e&&n)return n(t,!0);if(c)return c(t,!0);throw(n=new Error("Cannot find module '"+t+"'")).code="MODULE_NOT_FOUND",n}n=i[t]={exports:{}},o[t][0].call(n.exports,function(e){return a(o[t][1][e]||e)},n,n.exports,s,o,i,r)}return i[t].exports}for(var c="function"==typeof require&&require,e=0;ee.length)&&(t=e.length);for(var n=0,s=new Array(t);n: ").concat(h)),V.hasOwn(t.tags,"username")||(t.tags.username=J),t.tags["message-type"]="whisper";J=V.channel(t.tags.username);this.emits(["whisper","message"],[[J,t.tags,h,!1]]);break;case"PRIVMSG":t.tags.username=t.prefix.split("!")[0],"jtv"===t.tags.username?(c=V.username(h.split(" ")[0]),u=h.includes("auto"),h.includes("hosting you for")?(a=V.extractNumber(h),this.emit("hosted",m,c,a,u)):h.includes("hosting you")&&this.emit("hosted",m,c,0,u)):(a=V.get(this.opts.options.messagesLogLevel,"info"),c=V.actionMessage(h),t.tags["message-type"]=c?"action":"chat",h=c?c[1]:h,V.hasOwn(t.tags,"bits")?this.emit("cheer",m,t.tags,h):(V.hasOwn(t.tags,"msg-id")?"highlighted-message"===t.tags["msg-id"]?(u=t.tags["msg-id"],this.emit("redeem",m,t.tags.username,u,t.tags,h)):"skip-subs-mode-message"===t.tags["msg-id"]&&(l=t.tags["msg-id"],this.emit("redeem",m,t.tags.username,l,t.tags,h)):V.hasOwn(t.tags,"custom-reward-id")&&(l=t.tags["custom-reward-id"],this.emit("redeem",m,t.tags.username,l,t.tags,h)),c?(this.log[a]("[".concat(m,"] *<").concat(t.tags.username,">: ").concat(h)),this.emits(["action","message"],[[m,t.tags,h,!1]])):(this.log[a]("[".concat(m,"] <").concat(t.tags.username,">: ").concat(h)),this.emits(["chat","message"],[[m,t.tags,h,!1]]))));break;default:this.log.warn("Could not parse message:\n".concat(JSON.stringify(t,null,4)))}}},n.prototype.connect=function(){var s=this;return new Promise(function(t,n){s.server=V.get(s.opts.connection.server,"irc-ws.chat.twitch.tv"),s.port=V.get(s.opts.connection.port,80),s.secure&&(s.port=443),443===s.port&&(s.secure=!0),s.reconnectTimer=s.reconnectTimer*s.reconnectDecay,s.reconnectTimer>=s.maxReconnectInterval&&(s.reconnectTimer=s.maxReconnectInterval),s._openConnection(),s.once("_promiseConnect",function(e){e?n(e):t([s.server,~~s.port])})})},n.prototype._openConnection=function(){var e="".concat(this.secure?"wss":"ws","://").concat(this.server,":").concat(this.port,"/"),t={};"agent"in this.opts.connection&&(t.agent=this.opts.connection.agent),this.ws=new r(e,"irc",t),this.ws.onmessage=this._onMessage.bind(this),this.ws.onerror=this._onError.bind(this),this.ws.onclose=this._onClose.bind(this),this.ws.onopen=this._onOpen.bind(this)},n.prototype._onOpen=function(){var n=this;this._isConnected()&&(this.log.info("Connecting to ".concat(this.server," on port ").concat(this.port,"..")),this.emit("connecting",this.server,~~this.port),this.username=V.get(this.opts.identity.username,V.justinfan()),this._getToken().then(function(e){var t=V.password(e);n.log.info("Sending authentication to server.."),n.emit("logon");e="twitch.tv/tags twitch.tv/commands";n._skipMembership||(e+=" twitch.tv/membership"),n.ws.send("CAP REQ :"+e),t?n.ws.send("PASS ".concat(t)):V.isJustinfan(n.username)&&n.ws.send("PASS SCHMOOPIIE"),n.ws.send("NICK ".concat(n.username))}).catch(function(e){n.emits(["_promiseConnect","disconnected"],[[e],["Could not get a token."]])}))},n.prototype._getToken=function(){var e,t=this.opts.identity.password;return"function"==typeof t?(e=t())instanceof Promise?e:Promise.resolve(e):Promise.resolve(t)},n.prototype._onMessage=function(e){var t=this;e.data.trim().split("\r\n").forEach(function(e){e=q.msg(e);e&&t.handleMessage(e)})},n.prototype._onError=function(){var t=this;this.moderators={},this.userstate={},this.globaluserstate={},clearInterval(this.pingLoop),clearTimeout(this.pingTimeout),clearTimeout(this._updateEmotesetsTimer),this.reason=null===this.ws?"Connection closed.":"Unable to connect.",this.emits(["_promiseConnect","disconnected"],[[this.reason]]),this.reconnect&&this.reconnections===this.maxReconnectAttempts&&(this.emit("maxreconnect"),this.log.error("Maximum reconnection attempts reached.")),this.reconnect&&!this.reconnecting&&this.reconnections<=this.maxReconnectAttempts-1&&(this.reconnecting=!0,this.reconnections=this.reconnections+1,this.log.error("Reconnecting in ".concat(Math.round(this.reconnectTimer/1e3)," seconds..")),this.emit("reconnect"),setTimeout(function(){t.reconnecting=!1,t.connect().catch(function(e){return t.log.error(e)})},this.reconnectTimer)),this.ws=null},n.prototype._onClose=function(){var t=this;this.moderators={},this.userstate={},this.globaluserstate={},clearInterval(this.pingLoop),clearTimeout(this.pingTimeout),clearTimeout(this._updateEmotesetsTimer),this.wasCloseCalled?(this.wasCloseCalled=!1,this.reason="Connection closed.",this.log.info(this.reason),this.emits(["_promiseConnect","_promiseDisconnect","disconnected"],[[this.reason],[null],[this.reason]])):(this.emits(["_promiseConnect","disconnected"],[[this.reason]]),this.reconnect&&this.reconnections===this.maxReconnectAttempts&&(this.emit("maxreconnect"),this.log.error("Maximum reconnection attempts reached.")),this.reconnect&&!this.reconnecting&&this.reconnections<=this.maxReconnectAttempts-1&&(this.reconnecting=!0,this.reconnections=this.reconnections+1,this.log.error("Could not connect to server. Reconnecting in ".concat(Math.round(this.reconnectTimer/1e3)," seconds..")),this.emit("reconnect"),setTimeout(function(){t.reconnecting=!1,t.connect().catch(function(e){return t.log.error(e)})},this.reconnectTimer))),this.ws=null},n.prototype._getPromiseDelay=function(){return this.currentLatency<=600?600:this.currentLatency+100},n.prototype._sendCommand=function(s,o,i,r){var a=this;return new Promise(function(e,t){if(!a._isConnected())return t("Not connected to server.");var n;null!==s&&"number"!=typeof s||(null===s&&(s=a._getPromiseDelay()),V.promiseDelay(s).then(function(){return t("No response from Twitch.")})),null!==o?(n=V.channel(o),a.log.info("[".concat(n,"] Executing command: ").concat(i)),a.ws.send("PRIVMSG ".concat(n," :").concat(i))):(a.log.info("Executing command: ".concat(i)),a.ws.send(i)),"function"==typeof r?r(e,t):e()})},n.prototype._sendMessage=function(c,u,l,m){var h=this;return new Promise(function(e,t){if(!h._isConnected())return t("Not connected to server.");if(V.isJustinfan(h.getUsername()))return t("Cannot send anonymous messages.");var n,s=V.channel(u);h.userstate[s]||(h.userstate[s]={}),500<=l.length&&(n=V.splitLine(l,500),l=n[0],setTimeout(function(){h._sendMessage(c,u,n[1],function(){})},350)),h.ws.send("PRIVMSG ".concat(s," :").concat(l));var o={};Object.keys(h.emotesets).forEach(function(e){return h.emotesets[e].forEach(function(e){return(V.isRegex(e.code)?q.emoteRegex:q.emoteString)(l,e.code,e.id,o)})});var i=Object.assign(h.userstate[s],q.emotes({emotes:q.transformEmotes(o)||null})),r=V.get(h.opts.options.messagesLogLevel,"info"),a=V.actionMessage(l);a?(i["message-type"]="action",h.log[r]("[".concat(s,"] *<").concat(h.getUsername(),">: ").concat(a[1])),h.emits(["action","message"],[[s,i,a[1],!0]])):(i["message-type"]="chat",h.log[r]("[".concat(s,"] <").concat(h.getUsername(),">: ").concat(l)),h.emits(["chat","message"],[[s,i,l,!0]])),"function"==typeof m?m(e,t):e()})},n.prototype._updateEmoteset=function(s){var t,o=this,e=void 0!==s;e&&(s===this.emotes?e=!1:this.emotes=s),this._skipUpdatingEmotesets?e&&this.emit("emotesets",s,{}):(t=function(){0n&&(this._events[e].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[e].length),"function"==typeof console.trace&&console.trace()),this},o.prototype.once=function(e,t){if(!c(t))throw TypeError("listener must be a function");var n=!1;if(this._events.hasOwnProperty(e)&&"_"===e.charAt(0)){var s,o=1,i=e;for(s in this._events)this._events.hasOwnProperty(s)&&s.startsWith(i)&&o++;e+=o}function r(){"_"!==e.charAt(0)||isNaN(e.substr(e.length-1))||(e=e.substring(0,e.length-1)),this.removeListener(e,r),n||(n=!0,t.apply(this,arguments))}return r.listener=t,this.on(e,r),this},o.prototype.removeListener=function(e,t){var n,s,o,i;if(!c(t))throw TypeError("listener must be a function");if(!this._events||!this._events[e])return this;if(o=(n=this._events[e]).length,s=-1,n===t||c(n.listener)&&n.listener===t){if(delete this._events[e],this._events.hasOwnProperty(e+"2")&&"_"===e.charAt(0)){var r,a=e;for(r in this._events)this._events.hasOwnProperty(r)&&r.startsWith(a)&&(isNaN(parseInt(r.substr(r.length-1)))||(this._events[e+parseInt(r.substr(r.length-1)-1)]=this._events[r],delete this._events[r]));this._events[e]=this._events[e+"1"],delete this._events[e+"1"]}this._events.removeListener&&this.emit("removeListener",e,t)}else if(u(n)){for(i=o;0n?(t.command=e.slice(n),t):null;for(t.command=e.slice(n,s),n=s+1;32===e.charCodeAt(n);)n++;for(;n").replace(/\\"\\;/g,'"').replace(/\\'\\;/g,"'")},unescapeIRC:function(e){return e&&"string"==typeof e&&e.includes("\\")?e.replace(i,function(e,t){return t in a?a[t]:t}):e},escapeIRC:function(e){return e&&"string"==typeof e?e.replace(r,function(e,t){return t in c?"\\".concat(c[t]):t}):e},addWord:function(e,t){return e.length?e+" "+t:e+t},splitLine:function(e,t){var n=e.substring(0,t).lastIndexOf(" ");return[e.substring(0,n=-1===n?t-1:n),e.substring(n+1)]},extractNumber:function(e){for(var t=e.split(" "),n=0;n document.body.style.setProperty('--' + key, settings[key])); 25 | transparentBackgroundKeys.forEach((key) => document.body.style.setProperty('--' + key.replace('-color', '-background-color'), settings[key] + '50')); 26 | 27 | var update = (key, value) => { 28 | settings[key] = value; 29 | localStorage.setItem('config', JSON.stringify(settings)); 30 | document.body.style.setProperty('--' + key, value); 31 | // Generate transparent background color values 32 | if (transparentBackgroundKeys.indexOf(key) != -1) { 33 | document.body.style.setProperty(key.replace('-color', '-background-color'), value + '50'); 34 | } 35 | }; 36 | return { 37 | 'get': (key) => settings[key], 38 | 'set': update, 39 | 'toggle': (key) => update(key, !settings[key]), 40 | 'reset': () => { 41 | Object.assign(settings, defaultSettings); 42 | localStorage.setItem('config', JSON.stringify(defaultSettings)); 43 | } 44 | } 45 | }(); 46 | 47 | var HexCompressor = function() { 48 | const characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ=#'; 49 | return { 50 | color2string: (color) => { 51 | var code = ''; 52 | var firstHalf = parseInt(color.substr(1, 3), 16); 53 | code += characters.charAt(Math.floor(firstHalf / 64)); 54 | code += characters.charAt(firstHalf % 64); 55 | var secondHalf = parseInt(color.substr(4, 3), 16); 56 | code += characters.charAt(Math.floor(secondHalf / 64)); 57 | code += characters.charAt(secondHalf % 64); 58 | return code; 59 | }, 60 | string2color: (string) => '#' + 61 | ("00" + (characters.indexOf(string.charAt(0)) * 64 + characters.indexOf(string.charAt(1))).toString(16)).slice(-3) + 62 | ("00" + (characters.indexOf(string.charAt(2)) * 64 + characters.indexOf(string.charAt(3))).toString(16)).slice(-3) 63 | }; 64 | }(); 65 | 66 | var highlightUsers = Settings.get('highlight-users').toLowerCase().split(',').filter((user) => user != ''), 67 | highlightKeyphrases = Settings.get('highlight-keyphrases').toLowerCase().split(',').filter((phrase) => phrase != ''); 68 | 69 | // Object containing references to all relevant UI blocks 70 | var ui = { 71 | main: { 72 | curtain: document.getElementById('curtain'), 73 | fps: document.getElementById('fps') 74 | }, 75 | chat: { 76 | body: document.getElementById('chat'), 77 | container: document.getElementById('chat-container') 78 | }, 79 | commands: { 80 | body: document.getElementById('commands'), 81 | settings: document.getElementById('settings-toggle'), 82 | fullscreen: document.getElementById('fullscreen') 83 | }, 84 | messageEntry: { 85 | body: document.getElementById('message-entry'), 86 | username: document.getElementById('message-username'), 87 | field: document.querySelector('#message-entry .message-field') 88 | }, 89 | settings: { 90 | body: document.getElementById('settings'), 91 | twitch: { 92 | channel: document.getElementById('settings-channel'), 93 | channelOverride: document.getElementById('settings-channel-override'), 94 | identity: { 95 | toggle: document.getElementById('settings-twitch-messagefield'), 96 | body: document.getElementById('settings-twitch-messaging'), 97 | username: document.getElementById('settings-twitch-username'), 98 | token: document.getElementById('settings-twitch-token') 99 | } 100 | }, 101 | style: { 102 | custom: { 103 | container: document.getElementById('styles'), 104 | selector: document.getElementById('settings-custom-style'), 105 | exchange: document.getElementById('settings-custom-style-exchange'), 106 | field: document.getElementById('settings-custom-style-exchange-field'), 107 | preview: document.getElementById('style-template') 108 | }, 109 | fontSize: document.getElementById('settings-font-size'), 110 | hideCursor: document.getElementById('settings-hide-cursor'), 111 | adjustTitle: document.getElementById('settings-adjust-page-title'), 112 | showUnreadInTitle: document.getElementById('settings-unread-counter-in-page-title'), 113 | animateEmoji : document.getElementById('settings-animate-emoji') 114 | }, 115 | behaviour: { 116 | limitRate: { 117 | toggle: document.getElementById('settings-limit-message-rate'), 118 | body: document.getElementById('settings-limit-message-rate').parentNode.nextElementSibling, 119 | field: document.getElementById('settings-message-rate') 120 | }, 121 | reverseOrder: document.getElementById('settings-new-messages-on-top'), 122 | smoothScroll: { 123 | body: document.getElementById('settings-smooth-scroll').parentNode.nextElementSibling, 124 | duration: document.getElementById('settings-smooth-scroll-duration') 125 | }, 126 | chatDelay: { 127 | toggle: document.getElementById('settings-enable-chat-delay'), 128 | body: document.getElementById('settings-chat-delay').parentNode.parentNode, 129 | delay: document.getElementById('settings-chat-delay') 130 | } 131 | }, 132 | messageHandling: { 133 | inlineImages: { 134 | body: document.getElementById('settings-inline-images').parentNode.nextElementSibling.nextElementSibling, 135 | height: document.getElementById('settings-inline-images-height') 136 | }, 137 | timestamps: document.getElementById('settings-timestamps'), 138 | highlightUsers: document.getElementById('settings-highlight-users'), 139 | keyPhrases: document.getElementById('settings-highlight-keyphrases') 140 | } 141 | }, 142 | notifications: { 143 | chatOverload: { 144 | body: document.getElementById('chat-overload'), 145 | count: document.getElementById('chat-overload-count') 146 | }, 147 | networkStatus: document.getElementById('network-status'), 148 | keyPhrases: document.getElementById('settings-highlight-keyphrases') 149 | } 150 | }; 151 | 152 | /** Set up chat client **/ 153 | var configuration = { 154 | options: { 155 | skipMembership: true, 156 | skipUpdatingEmotesets: true // the API no longer exists on Kraken 157 | } 158 | }; 159 | if (Settings.get('identity')) { 160 | configuration.identity = Settings.get('identity'); 161 | } 162 | var client = new tmi.client(configuration); 163 | client.on('message', handleChat); 164 | client.on('roomstate', handleRoomstate); 165 | client.on('subscription', (channel, username, method, message, userstate) => handleSubscription(username, message, userstate)); 166 | client.on('resub', (channel, username, months, message, userstate, methods) => handleSubscription(username, message, userstate)); 167 | client.on('submysterygift', (channel, username, numbOfSubs, methods, userstate) => handleSubscription(username, null, userstate)); 168 | client.on('cheer', handleCheer); 169 | client.on('raided', (channel, username, viewers) => addNotice(`${username} raided the channel with ${viewers} viewers!`)); 170 | client.on('usernotice', (msgid, channel, userstate, message) => handleChat(channel, userstate, message)); 171 | client.on('slowmode', (channel, enabled, length) => addNotice(`Slowmode chat has been ${enabled ? 'activated' : 'deactivated'}.`)); 172 | client.on('followersonly', (channel, enabled, length) => addNotice(`Followers-only chat has been ${enabled ? 'activated' : 'deactivated'}.`)); 173 | client.on('emoteonly', (channel, enabled) => addNotice(`Emote-only chat has been ${enabled ? 'activated' : 'deactivated'}.`)); 174 | client.on('hosting', (channel, target) => addNotice(`The channel is now hosting ${target}.`)); 175 | client.on('unhost', (channel) => addNotice(`The channel has stopped hosting another channel.`)); 176 | client.on('messagedeleted', handleMessageDeletion); 177 | client.on('ban', (channel, username, reason, userstate) => handleModAction('ban', username, null, userstate)); 178 | client.on('timeout', (channel, username, reason, duration, userstate) => handleModAction('timeout', username, duration, userstate)); 179 | client.on('clearchat', (channel) => { 180 | ui.chat.body.textContent = ''; 181 | addNotice('Chat has been cleared by a moderator'); 182 | }); 183 | // Network connection monitoring 184 | client.on('disconnected', () => ui.notifications.networkStatus.classList.remove('hidden')); 185 | client.on('connected', () => { 186 | if (!ui.notifications.networkStatus.classList.contains('hidden')) { 187 | addNotice('Connection reestablished, resuming chat monitoring.'); 188 | } 189 | ui.notifications.networkStatus.classList.add('hidden'); 190 | }); 191 | client.connect().then(() => { 192 | let channelFromPath = (document.location.href.match(/channel=([A-Za-z0-9_]+)/) || [null])[1]; 193 | if (channelFromPath) { 194 | joinChannel(channelFromPath); 195 | ui.settings.twitch.channelOverride.classList.remove('hidden'); 196 | } else { 197 | joinChannel(Settings.get('channel')); 198 | } 199 | }); 200 | 201 | /** Interface interactions **/ 202 | // Message sending 203 | ui.messageEntry.body.addEventListener('submit', (e) => { 204 | let field = ui.messageEntry.field; 205 | if (field.value.trim().length > 0) { 206 | field.disabled = true; 207 | client.say(client.channels[0], field.value.trim()).then(() => { 208 | field.value = ''; 209 | field.disabled = false; 210 | field.focus(); 211 | }).catch(() => { 212 | field.disabled = false; 213 | field.focus(); 214 | }); 215 | } 216 | e.preventDefault(); 217 | }); 218 | if (document.body.classList.contains('show-message-entry')) { 219 | ui.messageEntry.field.focus(); 220 | } 221 | // Settings 222 | ui.commands.settings.addEventListener('click', () => { 223 | ui.settings.body.classList.toggle('hidden'); 224 | ui.settings.body.scrollTop = 0; 225 | ui.commands.settings.classList.toggle('open'); 226 | }); 227 | document.querySelectorAll('.help').forEach((help) => help.addEventListener('click', () => help.classList.toggle('visible'))); 228 | // Twitch 229 | ui.settings.twitch.channel.value = Settings.get('channel'); 230 | ui.settings.twitch.channel.addEventListener('input', (e) => e.target.value = e.target.value.replaceAll('https://www.twitch.tv/', '').replaceAll('twitch.tv/', '')); 231 | ui.settings.twitch.channel.form.addEventListener('submit', (e) => { 232 | var channel = ui.settings.twitch.channel.value; 233 | if (channel != '' && client.channels.indexOf(ensureHash(channel)) == -1) { 234 | if (client.channels.length > 0) { 235 | client.leave(ensureHash(client.channels[0])); 236 | } 237 | // Fade out all previous channel messages before joining new one 238 | ui.chat.body.querySelectorAll('div').forEach((msg) => msg.style.opacity = 0.5); 239 | Settings.set('channel', channel); 240 | joinChannel(channel); 241 | ui.settings.twitch.channelOverride.classList.add('hidden'); 242 | } 243 | e.preventDefault(); 244 | }); 245 | if (Settings.get('identity')) { 246 | let identity = ui.settings.twitch.identity; 247 | identity.username.value = Settings.get('identity').username; 248 | identity.token.value = Settings.get('identity').token; 249 | identity.body.classList.remove('disabled'); 250 | identity.toggle.classList.remove('disabled'); 251 | identity.toggle.disabled = false; 252 | ui.messageEntry.username.textContent = Settings.get('identity').username; 253 | document.body.classList.toggle('show-message-entry', Settings.get('twitch-messagefield')); 254 | } 255 | configureToggler('twitch-messagefield', () => { 256 | document.body.classList.toggle('show-message-entry', Settings.get('twitch-messagefield')); 257 | if (Settings.get('twitch-messagefield') && client.username.toLowerCase() != Settings.get('identity').username.toLowerCase()) { 258 | ui.messageEntry.username.textContent = Settings.get('identity').username; 259 | client.disconnect(); 260 | client.opts.identity = Settings.get('identity'); 261 | client.opts.username = Settings.get('identity').username; 262 | client.connect(); 263 | } 264 | }); 265 | ui.settings.twitch.identity.username.addEventListener('input', (e) => { 266 | let identity = ui.settings.twitch.identity; 267 | if (e.target.value.length > 0 && identity.token.value.length > 0 && identity.token.validity.valid) { 268 | Settings.set('identity', { 269 | 'username': identity.username.value, 270 | 'password': identity.token.value 271 | }); 272 | identity.body.classList.remove('disabled'); 273 | identity.toggle.disabled = false; 274 | } else { 275 | identity.body.classList.add('disabled'); 276 | identity.toggle.disabled = false; 277 | identity.toggle.checked = false; 278 | document.body.classList.remove('show-message-entry'); 279 | Settings.set('twitch-messagefield', false); 280 | Settings.set('identity', null); 281 | if (!client.username.startsWith('justinfan')) { // already logged out 282 | identity.username.textContent = ''; 283 | client.disconnect(); 284 | client.opts.identity = {}; 285 | delete client.opts.username; 286 | client.connect(); 287 | } 288 | } 289 | }); 290 | ui.settings.twitch.identity.token.addEventListener('input', (e) => { 291 | let identity = ui.settings.twitch.identity; 292 | if (/^[0-9a-z]{30}$/.test(e.target.value)) { 293 | e.target.value = "oauth:" + e.target.value; 294 | } 295 | if (!/^oauth:[0-9a-z]{30}$/.test(e.target.value)) { 296 | e.target.setCustomValidity('Invalid token'); 297 | e.target.reportValidity(); 298 | identity.body.classList.add('disabled'); 299 | identity.toggle.disabled = true; 300 | identity.toggle.checked = false; 301 | document.body.classList.remove('show-message-entry'); 302 | Settings.set('twitch-messagefield', false); 303 | Settings.set('identity', null); 304 | if (!client.username.startsWith('justinfan')) { // already logged out 305 | identity.username.textContent = ''; 306 | client.disconnect(); 307 | client.opts.identity = {}; 308 | delete client.opts.username; 309 | client.connect(); 310 | } 311 | return; 312 | } 313 | e.target.setCustomValidity(''); 314 | if (identity.username.value.length > 0) { 315 | Settings.set('identity', { 316 | 'username': identity.username.value, 317 | 'password': identity.token.value 318 | }); 319 | identity.body.classList.remove('disabled'); 320 | identity.toggle.disabled = false; 321 | } 322 | }); 323 | // Style 324 | if (Settings.get('show-command-buttons')) { 325 | ui.commands.body.classList.remove('hidden'); 326 | } 327 | if (document.fullscreenEnabled && Settings.get('support-fullscreen')) { 328 | ui.commands.fullscreen.addEventListener('click', () => { 329 | if (document.fullscreenElement) { 330 | document.exitFullscreen() 331 | } else { 332 | document.documentElement.requestFullscreen(); 333 | } 334 | }); 335 | } else { 336 | ui.commands.fullscreen.classList.add('hidden'); 337 | } 338 | [ 339 | { 340 | 'name': "default", 341 | 'background': "#000000", 342 | 'odd-background': "#111111", 343 | 'separator': "#444444", 344 | 'text': "#eeeeee", 345 | 'user': "#008000", 346 | 'moderator': "#8383f9", 347 | 'channel': "#0d86ff", 348 | 'notice': "#eeeeee", 349 | 'highlight': "#731180", 350 | 'vip': '#4686f8', 351 | 'admin': '#a970ff', 352 | 'staff': '#a970ff' 353 | }, { 354 | 'name': "bright", 355 | 'background': "#eeeeee", 356 | 'odd-background': "#dddddd", 357 | 'separator': "#bbbbbb", 358 | 'text': "#111111", 359 | 'user': "#00b000", 360 | 'moderator': "#8383f9", 361 | 'channel': "#0d86ff", 362 | 'notice': "#111111", 363 | 'highlight': "#731180", 364 | 'vip': '#4686f8', 365 | 'admin': '#a970ff', 366 | 'staff': '#a970ff' 367 | }, { 368 | 'name': 'LRR', 369 | 'background': "#202020", 370 | 'odd-background': "#111111", 371 | 'separator': "#464646", 372 | 'text': "#d2d2d2", 373 | 'user': "#5282ff", 374 | 'moderator': "#f15a24", 375 | 'channel': "#f15a24", 376 | 'notice': "#d2d2d2", 377 | 'highlight': "#e1480f", 378 | 'vip': '#aaf2a6', 379 | 'admin': '#a970ff', 380 | 'staff': '#a970ff' 381 | } 382 | ].forEach(createStylePreview); 383 | var colorFields = ['background', 'odd-background', 'separator', 'text', 'user', 'moderator', 'channel', 'notice', 'highlight', 'vip', 'admin', 'staff']; 384 | var customStyleValues = { 385 | 'name': "custom" 386 | }; 387 | colorFields.forEach(key => customStyleValues[key] = Settings.get(`${key}-color`)); 388 | var customStylePreview = createStylePreview(customStyleValues, true); 389 | colorFields.forEach(key => { 390 | document.getElementById(`settings-${key}-color`).value = Settings.get(`${key}-color`); 391 | document.getElementById(`settings-${key}-color`).addEventListener('change', (e) => { 392 | Settings.set(`${key}-color`, e.target.value); 393 | customStylePreview.style.setProperty(`--style-${key}`, e.target.value); 394 | if (['channel', 'notice', 'highlight'].indexOf(key) != -1) { 395 | customStylePreview.style.setProperty(`--style-${key}-background`, e.target.value + '50'); 396 | } 397 | updateImportExport(); 398 | }); 399 | }); 400 | updateImportExport(); 401 | ui.settings.style.custom.field.addEventListener('input', (e) => { 402 | let code = e.target.value; 403 | if (code.length == 36) { // Allow old exports to work 404 | code += ['vip', 'admin', 'staff'].map((name) => HexCompressor.color2string(Settings.get(`${name}-color`))).join(''); 405 | } 406 | if (!/^[0-9a-zA-Z+#]{48}$/.test(code)) { 407 | e.target.setCustomValidity('Invalid code'); 408 | e.target.reportValidity(); 409 | return; 410 | } 411 | e.target.setCustomValidity(''); 412 | colorFields.forEach((key, index) => { 413 | var newColor = HexCompressor.string2color(code.substring(index * 4, (index * 4) + 4)); 414 | Settings.set(key + '-color', newColor); 415 | document.getElementById('settings-' + key + '-color').value = newColor; 416 | }); 417 | }); 418 | ui.settings.style.custom.selector.classList.toggle('hidden', Settings.get('style-preset') != 'custom'); 419 | ui.settings.style.custom.exchange.classList.toggle('hidden', Settings.get('style-preset') != 'custom'); 420 | 421 | ui.settings.style.fontSize.value = Settings.get('font-size').slice(0, -2); // remove pixel unit 422 | ui.settings.style.fontSize.addEventListener('change', (e) => Settings.set('font-size', e.target.value + 'px')); 423 | 424 | ui.settings.style.animateEmoji.value = Settings.get('animate-emoji'); 425 | ui.settings.style.animateEmoji.addEventListener('change', (e) => { 426 | Settings.set('animate-emoji', e.target.value); 427 | if (e.target.value != 'auto') { // Adjust existing emoji for existing emoji if forced enabled or disabled 428 | Array.from(ui.chat.body.querySelectorAll('div.emoticon')).forEach(updateEmoji); 429 | } 430 | }); 431 | 432 | document.body.classList.toggle('hide-cursor', Settings.get('hide-cursor')); 433 | ui.settings.style.hideCursor.checked = Settings.get('hide-cursor'); 434 | configureToggler('hide-cursor', () => document.body.classList.toggle('hide-cursor', Settings.get('hide-cursor'))); 435 | document.addEventListener('mousemove', () => { 436 | if (Settings.get('hide-cursor')) { 437 | document.body.classList.remove('hide-cursor'); 438 | clearTimeout(lastMoveTimeoutId); 439 | lastMoveTimeoutId = setTimeout(() => Settings.get('hide-cursor') && document.body.classList.add('hide-cursor'), 4000); 440 | } 441 | }); 442 | 443 | ui.settings.style.adjustTitle.checked = Settings.get('adjust-page-title'); 444 | configureToggler('adjust-page-title', updateTitle); 445 | ui.settings.style.showUnreadInTitle.checked = Settings.get('unread-counter-in-page-title'); 446 | configureToggler('unread-counter-in-page-title'); 447 | // Monitor unread messages 448 | document.addEventListener('visibilitychange', updateUnreadCounter); 449 | 450 | // Chat Behavior 451 | document.body.classList.toggle('limit-message-rate', !Settings.get('limit-message-rate')); 452 | ui.settings.behaviour.limitRate.toggle.checked = Settings.get('limit-message-rate'); 453 | configureToggler('limit-message-rate', () => { 454 | document.body.classList.toggle('limit-message-rate', !Settings.get('limit-message-rate')); 455 | ui.settings.behaviour.limitRate.body.classList.toggle('hidden', !Settings.get('limit-message-rate')); 456 | if (!Settings.get('limit-message-rate')) { 457 | flushMessageQueue(); 458 | ui.notifications.chatOverload.body.classList.add('hidden'); 459 | } 460 | }); 461 | if (Settings.get('limit-message-rate')) { 462 | ui.settings.behaviour.limitRate.body.classList.remove('hidden'); 463 | } 464 | ui.settings.behaviour.limitRate.field.value = Settings.get('message-rate'); 465 | ui.settings.behaviour.limitRate.field.addEventListener('input', (e) => { 466 | var rate = parseInt(e.target.value); 467 | if (!isNaN(rate) && e.target.validity.valid) { 468 | Settings.set('message-rate', rate); 469 | } 470 | }); 471 | document.body.classList.toggle('reverse-order', !Settings.get('new-messages-on-top')); 472 | configureToggler('new-messages-on-top', () => { 473 | document.body.classList.toggle('reverse-order', !Settings.get('new-messages-on-top')); 474 | scrollDistance = scrollReference = 0; 475 | ui.chat.container.scrollTop = Settings.get('new-messages-on-top') ? 0 : ui.chat.container.scrollHeight - window.innerHeight; 476 | }); 477 | configureToggler('smooth-scroll', () => { 478 | scrollDistance = scrollReference = 0; 479 | ui.chat.container.scrollTop = Settings.get('new-messages-on-top') ? 0 : ui.chat.container.scrollHeight - window.innerHeight; 480 | ui.settings.behaviour.smoothScroll.body.classList.toggle('hidden', !Settings.get('smooth-scroll')); 481 | }); 482 | if (Settings.get('smooth-scroll')) { 483 | ui.settings.behaviour.smoothScroll.body.classList.remove('hidden'); 484 | } 485 | ui.settings.behaviour.smoothScroll.duration.value = Settings.get('smooth-scroll-duration'); 486 | ui.settings.behaviour.smoothScroll.duration.addEventListener('input', (e) => { 487 | var duration = parseInt(e.target.value); 488 | if (!isNaN(duration) && e.target.validity.valid) { 489 | Settings.set('smooth-scroll-duration', duration); 490 | } 491 | }); 492 | 493 | configureToggler('enable-chat-delay', () => { 494 | toggleChatDelay(); 495 | ui.settings.behaviour.chatDelay.body.classList.toggle('hidden', !Settings.get('enable-chat-delay')); 496 | }); 497 | if (Settings.get('enable-chat-delay')) { 498 | ui.settings.behaviour.chatDelay.body.classList.remove('hidden'); 499 | } 500 | ui.settings.behaviour.chatDelay.delay.value = Settings.get('chat-delay'); 501 | ui.settings.behaviour.chatDelay.delay.addEventListener('change', (e) => setChatDelay(e.target.value)); 502 | 503 | // Message Handling 504 | ui.chat.body.classList.toggle('align-messages', Settings.get('align-messages')); 505 | configureToggler('align-messages', () => ui.chat.body.classList.toggle('align-messages', Settings.get('align-messages'))); 506 | ['combine-messages', 'format-urls', 'shorten-urls', 'unfurl-youtube', 'show-subscriptions', 'show-bits', 'show-mod-actions'].forEach(configureToggler); 507 | configureToggler('inline-images', () => ui.settings.messageHandling.inlineImages.body.classList.toggle('hidden', !Settings.get('inline-images'))); 508 | if (Settings.get('inline-images')) { 509 | ui.settings.messageHandling.inlineImages.body.classList.remove('hidden'); 510 | } 511 | ui.settings.messageHandling.inlineImages.height.value = Settings.get('inline-images-height').slice(0, -2); // remove vh unit 512 | ui.settings.messageHandling.inlineImages.height.addEventListener('input', (e) => { 513 | var height = parseInt(e.target.value); 514 | if (!isNaN(height) && e.target.validity.valid) { 515 | Settings.set('inline-images-height', height + 'vh'); 516 | } 517 | }); 518 | configureToggler('unfurl-twitter', () => { 519 | if (typeof twttr == 'undefined') { 520 | var twitterScript = document.createElement('script'); 521 | twitterScript.src = 'https://platform.twitter.com/widgets.js'; 522 | document.body.appendChild(twitterScript); 523 | } 524 | }); 525 | if (Settings.get('unfurl-twitter')) { 526 | var twitterScript = document.createElement('script'); 527 | twitterScript.src = 'https://platform.twitter.com/widgets.js'; 528 | document.body.appendChild(twitterScript); 529 | } 530 | ui.settings.messageHandling.timestamps.value = Settings.get('timestamps'); 531 | ui.chat.body.classList.toggle('hide-timestamps', Settings.get('timestamps') == ''); 532 | ui.settings.messageHandling.timestamps.addEventListener('change', (e) => { 533 | Settings.set('timestamps', e.target.value); 534 | ui.chat.body.classList.toggle('hide-timestamps', e.target.value == ''); 535 | Array.from(ui.chat.body.querySelectorAll('.timestamp')).forEach(updateTimestamp); 536 | }); 537 | ui.settings.messageHandling.highlightUsers.value = Settings.get('highlight-users'); 538 | ui.settings.messageHandling.highlightUsers.addEventListener('input', (e) => { 539 | Settings.set('highlight-users', e.target.value.toLowerCase()); 540 | highlightUsers = e.target.value.toLowerCase().split(',').filter((user) => user != ''); 541 | }); 542 | ui.settings.messageHandling.keyPhrases.value = Settings.get('highlight-keyphrases'); 543 | ui.settings.messageHandling.keyPhrases.addEventListener('input', (e) => { 544 | Settings.set('highlight-keyphrases', e.target.value.toLowerCase()); 545 | highlightKeyphrases = e.target.value.toLowerCase().split(',').filter((phrase) => phrase != ''); 546 | }); 547 | configureToggler('show-fps', (e) => handleFPS(e.target.checked)); 548 | if (Settings.get('show-fps')) { 549 | handleFPS(true); 550 | } 551 | 552 | // Hotkeys 553 | document.body.addEventListener('keydown', (e) => { 554 | if (!Settings.get('support-hotkeys')) { 555 | return; 556 | } 557 | if ((e.key == 'H' || e.key == 'h') && e.shiftKey && e.ctrlKey) { 558 | ui.main.curtain.classList.toggle('hidden'); 559 | e.preventDefault(); 560 | } else if ((e.key == 'D' || e.key == 'd') && e.shiftKey && e.ctrlKey) { 561 | Settings.toggle('enable-chat-delay'); 562 | ui.settings.behaviour.chatDelay.toggle.checked = !ui.settings.behaviour.chatDelay.toggle.checked; 563 | ui.settings.behaviour.chatDelay.body.classList.toggle('hidden'); 564 | toggleChatDelay(); 565 | e.preventDefault(); 566 | } else if ((e.key == 'S' || e.key == 's') && e.shiftKey && e.ctrlKey) { 567 | ui.settings.body.classList.toggle('hidden'); 568 | ui.settings.body.scrollTop = 0; 569 | ui.commands.settings.classList.toggle('open'); 570 | e.preventDefault(); 571 | } else if ((e.key == 'Escape')) { 572 | ui.settings.body.classList.add('hidden'); 573 | ui.commands.settings.classList.remove('open'); 574 | e.preventDefault(); 575 | } 576 | }); 577 | 578 | function joinChannel(channel) { 579 | client.join(ensureHash(channel)).then(() => { 580 | console.log('Joined channel ' + channel); 581 | updateTitle() 582 | }, (error) => { 583 | addNotice(`Failed to join requested channel. Reason: ${decodeMessageId(error)}`); 584 | console.error('Failed to join requested channel', error); 585 | updateTitle(); 586 | }); 587 | } 588 | 589 | // Decode a Message ID returned by the Twitch API to a human-readable message 590 | function decodeMessageId(messageId) { 591 | let knownMessages = { 592 | 'msg_banned': 'You are permanently banned from talking in this channel.', 593 | 'msg_channel_blocked': 'Your account is not in good standing in this channel.', 594 | 'msg_channel_suspended': 'This channel does not exist or has been suspended.', 595 | 'msg_requires_verified_phone_number': 'A verified phone number is required to chat in this channel. Please visit https://www.twitch.tv/settings/security to verify your phone number.', 596 | 'msg_suspended': 'You don\'t have permission to perform that action.', 597 | 'msg_verified_email': 'This room requires a verified account to chat. Please verify your account at https://www.twitch.tv/settings/security.' 598 | }; 599 | return knownMessages[messageId] || messageId; 600 | } 601 | 602 | // Process the next frame, this is the main driver of the application 603 | var lastFrame = +new Date(); 604 | function step(now) { 605 | if (Settings.get('show-fps')) { 606 | frames++; 607 | } 608 | if (Settings.get('enable-chat-delay') && Settings.get('chat-delay') != 0) { 609 | while (delayQueue.length > 0 && parseInt(delayQueue[0].dataset.timestamp) + (Settings.get('chat-delay') * 1000) < Date.now()) { 610 | addMessage(delayQueue.shift(), true); 611 | } 612 | } 613 | if (Settings.get('limit-message-rate')) { 614 | if (messageQueue.length > 40) { 615 | ui.notifications.chatOverload.body.classList.remove('hidden'); 616 | // Cull the queue to a reasonable length and update the counter 617 | ui.notifications.chatOverload.count.textContent = parseInt(ui.notifications.chatOverload.count.textContent) + messageQueue.splice(-40).length; 618 | } 619 | if (messageQueue.length < 10 && !ui.notifications.chatOverload.body.classList.contains('hidden')) { 620 | ui.notifications.chatOverload.body.classList.add('hidden'); 621 | ui.notifications.chatOverload.count.textContent = "0"; 622 | } 623 | if (messageQueue.length > 0 && now - lastMessageTimestamp > 1000 / Settings.get('message-rate')) { 624 | processChat.apply(this, messageQueue.shift()); 625 | lastMessageTimestamp = now; 626 | } 627 | } 628 | if (Settings.get('smooth-scroll') && scrollDistance > 0) { 629 | // Estimate how far along we are in scrolling in the current scroll reference 630 | var currentStep = Settings.get('smooth-scroll-duration') / (now - lastFrame); 631 | scrollDistance -= scrollReference / currentStep; 632 | scrollDistance = Math.max(scrollDistance, 0); 633 | ui.chat.container.scrollTop = Math.round(Settings.get('new-messages-on-top') ? scrollDistance : ui.chat.container.scrollHeight - window.innerHeight - scrollDistance); 634 | } 635 | lastFrame = now; 636 | window.requestAnimationFrame(step); 637 | } 638 | window.requestAnimationFrame(step); 639 | 640 | /** Chat event handling **/ 641 | function handleChat(channel, userstate, message) { 642 | increaseUnreadCounter(); 643 | if (Settings.get('limit-message-rate')) { 644 | messageQueue.push([ channel, userstate, message ]); 645 | } else { 646 | processChat(channel, userstate, message); 647 | } 648 | } 649 | 650 | function processChat(channel, userstate, message) { 651 | try { 652 | // If enabled, combine messages instead of adding a new message 653 | var id = 'message-' + message.toLowerCase().replace(/[^\p{Letter}]/gu, ''); 654 | if (Settings.get('combine-messages') && document.getElementById(id)) { 655 | var matchedMessage = document.getElementById(id); 656 | if (!matchedMessage.counter) { 657 | var counterContainer = document.createElement('span'), 658 | counter = document.createElement('span'); 659 | counterContainer.className = 'counter'; 660 | counterContainer.innerHTML = '× '; 661 | counterContainer.appendChild(counter); 662 | counter.textContent = '1'; 663 | matchedMessage.appendChild(counterContainer); 664 | matchedMessage.counter = counter; 665 | } 666 | ui.chat.body.appendChild(matchedMessage); 667 | matchedMessage.querySelector('.counter').classList.add('bump'); 668 | matchedMessage.counter.textContent++; 669 | setTimeout(() => matchedMessage.querySelector('.counter').classList.remove('bump'), 150); 670 | return; 671 | } 672 | var chatLine = createChatLine(userstate, message); 673 | if (Settings.get('combine-messages')) { 674 | chatLine.id = id; 675 | } 676 | 677 | // Deal with loading user-provided inline images 678 | var userImages = Array.from(chatLine.querySelectorAll('img.user-image')); 679 | if (userImages.length > 0) { 680 | userImages.forEach((userImage) => { 681 | if (userImage.complete) { // most likely it was already cached 682 | userImage.classList.add('loaded'); 683 | return; 684 | } 685 | userImage.addEventListener('load', () => { 686 | if (userImage.dataset.mq && userImage.naturalWidth == 120) { // Failed to load, placeholder received 687 | if (userImage.dataset.hq) { 688 | userImage.src = userImage.dataset.hq; 689 | userImage.dataset.hq = ''; 690 | return; 691 | } else if (userImage.dataset.mq) { 692 | userImage.src = userImage.dataset.mq; 693 | userImage.dataset.mq = ''; 694 | return; 695 | } 696 | } 697 | var oldChatLineHeight = chatLine.scrollHeight; 698 | userImage.classList.add('loaded'); 699 | var loadingText = chatLine.querySelector('.image-loading'); 700 | if (chatLine.querySelector('.user-image:not(.loaded)') == null && loadingText != null) { 701 | loadingText.remove(); 702 | } 703 | scrollReference = scrollDistance += Math.max(0, chatLine.scrollHeight - oldChatLineHeight); 704 | }); 705 | userImage.addEventListener('error', () => { 706 | var loadingText = chatLine.querySelector('.image-loading'); 707 | if (loadingText) { 708 | loadingText.textContent = '[image loading failed]'; 709 | } 710 | }); 711 | }); 712 | if (userImages.some(image => !image.complete)) { 713 | var loadingText = document.createElement('span'); 714 | loadingText.className = 'image-loading'; 715 | loadingText.textContent = '[Loading image...]'; 716 | var firstBreakLine = chatLine.querySelector('br'); 717 | firstBreakLine.insertAdjacentText('beforebegin', ' '); 718 | firstBreakLine.insertAdjacentElement('beforebegin', loadingText); 719 | } 720 | } 721 | 722 | // Load Twitter/X messages, if any 723 | var tweets = Array.from(chatLine.querySelectorAll('div.tweet-embed')); 724 | if (tweets.length > 0 && typeof twttr != 'undefined' && twttr.init) { 725 | tweets.forEach((tweet) => { 726 | twttr.widgets 727 | .createTweet(tweet.dataset.tweet, tweet, { 728 | theme: 'dark', 729 | conversation: 'none', 730 | cards: 'hidden', 731 | dnt: 'true' 732 | }) 733 | .then(el => { 734 | scrollReference = scrollDistance += el.scrollHeight; 735 | }) 736 | .catch(e => console.log(e)); 737 | }); 738 | } 739 | addMessage(chatLine); 740 | // Check whether the message we just added was a message that was already deleted 741 | if (userstate.deleted) { 742 | deleteMessage(userstate.id); 743 | } 744 | } catch (error) { 745 | console.error('Error parsing chat message: ' + message, error); 746 | } 747 | } 748 | 749 | function handleRoomstate(channel, state) { 750 | flushDelayQueue(); 751 | flushMessageQueue(); 752 | addNotice(`Joined ${channel}.`); 753 | if (state.slow) { 754 | addNotice(`Channel is in slow mode.`); 755 | } 756 | if (state['followers-only'] != -1) { 757 | addNotice(`Channel is in followers-only mode.`); 758 | } 759 | if (state['emote-only']) { 760 | addNotice(`Channel is in emote-only mode.`); 761 | } 762 | if (Settings.get('enable-chat-delay') && Settings.get('chat-delay') != 0) { 763 | addNotice(`Chat is set to an artificial delay of ${Settings.get('chat-delay')} second${Settings.get('chat-delay') == 1 ? '' : 's'}.`); 764 | } 765 | } 766 | 767 | function handleSubscription(username, message, userstate) { 768 | if (!Settings.get('show-subscriptions')) { 769 | return; 770 | } 771 | var chatLine = document.createElement('div'); 772 | chatLine.className = 'highlight subscription'; 773 | 774 | var subscriptionNotice = document.createElement('div'); 775 | subscriptionNotice.textContent = userstate['system-msg'].replaceAll('\\s', ' '); 776 | chatLine.append(subscriptionNotice); 777 | 778 | if (message && message.length > 0) { 779 | chatLine.append(createChatLine(userstate, message)); 780 | } 781 | addMessage(chatLine); 782 | } 783 | 784 | function handleCheer(channel, userstate, message) { 785 | // We could consider to transform the cheer emotes in the message instead of removing them (https://dev.twitch.tv/docs/irc/tags/#privmsg-twitch-tags) 786 | var chatMessage = message; 787 | bitLevels.forEach((level) => chatMessage = chatMessage.replaceAll(new RegExp(`\\b[a-zA-Z]+${level}\\b`, 'g'), '')); 788 | var chatLine = createChatLine(userstate, chatMessage), 789 | cheer = document.createElement('span'), 790 | bitLevel = bitLevels.find((level) => parseInt(userstate.bits) >= level), 791 | cheerIcon = document.createElement('img'); 792 | 793 | if (Settings.get('show-bits')) { 794 | if (bitLevel == undefined) { 795 | console.warn(`Could not parse bits received from ${userstate.username}`, userstate.bits); 796 | return; 797 | } 798 | let imageStyle = Settings.get('animate-emoji') == 'yes' ? 'animated' : 'static'; // TODO: support 'auto' 799 | cheerIcon.src = `https://static-cdn.jtvnw.net/bits/dark/${imageStyle}/${bitLevel}/1.5.gif`; 800 | cheerIcon.alt = 'Bits'; 801 | cheer.appendChild(cheerIcon); 802 | cheer.className = `cheer cheer-${bitLevel}`; 803 | cheer.appendChild(document.createTextNode(userstate.bits)); 804 | chatLine.insertBefore(cheer, chatLine.lastChild); 805 | } 806 | addMessage(chatLine); 807 | } 808 | 809 | function handleMessageDeletion(channel, username, deletedMessage, userstate) { 810 | deleteMessage(userstate['target-msg-id']); 811 | } 812 | 813 | function handleModAction(action, username, duration, userstate) { 814 | if (Settings.get('show-mod-actions')) { 815 | if (action == 'timeout') { 816 | addNotice(`${username} was given a time-out of ${duration} second${duration == 1 ? '' : 's'}.`); 817 | } else if (action == 'ban') { 818 | addNotice(`${username} has been banned.`); 819 | } 820 | } 821 | Array.from(ui.chat.body.querySelectorAll(`span[data-user="${userstate["target-user-id"]}"]`)).map(message => message.id).forEach(deleteMessage); 822 | } 823 | 824 | function handleFPS(enable) { 825 | ui.main.fps.innerHTML = ' '; 826 | ui.main.fps.classList.toggle('hidden', !enable); 827 | lastFrameReset = Date.now(); 828 | frames = 0; 829 | if (enable) { 830 | fpsInterval = setInterval(updateFPS, 1000); 831 | } else { 832 | clearInterval(fpsInterval); 833 | } 834 | } 835 | 836 | function updateFPS() { 837 | var currentFrameTime = Date.now(); 838 | ui.main.fps.textContent = (frames / (currentFrameTime - lastFrameReset) * 1000).toFixed(1); 839 | lastFrameReset = currentFrameTime; 840 | frames = 0; 841 | } 842 | 843 | 844 | /** Helper functions **/ 845 | function configureToggler(key, callback) { 846 | document.getElementById(`settings-${key}`).checked = Settings.get(key); 847 | document.getElementById(`settings-${key}`).addEventListener('click', (e) => { 848 | Settings.toggle(key); 849 | if (typeof callback == 'function') { 850 | callback(e); 851 | } 852 | }); 853 | } 854 | 855 | function createChatLine(userstate, message) { 856 | //
$timestamp$username$message
857 | var chatLine = document.createElement('div'), 858 | chatTimestamp = document.createElement('span'), 859 | chatName = document.createElement('span'), 860 | chatMessage = document.createElement('span'); 861 | 862 | chatTimestamp.className = 'timestamp'; 863 | chatTimestamp.dataset.timestamp = userstate['tmi-sent-ts'] || Date.now(); 864 | updateTimestamp(chatTimestamp); 865 | chatLine.appendChild(chatTimestamp); 866 | chatName.className = 'chat-user'; 867 | if (userstate.badges?.vip) { 868 | chatName.classList.add('vip'); 869 | } 870 | if (userstate.badges?.admin) { 871 | chatName.classList.add('admin'); 872 | } 873 | if (userstate.badges?.staff) { 874 | chatName.classList.add('staff'); 875 | } 876 | if (userstate.mod) { 877 | chatName.classList.add('moderator'); 878 | } 879 | if (userstate['message-type'] == 'action') { 880 | chatName.classList.add('action'); 881 | } 882 | chatName.textContent = userstate['display-name'] || userstate.username; 883 | if (userstate['message-type'] == 'announcement') { 884 | chatName.textContent = '📢 ' + chatName.textContent; 885 | } 886 | if (chatName.textContent.toLowerCase() == removeHash(client.channels[0]).toLowerCase()) { 887 | chatLine.className = 'highlight channel'; 888 | } 889 | chatMessage.innerHTML = formatMessage(message, userstate.emotes); 890 | chatMessage.id = userstate.id; 891 | if (userstate['user-id']) { 892 | chatMessage.dataset.user = userstate['user-id']; 893 | } 894 | 895 | if (highlightUsers.indexOf(chatName.textContent.toLowerCase()) != -1) { 896 | chatLine.className = 'highlight'; 897 | } 898 | if (highlightKeyphrases.find((phrase) => message.toLowerCase().indexOf(phrase) != -1)) { 899 | chatLine.className = 'highlight'; 900 | } 901 | 902 | chatLine.appendChild(chatName); 903 | chatLine.appendChild(chatMessage); 904 | 905 | return chatLine; 906 | } 907 | 908 | function addNotice(message) { 909 | var chatLine = document.createElement('div'); 910 | chatLine.textContent = message; 911 | chatLine.className = 'notice'; 912 | addMessage(chatLine); 913 | } 914 | 915 | function addMessage(chatLine, bypass) { 916 | if (chatLine.className != 'notice' && !bypass && Settings.get('enable-chat-delay') && Settings.get('chat-delay') != 0) { 917 | chatLine.dataset.timestamp = Date.now(); 918 | delayQueue.push(chatLine); 919 | return; 920 | } 921 | ui.chat.body.appendChild(chatLine); 922 | // Calculate height for smooth scrolling 923 | scrollReference = scrollDistance += chatLine.scrollHeight; 924 | if (!Settings.get('new-messages-on-top') && !Settings.get('smooth-scroll')) { 925 | ui.chat.container.scrollTop = ui.chat.container.scrollHeight - window.innerHeight; 926 | } 927 | 928 | // Check whether we can remove some of the oldest messages 929 | while (chat.childNodes.length > 2 && ui.chat.body.scrollHeight - (window.innerHeight + (Settings.get('smooth-scroll') ? scrollDistance : 0)) > ui.chat.body.firstChild.scrollHeight + ui.chat.body.childNodes[1].scrollHeight) { 930 | // Always remove two elements at the same time to prevent switching the odd and even rows 931 | ui.chat.body.firstChild.remove(); 932 | ui.chat.body.firstChild.remove(); 933 | } 934 | } 935 | 936 | function deleteMessage(messageId) { 937 | var message = document.getElementById(messageId); 938 | if (message == null) { 939 | var messageToDelete = messageQueue.find(entry => entry[1].id == messageId); 940 | if (messageToDelete) { 941 | messageToDelete[2] = ''; // Text will be replaced, but just intended to put it back on one line 942 | messageToDelete[1].deleted = true; 943 | } 944 | return; 945 | } 946 | if (message.classList.contains('deleted')) { // Weird, but ok 947 | return; 948 | } 949 | message.parentNode.style.height = (message.parentNode.scrollHeight - 7) + 'px'; // 2 x 3px padding + 1px border = 7 950 | message.textContent = ''; 951 | message.classList.add('deleted'); 952 | } 953 | 954 | /* 955 | To deal with message formatting, the message gets turned into an array of characters first. 956 | Twitch provides the IDs of the emotes and from where to where they are located in the message. 957 | We replace those emote-related characters with empty strings and place an tag as a string at the 'from' location. 958 | Other changes take place in a similar way, by calculating the 'from' and 'to' values ourselves. 959 | As a last step, all entries in the array with 1 character are transformed into HTML entities if they are potentially dangerous. 960 | At the end, we join() the character array again, forming a message safe to assign to the innerHTML property. 961 | */ 962 | function formatMessage(text, emotes) { 963 | if (Settings.get('new-messages-on-top')) { 964 | text = text.replaceAll('^', '⌄'); 965 | } 966 | let message = text.split(''); 967 | message = formatEmotes(message, emotes); 968 | message = formatLinks(message, text); 969 | return htmlEntities(message).join(''); 970 | } 971 | 972 | function formatEmotes(text, emotes) { 973 | if (!emotes) { 974 | return text; 975 | } 976 | for (var id in emotes) { 977 | emotes[id].forEach((range) => { 978 | if (typeof range == 'string') { 979 | range = range.split('-').map(index => parseInt(index)); 980 | let emote = text.slice(range[0], range[1] + 1).join(''); 981 | let imageStyle = Settings.get('animate-emoji') == 'yes' ? 'default' : 'static'; // TODO: support 'auto' 982 | let baseUrl = `https://static-cdn.jtvnw.net/emoticons/v2/${id}/${imageStyle}/dark`; 983 | replaceText(text, `
`, range[0], range[1]); 984 | } 985 | }); 986 | }; 987 | return text; 988 | } 989 | 990 | function formatLinks(text, originalText) { 991 | var urlRegex = /(https?:\/\/)?(www\.)?([0-9a-zA-Z-_\.]+\.[0-9a-zA-Z]+\/)([0-9a-zA-Z-_+:;,|`%^\(\)\[\]#=&\/\.\?\|\~@]*[0-9a-zA-Z-_+:;|`%^\(\)\[\]#=&\/\.\?\|\~])?/g; 992 | var match; 993 | while ((match = urlRegex.exec(originalText)) !== null) { 994 | var urlText = url = match[0]; 995 | if (!match[1]) { 996 | url = 'https://' + url; 997 | } 998 | var path = match[4] || ''; 999 | if (Settings.get('inline-images')) { 1000 | var giphy = /^https?:\/\/giphy\.com\/gifs\/(.*-)?([a-zA-Z0-9]+)$/gm.exec(urlText); 1001 | if (giphy) { 1002 | url = `https://media1.giphy.com/media/${giphy[2].split("-").pop()}/giphy.gif`; 1003 | path = `media/${giphy[2].split("-").pop()}/giphy.gif`; 1004 | } 1005 | var imgur = /^https?:\/\/imgur\.com\/([a-zA-Z0-9]+)$/gm.exec(urlText); 1006 | if (imgur) { 1007 | url = `https://i.imgur.com/${imgur[1]}.gif`; 1008 | path = `${imgur[1]}.gif`; 1009 | } 1010 | var twimg = /^https?:\/\/pbs\.twimg\.com\/media\/([a-zA-Z0-9]+)\?format=([a-z]+).*$/gm.exec(urlText); 1011 | if (twimg) { 1012 | url = `https://pbs.twimg.com/media/${twimg[1]}.${twimg[2]}`; 1013 | path = `/media/${twimg[1]}.${twimg[2]}`; 1014 | } 1015 | if (match[1] && imageExtensions.some((extension) => path.endsWith(extension))) { 1016 | if (text.indexOf('
') == -1) { 1017 | text.push('
'); 1018 | } 1019 | text.push(``); 1020 | } 1021 | } 1022 | if (Settings.get('unfurl-youtube') && (match[3] == 'youtube.com/' || match[3] == 'youtu.be/')) { 1023 | var youtube = /^https?:\/\/(www\.)?(youtu\.be\/|youtube\.com\/watch\?v=)([^&?]+).*$/gm.exec(url); 1024 | if (youtube) { 1025 | if (text.indexOf('
') == -1) { 1026 | text.push('
'); 1027 | } 1028 | text.push(`YouTube video preview`); 1029 | } 1030 | } 1031 | if (Settings.get('unfurl-twitter') && (match[3] == 'twitter.com/' || match[3] == 'x.com/') && match[4] != undefined) { 1032 | var twitter = /^https?:\/\/(www\.)?x\.com.+\/([0-9]+)$/gm.exec(match[0]); 1033 | if (twitter) { 1034 | if (text.indexOf('
') == -1) { 1035 | text.push('
'); 1036 | } 1037 | text.push(`
`); 1038 | } 1039 | } 1040 | if (Settings.get('shorten-urls')) { 1041 | if (path.length < 25) { 1042 | urlText = match[3] + path; 1043 | } else { 1044 | urlText = match[3] + ' … '; 1045 | if (path.lastIndexOf('/') == -1) { 1046 | urlText += path.slice(-7); // No directory structure in the URL 1047 | } else { 1048 | urlText += path.substring(path.lastIndexOf('/')).slice(-10); // Show last directory if it is not too long 1049 | } 1050 | } 1051 | } 1052 | var replacement = Settings.get('format-urls') ? `${urlText}` : urlText; 1053 | replaceText(text, replacement, match.index, match.index + match[0].length - 1); 1054 | } 1055 | return text; 1056 | } 1057 | 1058 | function flushMessageQueue() { 1059 | messageQueue.forEach((args) => processChat.apply(this, args)); 1060 | messageQueue = []; 1061 | } 1062 | 1063 | function flushDelayQueue() { 1064 | delayQueue.forEach((chatLine) => addMessage(chatLine, true)); 1065 | delayQueue = []; 1066 | } 1067 | 1068 | function createStylePreview(style) { 1069 | var styleContainer = document.createElement('div'); 1070 | styleContainer.className = 'style-preview'; 1071 | var stylePreview = ui.settings.style.custom.preview.cloneNode(true); 1072 | stylePreview.removeAttribute('id'); 1073 | stylePreview.classList.remove('hidden'); 1074 | if (style.name == Settings.get('style-preset')) { 1075 | styleContainer.classList.add('active'); 1076 | Object.keys(style).filter(key => key != 'name').forEach(key => { 1077 | document.body.style.setProperty(`--${key}-color`, (style.name == 'custom' ? Settings.get(`${key}-color`) : style[key])); 1078 | if (['channel', 'notice', 'highlight'].indexOf(key) != -1) { 1079 | document.body.style.setProperty(`--${key}-background-color`, (style.name == 'custom' ? Settings.get(`${key}-color`) : style[key]) + '50'); 1080 | } 1081 | }); 1082 | } 1083 | styleContainer.addEventListener('click', () => { 1084 | Array.from(ui.settings.style.custom.container.querySelectorAll('.style-preview')).forEach(preview => preview.classList.remove('active')); 1085 | styleContainer.classList.add('active'); 1086 | Settings.set('style-preset', style.name); 1087 | ui.settings.style.custom.selector.classList.toggle('hidden', style.name != 'custom'); 1088 | ui.settings.style.custom.exchange.classList.toggle('hidden', style.name != 'custom'); 1089 | Object.keys(style).filter(key => key != 'name').forEach(key => { 1090 | document.body.style.setProperty(`--${key}-color`, (style.name == 'custom' ? Settings.get(`${key}-color`) : style[key])); 1091 | if (['channel', 'notice', 'highlight'].indexOf(key) != -1) { 1092 | document.body.style.setProperty(`--${key}-background-color`, (style.name == 'custom' ? Settings.get(`${key}-color`) : style[key]) + '50'); 1093 | } 1094 | }); 1095 | }); 1096 | Object.keys(style).forEach(key => stylePreview.style.setProperty(`--style-${key}`, style[key])); 1097 | ['channel', 'notice', 'highlight'].forEach(key => stylePreview.style.setProperty(`--style-${key}-background`, style[key] + '50')); 1098 | styleContainer.textContent = style.name; 1099 | styleContainer.appendChild(stylePreview); 1100 | ui.settings.style.custom.container.appendChild(styleContainer); 1101 | return stylePreview; 1102 | } 1103 | 1104 | function updateEmoji(field) { 1105 | let needle = Settings.get('animate-emoji') == 'yes' ? '/static/dark/' : '/default/dark/'; 1106 | let replacement = Settings.get('animate-emoji') == 'yes' ? '/default/dark/' : '/static/dark/'; 1107 | field.style.backgroundImage = field.style.backgroundImage.replace(needle, replacement); 1108 | } 1109 | 1110 | function updateTimestamp(field) { 1111 | var formats = { 1112 | 'short24': (now) => (new Date(now)).toLocaleTimeString('en-GB').replace(/:\d\d$/, ''), 1113 | 'long24': (now) => (new Date(now)).toLocaleTimeString('en-GB'), 1114 | 'short12': (now) => (new Date(now)).toLocaleTimeString('en-US').replace(/:\d\d /, ' ').replace(/^(\d):/, '0$1:'), 1115 | 'long12': (now) => (new Date(now)).toLocaleTimeString('en-US').replace(/^(\d):/, '0$1:'), 1116 | 'short': (now) => (new Date(now)).toLocaleTimeString('en-GB').replace(/^\d\d:/, ''), 1117 | '': () => {} 1118 | }; 1119 | field.textContent = formats[Settings.get('timestamps')](parseInt(field.dataset.timestamp)); 1120 | } 1121 | 1122 | function toggleChatDelay() { 1123 | if (Settings.get('enable-chat-delay')) { 1124 | let delay = Settings.get('chat-delay'); 1125 | addNotice(`Artificial chat delay set to ${delay} second${delay == 1 ? '' : 's'}`); 1126 | } else { 1127 | addNotice('Artificial chat delay disabled'); 1128 | flushDelayQueue(); 1129 | } 1130 | } 1131 | 1132 | function setChatDelay(delay) { 1133 | Settings.set('chat-delay', delay); 1134 | addNotice(`Artificial chat delay set to ${delay} second${delay == 1 ? '' : 's'}`); 1135 | if (delay == 0) { 1136 | flushDelayQueue(); 1137 | } 1138 | } 1139 | 1140 | function updateImportExport() { 1141 | var code = ''; 1142 | colorFields.forEach(key => code += HexCompressor.color2string(Settings.get(key + '-color'))); 1143 | ui.settings.style.custom.field.value = code; 1144 | } 1145 | 1146 | function updateTitle() { 1147 | var pageTitle = 'Twitch Chat Monitor'; 1148 | if (Settings.get('adjust-page-title')) { 1149 | pageTitle = ensureHash(client.channels[0]) + ' - ' + pageTitle; 1150 | } 1151 | if (Settings.get('unread-counter-in-page-title') && unreadMessages > 0) { 1152 | pageTitle = '(' + (unreadMessages > 99 ? '99+' : unreadMessages) + ') ' + pageTitle; 1153 | } 1154 | document.title = pageTitle; 1155 | } 1156 | 1157 | function increaseUnreadCounter() { 1158 | if (document.visibilityState == 'hidden') { 1159 | unreadMessages++; 1160 | updateTitle(); 1161 | } 1162 | } 1163 | 1164 | function updateUnreadCounter() { 1165 | if (document.visibilityState == 'visible') { 1166 | unreadMessages = 0; 1167 | updateTitle(); 1168 | } 1169 | } 1170 | 1171 | function ensureHash(text) { 1172 | if (!text.startsWith('#')) { 1173 | return '#' + text; 1174 | } 1175 | return text; 1176 | } 1177 | 1178 | function removeHash(text) { 1179 | if (text != undefined && text.startsWith('#')) { 1180 | return text.substring(1); 1181 | } 1182 | return text; 1183 | } 1184 | 1185 | function replaceText(text, replacement, from, to) { 1186 | for (var i = from + 1; i <= to; i++) { 1187 | text[i] = ''; 1188 | } 1189 | text.splice(from, 1, replacement); 1190 | } 1191 | 1192 | function htmlEntities(html) { 1193 | const entityRegex = /[\u00A0-\u9999<>\&]/gim; 1194 | return html.map((character) => { 1195 | if (character.length == 1) { 1196 | return character.replace(entityRegex, (match) => '&#' + match.charCodeAt(0) + ';'); 1197 | } 1198 | return character; 1199 | }); 1200 | } 1201 | --------------------------------------------------------------------------------