/*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 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); //If the chatbox is empty if(this.chatBuffer.childElementCount <= 0){ //For each chat held in the chat buffer for(let chat of data.chatBuffer){ //Display the chat this.displayChat(chat); } } } /** * 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); } } }