548 lines
20 KiB
HTML
548 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
|
|
<head>
|
|
<script>
|
|
channel = "youraveragebo";
|
|
//if this isn't set, external emotes can't be loaded until
|
|
//after the first chat message is received
|
|
channelId = undefined;
|
|
messageLimit = 20;
|
|
enable7tv = true;
|
|
deleteMessages = true;
|
|
</script>
|
|
<style>
|
|
.wrapper,
|
|
html,
|
|
body {
|
|
height: 100%;
|
|
margin: 0;
|
|
}
|
|
|
|
.ChatBody {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-wrap: nowrap;
|
|
justify-content: flex-end;
|
|
height: 100%;
|
|
}
|
|
|
|
.MessageBox {
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: nowrap;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.Username {
|
|
padding-right: 10px;
|
|
}
|
|
|
|
.Message {
|
|
padding-bottom: 10px;
|
|
}
|
|
</style>
|
|
<link rel="stylesheet" href="chat.css">
|
|
</head>
|
|
|
|
<body>
|
|
<div id="ChatBody" class="ChatBody">
|
|
|
|
</div>
|
|
<template id="Template-MessageBox">
|
|
<div class="MessageBox" data-userid="">
|
|
<div class="Badges"></div>
|
|
<div class="Username">Username</div>
|
|
<div class="Message">Message</div>
|
|
</div>
|
|
</template>
|
|
<template id="Template-BadgeBox">
|
|
<div class="BadgeBox">
|
|
<img class="Badge" />
|
|
</div>
|
|
</template>
|
|
<template id="Template-Emote">
|
|
<img class="Emote" />
|
|
</template>
|
|
<template id="Template-MessageFragment">
|
|
<span class="MessageFragment"></span>
|
|
</template>
|
|
<script>
|
|
class Mutex {
|
|
constructor() {
|
|
this.locked = false;
|
|
}
|
|
|
|
async lock() {
|
|
while (this.locked) {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
this.locked = true;
|
|
}
|
|
|
|
unlock() {
|
|
this.locked = false;
|
|
}
|
|
}
|
|
function escapeRegex(text) {
|
|
if (typeof RegExp.escape === typeof undefined) {
|
|
return text.replace("\\").replace("?", "\\?").replace(")", "\\)")
|
|
.replace("[", "\\[").replace("]", "\\]").replace("^", "\\^")
|
|
.replace("$", "\\$").replace("|", "\\|");
|
|
}
|
|
return RegExp.escape(text);
|
|
}
|
|
class ExternEmote {
|
|
constructor(text, url) {
|
|
this.text = text;
|
|
this.url = url;
|
|
// this.reg = new RegExp(`(?<=\\s|^)${RegExp.escape(text)}(?=\\s|$)`);
|
|
this.reg = new RegExp(`(?<=\\s|^)${escapeRegex(text)}(?=\\s|$)`);
|
|
}
|
|
}
|
|
const mutex7tv = new Mutex();
|
|
var emotes7tv = null;
|
|
async function get7tvEmotes(id) {
|
|
try {
|
|
mutex7tv.lock();
|
|
if (emotes7tv !== null) {
|
|
return emotes7tv;
|
|
}
|
|
const url = `https://api.7tv.app/v3/users/twitch/${id}`;
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) {
|
|
throw new Error(`failed to load 7tv: ${resp.status}`);
|
|
}
|
|
const json = await resp.json();
|
|
const emotes = [];
|
|
for (const emote of json['emote_set']['emotes']) {
|
|
emotes.push(new ExternEmote(emote['name'], `https://cdn.7tv.app/emote/${emote['id']}/1x.webp`));
|
|
}
|
|
emotes7tv = emotes;
|
|
return emotes;
|
|
} finally {
|
|
mutex7tv.unlock();
|
|
}
|
|
}
|
|
const mutexBttv = new Mutex();
|
|
var emotesBttv = null;
|
|
async function getBttvEmotes(id) {
|
|
try {
|
|
mutexBttv.lock();
|
|
const url = `https://api.betterttv.net/3/cached/users/twitch/${id}`;
|
|
|
|
} finally {
|
|
mutexBttv.unlock();
|
|
}
|
|
|
|
}
|
|
const templateEmote = document.getElementById("Template-Emote");
|
|
const templateMessageFrag = document.getElementById("Template-MessageFragment");
|
|
function createMessageFragment(text) {
|
|
return document.createTextNode(text);
|
|
}
|
|
async function applyExternalEmotes(text, channelId) {
|
|
console.assert(typeof text === 'string', `text expected string, got: ${typeof text}`);
|
|
const emotesFor7tv = await get7tvEmotes(channelId);
|
|
var textParts = [text];
|
|
var emoteParts = [];
|
|
for (const emote of emotesFor7tv) {
|
|
let newTextParts = [];
|
|
let newEmoteParts = [];
|
|
for (const ind in emoteParts) {
|
|
const spl = textParts[ind].split(emote.reg);
|
|
const len = spl.length;
|
|
newTextParts = newTextParts.concat(spl);
|
|
if (len > 1) {
|
|
newEmoteParts = newEmoteParts.concat(Array(len - 1).fill(createEmote(emote.url, emote.text)));
|
|
}
|
|
newEmoteParts.push(emoteParts[ind]);
|
|
}
|
|
// console.log(textParts);
|
|
const spl = textParts[textParts.length - 1].split(emote.reg);
|
|
// console.log(spl);
|
|
const len = spl.length;
|
|
newTextParts = newTextParts.concat(spl);
|
|
if (len > 1) {
|
|
newEmoteParts = newEmoteParts.concat(Array(len - 1).fill(createEmote(emote.url, emote.text)));
|
|
}
|
|
textParts = newTextParts;
|
|
emoteParts = newEmoteParts;
|
|
console.assert(textParts.length == emoteParts.length + 1, `text: ${textParts.length}, emotes: ${emoteParts.length}`);
|
|
}
|
|
console.assert(textParts.length == emoteParts.length + 1, `text: ${textParts.length}, emotes: ${emoteParts.length}`);
|
|
const res = [];
|
|
for (const ind in emoteParts) {
|
|
res.push(createMessageFragment(textParts[ind]));
|
|
res.push(emoteParts[ind]);
|
|
}
|
|
res.push(createMessageFragment(textParts.at(-1)));
|
|
return res;
|
|
}
|
|
function createEmote(url, title) {
|
|
const img = templateEmote.content.cloneNode(true).firstElementChild;
|
|
img.src = url;
|
|
img.title = title;
|
|
return img;
|
|
}
|
|
async function createEmotedMessage(message) {
|
|
let nodes = [];
|
|
var text = message.parameters[1];
|
|
const emotes = message.emotes();
|
|
emotes.sort((em) => em.end);
|
|
// console.log(emotes);
|
|
for (const emote of emotes) {
|
|
const start = text.substring(0, emote.start);
|
|
const end = text.substring(emote.end + 1);
|
|
const img = createEmote(emote.url(1), text.substring(emote.start, emote.end+1));
|
|
const extern = await applyExternalEmotes(end, message.channelId());
|
|
extern.reverse();
|
|
nodes = nodes.concat(extern);
|
|
// nodes.push(createMessageFragment(end));
|
|
nodes.push(img);
|
|
text = start;
|
|
// text = start + img.outerHTML + end;
|
|
}
|
|
const extern = await applyExternalEmotes(text, message.channelId());
|
|
extern.reverse();
|
|
nodes = nodes.concat(extern);
|
|
nodes.reverse();
|
|
return nodes;
|
|
}
|
|
function messageTags(s) {
|
|
s = s.trimStart("@");
|
|
let key = "";
|
|
let value = "";
|
|
const FINDING_KEY = 0;
|
|
const FINDING_VALUE = 1;
|
|
const VALUE_ESCAPED = 2;
|
|
let state = FINDING_KEY;
|
|
tags = {};
|
|
for (const c of s) {
|
|
switch (state) {
|
|
case FINDING_KEY:
|
|
if (c === "=") {
|
|
state = FINDING_VALUE;
|
|
} else if (c === ";") {
|
|
state = FINDING_KEY;
|
|
tags[key] = "";
|
|
key = "";
|
|
} else if (c === " ") {
|
|
tags[key] = value;
|
|
return tags;
|
|
} else {
|
|
key += c;
|
|
}
|
|
break;
|
|
case FINDING_VALUE:
|
|
if (c === "\\") {
|
|
state = VALUE_ESCAPED;
|
|
} else if (c === ";") {
|
|
tags[key] = value;
|
|
key = "";
|
|
value = "";
|
|
state = FINDING_KEY;
|
|
} else if (c === " ") {
|
|
tags[key] = value;
|
|
return tags;
|
|
} else if (c === "\n" || c === "\r" || c === "\0") {
|
|
throw new Error("found invalid character while parsing message tags");
|
|
} else {
|
|
value += c;
|
|
}
|
|
break;
|
|
case VALUE_ESCAPED:
|
|
// if (c === ":") {
|
|
// value += ":"
|
|
// state = FINDING_VALUE
|
|
// }
|
|
if (c === "s") {
|
|
value += " "
|
|
state = FINDING_VALUE;
|
|
} else if (c === "r") {
|
|
value += "\r";
|
|
state = FINDING_VALUE;
|
|
} else if (c === "n") {
|
|
value += "\n"
|
|
state = FINDING_VALUE;
|
|
} else if (c === ";") {
|
|
tags[key] = value;
|
|
key = "";
|
|
value = "";
|
|
state = FINDING_KEY;
|
|
} else if (c === " ") {
|
|
tags[key] = value;
|
|
return tags;
|
|
} else if (c === "\n" || c === "\r" || c === "\0") {
|
|
throw new Error("found invalid character while parsing message tags");
|
|
} else {
|
|
value += c;
|
|
state = FINDING_VALUE;
|
|
}
|
|
break;
|
|
default:
|
|
throw new Error("invalid enum while parsing message tags");
|
|
}
|
|
}
|
|
if (key.length > 0) {
|
|
tags[key] = value;
|
|
}
|
|
return tags;
|
|
}
|
|
function Emote(id, start, end) {
|
|
this.id = id.trim();
|
|
this.start = parseInt(start);
|
|
this.end = parseInt(end);
|
|
this.url = (scale) => {
|
|
return `https://static-cdn.jtvnw.net/emoticons/v2/${this.id}/static/dark/${scale}.0`
|
|
}
|
|
}
|
|
function parseEmotes(s) {
|
|
s = s.trim();
|
|
if (s.length === 0) {
|
|
return [];
|
|
}
|
|
res = [];
|
|
for (const emote of s.split("/")) {
|
|
const spl = emote.split(":");
|
|
const id = spl[0];
|
|
for (const indices of spl[1].split(",")) {
|
|
const splInd = indices.split("-");
|
|
res.push(new Emote(id, splInd[0], splInd[1]));
|
|
}
|
|
}
|
|
return res;
|
|
}
|
|
function Badge(badge, version) {
|
|
this.badge = badge;
|
|
this.version = version;
|
|
}
|
|
function parseBadges(s) {
|
|
s = s.trim();
|
|
res = [];
|
|
for (const b of s.split(",")) {
|
|
const spl = b.split("/");
|
|
res.push(new Badge(spl[0], spl[1]));
|
|
}
|
|
return res;
|
|
}
|
|
class IrcMessage {
|
|
constructor(s) {
|
|
s = s.trim();
|
|
this.rawMessage = s;
|
|
this.messageTags = undefined;
|
|
if (s.startsWith("@")) { //mressage tags
|
|
s = s.substring(1);
|
|
const split = s.split(" ");
|
|
this.messageTags = messageTags(split[0]);
|
|
s = split.slice(1).join(" ");
|
|
}
|
|
this.prefix = undefined;
|
|
if (s.startsWith(":")) { //prefix
|
|
s = s.substring(1);
|
|
const split = s.split(" ");
|
|
this.prefix = split[0];
|
|
s = split.slice(1).join(" ").trimStart(" ");
|
|
}
|
|
const splCommand = s.split(" ");
|
|
this.messageType = splCommand[0];
|
|
this.rawParameters = undefined;
|
|
this.parameters = [];
|
|
if (splCommand.length >= 2) { //parameters
|
|
s = splCommand.slice(1).join(" ");
|
|
this.rawParameters = s;
|
|
if (s.startsWith(":")) {
|
|
this.parameters.push(s.substring(1));
|
|
} else {
|
|
const spl = s.split(" :");
|
|
const splFinal = spl.slice(1).join(" :");
|
|
for (let x of spl[0].split(" ")) {
|
|
x = x.trim(" ");
|
|
if (x.length > 0) {
|
|
this.parameters.push(x);
|
|
}
|
|
}
|
|
this.parameters.push(splFinal);
|
|
}
|
|
}
|
|
}
|
|
emotes() {
|
|
const emotes = this.messageTags["emotes"];
|
|
if (typeof emotes === 'string') {
|
|
return parseEmotes(emotes);
|
|
}
|
|
return [];
|
|
}
|
|
badges() {
|
|
const badges = this.messageTags["badges"];
|
|
if (typeof badges === 'string') {
|
|
return parseBadges(badges);
|
|
}
|
|
return [];
|
|
}
|
|
id() {
|
|
const id = this.messageTags["id"];
|
|
if (typeof id === 'string') {
|
|
return id;
|
|
}
|
|
return "";
|
|
}
|
|
userid() {
|
|
const userid = this.messageTags["user-id"];
|
|
if (typeof userid === 'string') {
|
|
return userid;
|
|
}
|
|
return "";
|
|
}
|
|
mod() {
|
|
const mod = this.messageTags["mod"];
|
|
return mod === "1";
|
|
}
|
|
turbo() {
|
|
const turbo = this.messageTags["turbo"];
|
|
if (typeof turbo === 'string') {
|
|
return turbo;
|
|
}
|
|
return false;
|
|
}
|
|
admin() {
|
|
const admin = this.messageTags["user-type"];
|
|
if (typeof admin === 'string') {
|
|
return userType === "admin";
|
|
}
|
|
return false;
|
|
}
|
|
globalMod() {
|
|
const globalMod = this.messageTags["user-type"];
|
|
if (typeof globalMod === 'string') {
|
|
return userType === "global_mod";
|
|
}
|
|
return false;
|
|
}
|
|
staff() {
|
|
const staff = this.messageTags["user-type"];
|
|
if (typeof staff === 'string') {
|
|
return userType === "staff";
|
|
}
|
|
return false;
|
|
}
|
|
vip() {
|
|
const vip = this.messageTags["vip"];
|
|
return typeof vip !== 'undefined';
|
|
}
|
|
displayName() {
|
|
const displayName = this.messageTags['display-name'];
|
|
if (typeof displayName === 'string') {
|
|
return displayName;
|
|
}
|
|
return "";
|
|
}
|
|
bits() {
|
|
const bits = this.messageTags['bits'];
|
|
if (typeof bits === 'string') {
|
|
return parseInt(bits);
|
|
}
|
|
return 0;
|
|
}
|
|
color() {
|
|
const color = this.messageTags['color'];
|
|
if (typeof color === 'string') {
|
|
return color;
|
|
}
|
|
return "black";
|
|
}
|
|
subscriber() {
|
|
const subscriber = this.messageTags['subscriber'];
|
|
if (typeof subscriber === 'string') {
|
|
return subscriber;
|
|
}
|
|
return false;
|
|
}
|
|
channelId() {
|
|
const channelId = this.messageTags['room-id'];
|
|
if (typeof channelId === 'string') {
|
|
return channelId;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
const chatBody = document.getElementById("ChatBody");
|
|
const chatTemplate = document.getElementById("Template-MessageBox");
|
|
async function pushMessage(message) {
|
|
// const text = message.parameters[1];
|
|
const newElem = chatTemplate.content.cloneNode(true).firstElementChild;
|
|
newElem.id = message.id();
|
|
newElem.dataset.userid = message.userid();
|
|
const messageElem = newElem.querySelector(".Message");
|
|
const emoted = await createEmotedMessage(message);
|
|
messageElem.textContent = "";
|
|
messageElem.append(...emoted);
|
|
const userElem = newElem.querySelector(".Username");
|
|
userElem.textContent = message.displayName();
|
|
const userColor = message.color();
|
|
userElem.style.color = userColor;
|
|
if (message.vip()) {
|
|
newElem.classList.add("user-vip");
|
|
}
|
|
if (message.mod()) {
|
|
newElem.classList.add("user-mod");
|
|
}
|
|
if (message.subscriber()) {
|
|
newElem.classList.add("user-sub");
|
|
}
|
|
const chatElems = chatBody.children;
|
|
if (chatElems.length >= messageLimit) {
|
|
chatElems[0].remove();
|
|
}
|
|
chatBody.appendChild(newElem);
|
|
console.log(message);
|
|
}
|
|
async function deleteMessage(message) {
|
|
if (!deleteMessages) {
|
|
return;
|
|
}
|
|
const target = message.messageTags['target-msg-id'];
|
|
if (typeof target === 'string') {
|
|
const del = document.getElementById(target);
|
|
del.remove();
|
|
}
|
|
}
|
|
async function clearUser(message) {
|
|
if (!deleteMessages) {
|
|
return;
|
|
}
|
|
const target = message.messageTags['target-user-id'];
|
|
if (typeof target === 'string') {
|
|
const dels = document.querySelectorAll(`[data-userid='${target}']`);
|
|
for (const del of dels) {
|
|
del.remove();
|
|
}
|
|
}
|
|
}
|
|
websocket = new WebSocket("wss://irc-ws.chat.twitch.tv:443");
|
|
websocket.onopen = (event) => {
|
|
websocket.send("PASS password\n");
|
|
websocket.send("NICK justinfan1234\n");
|
|
websocket.send("CAP REQ :twitch.tv/membership twitch.tv/tags\n");
|
|
websocket.send(`JOIN #${channel}\n`);
|
|
};
|
|
websocket.onmessage = async (event) => {
|
|
console.log(event.data);
|
|
for (const line of event.data.split('\n')) {
|
|
const tr = line.trim();
|
|
if (tr.length > 0) {
|
|
const message = new IrcMessage(line);
|
|
if (message.messageType === "PRIVMSG") {
|
|
await pushMessage(message);
|
|
} else if (message.messageType === "PING") {
|
|
const pong = tr.replace("PING", "PONG");
|
|
websocket.send(pong);
|
|
} else if (message.messageType === "CLEARMSG") {
|
|
await deleteMessage(message);
|
|
} else if (message.messageType === "CLEARCHAT") {
|
|
await clearUser(message);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
</body> |