canopy/www/js/channel/chatPostprocessor.js

487 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*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 chatPostprocessor{
constructor(client){
this.client = client;
}
postprocess(chatEntry, rawData){
this.rawData = rawData;
//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();
//Inject links into un-processed placeholders
this.processLinks();
//Inject clickable command examples
this.processCommandExamples();
//Inject clickable channel names
this.processChannelNames();
//Inject clickable usernames
this.processUsernames();
//Inject whitespace into un-processed words
this.addWhitespace();
//Handle non-standard chat types
this.handleChatType();
//Process spoilers
this.processSpoilers();
//Inject the pre-processed chat into the chatEntry node
this.injectBody();
//Return the pre-processed node
return this.chatEntry;
}
splitMessage(){
//Create an empty array to hold the body
this.messageArray = [];
//First unescape char codes to keep from splitting on them
//This also means all text should be added to element via textContent and *NOT* innerHTML
//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
splitString.forEach((string) => {
//create a word object
const wordObj = {
string: string,
filterClasses: [],
type: "word"
}
//Add it to our body array
this.messageArray.push(wordObj);
});
}
injectBody(){
//Create an empty array to hold the objects to inject
const injectionArray = [];
//For each word object
this.messageArray.forEach((wordObj) => {
if(wordObj.type == 'word'){
//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');
link.classList.add('chat-link');
link.href = wordObj.link;
//Use textContent to be safe since links can't be escaped serverside
link.textContent = wordObj.link;
//Append node to chatBody
combineNode(wordObj, link);
}else if(wordObj.type == 'deadLink'){
//Create a text span node from our link
const badLink = document.createElement('a');
badLink.classList.add('chat-dead-link', 'danger-link');
badLink.href = wordObj.link;
//Use textContent to be safe since links can't be escaped serverside
badLink.textContent = wordObj.link;
//Append node to chatBody
combineNode(wordObj, badLink);
}else if(wordObj.type == 'malformedLink'){
//Create a text span node from our link
const malformedLink = document.createElement('span');
malformedLink.classList.add('chat-malformed-link');
//Use textContent to be safe since links can't be escaped (this is why we don't just add it using injectString)
//arguably we could sanatize malformed links serverside since they're never actually used as links
malformedLink.textContent = wordObj.link;
//Append node to chatBody
combineNode(wordObj, malformedLink);
}else if(wordObj.type == 'image'){
//Create an img node from our link
const img = document.createElement('img');
img.classList.add('chat-img');
img.src = wordObj.link;
//Look for an emote by link since emotes are tx'd as bare links
const emote = this.client.chatBox.commandPreprocessor.getEmoteByLink(wordObj.link);
//If this is a known emote
if(emote != null){
//Set the hover text to the emote's name
img.title = `[${emote.name}]`;
}
//Append node to chatBody
combineNode(wordObj, img);
}else if(wordObj.type == 'video'){
//Create a video node from our link
const vid = document.createElement('video');
vid.classList.add('chat-video');
vid.src = wordObj.link;
vid.controls = false;
vid.autoplay = true;
vid.loop = true;
vid.muted = true;
//Look for an emote by link since emotes are tx'd as bare links
const emote = this.client.chatBox.commandPreprocessor.getEmoteByLink(wordObj.link);
//If this is a known emote
if(emote != null){
//Set the hover text to the emote's name
vid.title = `[${emote.name}]`;
}
combineNode(wordObj, vid);
}else if(wordObj.type == 'command'){
//Create link node
const link = document.createElement('a');
//Set class
link.classList.add('chat-link');
//Set href and inner text
link.href = "javascript:";
link.textContent = wordObj.command;
//Add chatbox functionality
link.addEventListener('click', () => {this.client.chatBox.commandPreprocessor.preprocess(wordObj.command)});
//We don't have to worry about injecting this into whitespace since there shouldn't be any here.
injectionArray.push(link);
}else if(wordObj.type == "username"){
//Create link node
const link = document.createElement('a');
//set class
link.classList.add(wordObj.color);
//Set href and inner text
link.href = "javascript:";
link.textContent = wordObj.string;
//add chatbox functionality
link.addEventListener('click', () => {this.client.chatBox.chatPrompt.value += `${wordObj.string} `});
//We don't have to worry about injecting this into whitespace since there shouldn't be any here.
injectionArray.push(link);
}else if(wordObj.type == "channel"){
//Create link node
const link = document.createElement('a');
//set class
link.classList.add('chat-link');
//Set href and inner text
link.href = `/c/${wordObj.chan}`;
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);
}else{
console.warn("Unknown chat postprocessor word type:");
console.warn(wordObj);
}
});
//For each item found in the injection array
for(let itemIndex in injectionArray){
const item = injectionArray[itemIndex];
//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 combineNode(wordObj, node, placeholder = '␜'){
//Split string by the placeholder so we can keep surrounding whitespace
const splitWord = wordObj.string.split(placeholder, 2);
//Create combined node
const combinedSpan = document.createElement('span');
//Add the first part of the text
combinedSpan.textContent = splitWord[0];
//Add in the requestd node
combinedSpan.appendChild(node);
//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(){
//for each word object in the body
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
const lastChar = wordObj.string[wordObj.string.length - 1];
//if the last char is !
if(lastChar == '!' || lastChar == '/'){
//get next word
const nextWord = this.messageArray[wordIndex + 1];
//if we have another word
if(nextWord != null){
const command = lastChar + nextWord.string;
//Take out the command marker
this.messageArray[wordIndex].string = wordObj.string.slice(0,-1);
const commandObj = {
type: "command",
string: nextWord.string,
filterClasses: [],
command: command
}
this.messageArray[wordIndex + 1] = commandObj;
}
}
}
});
}
processChannelNames(){
//for each word object in the body
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 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 & or # (avoid spoilers)
if(lastChar == '#' && secondLastChar != '#'){
//get next word
const nextWord = this.messageArray[wordIndex + 1];
//if we have another word
if(nextWord != null){
//Take out the chan marker
this.messageArray[wordIndex].string = wordObj.string.slice(0,-1);
const commandObj = {
type: "channel",
string: lastChar + nextWord.string,
filterClasses: [],
chan: nextWord.string
}
this.messageArray[wordIndex + 1] = commandObj;
}
}
}
});
}
processUsernames(){
//for each word object in the body
this.messageArray.forEach((wordObj, wordIndex) => {
//if the word object hasn't been pre-processed elsewhere
if(wordObj.type == "word"){
//Check for user and get their color
const color = this.client.userList.colorMap.get(wordObj.string);
//If the current word is the username of a connected user
if(color != null){
//Mark it as so
this.messageArray[wordIndex].type = "username";
//Store their color
this.messageArray[wordIndex].color = color;
}
}
});
}
addWhitespace(){
//for each word object in the body
this.messageArray.forEach((wordObj, wordIndex) => {
//if the word object hasn't been pre-processed elsewhere
if(wordObj.type == "word"){
//Create an empty array to hold our word
var wordArray = [];
//For each character in the string of the current word object
this.messageArray[wordIndex].string.split("").forEach((char, charIndex) => {
//push the current character to the wordArray
wordArray.push(char);
//After eight characters
if(charIndex > 8){
//Push an invisible line-break character between every character
wordArray.push("");
}
});
//Join the wordArray into a single string, and use it to set the current wordObject's string
this.messageArray[wordIndex].string = wordArray.join("");
}
});
}
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){
//Don't bother
return;
}
//For every link received in this message
this.rawData.links.forEach((link, linkIndex) => {
//For every word obj in the message array
this.messageArray.forEach((wordObj, wordIndex) => {
//Check current wordobj for link (placeholder may contain whitespace with it)
if(wordObj.string.match(`${linkIndex}`)){
//Set current word object in the body array to the new link object
this.messageArray[wordIndex] = {
//Don't want to use a numbered placeholder to make this easier during body injection
//but we also don't want to clobber any surrounding whitespace
string: wordObj.string.replace(`${linkIndex}`, '␜'),
link: link.link,
type: link.type
}
}
})
});
}
handleChatType(){
if(this.rawData.type == "whisper"){
//add whisper class
this.chatBody.classList.add('whisper');
}else if(this.rawData.type == "announcement"){
//Squash the high-level
this.chatEntry.querySelector('.high-level').remove();
//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.textContent = `${userNode.textContent.slice(0,-2)} Announcement`;
//Add/remove relevant classes
userNode.classList.remove('chat-entry-username');
userNode.classList.add('announcement-title');
this.chatBody.classList.add('announcement-body');
this.chatEntry.classList.add('announcement');
}else if(this.rawData.type == "toke"){
//Squash the high-level
this.chatEntry.querySelector('.high-level').remove();
//remove the username
this.chatEntry.querySelector('.chat-entry-username').remove();
//Add toke/tokewhisper class
this.chatBody.classList.add("toke");
}else if(this.rawData.type == "tokewhisper"){
//Squash the high-level
this.chatEntry.querySelector('.high-level').remove();
//remove the username
this.chatEntry.querySelector('.chat-entry-username').remove();
//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");
}
}
}