/*Canopy - The next generation of stoner streaming software Copyright (C) 2024-2025 Rainbownapkin and the TTN Community This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see .*/ class chatPostprocessor{ constructor(client){ this.client = client; } postprocess(chatEntry, rawData){ this.rawData = rawData; //Set current chat nodes this.chatEntry = chatEntry; this.chatBody = this.chatEntry.querySelector(".chat-entry-body"); //Split the chat message into an array of objects representing each word this.splitMessage(); //Inject links into un-processed placeholders this.processLinks(); //Inject clickable command examples this.processCommandExamples(); //Inject clickable channel names this.processChannelNames(); //Inject clickable usernames this.processUsernames(); //Inject whitespace into un-processed words this.addWhitespace(); //Handle non-standard chat types this.handleChatType(); //Inject the pre-processed chat into the chatEntry node this.injectBody(); //Return the pre-processed node return this.chatEntry; } splitMessage(){ //Create an empty array to hold the body this.messageArray = []; //First unescape forward-slashes to keep them from splitting, then.. //Split string by word-boundries, with negative lookaheads to exclude file seperators so we don't split link placeholders const splitString = this.rawData.msg.replaceAll('/','/').split(/(? { //create a word object const wordObj = { //re-escape slashes for safety string: string.replaceAll('/','/'), type: "word" } //Add it to our body array this.messageArray.push(wordObj); }); } injectBody(){ //Create an empty array to hold the objects to inject const injectionArray = [""]; const _this = this; //For each word object this.messageArray.forEach((wordObj) => { if(wordObj.type == 'word'){ //Inject current wordObj string into the chat body injectString(wordObj.string); }else if(wordObj.type == 'link'){ //Create a link node from our link const link = document.createElement('a'); link.classList.add('chat-link'); link.href = wordObj.link; //Use textContent to be safe since links can't be escaped serverside link.textContent = wordObj.link; //Append node to chatBody injectNode(wordObj, link); }else if(wordObj.type == 'deadLink'){ //Create a text span node from our link const badLink = document.createElement('a'); badLink.classList.add('chat-dead-link', 'danger-link'); badLink.href = wordObj.link; //Use textContent to be safe since links can't be escaped serverside badLink.textContent = wordObj.link; //Append node to chatBody injectNode(wordObj, badLink); }else if(wordObj.type == 'malformedLink'){ //Create a text span node from our link const malformedLink = document.createElement('span'); malformedLink.classList.add('chat-malformed-link'); //Use textContent to be safe since links can't be escaped (this is why we don't just add it using injectString) //arguably we could sanatize malformed links serverside since they're never actually used as links malformedLink.textContent = wordObj.link; //Append node to chatBody injectNode(wordObj, malformedLink); }else if(wordObj.type == 'image'){ //Create an img node from our link const img = document.createElement('img'); img.classList.add('chat-img'); img.src = wordObj.link; //Look for an emote by link since emotes are tx'd as bare links const emote = this.client.chatBox.commandPreprocessor.getEmoteByLink(wordObj.link); //If this is a known emote if(emote != null){ //Set the hover text to the emote's name img.title = `[${emote.name}]`; } //Append node to chatBody injectNode(wordObj, img); }else if(wordObj.type == 'video'){ //Create a video node from our link const vid = document.createElement('video'); vid.classList.add('chat-video'); vid.src = wordObj.link; vid.controls = false; vid.autoplay = true; vid.loop = true; vid.muted = true; //Look for an emote by link since emotes are tx'd as bare links const emote = this.client.chatBox.commandPreprocessor.getEmoteByLink(wordObj.link); //If this is a known emote if(emote != null){ //Set the hover text to the emote's name vid.title = `[${emote.name}]`; } injectNode(wordObj, vid); }else if(wordObj.type == 'command'){ //Create link node const link = document.createElement('a'); //Set class link.classList.add('chat-link'); //Set href and inner text link.href = "javascript:"; link.innerText = wordObj.command; //Add chatbox functionality link.addEventListener('click', () => {this.client.chatBox.commandPreprocessor.preprocess(wordObj.command)}); //We don't have to worry about injecting this into whitespace since there shouldn't be any here. injectionArray.push(link); }else if(wordObj.type == "username"){ //Create link node const link = document.createElement('a'); //set class link.classList.add(wordObj.color); //Set href and inner text link.href = "javascript:"; link.innerText = wordObj.string; //add chatbox functionality link.addEventListener('click', () => {this.client.chatBox.chatPrompt.value += `${wordObj.string} `}); //We don't have to worry about injecting this into whitespace since there shouldn't be any here. injectionArray.push(link); }else if(wordObj.type == "channel"){ //Create link node const link = document.createElement('a'); //set class link.classList.add('chat-link'); //Set href and inner text link.href = `/c/${wordObj.chan}`; link.innerText = wordObj.string; //We don't have to worry about injecting this into whitespace since there shouldn't be any here. injectionArray.push(link); }else{ console.warn("Unknown chat postprocessor word type:"); console.warn(wordObj); } }); //For each item found in the injection array injectionArray.forEach((item) => { //if it's a string if(typeof item == "string"){ //Create span (can't add to innerHTML without clobbering sibling DOM nodes) const text = document.createElement('span'); //Set text to innerHTML (can't just append as a text node since the message was escaped server-side) text.innerHTML = item; this.chatBody.appendChild(text); //Otherwise it should be a DOM node }else{ //Append the node to our chat body this.chatBody.appendChild(item); } }) //Like string.replace except it actually injects the node so we can keep things like event handlers function injectNode(wordObj, node, placeholder = '␜'){ //Split string by the placeholder so we can keep surrounding whitespace const splitWord = wordObj.string.split(placeholder, 2); //Append the first half of the string injectString(splitWord[0]); //Append the node injectionArray.push(node); //Append the second half of the string injectString(splitWord[1]); } function injectString(string){ //If the last item was a string if(typeof injectionArray[injectionArray.length - 1] == "string"){ //add the word string on to the end of the string injectionArray[injectionArray.length - 1] += string; }else{ //Pop the string at the end of the array injectionArray.push(string); } } } processCommandExamples(){ //for each word object in the body this.messageArray.forEach((wordObj, wordIndex) => { //if the word object hasn't been pre-processed elsewhere if(wordObj.type == "word"){ //Get last char of current word with slashes unescaped const unescaped = wordObj.string.replaceAll('/','/') const lastChar = unescaped[unescaped.length - 1]; //if the last char is ! if(lastChar == '!' || lastChar == '/'){ //get next word const nextWord = this.messageArray[wordIndex + 1]; //if we have another word if(nextWord != null){ const command = lastChar + nextWord.string; //Take out the command marker this.messageArray[wordIndex].string = unescaped.slice(0,-1); const commandObj = { type: "command", string: nextWord.string, command: command } this.messageArray[wordIndex + 1] = commandObj; } } } }); } processChannelNames(){ //for each word object in the body this.messageArray.forEach((wordObj, wordIndex) => { //if the word object hasn't been pre-processed elsewhere if(wordObj.type == "word"){ //Get last char of current word with slashes unescaped const lastChar = wordObj.string[wordObj.string.length - 1]; const secondLastChar = wordObj.string[wordObj.string.length - 2]; //if the last char is # and the second to last char isn't & (avoid escaped HTML char codes) if(lastChar == '#' && secondLastChar != '&'){ //get next word const nextWord = this.messageArray[wordIndex + 1]; //if we have another word if(nextWord != null){ //Take out the chan marker this.messageArray[wordIndex].string = wordObj.string.slice(0,-1); const commandObj = { type: "channel", string: lastChar + nextWord.string, chan: nextWord.string } this.messageArray[wordIndex + 1] = commandObj; } } } }); } processUsernames(){ //for each word object in the body this.messageArray.forEach((wordObj, wordIndex) => { //if the word object hasn't been pre-processed elsewhere if(wordObj.type == "word"){ //Check for user and get their color const color = this.client.userList.colorMap.get(wordObj.string); //If the current word is the username of a connected user if(color != null){ //Mark it as so this.messageArray[wordIndex].type = "username"; //Store their color this.messageArray[wordIndex].color = color; } } }); } addWhitespace(){ //for each word object in the body this.messageArray.forEach((wordObj, wordIndex) => { //if the word object hasn't been pre-processed elsewhere if(wordObj.type == "word"){ //Create an empty array to hold our word var wordArray = []; //For each character in the string of the current word object this.messageArray[wordIndex].string.split("").forEach((char, charIndex) => { //push the current character to the wordArray wordArray.push(char); //After eight characters if(charIndex > 8){ //Push an invisible line-break character between every character wordArray.push("ㅤ"); } }); //Join the wordArray into a single string, and use it to set the current wordObject's string this.messageArray[wordIndex].string = wordArray.join(""); } }); } processLinks(){ //If we don't have links if(this.rawData.links == null){ //Don't bother return; } //For every link received in this message this.rawData.links.forEach((link, linkIndex) => { //For every word obj in the message array this.messageArray.forEach((wordObj, wordIndex) => { //Check current wordobj for link (placeholder may contain whitespace with it) if(wordObj.string.match(`␜${linkIndex}`)){ //Set current word object in the body array to the new link object this.messageArray[wordIndex] = { //Don't want to use a numbered placeholder to make this easier during body injection //but we also don't want to clobber any surrounding whitespace string: wordObj.string.replace(`␜${linkIndex}`, '␜'), link: link.link, type: link.type } } }) }); } handleChatType(){ if(this.rawData.type == "whisper"){ //add whisper class this.chatBody.classList.add('whisper'); }else if(this.rawData.type == "announcement"){ //Squash the high-level this.chatEntry.querySelector('.high-level').remove(); //Get the username and make it into an announcement title (little hacky but this *IS* postprocessing) const userNode = this.chatEntry.querySelector('.chat-entry-username'); userNode.innerHTML = `${userNode.innerHTML.slice(0,-2)} Announcement`; //Add/remove relevant classes userNode.classList.remove('chat-entry-username'); userNode.classList.add('announcement-title'); this.chatBody.classList.add('announcement-body'); this.chatEntry.classList.add('announcement'); }else if(this.rawData.type == "toke"){ //Squash the high-level this.chatEntry.querySelector('.high-level').remove(); //remove the username this.chatEntry.querySelector('.chat-entry-username').remove(); //Add toke/tokewhisper class this.chatBody.classList.add("toke"); }else if(this.rawData.type == "tokewhisper"){ //Squash the high-level this.chatEntry.querySelector('.high-level').remove(); //remove the username this.chatEntry.querySelector('.chat-entry-username').remove(); //Add toke/tokewhisper class this.chatBody.classList.add("tokewhisper","serverwhisper"); } } }