From 77bc549653b7da8b8cfdf27cebec2eb3400627a5 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 11 Jan 2025 01:30:25 -0500 Subject: [PATCH] Added spoiler support. --- .gitignore | 3 +- src/app/channel/commandPreprocessor.js | 11 ++ www/css/channel.css | 4 +- www/css/global.css | 16 +++ www/js/channel/chat.js | 5 +- www/js/channel/chatPostprocessor.js | 184 +++++++++++++++++-------- www/js/channel/commandPreprocessor.js | 4 +- www/js/channel/userlist.js | 18 +-- www/js/utils.js | 8 ++ 9 files changed, 182 insertions(+), 71 deletions(-) diff --git a/.gitignore b/.gitignore index fd08358..57b3e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ package-lock.json config.json -state.json \ No newline at end of file +state.json +chatexamples.txt \ No newline at end of file diff --git a/src/app/channel/commandPreprocessor.js b/src/app/channel/commandPreprocessor.js index 5b6b7f7..bbe760a 100644 --- a/src/app/channel/commandPreprocessor.js +++ b/src/app/channel/commandPreprocessor.js @@ -131,6 +131,17 @@ class commandProcessor{ return true } + spoiler(preprocessor){ + //splice out our command + preprocessor.commandArray.splice(0,2); + + //Mark out the current message as a spoiler + preprocessor.chatType = 'spoiler'; + + //Make sure to throw the send flag + return true + } + async announce(preprocessor){ //Get the current channel from the database const chanDB = await channelModel.findOne({name: preprocessor.socket.chan}); diff --git a/www/css/channel.css b/www/css/channel.css index 539a211..9f8b226 100644 --- a/www/css/channel.css +++ b/www/css/channel.css @@ -283,8 +283,8 @@ span.user-entry{ #chat-panel-prompt-autocomplete{ position: absolute; text-wrap: nowrap; - cursor: pointer; user-select: none; + cursor: pointer; font-size: 10pt; z-index: 10; margin: 0; @@ -295,6 +295,8 @@ span.user-entry{ #chat-panel-prompt-autocomplete-filler{ visibility: hidden; user-select: none; + cursor: auto; + pointer-events: none; } .toke{ diff --git a/www/css/global.css b/www/css/global.css index ef6048e..f6b9b6a 100644 --- a/www/css/global.css +++ b/www/css/global.css @@ -182,4 +182,20 @@ p.tooltip, h3.tooltip{ .context-menu button{ margin: 2px 0; +} + +.spoiler:not(:hover){ + color: black; + background-color: black; + + img{ + color: black; + background-color: black; + filter: brightness(0); + } + + .interactive, a{ + color: black; + background-color: black; + } } \ No newline at end of file diff --git a/www/js/channel/chat.js b/www/js/channel/chat.js index 23a9a98..6ca3b7c 100644 --- a/www/js/channel/chat.js +++ b/www/js/channel/chat.js @@ -159,9 +159,10 @@ class chatBox{ const match = this.checkAutocomplete(); //Set placeholder to space out the autocomplete display - this.autocompletePlaceholder.innerHTML = this.chatPrompt.value; + //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.innerHTML = match.match.replace(match.word, ''); + this.autocompleteDisplay.textContent = match.match.replace(match.word, ''); } tabComplete(event){ diff --git a/www/js/channel/chatPostprocessor.js b/www/js/channel/chatPostprocessor.js index 97f8324..898ad9f 100644 --- a/www/js/channel/chatPostprocessor.js +++ b/www/js/channel/chatPostprocessor.js @@ -23,6 +23,7 @@ class chatPostprocessor{ //Set current chat nodes this.chatEntry = chatEntry; this.chatBody = this.chatEntry.querySelector(".chat-entry-body"); + this.filterSpans = []; //Split the chat message into an array of objects representing each word this.splitMessage(); @@ -45,6 +46,9 @@ class chatPostprocessor{ //Handle non-standard chat types this.handleChatType(); + //Process spoilers + this.processSpoilers(); + //Inject the pre-processed chat into the chatEntry node this.injectBody(); @@ -56,16 +60,19 @@ class chatPostprocessor{ //Create an empty array to hold the body this.messageArray = []; - //First unescape forward-slashes to keep them from splitting, then.. - //Split string by word-boundries, with negative lookaheads to exclude file seperators so we don't split link placeholders - const splitString = this.rawData.msg.replaceAll('/','/').split(/(? { //create a word object const wordObj = { - //re-escape slashes for safety - string: string.replaceAll('/','/'), + string: string, + filterClasses: [], type: "word" } @@ -76,14 +83,18 @@ class chatPostprocessor{ injectBody(){ //Create an empty array to hold the objects to inject - const injectionArray = [""]; + const injectionArray = []; - const _this = this; //For each word object this.messageArray.forEach((wordObj) => { if(wordObj.type == 'word'){ - //Inject current wordObj string into the chat body - injectString(wordObj.string); + //Create span node + const span = document.createElement('span'); + //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'); @@ -93,7 +104,7 @@ class chatPostprocessor{ link.textContent = wordObj.link; //Append node to chatBody - injectNode(wordObj, link); + combineNode(wordObj, link); }else if(wordObj.type == 'deadLink'){ //Create a text span node from our link const badLink = document.createElement('a'); @@ -103,7 +114,7 @@ class chatPostprocessor{ badLink.textContent = wordObj.link; //Append node to chatBody - injectNode(wordObj, badLink); + combineNode(wordObj, badLink); }else if(wordObj.type == 'malformedLink'){ //Create a text span node from our link const malformedLink = document.createElement('span'); @@ -113,7 +124,7 @@ class chatPostprocessor{ malformedLink.textContent = wordObj.link; //Append node to chatBody - injectNode(wordObj, malformedLink); + combineNode(wordObj, malformedLink); }else if(wordObj.type == 'image'){ //Create an img node from our link const img = document.createElement('img'); @@ -130,7 +141,7 @@ class chatPostprocessor{ } //Append node to chatBody - injectNode(wordObj, img); + combineNode(wordObj, img); }else if(wordObj.type == 'video'){ //Create a video node from our link const vid = document.createElement('video'); @@ -150,7 +161,7 @@ class chatPostprocessor{ vid.title = `[${emote.name}]`; } - injectNode(wordObj, vid); + combineNode(wordObj, vid); }else if(wordObj.type == 'command'){ //Create link node const link = document.createElement('a'); @@ -158,7 +169,7 @@ class chatPostprocessor{ link.classList.add('chat-link'); //Set href and inner text link.href = "javascript:"; - link.innerText = wordObj.command; + link.textContent = wordObj.command; //Add chatbox functionality link.addEventListener('click', () => {this.client.chatBox.commandPreprocessor.preprocess(wordObj.command)}); @@ -172,7 +183,7 @@ class chatPostprocessor{ link.classList.add(wordObj.color); //Set href and inner text link.href = "javascript:"; - link.innerText = wordObj.string; + link.textContent = wordObj.string; //add chatbox functionality link.addEventListener('click', () => {this.client.chatBox.chatPrompt.value += `${wordObj.string} `}); @@ -186,7 +197,7 @@ class chatPostprocessor{ link.classList.add('chat-link'); //Set href and inner text link.href = `/c/${wordObj.chan}`; - link.innerText = wordObj.string; + 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); @@ -198,48 +209,61 @@ class chatPostprocessor{ }); //For each item found in the injection array - injectionArray.forEach((item) => { - //if it's a string - if(typeof item == "string"){ - //Create span (can't add to innerHTML without clobbering sibling DOM nodes) - const text = document.createElement('span'); - //Set text to innerHTML (can't just append as a text node since the message was escaped server-side) - text.innerHTML = item; + for(let itemIndex in injectionArray){ + const item = injectionArray[itemIndex]; - this.chatBody.appendChild(text); - //Otherwise it should be a DOM node - }else{ - //Append the node to our chat body - this.chatBody.appendChild(item); + //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 injectNode(wordObj, node, placeholder = '␜'){ + function combineNode(wordObj, node, placeholder = '␜'){ //Split string by the placeholder so we can keep surrounding whitespace const splitWord = wordObj.string.split(placeholder, 2); - //Append the first half of the string - injectString(splitWord[0]); + //Create combined node + const combinedSpan = document.createElement('span'); - //Append the node - injectionArray.push(node); + //Add the first part of the text + combinedSpan.textContent = splitWord[0]; - //Append the second half of the string - injectString(splitWord[1]); - } + //Add in the requestd node + combinedSpan.appendChild(node); - function injectString(string){ - //If the last item was a string - if(typeof injectionArray[injectionArray.length - 1] == "string"){ - //add the word string on to the end of the string - injectionArray[injectionArray.length - 1] += string; - }else{ - //Pop the string at the end of the array - injectionArray.push(string); - } - } + //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); + } } processCommandExamples(){ @@ -247,9 +271,8 @@ class chatPostprocessor{ 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 unescaped - const unescaped = wordObj.string.replaceAll('/','/') - const lastChar = unescaped[unescaped.length - 1]; + //Get last char of current word + const lastChar = wordObj.string[wordObj.string.length - 1]; //if the last char is ! if(lastChar == '!' || lastChar == '/'){ @@ -259,11 +282,12 @@ class chatPostprocessor{ if(nextWord != null){ const command = lastChar + nextWord.string; //Take out the command marker - this.messageArray[wordIndex].string = unescaped.slice(0,-1); + this.messageArray[wordIndex].string = wordObj.string.slice(0,-1); const commandObj = { type: "command", string: nextWord.string, + filterClasses: [], command: command } @@ -279,12 +303,12 @@ class chatPostprocessor{ 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 unescaped + //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 & (avoid escaped HTML char codes) - if(lastChar == '#' && secondLastChar != '&'){ + //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 @@ -295,6 +319,7 @@ class chatPostprocessor{ const commandObj = { type: "channel", string: lastChar + nextWord.string, + filterClasses: [], chan: nextWord.string } @@ -349,6 +374,50 @@ class chatPostprocessor{ }); } + processSpoilers(){ + //Create empty array to hold spoilers (keep this seperate at first for internal function use) + const foundSpoilers = []; + //Spoiler detection stage + //For each word object in the message array + main: for(let wordIndex in this.messageArray){ + //Get the current word object + const wordObj = this.messageArray[wordIndex]; + + //If its a regular word and contains '##' + if(wordObj.type == 'word' && wordObj.string.match('##')){ + + //Crawl through detected spoilers + for(let spoiler of foundSpoilers){ + //If the current word object is part of a detected spoiler + if(wordIndex == spoiler.delimiters[0] || wordIndex == spoiler.delimiters[1]){ + //ignore it and continue on to the next word object + continue main; + } + } + + //Crawl throw word objects after the current one, not sure why wordIndex is saved as a string.. Thanks JS + for(let endIndex = (Number(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('##')){ + //Scrape out delimiters + wordObj.string = wordObj.string.replaceAll("##",''); + endObj.string = endObj.string.replaceAll("##",''); + //Add it to the list of detected spoilers, skipping out the delimiters + foundSpoilers.push({class: "spoiler", index: [Number(wordIndex) + 1, endIndex - 1], delimiters: [Number(wordIndex), endIndex]}); + //Break the nested end-detection loop + break; + } + } + } + } + + //Add found spoilers to filters list + this.filterSpans = this.filterSpans.concat(foundSpoilers); + } + processLinks(){ //If we don't have links if(this.rawData.links == null){ @@ -385,7 +454,7 @@ class chatPostprocessor{ //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.innerHTML = `${userNode.innerHTML.slice(0,-2)} Announcement`; + userNode.textContent = `${userNode.textContent.slice(0,-2)} Announcement`; //Add/remove relevant classes userNode.classList.remove('chat-entry-username'); @@ -410,6 +479,9 @@ class chatPostprocessor{ //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"); } } } \ No newline at end of file diff --git a/www/js/channel/commandPreprocessor.js b/www/js/channel/commandPreprocessor.js index 2be84be..a189760 100644 --- a/www/js/channel/commandPreprocessor.js +++ b/www/js/channel/commandPreprocessor.js @@ -78,8 +78,8 @@ class commandPreprocessor{ Object.keys(this.emotes).forEach((key) => { //For each emote in the current list this.emotes[key].forEach((emote) => { - //Inject emote links into the message - this.message = this.message.replaceAll(`[${emote.name}]`, emote.link); + //Inject emote links into the message, add invisible whitespace to the end to keep next character from mushing into the link + this.message = this.message.replaceAll(`[${emote.name}]`, `${emote.link}ㅤ`); }); }); } diff --git a/www/js/channel/userlist.js b/www/js/channel/userlist.js index c741298..018b677 100644 --- a/www/js/channel/userlist.js +++ b/www/js/channel/userlist.js @@ -130,21 +130,21 @@ class userList{ function renderContextMenu(event){ //Setup menu map let menuMap = new Map([ - ["Profile", ()=>{this.client.cPanel.setActivePanel(new panelObj(client, `${user.user}`, `/panel/profile?user=${user.user}`))}], - ["Mention", ()=>{client.chatBox.catChat(`${user.user} `)}], - ["Toke With", ()=>{client.chatBox.tokeWith(user.user)}], + ["Profile", ()=>{this.client.cPanel.setActivePanel(new panelObj(this.client, `${user.user}`, `/panel/profile?user=${user.user}`))}], + ["Mention", ()=>{this.client.chatBox.catChat(`${user.user} `)}], + ["Toke With", ()=>{this.client.chatBox.tokeWith(user.user)}], ]); - if(user.user != "Tokebot"){ - if(client.user.permMap.chan.get("kickUser")){ - menuMap.set("Kick", ()=>{client.chatBox.commandPreprocessor.preprocess(`!kick ${user.user}`)}); + if(user.user != "Tokebot" && user.user != this.client.user.user){ + if(this.client.user.permMap.chan.get("kickUser")){ + menuMap.set("Kick", ()=>{this.client.chatBox.commandPreprocessor.preprocess(`!kick ${user.user}`)}); } - if(client.user.permMap.chan.get("banUser")){ - menuMap.set("Channel Ban", ()=>{new chanBanUserPopup(client.channelName, user.user);}); + if(this.client.user.permMap.chan.get("banUser")){ + menuMap.set("Channel Ban", ()=>{new chanBanUserPopup(this.client.channelName, user.user);}); } - if(client.user.permMap.site.get("banUser")){ + if(this.client.user.permMap.site.get("banUser")){ menuMap.set("Site Ban", ()=>{new banUserPopup(user.user);}); } } diff --git a/www/js/utils.js b/www/js/utils.js index ea60e2c..12661f1 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -19,6 +19,14 @@ class canopyUtils{ this.ajax = new canopyAjaxUtils(); this.ux = new canopyUXUtils(); } + + //somehow this isn't built in to JS's unescape functions... + unescapeEntities(string){ + //Create a new DOMParser and tell it to parse string as HTML + const outNode = new DOMParser().parseFromString(string, "text/html"); + //Grab text content and send that shit out + return outNode.documentElement.textContent; + } } class canopyUXUtils{