Source: chatPostprocessor.js

/*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 <https://www.gnu.org/licenses/>.*/

/**
 * 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(chatEntry, rawData){
        //Create empty array to hold filter spans
        this.filterSpans = [];
        //Set raw message data
        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/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;
    }

    /**
     * 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(/(?<!-)(?<!␜)(?=\w)\b|(?!-)(?<=\w)\b|(?=\s)\B|(?<=\s)\B|ㅤ/g);

        //for each word in the splitstring
        splitString.forEach((string) => {
            //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");
        }
    }
}