Source: chat.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 which represents Canopy Chat Box UI
 */
class chatBox{
    /**
     * Instantiates a new Chat Box object
     * @param {channel} client - Parent client Management Object
     */
    constructor(client){
        /**
         * Parent Client Management Object
         */
        this.client = client

        /**
         * Whether or not chat-size should be locked to current media aspect ratio
         */
        this.aspectLock = true;

        /**
         * Whether or not the chat box should auto-scroll on new chat
         */
        this.autoScroll = true;

        /**
         * Chat-Width Minimum while sized to media Aspect-Ratio
         */
        this.chatWidthMinimum = localStorage.getItem('chatWidthMin') / 100;

        /**
         * Chat Buffer Scroll Top on last scroll
         */
        this.lastPos = 0;

        /**
         * Height of Chat Buffer on last scroll
         */
        this.lastHeight = 0;

        /**
         * Width of Chat Buffer on last scroll
         */
        this.lastWidth = 0;

        /**
         * Click-Dragger Object for handling dynamic chat/video split re-sizing
         */
        this.clickDragger = new canopyUXUtils.clickDragger("#chat-panel-drag-handle", "#chat-panel-div");
        
        /**
         * Command Pre-Processor Object
         */
        this.commandPreprocessor = new commandPreprocessor(client);

        /**
         * Chat Post-Processor Object
         */
        this.chatPostprocessor = new chatPostprocessor(client);

        //Element Nodes
        /**
         * Chat Panel Container Div
         */
        this.chatPanel = document.querySelector("#chat-panel-div");

        /**
         * High Level Selector
         */
        this.highSelect = document.querySelector("#chat-panel-high-level-select");

        /**
         * Flair Selector
         */
        this.flairSelect = document.querySelector("#chat-panel-flair-select");

        /**
         * Chat Buffer Div
         */
        this.chatBuffer = document.querySelector("#chat-panel-buffer-div");

        /**
         * Chat Prompt
         */
        this.chatPrompt = document.querySelector("#chat-panel-prompt");

        /**
         * Auto-Complete Placeholder
         */
        this.autocompletePlaceholder = document.querySelector("#chat-panel-prompt-autocomplete-filler");

        /**
         * Auto-Complete Display
         */
        this.autocompleteDisplay = document.querySelector("#chat-panel-prompt-autocomplete-display");

        /**
         * Settings Panel Icon
         */
        this.settingsIcon = document.querySelector("#chat-panel-settings-icon");

        /**
         * Admin Panel Icon
         */
        this.adminIcon = document.querySelector("#chat-panel-admin-icon");

        /**
         * Emote Icon
         */
        this.emoteIcon = document.querySelector("#chat-panel-emote-icon");

        /**
         * Send Chat/Command Button
         */
        this.sendButton = document.querySelector("#chat-panel-send-button");

        /**
         * Aspect Lock Icon
         * Seems weird to stick this in here, but the split is dictated by chat width :P
         */
        this.aspectLockIcon = document.querySelector("#media-panel-aspect-lock-icon");

        /**
         * Hide Chat Icon
         */
        this.hideChatIcon = document.querySelector("#chat-panel-div-hide");

        /**
         * Show Chat Icon
         */
        this.showChatIcon = document.querySelector("#media-panel-show-chat-icon");

        //Setup functions
        this.setupInput();
        this.defineListeners();
        this.sizeToAspect();
    }

    /**
     * Defines input-related event listeners
     */
    setupInput(){
        //Chat bar
        this.chatPrompt.addEventListener("keydown", this.send.bind(this));
        this.chatPrompt.addEventListener("keydown", this.tabComplete.bind(this));
        this.chatPrompt.addEventListener("input", this.displayAutocomplete.bind(this));
        this.autocompleteDisplay.addEventListener("click", this.tabComplete.bind(this));
        this.sendButton.addEventListener("click", this.send.bind(this));
        this.settingsIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new settingsPanel(client))});
        this.adminIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new queuePanel(client))});
        this.emoteIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new emotePanel(client))});

        //Header icons
        this.aspectLockIcon.addEventListener("click", this.lockAspect.bind(this));
        this.showChatIcon.addEventListener("click", ()=>{this.toggleUI()});
        this.hideChatIcon.addEventListener("click", ()=>{this.toggleUI()});
        this.highSelect.addEventListener("change", this.setHighLevel.bind(this));
        this.flairSelect.addEventListener("change", this.setFlair.bind(this));

        //Clickdragger/Resize
        this.clickDragger.handle.addEventListener("mousedown", this.unlockAspect.bind(this));
        this.clickDragger.handle.addEventListener("clickdrag", this.handleAutoScroll.bind(this));
        window.addEventListener("resize", this.resizeAspect.bind(this));

        //chatbuffer
        this.chatBuffer.addEventListener('scroll', this.scrollHandler.bind(this));
    }

    /**
     * Defines network-related event listners
     */
    defineListeners(){
        this.client.socket.on("chatMessage", this.displayChat.bind(this));
        this.client.socket.on("clearChat", this.clearChat.bind(this));
    }

    /**
     * Clears chat on command from server
     * @param {Object} data - Data from server
     */
    clearChat(data){
        //If we where passed a user to check
        if(data.user != null){
            var clearedChats = document.querySelectorAll(`.chat-entry-${data.user}`);
        }else{
            var clearedChats = document.querySelectorAll('.chat-entry');
        }

        //For each chat found
        clearedChats.forEach((chat) => {
            //fuckin' nukem!
            chat.remove();
        });
    }

    /**
     * Receives, Post-Processes, and Displays chat messages from server
     * @param {Object} data De-hydrated chat object from server
     */
    displayChat(data){
        //Create chat-entry span
        var chatEntry = document.createElement('span');
        chatEntry.classList.add("chat-panel-buffer","chat-entry",`chat-entry-${data.user}`);

        //Create high-level label
        var highLevel = document.createElement('p');
        highLevel.classList.add("chat-panel-buffer","chat-entry-high-level","high-level");
        highLevel.textContent = utils.unescapeEntities(`${data.highLevel}`);
        chatEntry.appendChild(highLevel);

        //If we're not using classic flair
        if(data.flair != "classic"){
            //Use flair
            var flair = `flair-${data.flair}`;
        //Otherwise
        }else{
            //Pull user's assigned color from the color map
            var flair =  this.client.userList.colorMap.get(data.user);
        }

        //Create username label
        var userLabel = document.createElement('p');
        userLabel.classList.add("chat-panel-buffer", "chat-entry-username", );
        
        //Create color span
        var flairSpan = document.createElement('span');
        flairSpan.classList.add("chat-entry-flair-span", flair);
        flairSpan.innerHTML = data.user;

        //Inject flair span into user label before the colon
        userLabel.innerHTML = `${flairSpan.outerHTML}: `;

        //Append user label
        chatEntry.appendChild(userLabel);

        //Create chat body
        var chatBody = document.createElement('p');
        chatBody.classList.add("chat-panel-buffer","chat-entry-body");
        chatEntry.appendChild(chatBody);

        //Append the post-processed chat-body to the chat buffer
        this.chatBuffer.appendChild(this.chatPostprocessor.postprocess(chatEntry, data));

        //Set size to aspect on launch
        this.resizeAspect();
    }

    /**
     * Concatenate Text into Chat Prompt
     * @param {String} text - Text to Concatenate
     */
    catChat(text){
        this.chatPrompt.value += text;
        this.displayAutocomplete();
    }

    /**
     * Calls a toke command out with a specified user
     * @param {String} user - User to toke with
     */
    tokeWith(user){
        this.commandPreprocessor.preprocess(user == this.client.user.user ? "!toke up fuckers" : `!toke up ${user}`);
    }

    /**
     * Pre-processes and sends text from chat prompt to server
     * @param {Event} event - Event passed down from Event Handler
     */
    send(event){
        if((!event || !event.key || event.key == "Enter") && this.chatPrompt.value){
            this.commandPreprocessor.preprocess(this.chatPrompt.value);
            //Clear our prompt and autocomplete nodes
            this.chatPrompt.value = "";
            this.autocompletePlaceholder.innerHTML = '';
            this.autocompleteDisplay.innerHTML = '';
        }
    }

    /**
     * Displays auto-complete text against current prompt input
     * @param {Event} event - Event passed down from Event Handler
     */
    displayAutocomplete(event){
        //Find current match
        const match = this.checkAutocomplete();

        //Set placeholder to space out the autocomplete display
        //Use text content because it's unescaped, and while this only effects local users, it'll keep someone from noticing and whinging about it
        this.autocompletePlaceholder.textContent = this.chatPrompt.value;
        //Set the autocomplete display
        this.autocompleteDisplay.textContent = match.match.replace(match.word, '');
    }

    /**
     * Called upon tab-complete
     * @param {Event} event - Event passed down from Event Handler
     */
    tabComplete(event){
        //If we hit tab or this isn't a keyboard event
        if(event.key == "Tab" || event.key == null){
            //Prevent default action
            event.preventDefault();

            //return focus to the chat prompt
            this.chatPrompt.focus();

            //Grab autocompletion match
            const match = this.checkAutocomplete();

            //If we have a match
            if(match.match != ''){
                //Autocomplete the current word
                this.chatPrompt.value += match.match.replace(match.word, '');

                //Clear out the autocomplete display
                this.autocompleteDisplay.innerHTML = '';
            }
        }
    }

    /**
     * Checks string input against auto-complete dictionary to generate the best guess as to what the user is typing
     * @param {String} input - Current input from Chat Prompt
     * @returns {Object} Object containing word we where handed and the match we found
     */
    checkAutocomplete(input = this.chatPrompt.value){
        //Rebuild this fucker every time because it really doesn't take that much compute power and emotes/used tokes change
        //Worst case we could store it persistantly and update as needed but I think that might be much
        const dictionary = this.commandPreprocessor.buildAutocompleteDictionary();       

        //Split our input by whitespace
        const splitInput = input.split(/\s/g);
        //Get the current word we're working on
        const word = splitInput[splitInput.length - 1];
        let matches = [];


        //Run through dictionary sets
        for(let set of Object.keys(dictionary)){
            //Go through the current definitions of the current dictionary set
            //I went with a for loop instead of a filter beacuse I wanted to pull the processed definition with pre/postfix
            //and also directly push it into a shared array :P
            for(let cmd of dictionary[set].cmds){

                //Append the proper prefix/postfix to the current command
                const definition = (`${dictionary[set].prefix}${cmd[0]}${dictionary[set].postfix}`);

                //if definition starts with the current word and the command is enabled
                if((word == '' ? false : definition.indexOf(word) == 0) && cmd[1]){
                    //Add definition to match list
                    matches.push(definition);
                }
            }
        }

        //If we found jack shit
        if(matches.length == 0){
            //Return jack shit
            return {
                match: '',
                word
            };
        //If we got something
        }else{
            //return our top match
            return {
                match: matches[0],
                word
            };

        }
    }

    /**
     * Handles initial client meta-data dump from server upon connection
     * @param {Object} data - Data dump from server
     */
    handleClientInfo(data){
        this.updateFlairSelect(data.flairList, data.user.flair);
        this.updateHighSelect(data.user.highLevel);
    }

    /**
     * Sets user high-level
     * @param {Event} event - Event passed down from Event Handler
     */
    setHighLevel(event){
        const highLevel = event.target.value;

        this.client.socket.emit("setHighLevel", {highLevel});
    }

    /**
     * Sets user flair
     * @param {Event} event - Event passed down from Event Handler
     */
    setFlair(event){
        const flair = event.target.value;

        this.client.socket.emit("setFlair", {flair});
    }

    /**
     * Handles High-Level updates from the server
     * @param {Number} highLevel - High Level to Set
     */
    updateHighSelect(highLevel){
        this.highSelect.value = highLevel;
    }

    /**
     * Handles flair updates from the server
     * @param {Array} fliarList - List of flairs to put into flair select
     * @param {String} fliar - Flair to set
     */
    updateFlairSelect(flairList, flair){
        //clear current flair select
        this.flairSelect.innerHTML = "";

        //For each flair in flairlist
        flairList.forEach((flair) => {
            //Create an option
            var flairOption = document.createElement('option');
            //Set the name and innerHTML
            flairOption.value = flair.name;
            flairOption.textContent = utils.unescapeEntities(flair.displayName);

            //Append it to the select
            this.flairSelect.appendChild(flairOption);
        });

        //Set the selected flair in the UI
        this.flairSelect.value = flair;
        //Re-style the UI, do this in two seperate steps in-case we're running for the first time and have nothing to replace.
        this.flairSelect.className = this.flairSelect.className.replace(/flair-\S*/, "");
        this.flairSelect.classList.add(`flair-${flair}`);
    }

    /**
     * Locks chat-size to aspect ratio of media
     * @param {Event} event - Event passed down from Event Handler
     */
    lockAspect(event){
        //prevent the user from breaking shit :P
        if(this.chatPanel.style.display != "none"){
            this.aspectLock = true;
            this.aspectLockIcon.style.display = "none";
            this.sizeToAspect();
        }
    }

    /**
     * Un-locks chat-size to aspect ratio of media
     * @param {Event} event - Event passed down from Event Handler
     */
    unlockAspect(event){
        //Disable aspect lock
        this.aspectLock = false;

        //Show aspect lock icon
        this.aspectLockIcon.style.display = "inline";
    }

    /**
     * Re-sizes chat back to aspect ratio on window re-size when chat box is aspect locked
     * Also prevents horizontal scroll-bars from chat/window resizing
     * @param {Event} event - Event passed down from Event Handler
     */
    resizeAspect(event){
        const playerHidden = this.client.player.playerDiv.style.display == "none";

        //If the aspect is locked and the player is hidden
        if(this.aspectLock && !playerHidden){
            this.sizeToAspect();
        //Otherwise
        }else{
            //Fix the clickDragger on userlist
            this.client.userList.clickDragger.fixCutoff();
        }

        //Autoscroll chat in-case we fucked it up
        this.handleAutoScroll();
    }

    /**
     * Re-sizes chat box relative to media aspect ratio
     */
    sizeToAspect(){
        if(this.chatPanel.style.display != "none"){
            var targetVidWidth = this.client.player.getRatio() * this.chatPanel.getBoundingClientRect().height;
            const targetChatWidth = window.innerWidth - targetVidWidth;
            //This should be changeable in settings later on, for now it defaults to 20%
            const limit = window.innerWidth * this.chatWidthMinimum;

            //Set width to target or 20vw depending on whether or not we've hit the width limit
            this.chatPanel.style.flexBasis = targetChatWidth > limit ? `${targetChatWidth}px` : `${this.chatWidthMinimum * 100}vw`;

            //Fix busted layout
            var pageBreak = document.body.scrollWidth - document.body.getBoundingClientRect().width;
            this.chatPanel.style.flexBasis = `${this.chatPanel.getBoundingClientRect().width + pageBreak}px`;
            //This sometimes gets called before userList ahs been initiated :p
            if(this.client.userList != null){
                this.client.userList.clickDragger.fixCutoff();
            }
        }
    } 

    /**
     * Toggles Chat Box UX
     * @param {Boolean} show - Whether or not to show Chat Box UX
     */
    toggleUI(show = !this.chatPanel.checkVisibility()){
        if(show){
            this.chatPanel.style.display = "flex";
            this.showChatIcon.style.display = "none";
            this.client.player.hideVideoIcon.style.display = "flex";
            this.client.userList.clickDragger.fixCutoff();
        }else{
            this.chatPanel.style.display = "none";
            this.showChatIcon.style.display = "flex";
            this.client.player.hideVideoIcon.style.display = "none";
        }
    }

    /**
     * Handles Video Toggling
     * @param {Boolean} show - Whether or not the video is currently being hidden
     */
    handleVideoToggle(show){
        //If we're enabling the video
        if(show){
            //Show hide chat icon
            this.hideChatIcon.style.display = "flex";

            //Re-enable the click dragger
            this.clickDragger.enabled = true;

            //Lock the chat to aspect ratio of the video, to make sure the chat width isn't breaking shit
            this.lockAspect();
        //If we're disabling the video
        }else{
            //Hide hide hide hide hide hide chat icon
            this.hideChatIcon.style.display = "none";

            //Need to clear the width from the split, or else it doesn't display properly
            this.chatPanel.style.flexBasis = "100%";

            //Disable the click dragger
            this.clickDragger.enabled = false;
        }
    }

    /**
     * Handles scrolling within the chat buffer
     * @param {Event} event - Event passed down from Event Handler
     */
    scrollHandler(event){
        //If we're just starting out
        if(this.lastPos == 0){
            //Set last pos for the first time
            this.lastPos = this.chatBuffer.scrollTop;
        }

        //Calculate scroll delta
        const deltaY = this.chatBuffer.scrollTop - this.lastPos;

        //Grab visible bounding rect so we don't have to do it again (can't use offset because someone might zoom in :P)
        const bufferRect = this.chatBuffer.getBoundingClientRect();
        const bufferHeight = Math.round(bufferRect.height);
        const bufferWidth = Math.round(bufferRect.width);

        if(this.lastHeight == 0){
            this.lastHeight = bufferHeight;
        }

        if(this.lastWidth == 0){
            this.lastWidth = bufferWidth;
        }

        //If we're scrolling up
        if(deltaY < 0){
            //If we have room to scroll, and we didn't resize
            if(this.chatBuffer.scrollHeight > bufferHeight && (this.lastWidth == bufferWidth && this.lastHeight == bufferHeight)){
                //Disable auto scrolling
                this.autoScroll = false;
            }else{
                this.handleAutoScroll();
            }
        //Otherwise if the difference between the chat buffers scroll height and offset height is equal to the scroll top
        //(Because it is scrolled all the way down)
        }else if((this.chatBuffer.scrollHeight - bufferHeight) == this.chatBuffer.scrollTop){
            this.autoScroll = true;
        }

        //Set last post/size for next the run
        this.lastPos = this.chatBuffer.scrollTop;
        this.lastHeight = bufferHeight;
        this.lastWidth = bufferWidth;
    }

    /**
     * Auto-scrolls chat buffer when new chats are entered.
     */
    handleAutoScroll(){
        //If autoscroll is enabled
        if(this.autoScroll){
            //Set chatBuffer scrollTop to the difference between scrollHeight and buffer height (scroll to the bottom)
            this.chatBuffer.scrollTop = this.chatBuffer.scrollHeight - Math.round(this.chatBuffer.getBoundingClientRect().height);
        }
    }
}