475 lines
18 KiB
JavaScript
475 lines
18 KiB
JavaScript
/*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 for Object 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 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
|
|
this.chatPanel = document.querySelector("#chat-panel-div");
|
|
this.highSelect = document.querySelector("#chat-panel-high-level-select");
|
|
this.flairSelect = document.querySelector("#chat-panel-flair-select");
|
|
this.chatBuffer = document.querySelector("#chat-panel-buffer-div");
|
|
this.chatPrompt = document.querySelector("#chat-panel-prompt");
|
|
this.autocompletePlaceholder = document.querySelector("#chat-panel-prompt-autocomplete-filler");
|
|
this.autocompleteDisplay = document.querySelector("#chat-panel-prompt-autocomplete-display");
|
|
this.settingsIcon = document.querySelector("#chat-panel-settings-icon");
|
|
this.adminIcon = document.querySelector("#chat-panel-admin-icon");
|
|
this.emoteIcon = document.querySelector("#chat-panel-emote-icon");
|
|
this.sendButton = document.querySelector("#chat-panel-send-button");
|
|
//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");
|
|
this.hideChatIcon = document.querySelector("#chat-panel-div-hide");
|
|
this.showChatIcon = document.querySelector("#media-panel-show-chat-icon");
|
|
|
|
//Setup functions
|
|
this.setupInput();
|
|
this.defineListeners();
|
|
this.sizeToAspect();
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
defineListeners(){
|
|
this.client.socket.on("chatMessage", this.displayChat.bind(this));
|
|
this.client.socket.on("clearChat", this.clearChat.bind(this));
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
catChat(text){
|
|
this.chatPrompt.value += text;
|
|
this.displayAutocomplete();
|
|
}
|
|
|
|
tokeWith(user){
|
|
this.commandPreprocessor.preprocess(user == this.client.user.user ? "!toke up fuckers" : `!toke up ${user}`);
|
|
}
|
|
|
|
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 = '';
|
|
}
|
|
}
|
|
|
|
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, '');
|
|
}
|
|
|
|
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 = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
};
|
|
|
|
}
|
|
}
|
|
|
|
handleClientInfo(data){
|
|
this.updateFlairSelect(data.flairList, data.user.flair);
|
|
this.updateHighSelect(data.user.highLevel);
|
|
}
|
|
|
|
setHighLevel(event){
|
|
const highLevel = event.target.value;
|
|
|
|
this.client.socket.emit("setHighLevel", {highLevel});
|
|
}
|
|
|
|
setFlair(event){
|
|
const flair = event.target.value;
|
|
|
|
this.client.socket.emit("setFlair", {flair});
|
|
}
|
|
|
|
updateHighSelect(highLevel){
|
|
this.highSelect.value = highLevel;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
unlockAspect(event){
|
|
//Disable aspect lock
|
|
this.aspectLock = false;
|
|
|
|
//Show aspect lock icon
|
|
this.aspectLockIcon.style.display = "inline";
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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 * .2;
|
|
|
|
//Set width to target or 20vh depending on whether or not we've hit the width limit
|
|
this.chatPanel.style.flexBasis = targetChatWidth > limit ? `${targetChatWidth}px` : '20vh';
|
|
|
|
//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();
|
|
}
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
} |