Added spoiler support.

This commit is contained in:
rainbow napkin 2025-01-11 01:30:25 -05:00
parent b56c9a3365
commit 77bc549653
9 changed files with 182 additions and 71 deletions

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules/ node_modules/
package-lock.json package-lock.json
config.json config.json
state.json state.json
chatexamples.txt

View file

@ -131,6 +131,17 @@ class commandProcessor{
return true 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){ async announce(preprocessor){
//Get the current channel from the database //Get the current channel from the database
const chanDB = await channelModel.findOne({name: preprocessor.socket.chan}); const chanDB = await channelModel.findOne({name: preprocessor.socket.chan});

View file

@ -283,8 +283,8 @@ span.user-entry{
#chat-panel-prompt-autocomplete{ #chat-panel-prompt-autocomplete{
position: absolute; position: absolute;
text-wrap: nowrap; text-wrap: nowrap;
cursor: pointer;
user-select: none; user-select: none;
cursor: pointer;
font-size: 10pt; font-size: 10pt;
z-index: 10; z-index: 10;
margin: 0; margin: 0;
@ -295,6 +295,8 @@ span.user-entry{
#chat-panel-prompt-autocomplete-filler{ #chat-panel-prompt-autocomplete-filler{
visibility: hidden; visibility: hidden;
user-select: none; user-select: none;
cursor: auto;
pointer-events: none;
} }
.toke{ .toke{

View file

@ -182,4 +182,20 @@ p.tooltip, h3.tooltip{
.context-menu button{ .context-menu button{
margin: 2px 0; 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;
}
} }

View file

@ -159,9 +159,10 @@ class chatBox{
const match = this.checkAutocomplete(); const match = this.checkAutocomplete();
//Set placeholder to space out the autocomplete display //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 //Set the autocomplete display
this.autocompleteDisplay.innerHTML = match.match.replace(match.word, ''); this.autocompleteDisplay.textContent = match.match.replace(match.word, '');
} }
tabComplete(event){ tabComplete(event){

View file

@ -23,6 +23,7 @@ class chatPostprocessor{
//Set current chat nodes //Set current chat nodes
this.chatEntry = chatEntry; this.chatEntry = chatEntry;
this.chatBody = this.chatEntry.querySelector(".chat-entry-body"); this.chatBody = this.chatEntry.querySelector(".chat-entry-body");
this.filterSpans = [];
//Split the chat message into an array of objects representing each word //Split the chat message into an array of objects representing each word
this.splitMessage(); this.splitMessage();
@ -45,6 +46,9 @@ class chatPostprocessor{
//Handle non-standard chat types //Handle non-standard chat types
this.handleChatType(); this.handleChatType();
//Process spoilers
this.processSpoilers();
//Inject the pre-processed chat into the chatEntry node //Inject the pre-processed chat into the chatEntry node
this.injectBody(); this.injectBody();
@ -56,16 +60,19 @@ class chatPostprocessor{
//Create an empty array to hold the body //Create an empty array to hold the body
this.messageArray = []; this.messageArray = [];
//First unescape forward-slashes to keep them from splitting, then.. //First unescape char codes to keep from splitting on them
//Split string by word-boundries, with negative lookaheads to exclude file seperators so we don't split link placeholders //This also means all text should be added to element via textContent and *NOT* innerHTML
const splitString = this.rawData.msg.replaceAll('&#x2F;','/').split(/(?<!␜)\b/g); //I'd rather not do this, but pre-processing everything while preserving codes is a fucking nightmare
//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
//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 //for each word in the splitstring
splitString.forEach((string) => { splitString.forEach((string) => {
//create a word object //create a word object
const wordObj = { const wordObj = {
//re-escape slashes for safety string: string,
string: string.replaceAll('/','&#x2F;'), filterClasses: [],
type: "word" type: "word"
} }
@ -76,14 +83,18 @@ class chatPostprocessor{
injectBody(){ injectBody(){
//Create an empty array to hold the objects to inject //Create an empty array to hold the objects to inject
const injectionArray = [""]; const injectionArray = [];
const _this = this;
//For each word object //For each word object
this.messageArray.forEach((wordObj) => { this.messageArray.forEach((wordObj) => {
if(wordObj.type == 'word'){ if(wordObj.type == 'word'){
//Inject current wordObj string into the chat body //Create span node
injectString(wordObj.string); const span = document.createElement('span');
//Set span text
span.textContent = wordObj.string;
//Inject node into array
injectionArray.push(span);
}else if(wordObj.type == 'link'){ }else if(wordObj.type == 'link'){
//Create a link node from our link //Create a link node from our link
const link = document.createElement('a'); const link = document.createElement('a');
@ -93,7 +104,7 @@ class chatPostprocessor{
link.textContent = wordObj.link; link.textContent = wordObj.link;
//Append node to chatBody //Append node to chatBody
injectNode(wordObj, link); combineNode(wordObj, link);
}else if(wordObj.type == 'deadLink'){ }else if(wordObj.type == 'deadLink'){
//Create a text span node from our link //Create a text span node from our link
const badLink = document.createElement('a'); const badLink = document.createElement('a');
@ -103,7 +114,7 @@ class chatPostprocessor{
badLink.textContent = wordObj.link; badLink.textContent = wordObj.link;
//Append node to chatBody //Append node to chatBody
injectNode(wordObj, badLink); combineNode(wordObj, badLink);
}else if(wordObj.type == 'malformedLink'){ }else if(wordObj.type == 'malformedLink'){
//Create a text span node from our link //Create a text span node from our link
const malformedLink = document.createElement('span'); const malformedLink = document.createElement('span');
@ -113,7 +124,7 @@ class chatPostprocessor{
malformedLink.textContent = wordObj.link; malformedLink.textContent = wordObj.link;
//Append node to chatBody //Append node to chatBody
injectNode(wordObj, malformedLink); combineNode(wordObj, malformedLink);
}else if(wordObj.type == 'image'){ }else if(wordObj.type == 'image'){
//Create an img node from our link //Create an img node from our link
const img = document.createElement('img'); const img = document.createElement('img');
@ -130,7 +141,7 @@ class chatPostprocessor{
} }
//Append node to chatBody //Append node to chatBody
injectNode(wordObj, img); combineNode(wordObj, img);
}else if(wordObj.type == 'video'){ }else if(wordObj.type == 'video'){
//Create a video node from our link //Create a video node from our link
const vid = document.createElement('video'); const vid = document.createElement('video');
@ -150,7 +161,7 @@ class chatPostprocessor{
vid.title = `[${emote.name}]`; vid.title = `[${emote.name}]`;
} }
injectNode(wordObj, vid); combineNode(wordObj, vid);
}else if(wordObj.type == 'command'){ }else if(wordObj.type == 'command'){
//Create link node //Create link node
const link = document.createElement('a'); const link = document.createElement('a');
@ -158,7 +169,7 @@ class chatPostprocessor{
link.classList.add('chat-link'); link.classList.add('chat-link');
//Set href and inner text //Set href and inner text
link.href = "javascript:"; link.href = "javascript:";
link.innerText = wordObj.command; link.textContent = wordObj.command;
//Add chatbox functionality //Add chatbox functionality
link.addEventListener('click', () => {this.client.chatBox.commandPreprocessor.preprocess(wordObj.command)}); link.addEventListener('click', () => {this.client.chatBox.commandPreprocessor.preprocess(wordObj.command)});
@ -172,7 +183,7 @@ class chatPostprocessor{
link.classList.add(wordObj.color); link.classList.add(wordObj.color);
//Set href and inner text //Set href and inner text
link.href = "javascript:"; link.href = "javascript:";
link.innerText = wordObj.string; link.textContent = wordObj.string;
//add chatbox functionality //add chatbox functionality
link.addEventListener('click', () => {this.client.chatBox.chatPrompt.value += `${wordObj.string} `}); link.addEventListener('click', () => {this.client.chatBox.chatPrompt.value += `${wordObj.string} `});
@ -186,7 +197,7 @@ class chatPostprocessor{
link.classList.add('chat-link'); link.classList.add('chat-link');
//Set href and inner text //Set href and inner text
link.href = `/c/${wordObj.chan}`; 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. //We don't have to worry about injecting this into whitespace since there shouldn't be any here.
injectionArray.push(link); injectionArray.push(link);
@ -198,48 +209,61 @@ class chatPostprocessor{
}); });
//For each item found in the injection array //For each item found in the injection array
injectionArray.forEach((item) => { for(let itemIndex in injectionArray){
//if it's a string const item = injectionArray[itemIndex];
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;
this.chatBody.appendChild(text); //Currently this doesnt support multiple overlapping span-type filters
//Otherwise it should be a DOM node //not a huge deal since we only have once (spoiler)
}else{ //All others can be applied per-node without any usability side effects
//Append the node to our chat body const curFilter = this.filterSpans.filter(filterFilters)[0];
this.chatBody.appendChild(item); 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 //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 //Split string by the placeholder so we can keep surrounding whitespace
const splitWord = wordObj.string.split(placeholder, 2); const splitWord = wordObj.string.split(placeholder, 2);
//Append the first half of the string //Create combined node
injectString(splitWord[0]); const combinedSpan = document.createElement('span');
//Append the node //Add the first part of the text
injectionArray.push(node); combinedSpan.textContent = splitWord[0];
//Append the second half of the string //Add in the requestd node
injectString(splitWord[1]); combinedSpan.appendChild(node);
}
function injectString(string){ //Finish it off with the last bit of text
//If the last item was a string combinedSpan.insertAdjacentText('beforeend', splitWord[1]);
if(typeof injectionArray[injectionArray.length - 1] == "string"){
//add the word string on to the end of the string //Add to injection array as three nested items to keep arrays lined up
injectionArray[injectionArray.length - 1] += string; injectionArray.push(combinedSpan);
}else{ }
//Pop the string at the end of the array
injectionArray.push(string);
}
}
} }
processCommandExamples(){ processCommandExamples(){
@ -247,9 +271,8 @@ class chatPostprocessor{
this.messageArray.forEach((wordObj, wordIndex) => { this.messageArray.forEach((wordObj, wordIndex) => {
//if the word object hasn't been pre-processed elsewhere //if the word object hasn't been pre-processed elsewhere
if(wordObj.type == "word"){ if(wordObj.type == "word"){
//Get last char of current word with slashes unescaped //Get last char of current word
const unescaped = wordObj.string.replaceAll('&#x2F;','/') const lastChar = wordObj.string[wordObj.string.length - 1];
const lastChar = unescaped[unescaped.length - 1];
//if the last char is ! //if the last char is !
if(lastChar == '!' || lastChar == '/'){ if(lastChar == '!' || lastChar == '/'){
@ -259,11 +282,12 @@ class chatPostprocessor{
if(nextWord != null){ if(nextWord != null){
const command = lastChar + nextWord.string; const command = lastChar + nextWord.string;
//Take out the command marker //Take out the command marker
this.messageArray[wordIndex].string = unescaped.slice(0,-1); this.messageArray[wordIndex].string = wordObj.string.slice(0,-1);
const commandObj = { const commandObj = {
type: "command", type: "command",
string: nextWord.string, string: nextWord.string,
filterClasses: [],
command: command command: command
} }
@ -279,12 +303,12 @@ class chatPostprocessor{
this.messageArray.forEach((wordObj, wordIndex) => { this.messageArray.forEach((wordObj, wordIndex) => {
//if the word object hasn't been pre-processed elsewhere //if the word object hasn't been pre-processed elsewhere
if(wordObj.type == "word"){ 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 lastChar = wordObj.string[wordObj.string.length - 1];
const secondLastChar = wordObj.string[wordObj.string.length - 2]; 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 the last char is # and the second to last char isn't & or # (avoid spoilers)
if(lastChar == '#' && secondLastChar != '&'){ if(lastChar == '#' && secondLastChar != '#'){
//get next word //get next word
const nextWord = this.messageArray[wordIndex + 1]; const nextWord = this.messageArray[wordIndex + 1];
//if we have another word //if we have another word
@ -295,6 +319,7 @@ class chatPostprocessor{
const commandObj = { const commandObj = {
type: "channel", type: "channel",
string: lastChar + nextWord.string, string: lastChar + nextWord.string,
filterClasses: [],
chan: nextWord.string 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(){ processLinks(){
//If we don't have links //If we don't have links
if(this.rawData.links == null){ 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) //Get the username and make it into an announcement title (little hacky but this *IS* postprocessing)
const userNode = this.chatEntry.querySelector('.chat-entry-username'); 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 //Add/remove relevant classes
userNode.classList.remove('chat-entry-username'); userNode.classList.remove('chat-entry-username');
@ -410,6 +479,9 @@ class chatPostprocessor{
//Add toke/tokewhisper class //Add toke/tokewhisper class
this.chatBody.classList.add("tokewhisper","serverwhisper"); this.chatBody.classList.add("tokewhisper","serverwhisper");
}else if(this.rawData.type == "spoiler"){
//Set whole-body spoiler
this.chatBody.classList.add("spoiler");
} }
} }
} }

View file

@ -78,8 +78,8 @@ class commandPreprocessor{
Object.keys(this.emotes).forEach((key) => { Object.keys(this.emotes).forEach((key) => {
//For each emote in the current list //For each emote in the current list
this.emotes[key].forEach((emote) => { this.emotes[key].forEach((emote) => {
//Inject emote links into the message //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); this.message = this.message.replaceAll(`[${emote.name}]`, `${emote.link}`);
}); });
}); });
} }

View file

@ -130,21 +130,21 @@ class userList{
function renderContextMenu(event){ function renderContextMenu(event){
//Setup menu map //Setup menu map
let menuMap = new Map([ let menuMap = new Map([
["Profile", ()=>{this.client.cPanel.setActivePanel(new panelObj(client, `${user.user}`, `/panel/profile?user=${user.user}`))}], ["Profile", ()=>{this.client.cPanel.setActivePanel(new panelObj(this.client, `${user.user}`, `/panel/profile?user=${user.user}`))}],
["Mention", ()=>{client.chatBox.catChat(`${user.user} `)}], ["Mention", ()=>{this.client.chatBox.catChat(`${user.user} `)}],
["Toke With", ()=>{client.chatBox.tokeWith(user.user)}], ["Toke With", ()=>{this.client.chatBox.tokeWith(user.user)}],
]); ]);
if(user.user != "Tokebot"){ if(user.user != "Tokebot" && user.user != this.client.user.user){
if(client.user.permMap.chan.get("kickUser")){ if(this.client.user.permMap.chan.get("kickUser")){
menuMap.set("Kick", ()=>{client.chatBox.commandPreprocessor.preprocess(`!kick ${user.user}`)}); menuMap.set("Kick", ()=>{this.client.chatBox.commandPreprocessor.preprocess(`!kick ${user.user}`)});
} }
if(client.user.permMap.chan.get("banUser")){ if(this.client.user.permMap.chan.get("banUser")){
menuMap.set("Channel Ban", ()=>{new chanBanUserPopup(client.channelName, user.user);}); 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);}); menuMap.set("Site Ban", ()=>{new banUserPopup(user.user);});
} }
} }

View file

@ -19,6 +19,14 @@ class canopyUtils{
this.ajax = new canopyAjaxUtils(); this.ajax = new canopyAjaxUtils();
this.ux = new canopyUXUtils(); 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{ class canopyUXUtils{