/*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 contianing client-side message post-processing code */ class chatPostprocessor{ /** * Instantiates a new Chat Post-Processor object * @param {channel} client - Parent client Management Object */ constructor(client){ /** * Parent Client Management Object */ this.client = client; } /** * Post-Processes a single message from the server and returns a presntable DOM Node * @param {Node} chatEntry - Chat entry generated by initial chatBox method * @param {Object} rawData - Raw data from server * @returns {Node} Post-Processed Chat Entry */ postprocess(rawData, pm = false){ //Create empty array to hold filter spans this.filterSpans = []; //Set raw message data this.rawData = rawData; //Set current chat nodes this.buildEntry(pm); //Split the chat message into an array of objects representing each word/chunk this.splitMessage(); //Process Qoutes this.processQoute(); //Re-Hydrate and Inject links and embedded media into un-processed placeholders this.processLinks(); //Inject clickable command examples this.processCommandExamples(); //Inject clickable channel names this.processChannelNames(); //Inject clickable usernames this.processUsernames(); //Detect inline spoilers this.processSpoilers(); //Detect inline strikethrough this.processStrikethrough(); //Detect inline bold text this.processBold(); //Detect inline italics this.processItalics(); //Inject whitespace into long ass-words this.addWhitespace(); //Handle non-standard chat types this.handleChatType(); //Inject the pre-processed chat hyper-text into the chatEntry node this.injectBody(); //Return the pre-processed node return this.chatEntry; } buildEntry(pm){ const classSuffix = pm ? 'pm-panel-sesh' : 'chat'; const classSuffixAlt = pm ? classSuffix : 'chat-panel'; //Create chat-entry span this.chatEntry = document.createElement('span'); this.chatEntry.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry`,`${classSuffix}-entry-${this.rawData.user}`); //Create high-level label var highLevel = document.createElement('p'); highLevel.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry-high-level`,"high-level"); highLevel.textContent = utils.unescapeEntities(`${this.rawData.highLevel}`); this.chatEntry.appendChild(highLevel); //If we're not using classic flair if(this.rawData.flair != "classic"){ //Use flair var flair = `flair-${this.rawData.flair}`; //Otherwise }else{ //Pull user's assigned color from the color map var flair = this.client.userList.colorMap.get(this.rawData.user); } //Create username label var userLabel = document.createElement('p'); userLabel.classList.add(`${classSuffixAlt}-buffer`, `${classSuffix}-entry-username`, ); //Create color span var flairSpan = document.createElement('span'); flairSpan.classList.add(`${classSuffix}-entry-flair-span`, flair); flairSpan.innerHTML = this.rawData.user; //Inject flair span into user label before the colon userLabel.innerHTML = `${flairSpan.outerHTML}: `; //Append user label this.chatEntry.appendChild(userLabel); //Create chat body this.chatBody = document.createElement('p'); this.chatBody.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry-body`); //Append chat body to chat entry this.chatEntry.appendChild(this.chatBody); } /** * Splits message into an array of Word Objects for further processing */ splitMessage(){ //Create an empty array to hold the body this.messageArray = []; //Unescape any sanatized char codes as we use .textContent for double-safety, and to prevent splitting of char codes //Split string by word-boundries on words and non-word boundries around whitespace, with negative lookaheads to exclude file seperators so we don't split link placeholders, and dashes so we dont split usernames and other things //Also split by any invisble whitespace as a crutch to handle mushed links/emotes //If we can one day figure out how to split non-repeating special chars instead of special chars with whitespace, that would be perf, unfortunately my brain hasn't rotted enough to understand regex like that just yet. const splitString = utils.unescapeEntities(this.rawData.msg).split(/(? { //create a word object const wordObj = { string: string, filterClasses: [], type: "word" } //Add it to our body array this.messageArray.push(wordObj); }); } /** * Injects word objects into chat-entry as proper DOM Nodes */ injectBody(){ //Create an empty array to hold the objects to inject const injectionArray = []; //For each word object this.messageArray.forEach((wordObj) => { if(wordObj.type == 'word'){ //Create span node const span = document.createElement('span'); //Set span filter classes span.classList.add(...wordObj.filterClasses); //Set span text span.textContent = wordObj.string; //Inject node into array injectionArray.push(span); }else if(wordObj.type == 'link'){ //Create a link node from our link const link = document.createElement('a'); link.classList.add('chat-link', ...wordObj.filterClasses); link.href = wordObj.link; link.target = "_blank"; //Use textContent to be safe since links can't be escaped serverside link.textContent = wordObj.link; //Append node to chatBody combineNode(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', ...wordObj.filterClasses); badLink.href = wordObj.link; badLink.target = "_blank"; //Use textContent to be safe since links can't be escaped serverside badLink.textContent = wordObj.link; //Append node to chatBody combineNode(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', ...wordObj.filterClasses); //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 combineNode(wordObj, malformedLink); }else if(wordObj.type == 'image'){ //Create an img node from our link const img = document.createElement('img'); img.classList.add('chat-img', ...wordObj.filterClasses); 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 combineNode(wordObj, img); }else if(wordObj.type == 'video'){ //Create a video node from our link const vid = document.createElement('video'); vid.classList.add('chat-video', ...wordObj.filterClasses); 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}]`; } combineNode(wordObj, vid); }else if(wordObj.type == 'command'){ //Create link node const link = document.createElement('a'); //Set class link.classList.add('chat-link', ...wordObj.filterClasses); //Set href and inner text link.href = "javascript:"; link.textContent = 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, ...wordObj.filterClasses); //Set href and inner text link.href = "javascript:"; link.textContent = 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', ...wordObj.filterClasses); //Set href and inner text link.href = `/c/${wordObj.chan}`; link.target = "_blank" link.textContent = 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 for(let itemIndex in injectionArray){ const item = injectionArray[itemIndex]; //Currently this doesnt support multiple overlapping span-type filters //not a huge deal since we only have once (spoiler) //All others can be applied per-node without any usability side effects const curFilter = this.filterSpans.filter(filterFilters)[0]; let appendBody = this.chatBody; //If we have a filter span if(curFilter != null){ //If we're beggining the array if(itemIndex == curFilter.index[0]){ //Create the span appendBody = document.createElement('span'); //Label it for what it is appendBody.classList.add(curFilter.class); //Add it to the chat body this.chatBody.appendChild(appendBody); //Otherwise }else{ //Use the existing span appendBody = (this.chatBody.children[this.chatBody.children.length - 1]); } } //Append the node to our chat body appendBody.appendChild(item); function filterFilters(filter){ //If the index is within the filter span return filter.index[0] <= itemIndex && filter.index[1] >= itemIndex; } } //Like string.replace except it actually injects the node so we can keep things like event handlers function combineNode(wordObj, node, placeholder = '␜'){ //Split string by the placeholder so we can keep surrounding whitespace const splitWord = wordObj.string.split(placeholder, 2); //Create combined node const combinedSpan = document.createElement('span'); //Add the first part of the text combinedSpan.textContent = splitWord[0]; //Add in the requestd node combinedSpan.appendChild(node); //Finish it off with the last bit of text combinedSpan.insertAdjacentText('beforeend', splitWord[1]); //Add to injection array as three nested items to keep arrays lined up injectionArray.push(combinedSpan); } } /** * Processes qouted text in chat */ processQoute(){ //If the message starts off with '>' if(this.messageArray[0].string[0] == '>'){ this.chatBody.classList.add("qoute"); } } /** * Processes clickable command examples in chat */ 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 const lastChar = wordObj.string[wordObj.string.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 = wordObj.string.slice(0,-1); const commandObj = { type: "command", string: nextWord.string, filterClasses: [], command: command } this.messageArray[wordIndex + 1] = commandObj; } } } }); } /** * Processes clickable channel names in chat */ 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 pounds 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 & or # (avoid spoilers) 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, filterClasses: [], chan: nextWord.string } this.messageArray[wordIndex + 1] = commandObj; } } } }); } /** * Processes clickable username callouts in chat */ 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; } } }); } /** * Injects invisible whitespace in long-ass words to prevent fucking up the chat buffer size */ 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(""); } }); } /** * Searches for text in-between a specific delimiter and runs a given callback against it * * Internal command used by several text filters to prevent code re-writes * @param {String} delimiter - delimiter to search string by * @param {Function} cb - Callback function to run against found strings * @returns {Array} - list of found instances of filter */ processFilter(delimiter, cb){ //Create empty array to hold spoilers (keep this seperate at first for internal function use) const foundFilters = []; //Spoiler detection stage //For each word object in the message array main: for(let wordIndex = 0; wordIndex < this.messageArray.length; wordIndex++){ //Get the current word object const wordObj = this.messageArray[wordIndex]; //If its a regular word and contains '##' if(wordObj.type == 'word' && wordObj.string.match(utils.escapeRegex(delimiter))){ //Crawl through detected spoilers for(let spoiler of foundFilters){ //If the current word object is part of a detected spoiler if(wordIndex == spoiler[0] || wordIndex == spoiler[1]){ //ignore it and continue on to the next word object continue main; } } //Crawl throw word objects after the current one for(let endIndex = (wordIndex + 1); endIndex < this.messageArray.length; endIndex++){ //Get the current end object const endObj = this.messageArray[endIndex]; //If its a regular word and contains '##' if(endObj.type == 'word' && endObj.string.match(utils.escapeRegex(delimiter))){ //Setup the found filter array const foundFilter = [wordIndex, endIndex]; //Scrape out delimiters wordObj.string = wordObj.string.replaceAll(delimiter,''); endObj.string = endObj.string.replaceAll(delimiter,''); //Add it to the list of detected filters foundFilters.push(foundFilter); //Run the filter callback cb(foundFilter) //Break the nested end-detection loop break; } } } } return foundFilters; } /** * Processes in-line spoilers */ processSpoilers(){ //Process spoilers using '##' delimiter this.processFilter('##', (foundSpoiler)=>{ //For each found spoiler add it to the list of found filter spans this.filterSpans.push({class: "spoiler", index: [foundSpoiler[0] + 1, foundSpoiler[1] - 1], delimiters: [foundSpoiler[0], foundSpoiler[1]]}); }); } /** * Processes in-line Strike-through */ processStrikethrough(){ //Process strikethrough's using '~~' delimiter this.processFilter('~~', (foundStrikethrough)=>{ for(let wordIndex = foundStrikethrough[0]; wordIndex < foundStrikethrough[1]; wordIndex++){ this.messageArray[wordIndex].filterClasses.push("strikethrough"); } }) } /** * Processes in-line Bold/Strong text */ processBold(){ //Process strong text using '*' delimiter this.processFilter('**', (foundStrikethrough)=>{ for(let wordIndex = foundStrikethrough[0]; wordIndex < foundStrikethrough[1]; wordIndex++){ this.messageArray[wordIndex].filterClasses.push("bold"); } }) } /** * Processes in-line Italics */ processItalics(){ //Process italics using '__' delimiter this.processFilter('*', (foundStrikethrough)=>{ for(let wordIndex = foundStrikethrough[0]; wordIndex < foundStrikethrough[1]; wordIndex++){ this.messageArray[wordIndex].filterClasses.push("italics"); } }) } /** * Processes clickable links and embedded media */ 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, filterClasses: [] } } }) }); } /** * Marks chat nodes in-case of non-standard chat types */ 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.textContent = `${userNode.textContent.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"); }else if(this.rawData.type == "spoiler"){ //Set whole-body spoiler this.chatBody.classList.add("spoiler"); }else if(this.rawData.type == "strikethrough"){ //Set whole-body spoiler this.chatBody.classList.add("strikethrough"); }else if(this.rawData.type == "bold"){ //Set whole-body spoiler this.chatBody.classList.add("bold"); }else if(this.rawData.type == "italics"){ //Set whole-body spoiler this.chatBody.classList.add("italics"); } } }