From eaca9db987b333d677c7ae2e5bb18ed38e568622 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 10 Nov 2025 20:11:34 -0500 Subject: [PATCH 01/30] Mods from old codebase now properly migrated. Version number upgraded. --- README.md | 2 +- package.json | 2 +- src/schemas/user/migrationSchema.js | 11 ++++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9902977..c6f2762 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Canopy -0.4-INDEV Hotfix 2 +0.4-INDEV Hotfix 3 ========= Canopy - /ˈkæ.nə.pi/: diff --git a/package.json b/package.json index b758038..d01d5aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "canopy-of-indev", - "version": "0.4.2", + "version": "0.4.3", "license": "AGPL-3.0-only", "dependencies": { "@braintree/sanitize-url": "^7.1.1", diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index c17ed51..9aff595 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -217,13 +217,22 @@ migrationSchema.statics.ingestLegacyUser = async function(rawProfile){ return; } + //Pull rank, dropping over-ranked users down to current enum length + let rank = Math.min(Math.max(0, profileArray[3]), permissionModel.rankEnum.length - 1); + + //If this user was a mod on the old site + if(rank == 2){ + //Set them up as a mod here + rank = permissionModel.rankEnum.length - 2; + } + //Create migration profile object from scraped info const migrationProfile = new this({ user: profileArray[1], pass: profileArray[2], //Clamp rank to 0 and the max setting allowed by the rank enum - rank: Math.min(Math.max(0, profileArray[3]), permissionModel.rankEnum.length - 1), + rank, email: validator.normalizeEmail(profileArray[4]), date: profileArray[7], }) From ecebcf0d32d8ca69bfd9f893f59bb5d1651dbe3d Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 10 Nov 2025 20:31:07 -0500 Subject: [PATCH 02/30] Killed harmless bug related to official YT Api that cluttered the console. --- www/js/channel/channel.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 5d50499..6da26c3 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -337,13 +337,16 @@ function onYouTubeIframeAPIReady(){ //Set embed api to true client.ytEmbedAPILoaded = true; - //Get currently playing item - const nowPlaying = client.player.mediaHandler.nowPlaying; + //If the player is ready and has a mediaHandler loaded + if(client.player != null && client.player.mediaHandler != null){ + //Get currently playing item + const nowPlaying = client.player.mediaHandler.nowPlaying; - //If we're playing a youtube video and the official embeds are enabled - if(nowPlaying.type == 'yt' && localStorage.getItem('ytPlayerType') == "embed"){ - //Restart the video now that the embed api has loaded - client.player.start({media: nowPlaying}); + //If we're playing a youtube video and the official embeds are enabled + if(nowPlaying.type == 'yt' && localStorage.getItem('ytPlayerType') == "embed"){ + //Restart the video now that the embed api has loaded + client.player.start({media: nowPlaying}); + } } } From dd4d789d9fe24e75339caf137d5895ac2aadcbc3 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 10 Nov 2025 20:46:07 -0500 Subject: [PATCH 03/30] Added 'PM' option to userlist context menu. --- www/js/channel/panels/pmPanel.js | 19 +++++++++++++++++-- www/js/channel/userlist.js | 3 ++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index aaab30b..bcebc97 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -24,7 +24,7 @@ class pmPanel extends panelObj{ * @param {channel} client - Parent client Management Object * @param {Document} panelDocument - Panel Document */ - constructor(client, panelDocument){ + constructor(client, panelDocument, startSesh){ super(client, "Private Messaging", "/panel/pm", panelDocument); /** @@ -71,7 +71,17 @@ class pmPanel extends panelObj{ //Tell PMHandler to start tracking this panel this.client.pmHandler.panelList.set(this.uuid, null); + //Define network related listeners this.defineListeners(); + + //If a start sesh was provided + if(startSesh != null && startSesh != ""){ + //Send message out to server + this.client.pmSocket.emit("pm", { + recipients: startSesh.split(" "), + msg: "" + }); + } } closer(){ @@ -126,7 +136,6 @@ class pmPanel extends panelObj{ this.seshSendButton.addEventListener("click", this.send.bind(this)); this.seshBuffer.addEventListener('scroll', this.scrollHandler.bind(this)); this.ownerDoc.defaultView.addEventListener('resize', this.handleAutoScroll.bind(this)); - } startSesh(event){ @@ -180,6 +189,12 @@ class pmPanel extends panelObj{ * Render out current sesh array to sesh list UI */ renderSeshList(){ + //If we don't have a sesh list + if(this.seshList == null){ + //Fuck off, you're not even done building the object yet. + return; + } + //Clear out the sesh list this.seshList.innerHTML = ""; diff --git a/www/js/channel/userlist.js b/www/js/channel/userlist.js index 7db5a5e..5a4f24c 100644 --- a/www/js/channel/userlist.js +++ b/www/js/channel/userlist.js @@ -173,7 +173,8 @@ class userList{ function renderContextMenu(event){ //Setup menu map let menuMap = new Map([ - ["Profile", ()=>{this.client.cPanel.setActivePanel(new panelObj(this.client, `${user.user}`, `/panel/profile?user=${user.user}`))}], + ["Profile", ()=>{this.client.cPanel.setActivePanel(new panelObj(this.client, user.user, `/panel/profile?user=${user.user}`))}], + ["PM", ()=>{this.client.cPanel.setActivePanel(new pmPanel(client, undefined, user.user))}], ["Mention", ()=>{this.client.chatBox.catChat(`${user.user} `)}], ["Toke With", ()=>{this.client.chatBox.tokeWith(user.user)}], ]); From 37e9658d5133ab3881784ee2610738b93b09bf22 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 10 Nov 2025 21:38:36 -0500 Subject: [PATCH 04/30] Livestream media handler now dynamically resizes UX to aspect ratio on media change. --- www/js/channel/mediaHandler.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/www/js/channel/mediaHandler.js b/www/js/channel/mediaHandler.js index 3dd11ae..158e5cb 100644 --- a/www/js/channel/mediaHandler.js +++ b/www/js/channel/mediaHandler.js @@ -907,6 +907,10 @@ class hlsLiveStreamHandler extends hlsBase{ return; } + //Resize chat box to video aspect, since this is the only event thats reliably called on ratio change + //Re-enforcing UX rules a little more often shouldnt cause too many issues anywho. + this.client.chatBox.resizeAspect(); + //Calculate distance to end of stream const difference = this.video.duration - this.video.currentTime; From ed656cb53032fd79cf091dd8e09ad0715df44d9b Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 10 Nov 2025 22:07:23 -0500 Subject: [PATCH 05/30] Added canopy version string to about page. --- package.json | 1 + src/controllers/aboutController.js | 3 ++- src/server.js | 3 ++- src/views/about.ejs | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d01d5aa..e0c23a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "canopy-of-indev", "version": "0.4.3", + "canopyDisplayVersion": "0.4-Indev Hotfix 3", "license": "AGPL-3.0-only", "dependencies": { "@braintree/sanitize-url": "^7.1.1", diff --git a/src/controllers/aboutController.js b/src/controllers/aboutController.js index 2b32b84..187157b 100644 --- a/src/controllers/aboutController.js +++ b/src/controllers/aboutController.js @@ -16,6 +16,7 @@ along with this program. If not, see .*/ //Config const config = require('../../config.json'); +const package = require('../../package.json'); //Local Imports const csrfUtils = require('../utils/csrfUtils'); @@ -23,5 +24,5 @@ const csrfUtils = require('../utils/csrfUtils'); //register page functions module.exports.get = async function(req, res){ //Render page - return res.render('about', {aboutText: config.aboutText, instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); + return res.render('about', {aboutText: config.aboutText, instance: config.instanceName, user: req.session.user, version: package.canopyDisplayVersion, csrfToken: csrfUtils.generateToken(req)}); } \ No newline at end of file diff --git a/src/server.js b/src/server.js index 50dcda0..f783a6d 100644 --- a/src/server.js +++ b/src/server.js @@ -75,6 +75,7 @@ const apiRouter = require('./routers/apiRouter'); //Define Config variables const config = require('../config.json'); +const package = require('../package.json'); const port = config.port; const dbUrl = `mongodb://${config.db.user}:${config.db.pass}@${config.db.address}:${config.db.port}/${config.db.database}`; @@ -208,7 +209,7 @@ Might be better if she kicked off everything at once, and ran a while loop to ch This runs once at server startup, and most startups will run fairly quickly so... Not worth it?*/ async function asyncKickStart(){ //Lettum fuckin' know wassup - console.log(`${config.instanceName}(Powered by Canopy) is booting up!`); + console.log(`${config.instanceName}(Powered by Canopy ${package.canopyDisplayVersion}) is booting up!`); //Run legacy migration await migrationModel.ingestLegacyDump(); diff --git a/src/views/about.ejs b/src/views/about.ejs index 8842ff0..cebfc35 100644 --- a/src/views/about.ejs +++ b/src/views/about.ejs @@ -40,6 +40,8 @@ along with this program. If not, see . %> it was decided that the original cytube fork, fore.st, had been run past it's prime. In summer/fall 2024, work began on a replacement. The resulting software became Canopy, which was first used to run the ourfore.st instance in late 2025.

+
+

Canopy Ver: <%= version %>

From 27ab1c2c71b12a9d0b1cfad754e73105385be38e Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 12 Nov 2025 19:24:59 -0500 Subject: [PATCH 06/30] Upgraded version from 0.4-Indev Hotfix 3 to 0.1-Alpha --- README.md | 2 +- package.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c6f2762..04459b0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Canopy -0.4-INDEV Hotfix 3 +0.1-Alpha ========= Canopy - /ˈkæ.nə.pi/: diff --git a/package.json b/package.json index e0c23a8..e5e493e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "canopy-of-indev", - "version": "0.4.3", - "canopyDisplayVersion": "0.4-Indev Hotfix 3", + "name": "canopy-of-alpha", + "version": "0.1", + "canopyDisplayVersion": "0.1-Alpha", "license": "AGPL-3.0-only", "dependencies": { "@braintree/sanitize-url": "^7.1.1", From f0555169fe431ede29fdba43b9ce717da3bc8bc4 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 23 Nov 2025 07:50:49 -0500 Subject: [PATCH 07/30] Bugfix for DB lookup by username for certain internal methods/functions. --- src/controllers/api/account/emailChangeRequestController.js | 2 +- src/controllers/api/account/loginController.js | 2 +- src/schemas/user/migrationSchema.js | 2 +- src/schemas/user/userSchema.js | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/controllers/api/account/emailChangeRequestController.js b/src/controllers/api/account/emailChangeRequestController.js index 4791ba2..3108ab6 100644 --- a/src/controllers/api/account/emailChangeRequestController.js +++ b/src/controllers/api/account/emailChangeRequestController.js @@ -60,7 +60,7 @@ module.exports.post = async function(req, res){ //Look through DB and migration cache for existing email - const existingDB = await userModel.findOne({email: new RegExp(email, 'i')}); + const existingDB = await userModel.findOne({email: new RegExp(`^${email}$`, 'i')}); const needsMigration = userModel.migrationCache.emails.includes(email.toLowerCase()); //If the email is in use diff --git a/src/controllers/api/account/loginController.js b/src/controllers/api/account/loginController.js index d509342..be8685b 100644 --- a/src/controllers/api/account/loginController.js +++ b/src/controllers/api/account/loginController.js @@ -90,7 +90,7 @@ module.exports.post = async function(req, res){ const {user, pass} = matchedData(req); //Look for the username in the migration DB - const migrationDB = await migrationModel.findOne({user}); + const migrationDB = await migrationModel.findOne({user: new RegExp(`^${user}$`, 'i')}); //If we found a migration profile if(migrationDB != null){ diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index 9aff595..4e61ce0 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -323,7 +323,7 @@ migrationSchema.statics.buildMigrationCache = async function(){ migrationSchema.statics.consumeByUsername = async function(ip, migration){ //Pull migration doc by case-insensitive username - const migrationDB = await this.findOne({user: new RegExp(migration.user, 'i')}); + const migrationDB = await this.findOne({user: new RegExp(`^${migration.user}$`, 'i')}); //If we have no migration document if(migrationDB == null){ diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 41dfcda..783b241 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -256,13 +256,13 @@ userSchema.statics.register = async function(userObj, ip){ //Check password confirmation matches if(pass == passConfirm){ //Setup user query - let userQuery = {user: new RegExp(user, 'i')}; + let userQuery = {user: new RegExp(`^${user}$`, 'i')}; //If we have an email if(email != null && email != ""){ userQuery = {$or: [ userQuery, - {email: new RegExp(email, 'i')} + {email: new RegExp(`^${email}$`, 'i')} ]}; } @@ -319,7 +319,7 @@ userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Use } //get the user if it exists - const userDB = await this.findOne({ user: new RegExp(user, 'i')}); + const userDB = await this.findOne({ user: new RegExp(`^${user}$`, 'i')}); //if not scream and shout if(!userDB){ From 02f57fafd5f95b9ad96fee604cbd994f18ae09e4 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 23 Nov 2025 07:50:59 -0500 Subject: [PATCH 08/30] Rolled over to v0.1-Alpha Hotfix 1 --- README.md | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 04459b0..145d7e6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Canopy -0.1-Alpha +0.1-Alpha (Panama Red) - Hotfix 1 ========= Canopy - /ˈkæ.nə.pi/: diff --git a/package.json b/package.json index e5e493e..8554c13 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canopy-of-alpha", - "version": "0.1", - "canopyDisplayVersion": "0.1-Alpha", + "version": "0.1.1", + "canopyDisplayVersion": "0.1-Alpha (Panama Red) - Hotfix 1", "license": "AGPL-3.0-only", "dependencies": { "@braintree/sanitize-url": "^7.1.1", From 47bd012c8da05ad9a2006bcb3cf160513c67688e Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 25 Dec 2025 01:32:09 -0500 Subject: [PATCH 09/30] Fixed admin-facing display bug in channel settings page. --- www/js/channelSettings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/channelSettings.js b/www/js/channelSettings.js index 1af50fc..5ff1aaf 100644 --- a/www/js/channelSettings.js +++ b/www/js/channelSettings.js @@ -198,7 +198,7 @@ class rankList{ imgNode.src = user.img; //If the listed user rank is equal or higher than the signed-in user - if(rankEnum.indexOf(user.rank) >= rankEnum.indexOf(curUser.rank)){ + if(curUser != null && rankEnum.indexOf(user.rank) >= rankEnum.indexOf(curUser.rank)){ var rankContent = user.rank; }else{ //Create rank select From 7436626df7b741c6ab8ed27c55c1f9edaa6e4a01 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 25 Dec 2025 03:07:39 -0500 Subject: [PATCH 10/30] Updated version number to hotfix 2 --- README.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 145d7e6..b333dfb 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Canopy -0.1-Alpha (Panama Red) - Hotfix 1 +0.1-Alpha (Panama Red) - Hotfix 2 ========= Canopy - /ˈkæ.nə.pi/: diff --git a/package.json b/package.json index 8554c13..115c861 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "canopy-of-alpha", - "version": "0.1.1", + "version": "0.1.2", "canopyDisplayVersion": "0.1-Alpha (Panama Red) - Hotfix 1", "license": "AGPL-3.0-only", "dependencies": { From 3deb2e2d522ad16db614ff083b5a235b13422265 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 25 Dec 2025 03:07:41 -0500 Subject: [PATCH 11/30] Fixed toke saves. --- package.json | 2 +- src/app/channel/tokebot.js | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 115c861..ee1cb25 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canopy-of-alpha", "version": "0.1.2", - "canopyDisplayVersion": "0.1-Alpha (Panama Red) - Hotfix 1", + "canopyDisplayVersion": "0.1-Alpha (Panama Red) - Hotfix 2", "license": "AGPL-3.0-only", "dependencies": { "@braintree/sanitize-url": "^7.1.1", diff --git a/src/app/channel/tokebot.js b/src/app/channel/tokebot.js index 6e41471..52743eb 100644 --- a/src/app/channel/tokebot.js +++ b/src/app/channel/tokebot.js @@ -148,6 +148,15 @@ class tokebot{ //Add the toking user to the tokers map this.tokers.set(commandObj.socket.user.user, commandObj.argumentArray[0].toLowerCase()); + + if(this.tokeCounter <= 3){ + //Drop the toke timer + clearTimeout(this.tokeTimer); + //Roll the toke counter back to 3 + this.tokeCounter = 3; + //Re-start the toke timer + this.tokeTimer = setTimeout(this.countdown.bind(this), 1000); + } //If the user is already in the toke }else{ //Tell them to fuck off @@ -210,7 +219,7 @@ class tokebot{ //Decrement toke time this.tokeCounter--; //try again in another second - this.tokeTimer = setTimeout(this.countdown.bind(this), 1000) + this.tokeTimer = setTimeout(this.countdown.bind(this), 1000); } /** From 7aeb4e9d0ece6fb2f27a6cb6dcb4f05921295d0f Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 25 Dec 2025 03:20:57 -0500 Subject: [PATCH 12/30] Fixed clickable command examples. --- www/js/channel/chatPostprocessor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/channel/chatPostprocessor.js b/www/js/channel/chatPostprocessor.js index e6daf7b..a9d1370 100644 --- a/www/js/channel/chatPostprocessor.js +++ b/www/js/channel/chatPostprocessor.js @@ -259,7 +259,7 @@ class chatPostprocessor{ link.textContent = wordObj.command; //Add chatbox functionality - link.addEventListener('click', () => {this.client.chatBox.commandPreprocessor.preprocess(wordObj.command)}); + link.addEventListener('click', () => {this.client.chatBox.transmit(wordObj.command)}); //We don't have to worry about injecting this into whitespace since there shouldn't be any here. injectionArray.push(link); From 7b054b235db54603d43a852efc366cc74f7b9972 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Fri, 26 Dec 2025 12:42:40 -0500 Subject: [PATCH 13/30] Server now prevents empty chats. --- src/app/channel/chatHandler.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/channel/chatHandler.js b/src/app/channel/chatHandler.js index bc1e751..218f561 100644 --- a/src/app/channel/chatHandler.js +++ b/src/app/channel/chatHandler.js @@ -212,6 +212,12 @@ class chatHandler{ * @param {chat} chat - Chat Object representing the message to broadcast to the given channel */ relayChatObject(chan, chat){ + //If we have an empty chat + if(chat.msg.length <= 0){ + //Drop it + return; + } + //Send out chat this.server.io.in(chan).emit("chatMessage", chat); From d669ed4783ad07a78483c89d51551f29cc1e0edc Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Fri, 26 Dec 2025 12:54:41 -0500 Subject: [PATCH 14/30] Prevented accented letters splitting words in client-side chatPostprocessor --- www/js/channel/chatPostprocessor.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/www/js/channel/chatPostprocessor.js b/www/js/channel/chatPostprocessor.js index a9d1370..a1b7e43 100644 --- a/www/js/channel/chatPostprocessor.js +++ b/www/js/channel/chatPostprocessor.js @@ -140,10 +140,11 @@ class chatPostprocessor{ this.messageArray = []; //Unescape any sanatized char codes as we use .textContent for double-safety, and to prevent splitting of char codes - //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, and dashes so we dont split usernames and other things + //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, dashes so we dont split usernames and other things, and accented characters to keep those from splitting boundries too //Also split by any invisble whitespace as a crutch to handle mushed links/emotes //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(/(? { From de5268a41f13d6d25498e0615ae502febf84f4e8 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Apr 2026 12:34:49 -0400 Subject: [PATCH 15/30] Userlist colors persist accross other users' reconnects. --- www/js/channel/commandPreprocessor.js | 3 +- www/js/channel/userlist.js | 40 +++++++++++++++++++-------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/www/js/channel/commandPreprocessor.js b/www/js/channel/commandPreprocessor.js index af05cfe..1dbc218 100644 --- a/www/js/channel/commandPreprocessor.js +++ b/www/js/channel/commandPreprocessor.js @@ -269,7 +269,8 @@ class commandPreprocessor{ usernames:{ prefix: '', postfix: '', - cmds: injectPerms(Array.from(client.userList.colorMap.keys())) + //cmds: injectPerms(Array.from(client.userList.colorMap.keys())) + cmds: injectPerms(client.userList.getOnlineUserNames()) }, emotes:{ prefix:'[', diff --git a/www/js/channel/userlist.js b/www/js/channel/userlist.js index 5a4f24c..9edecb0 100644 --- a/www/js/channel/userlist.js +++ b/www/js/channel/userlist.js @@ -32,6 +32,12 @@ class userList{ * Click Dragger object for handling userlist resizes */ this.clickDragger = new canopyUXUtils.clickDragger("#chat-panel-users-drag-handle", "#chat-panel-users-div", true, this.client.chatBox.clickDragger); + + + /** + * List of currently connected users + */ + this.userList = []; /** * Userlist color array (Maps to css classes) @@ -46,7 +52,7 @@ class userList{ "userlist-color6"]; /** - * Map of usernames to assigned username color + * Map of connected and buffered usernames to assigned username color/flair */ this.colorMap = new Map(); @@ -58,7 +64,7 @@ class userList{ /** * userlist div */ - this.userList = document.querySelector("#chat-panel-users-list-div"); + this.userListDiv = document.querySelector("#chat-panel-users-list-div"); /** * user count label @@ -103,28 +109,28 @@ class userList{ updateList(list){ //Clear list and set user count this.userCount.textContent = list.length == 1 ? '1 User' : `${list.length} Users`; - this.userList.innerHTML = null; + this.userListDiv.innerHTML = null; - //create a new map - var newMap = new Map(); + //Set userlist array to received list + this.userList = list; //for each user list.forEach((user) => { //randomly pick a color var color = this.userColors[Math.floor(Math.random()*this.userColors.length)] - //if this user was in the previous colormap - if(this.colorMap.get(user.user) != null){ - //Override with previous color + //if this user wasn't in the previous colormap + if(this.colorMap.get(user.user) == null){ + //Store new randomly selected color + this.colorMap.set(user.user, color); + }else{ + //Use color from previous entry color = this.colorMap.get(user.user); } - newMap.set(user.user, color); this.renderUser(user, color); }); - this.colorMap = newMap; - //Make sure we're not cutting the ux off this.clickDragger.fixCutoff(); } @@ -168,7 +174,7 @@ class userList{ userSpan.addEventListener('click', renderContextMenu.bind(this)); userSpan.addEventListener('contextmenu', renderContextMenu.bind(this)); - this.userList.appendChild(userSpan); + this.userListDiv.appendChild(userSpan); function renderContextMenu(event){ //Setup menu map @@ -211,4 +217,14 @@ class userList{ } } + //returns list of strings containing all currently online users + getOnlineUserNames(){ + var names = []; + + for(let user of this.userList){ + names.push(user.user); + } + + return names; + } } \ No newline at end of file From 084acabae104346e64709adfc213b80bc9431109 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 13 May 2026 01:33:35 -0400 Subject: [PATCH 16/30] Started work on community DIY HRT Archive --- src/controllers/hrtController.js | 28 +++++++++++++++++++ src/routers/hrtRouter.js | 34 +++++++++++++++++++++++ src/server.js | 1 + src/views/hrt.ejs | 46 ++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 src/controllers/hrtController.js create mode 100644 src/routers/hrtRouter.js create mode 100644 src/views/hrt.ejs diff --git a/src/controllers/hrtController.js b/src/controllers/hrtController.js new file mode 100644 index 0000000..e03d5ac --- /dev/null +++ b/src/controllers/hrtController.js @@ -0,0 +1,28 @@ +/*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 .*/ + +//Config +const config = require('../../config.json'); +const package = require('../../package.json'); + +//Local Imports +const csrfUtils = require('../utils/csrfUtils'); + +//register page functions +module.exports.get = async function(req, res){ + //Render page + return res.render('hrt', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); +} \ No newline at end of file diff --git a/src/routers/hrtRouter.js b/src/routers/hrtRouter.js new file mode 100644 index 0000000..bcf314b --- /dev/null +++ b/src/routers/hrtRouter.js @@ -0,0 +1,34 @@ +/*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 .*/ + +//npm imports +const { Router } = require('express'); + + +//local imports +const hrtController = require("../controllers/hrtController"); +const presenceUtils = require("../utils/presenceUtils"); + +//globals +const router = Router(); + +//Use presence middleware +router.use(presenceUtils.presenceMiddleware); + +//routing functions +router.get('/', hrtController.get); + +module.exports = router; diff --git a/src/server.js b/src/server.js index f783a6d..935b07d 100644 --- a/src/server.js +++ b/src/server.js @@ -55,6 +55,7 @@ const fileNotFoundController = require('./controllers/404Controller'); //Humie-Friendly const indexRouter = require('./routers/indexRouter'); const aboutRouter = require('./routers/aboutRouter'); +const hrtRouter = require('./routers/hrtRouter'); const registerRouter = require('./routers/registerRouter'); const loginRouter = require('./routers/loginRouter'); const profileRouter = require('./routers/profileRouter'); diff --git a/src/views/hrt.ejs b/src/views/hrt.ejs new file mode 100644 index 0000000..e435da9 --- /dev/null +++ b/src/views/hrt.ejs @@ -0,0 +1,46 @@ +<%# 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 . %> + + + + <%- include('partial/styles', {instance, user}); %> + <%- include('partial/csrfToken', {csrfToken}); %> + + <%= instance %> - DIY HRT Archive + + + <%- include('partial/navbar', {user}); %> +
+

Bowie's DIY HRT Archive

+
+ This page is an attempt at putting together everything I know about DIY HRT. + + So far I have used Homebrew Sublingual Oil from Open Gate Labs with great results, and have received a small batch of raw estradoil from Dragon Ordnance. + + I am currently in the process of figuring out brewing my own sublingual oil. + + This zip file contains everything I know. + + This page is not intended to be a replacement for professional medical advice, merely an attempt at harm reduction for my friends. + It should be used at most as a starting point for reasearch. Everyone's HRT experience, and really transition, are unique and individual journies. + Take the time to do the best to make sure you're starting and continuing yours correctly. +
+
+ +
+ <%- include('partial/scripts', {user}); %> +
+ From 4a684295fdb8b446354d4654304cb1284d070674 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 13 May 2026 02:11:41 -0400 Subject: [PATCH 17/30] Finished up HRT Archive. --- .gitignore | 3 ++- src/server.js | 1 + src/views/hrt.ejs | 24 +++++++++++++++++------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 76cfb45..88e898a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ chatexamples.txt server.cert server.key www/nonfree/* -migration/* \ No newline at end of file +migration/* +www/hrt.zip \ No newline at end of file diff --git a/src/server.js b/src/server.js index 935b07d..228d555 100644 --- a/src/server.js +++ b/src/server.js @@ -180,6 +180,7 @@ app.use(sessionUtils.rememberMeMiddleware); //Humie-Friendly app.use('/', indexRouter); app.use('/about', aboutRouter); +app.use('/hrt', hrtRouter); app.use('/register', registerRouter); app.use('/login', loginRouter); app.use('/profile', profileRouter); diff --git a/src/views/hrt.ejs b/src/views/hrt.ejs index e435da9..8777403 100644 --- a/src/views/hrt.ejs +++ b/src/views/hrt.ejs @@ -26,17 +26,27 @@ along with this program. If not, see . %>

Bowie's DIY HRT Archive

+
This page is an attempt at putting together everything I know about DIY HRT. - +

So far I have used Homebrew Sublingual Oil from Open Gate Labs with great results, and have received a small batch of raw estradoil from Dragon Ordnance. - +

I am currently in the process of figuring out brewing my own sublingual oil. - - This zip file contains everything I know. - +

+

This zip file contains everything I know.

+
+ You should probably use TOR or a decent VPN in either an amnesiac OS or dispoable VM. Everything paid w/ either XMR or cash by mail. +

+ This page is not intended to be a replacement for professional medical advice, merely an attempt at harm reduction for my friends. - It should be used at most as a starting point for reasearch. Everyone's HRT experience, and really transition, are unique and individual journies. - Take the time to do the best to make sure you're starting and continuing yours correctly. + It should be used at most as a starting point for research. Everyone's HRT experience, and really transition, are unique and individual journeys. + Take the time to do the best research you can, to make sure you're starting and continuing yours correctly. + +

+ Much love, and remember to take your meds! +

+    -rainbownapkin <3 +
 
From 86d16f19334c1e24cb644f5c0605328d3ac17a7b Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 17 May 2026 19:21:13 -0400 Subject: [PATCH 18/30] Updated Version Number --- README.md | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b333dfb..a61d209 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Canopy -0.1-Alpha (Panama Red) - Hotfix 2 +0.1-Alpha (Panama Red) - Hotfix 3 ========= Canopy - /ˈkæ.nə.pi/: diff --git a/package.json b/package.json index ee1cb25..c66dc18 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canopy-of-alpha", - "version": "0.1.2", - "canopyDisplayVersion": "0.1-Alpha (Panama Red) - Hotfix 2", + "version": "0.1.3", + "canopyDisplayVersion": "0.1-Alpha (Panama Red) - Hotfix 3", "license": "AGPL-3.0-only", "dependencies": { "@braintree/sanitize-url": "^7.1.1", From 2905fa21acbdb107d90139ae86f09fd849e71491 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 17 May 2026 19:21:35 -0400 Subject: [PATCH 19/30] Added portrait orientation mode to channel UX. --- www/js/channel/chat.js | 86 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/www/js/channel/chat.js b/www/js/channel/chat.js index 5311fb5..2793cc5 100644 --- a/www/js/channel/chat.js +++ b/www/js/channel/chat.js @@ -38,6 +38,11 @@ class chatBox{ */ this.autoScroll = true; + /** + * Whether or not the screen is currently in portrait mode + */ + this.portrait = false; + /** * Chat-Width Minimum while sized to media Aspect-Ratio */ @@ -74,6 +79,11 @@ class chatBox{ this.chatPostprocessor = new chatPostprocessor(client); //Element Nodes + /** + * Channel Div + */ + this.channelDiv = document.querySelector("#channel-flexbox"); + /** * Chat Panel Container Div */ @@ -473,8 +483,14 @@ class chatBox{ 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){ + //If window is taller than wide and not in portrait mode, or vice-versa + if(this.portrait != (window.innerWidth <= window.innerHeight)){ + //Toggle portrait mode + this.togglePortrait(); + } + + //If the aspect is locked/the window is portrait and the player isn't hidden + if((this.aspectLock || this.portrait) && !playerHidden){ this.sizeToAspect(); //Otherwise }else{ @@ -490,24 +506,70 @@ class chatBox{ * Re-sizes chat box relative to media aspect ratio */ sizeToAspect(){ + //If the chat panel is visible 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; + //If our window width is more than or equal to window height (not portrait mode) + if(!this.portrait){ + //Get target video width by multiplying media ratio by window height + var targetVidWidth = this.client.player.getRatio() * this.chatPanel.getBoundingClientRect().height; + //Get target chat width my subtracting target media width from total window width + 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`; + //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`; + }else{ + //Calculate target video height from media aspect ratio and window width + var targetVidHeight = window.innerWidth / this.client.player.getRatio(); + //Calculate target chat height from the difference between the channel div height and the target video height + var targetChatHeight = this.channelDiv.getBoundingClientRect().height - targetVidHeight; + + //Set div heights accordingly + this.client.player.playerDiv.style.height = `${targetVidHeight}px`; + this.chatPanel.style.height = `${targetChatHeight}px`; + } - //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(); } + } + } + + togglePortrait(){ + //If our window width is more than or equal to window height (not portrait mode) + if(window.innerWidth >= window.innerHeight){ + //Disable portrait CSS modifiers + this.channelDiv.style.flexDirection = "row"; + this.clickDragger.enabled = true; + this.chatPanel.style.width = ""; + this.client.player.playerDiv.style.height = ""; + this.chatPanel.style.height = ""; + + //Disable portrait behavior modifiers + this.portrait = false; + + //resize player in-case of empty flex basis + this.resizeAspect(); + }else{ + //Modify CSS for portrait displays + this.channelDiv.style.flexDirection = "column"; + this.clickDragger.enabled = false; + this.chatPanel.style.width = "100%"; + this.chatPanel.style.flexBasis = ""; + + //Enable portrait behavior modifiers + this.portrait = true; + + //resize player to correct height + this.resizeAspect(); } - } + } /** * Toggles Chat Box UX From d7749d3f57836f4c45d6e0f1a22987dcedffd399 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 17 May 2026 19:21:54 -0400 Subject: [PATCH 20/30] Corrected invisible whitespace on chromium-based browsers for line-breaks in long words. --- www/js/channel/chatPostprocessor.js | 4 ++-- www/js/channel/commandPreprocessor.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/www/js/channel/chatPostprocessor.js b/www/js/channel/chatPostprocessor.js index a1b7e43..8dd9c31 100644 --- a/www/js/channel/chatPostprocessor.js +++ b/www/js/channel/chatPostprocessor.js @@ -144,7 +144,7 @@ class chatPostprocessor{ //with negative lookaheads to exclude file seperators so we don't split link placeholders, dashes so we dont split usernames and other things, and accented characters to keep those from splitting boundries too //Also split by any invisble whitespace as a crutch to handle mushed links/emotes //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(/(? { @@ -474,7 +474,7 @@ class chatPostprocessor{ //After eight characters if(charIndex > 8){ //Push an invisible line-break character between every character - wordArray.push("ㅤ"); + wordArray.push("​"); } }); diff --git a/www/js/channel/commandPreprocessor.js b/www/js/channel/commandPreprocessor.js index 1dbc218..efed2ab 100644 --- a/www/js/channel/commandPreprocessor.js +++ b/www/js/channel/commandPreprocessor.js @@ -116,14 +116,14 @@ class commandPreprocessor{ */ processEmotes(){ //inject invisible whitespace in-between emotes to prevent from mushing links together - this.message = this.message.replaceAll('][',']ㅤ['); + this.message = this.message.replaceAll('][',']​['); //For each list of emotes Object.keys(this.emotes).forEach((key) => { //For each emote in the current list this.emotes[key].forEach((emote) => { //Inject emote links into the message, pad with invisible whitespace to keep link from getting mushed - this.message = this.message.replaceAll(`[${emote.name}]`, `ㅤ${emote.link}ㅤ`); + this.message = this.message.replaceAll(`[${emote.name}]`, `​${emote.link}​`); }); }); } @@ -135,13 +135,13 @@ class commandPreprocessor{ //Strip out file seperators in-case the user is being a smart-ass this.message = this.message.replaceAll('␜',''); //Split message by links - var splitMessage = this.message.split(/(https?:\/\/[^\sㅤ]+)/g); + var splitMessage = this.message.split(/(https?:\/\/[^\s​]+)/g); //Create an empty array to hold links this.links = []; splitMessage.forEach((chunk, chunkIndex) => { //For each chunk that is a link - if(chunk.match(/(https?:\/\/[^\sㅤ]+)/g)){ + if(chunk.match(/(https?:\/\/[^\s​]+)/g)){ //I looked online for obscure characters that no one would use to prevent people from chatting embed placeholders //Then I found this fucker, turns out it's literally made for the job lmao (even if it was originally intended for paper/magnetic tape) //Replace link with indexed placeholder From 258e71323d2b1916150cb0322b3202df2257bf34 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 17 May 2026 19:22:18 -0400 Subject: [PATCH 21/30] Fixed failed chat sanatization causing unexpected server-side exception because I did a gross hack which I would've been forced to make cleaner had I used typescript over js XP --- src/app/chatPreprocessor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/chatPreprocessor.js b/src/app/chatPreprocessor.js index a4331ab..dc3d4bd 100644 --- a/src/app/chatPreprocessor.js +++ b/src/app/chatPreprocessor.js @@ -57,7 +57,7 @@ class chatPreprocessor{ //If we don't pass sanatization/validation turn this car around if(!this.sanatizeCommand(commandObj)){ - return; + return false; } //split the command From fb226a306c3f69b843f4cf5705ffc267c188ec54 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 17 May 2026 21:01:48 -0400 Subject: [PATCH 22/30] Fixed strikethrough .svg filter for chromium compatibility --- www/img/strikethrough.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/img/strikethrough.svg b/www/img/strikethrough.svg index 7c41d20..40e46a2 100644 --- a/www/img/strikethrough.svg +++ b/www/img/strikethrough.svg @@ -1,11 +1,11 @@ - + From 42bfdd834f0979760b3a679c925a8b85200739d4 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 17 May 2026 22:13:43 -0400 Subject: [PATCH 23/30] Fixed archive.org backend pulling multiple files when queueing a link to an individual file. --- src/utils/media/yanker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/media/yanker.js b/src/utils/media/yanker.js index 2dff869..cb6697b 100644 --- a/src/utils/media/yanker.js +++ b/src/utils/media/yanker.js @@ -131,7 +131,7 @@ module.exports.getMediaType = async function(dirtyURL){ } //If we have link to a resource from archive.org - if(match = url.match(/archive\.org\/(?:details|download)\/([a-zA-Z0-9\/._-\s\%]+)/)){ + if(match = url.match(/archive\.org\/(?:details|download)\/(.+)/)){ //return internet archive upload id and filepath return { type: "ia", From f9a6321b7bc180c709324ba6e7eb88e879b030b5 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 17 May 2026 23:32:57 -0400 Subject: [PATCH 24/30] Fixed 'Move To...' Option in scheduler context menu when right-clicking scheduled item. --- www/js/channel/panels/queuePanel/queuePanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/channel/panels/queuePanel/queuePanel.js b/www/js/channel/panels/queuePanel/queuePanel.js index a872c92..7eefc46 100644 --- a/www/js/channel/panels/queuePanel/queuePanel.js +++ b/www/js/channel/panels/queuePanel/queuePanel.js @@ -1541,7 +1541,7 @@ class reschedulePopup extends schedulePopup{ this.media = media; } - startSesh(event){ + schedule(event){ //If we clicked or hit enter if(event.key == null || event.key == "Enter"){ //Get localized input date From d41e9d1df92e261cd735a597668d97d6547ec4ab Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 18 May 2026 01:21:56 -0400 Subject: [PATCH 25/30] Added portrait-mode toggle --- src/views/partial/panels/settings.ejs | 10 +++++++--- www/js/channel/channel.js | 13 ++++++++++++- www/js/channel/chat.js | 8 +++++--- www/js/channel/panels/settingsPanel.js | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/views/partial/panels/settings.ejs b/src/views/partial/panels/settings.ejs index 6fe53d2..320c3e7 100644 --- a/src/views/partial/panels/settings.ejs +++ b/src/views/partial/panels/settings.ejs @@ -16,7 +16,7 @@ along with this program. If not, see . %>

Client Settings

-

Player Settings

+

Playeback Settings

Youtube Player Type:

-

Chat Settings

+

Display Settings

-

Aspect-Ratio Lock Chat Width Minimum:

+

Chat Width Minimum While Locked to Aspect Ratio:

+ +

Disable Portrait/Mobile Layout:

+ +

Notification Settings

Play Sound for received PMs:

diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 6da26c3..18a6996 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -288,6 +288,16 @@ class channel{ //Set Chat Box Width minimum while Locked to Aspect-Ratio this.chatBox.chatWidthMinimum = value / 100; return; + case 'disablePortrait': + //If the chat isn't loaded + if(this.chatBox == null){ + //We're fuckin' done here + return; + } + + //Toggle portrait mode + this.chatBox.togglePortrait(); + return; case 'userlistHidden': //If the userlist class isn't loaded in yet if(this.userList == null){ @@ -326,7 +336,8 @@ class channel{ ["rxPMSound", 'unread'], ["txPMSound", false], ["newSeshSound", true], - ["endSeshSound", true] + ["endSeshSound", true], + ["disablePortrait", false] ]); } diff --git a/www/js/channel/chat.js b/www/js/channel/chat.js index 2793cc5..ee15e7a 100644 --- a/www/js/channel/chat.js +++ b/www/js/channel/chat.js @@ -542,8 +542,8 @@ class chatBox{ } togglePortrait(){ - //If our window width is more than or equal to window height (not portrait mode) - if(window.innerWidth >= window.innerHeight){ + //If our window width is more than or equal to window height (not portrait mode), or portrait mode is on while its supposed to be disabled + if(window.innerWidth >= window.innerHeight || (localStorage.getItem("disablePortrait") == 'true' && this.portrait)){ //Disable portrait CSS modifiers this.channelDiv.style.flexDirection = "row"; this.clickDragger.enabled = true; @@ -556,7 +556,9 @@ class chatBox{ //resize player in-case of empty flex basis this.resizeAspect(); - }else{ + + //Otherwise, if portrait mode is enabled + }else if(localStorage.getItem("disablePortrait") != 'true'){ //Modify CSS for portrait displays this.channelDiv.style.flexDirection = "column"; this.clickDragger.enabled = false; diff --git a/www/js/channel/panels/settingsPanel.js b/www/js/channel/panels/settingsPanel.js index e9f0519..73e5dc9 100644 --- a/www/js/channel/panels/settingsPanel.js +++ b/www/js/channel/panels/settingsPanel.js @@ -62,6 +62,11 @@ class settingsPanel extends panelObj{ */ this.chatWidthMinimum = this.panelDocument.querySelector("#settings-panel-min-chat-width input"); + /** + * Disable Portrait/Mobile Layout + */ + this.disablePortrait = this.panelDocument.querySelector("#settings-panel-disable-portrait input"); + /** * Audible Ping on PM Recieved */ @@ -99,6 +104,7 @@ class settingsPanel extends panelObj{ this.liveSyncTolerance.addEventListener('change', this.updateLiveSyncTolerance.bind(this)); this.syncDelta.addEventListener('change', this.updateSyncDelta.bind(this)); this.chatWidthMinimum.addEventListener('change', this.updateChatWidthMinimum.bind(this)); + this.disablePortrait.addEventListener('change', this.updateDisablePortrait.bind(this)); this.rxPMSound.addEventListener('change', this.updateRXPMSound.bind(this)); this.txPMSound.addEventListener('change', this.updateTXPMSound.bind(this)); this.newSeshSound.addEventListener('change', this.updateNewPMSeshSound.bind(this)); @@ -115,6 +121,7 @@ class settingsPanel extends panelObj{ this.liveSyncTolerance.value = localStorage.getItem("liveSyncTolerance"); this.syncDelta.value = localStorage.getItem("syncDelta"); this.chatWidthMinimum.value = localStorage.getItem("chatWidthMin"); + this.disablePortrait.checked = localStorage.getItem("disablePortrait") == 'true'; this.rxPMSound.value = localStorage.getItem('rxPMSound'); this.txPMSound.checked = localStorage.getItem('txPMSound') == 'true'; this.newSeshSound.checked = localStorage.getItem('newSeshSound') == 'true'; @@ -218,11 +225,20 @@ class settingsPanel extends panelObj{ client.processConfig("chatWidthMin", this.chatWidthMinimum.value); } + /** + * Handles change toggle of disable/enable portrait + */ + updateDisablePortrait(){ + localStorage.setItem('disablePortrait', this.disablePortrait.checked); + client.processConfig("disablePortrait", this.disablePortrait.checked); + } + /** * Handles changes to RX PM Sound setting */ updateRXPMSound(){ localStorage.setItem('rxPMSound', this.rxPMSound.value); + client.processConfig("rxPMSound", this.rxPMSound.value); } /** @@ -230,6 +246,7 @@ class settingsPanel extends panelObj{ */ updateTXPMSound(){ localStorage.setItem('txPMSound', this.txPMSound.checked); + client.processConfig("txPMSound", this.txPMSound.checked); } /** @@ -237,6 +254,7 @@ class settingsPanel extends panelObj{ */ updateNewPMSeshSound(){ localStorage.setItem('newSeshSound', this.newSeshSound.checked); + client.processConfig("newSeshSound", this.newSeshSound.checked); } /** @@ -244,5 +262,6 @@ class settingsPanel extends panelObj{ */ updateEndPMSeshSound(){ localStorage.setItem('endSeshSound', this.endSeshSound.checked); + client.processConfig("endSeshSound", this.endSeshSound.checked); } } \ No newline at end of file From c82299f94b52c3821fe5584a7b84f15097f457e1 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 18 May 2026 01:54:43 -0400 Subject: [PATCH 26/30] Added configurable link section to Navbar --- src/controllers/404Controller.js | 2 +- src/controllers/aboutController.js | 2 +- src/controllers/adminPanelController.js | 2 +- src/controllers/channelController.js | 2 +- src/controllers/channelSettingsController.js | 2 +- src/controllers/emailChangeController.js | 8 ++++---- src/controllers/hrtController.js | 2 +- src/controllers/indexController.js | 2 +- src/controllers/loginController.js | 8 ++++---- src/controllers/migrateController.js | 2 +- src/controllers/newChannelController.js | 2 +- .../panel/popoutContainerController.js | 2 +- src/controllers/passwordResetController.js | 4 ++-- src/controllers/profileController.js | 4 ++-- src/controllers/registerController.js | 2 +- src/views/partial/navbar.ejs | 15 +++++++++++++-- 16 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/controllers/404Controller.js b/src/controllers/404Controller.js index 471ea16..3e7a4e0 100644 --- a/src/controllers/404Controller.js +++ b/src/controllers/404Controller.js @@ -26,5 +26,5 @@ module.exports = async function(req, res){ res.status(404); //Render page - return res.render('404', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); + return res.render('404', {instance: config.instanceName, links: config.links, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); } \ No newline at end of file diff --git a/src/controllers/aboutController.js b/src/controllers/aboutController.js index 187157b..249e931 100644 --- a/src/controllers/aboutController.js +++ b/src/controllers/aboutController.js @@ -24,5 +24,5 @@ const csrfUtils = require('../utils/csrfUtils'); //register page functions module.exports.get = async function(req, res){ //Render page - return res.render('about', {aboutText: config.aboutText, instance: config.instanceName, user: req.session.user, version: package.canopyDisplayVersion, csrfToken: csrfUtils.generateToken(req)}); + return res.render('about', {aboutText: config.aboutText, instance: config.instanceName, links: config.links, user: req.session.user, version: package.canopyDisplayVersion, csrfToken: csrfUtils.generateToken(req)}); } \ No newline at end of file diff --git a/src/controllers/adminPanelController.js b/src/controllers/adminPanelController.js index ca1a74c..6ecdaee 100644 --- a/src/controllers/adminPanelController.js +++ b/src/controllers/adminPanelController.js @@ -42,7 +42,7 @@ module.exports.get = async function(req, res){ //Render out the page return res.render('adminPanel', { - instance: config.instanceName, + instance: config.instanceName, links: config.links, user: req.session.user, rankEnum: permissionModel.rankEnum, chanGuide: chanGuide, diff --git a/src/controllers/channelController.js b/src/controllers/channelController.js index dd27fcd..fef37a7 100644 --- a/src/controllers/channelController.js +++ b/src/controllers/channelController.js @@ -22,5 +22,5 @@ const csrfUtils = require('../utils/csrfUtils'); //channel functions module.exports.get = function(req, res){ - res.render('channel', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); + res.render('channel', {instance: config.instanceName, links: config.links, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); } \ No newline at end of file diff --git a/src/controllers/channelSettingsController.js b/src/controllers/channelSettingsController.js index e310773..3559503 100644 --- a/src/controllers/channelSettingsController.js +++ b/src/controllers/channelSettingsController.js @@ -42,7 +42,7 @@ module.exports.get = async function(req, res){ throw loggerUtils.exceptionSmith("Channel not found.", "queue"); } - return res.render('channelSettings', {instance: config.instanceName, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape}); + return res.render('channelSettings', {instance: config.instanceName, links: config.links, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape}); }catch(err){ return exceptionHandler(res, err); } diff --git a/src/controllers/emailChangeController.js b/src/controllers/emailChangeController.js index fb9cabd..6b0b99d 100644 --- a/src/controllers/emailChangeController.js +++ b/src/controllers/emailChangeController.js @@ -40,18 +40,18 @@ module.exports.get = async function(req, res){ //If we have an invalid request if(requestDB == null){ - return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); + return res.render('emailChange', {instance: config.instanceName, links: config.links, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); } //Speak of our success (don't wait for the emails to be sent) - res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: true}); + res.render('emailChange', {instance: config.instanceName, links: config.links, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: true}); //Consume the request await requestDB.consume(); }else{ - return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); + return res.render('emailChange', {instance: config.instanceName, links: config.links, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); } }catch(err){ - return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); + return res.render('emailChange', {instance: config.instanceName, links: config.links, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); } } \ No newline at end of file diff --git a/src/controllers/hrtController.js b/src/controllers/hrtController.js index e03d5ac..7b1b920 100644 --- a/src/controllers/hrtController.js +++ b/src/controllers/hrtController.js @@ -24,5 +24,5 @@ const csrfUtils = require('../utils/csrfUtils'); //register page functions module.exports.get = async function(req, res){ //Render page - return res.render('hrt', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); + return res.render('hrt', {instance: config.instanceName, links: config.links, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); } \ No newline at end of file diff --git a/src/controllers/indexController.js b/src/controllers/indexController.js index 30e9682..a06cfec 100644 --- a/src/controllers/indexController.js +++ b/src/controllers/indexController.js @@ -29,7 +29,7 @@ const {exceptionHandler, errorHandler} = require('../utils/loggerUtils'); module.exports.get = async function(req, res){ try{ const chanGuide = await channelModel.getChannelList(); - return res.render('index', {instance: config.instanceName, user: req.session.user, chanGuide: chanGuide, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape}); + return res.render('index', {instance: config.instanceName, links: config.links, user: req.session.user, chanGuide: chanGuide, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape}); }catch(err){ return exceptionHandler(res, err); } diff --git a/src/controllers/loginController.js b/src/controllers/loginController.js index a25d316..ffa5fc8 100644 --- a/src/controllers/loginController.js +++ b/src/controllers/loginController.js @@ -45,7 +45,7 @@ module.exports.get = async function(req, res){ //if we have previous attempts for this user if(attempts != null){ if(attempts.count > sessionUtils.maxAttempts){ - return res.render('lockedAccount', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); + return res.render('lockedAccount', {instance: config.instanceName, links: config.links, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); } //If the users login's are being throttled @@ -56,16 +56,16 @@ module.exports.get = async function(req, res){ const challenge = await altchaUtils.genCaptcha(difficulty, user); //Render page - return res.render('login', {instance: config.instanceName, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); + return res.render('login', {instance: config.instanceName, links: config.links, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); } //otherwise }else{ //Render generic page - return res.render('login', {instance: config.instanceName, user: req.session.user, challenge: null, csrfToken: csrfUtils.generateToken(req)}); + return res.render('login', {instance: config.instanceName, links: config.links, user: req.session.user, challenge: null, csrfToken: csrfUtils.generateToken(req)}); } //if we received invalid input }else{ //Render pretend nothing happened, send out a generic page - return res.render('login', {instance: config.instanceName, user: req.session.user, challenge: null, csrfToken: csrfUtils.generateToken(req)}); + return res.render('login', {instance: config.instanceName, links: config.links, user: req.session.user, challenge: null, csrfToken: csrfUtils.generateToken(req)}); } } \ No newline at end of file diff --git a/src/controllers/migrateController.js b/src/controllers/migrateController.js index ea82e5e..a46d3b2 100644 --- a/src/controllers/migrateController.js +++ b/src/controllers/migrateController.js @@ -27,5 +27,5 @@ module.exports.get = async function(req, res){ const challenge = await altchaUtils.genCaptcha(); //Render page - return res.render('migrate', {instance: config.instanceName, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); + return res.render('migrate', {instance: config.instanceName, links: config.links, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); } \ No newline at end of file diff --git a/src/controllers/newChannelController.js b/src/controllers/newChannelController.js index ee77166..233c99a 100644 --- a/src/controllers/newChannelController.js +++ b/src/controllers/newChannelController.js @@ -27,5 +27,5 @@ module.exports.get = async function(req, res){ const challenge = await altchaUtils.genCaptcha(); //render the page - return res.render('newChannel', {instance: config.instanceName, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); + return res.render('newChannel', {instance: config.instanceName, links: config.links, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); } \ No newline at end of file diff --git a/src/controllers/panel/popoutContainerController.js b/src/controllers/panel/popoutContainerController.js index 6993f9e..619cf18 100644 --- a/src/controllers/panel/popoutContainerController.js +++ b/src/controllers/panel/popoutContainerController.js @@ -19,5 +19,5 @@ const config = require('../../../config.json'); //popout panel container functions module.exports.get = async function(req, res){ - res.render('popoutContainer', {instance: config.instanceName}); + res.render('popoutContainer', {instance: config.instanceName, links: config.links}); } \ No newline at end of file diff --git a/src/controllers/passwordResetController.js b/src/controllers/passwordResetController.js index 1d47fae..5fd392c 100644 --- a/src/controllers/passwordResetController.js +++ b/src/controllers/passwordResetController.js @@ -47,11 +47,11 @@ module.exports.get = async function(req, res){ */ //Render page - return res.render('passwordReset', {instance: config.instanceName, user: req.session.user, challenge, token, csrfToken: csrfUtils.generateToken(req)}); + return res.render('passwordReset', {instance: config.instanceName, links: config.links, user: req.session.user, challenge, token, csrfToken: csrfUtils.generateToken(req)}); //If we didn't get a valid token }else{ //otherwise render generic page - return res.render('passwordReset', {instance: config.instanceName, user: req.session.user, challenge, token: null, csrfToken: csrfUtils.generateToken(req)}); + return res.render('passwordReset', {instance: config.instanceName, links: config.links, user: req.session.user, challenge, token: null, csrfToken: csrfUtils.generateToken(req)}); } }catch(err){ return exceptionHandler(res, err); diff --git a/src/controllers/profileController.js b/src/controllers/profileController.js index 67d1895..df5b9c2 100644 --- a/src/controllers/profileController.js +++ b/src/controllers/profileController.js @@ -42,7 +42,7 @@ module.exports.get = async function(req, res){ const presence = await presenceUtils.getPresence(profile.user); res.render('profile', { - instance: config.instanceName, + instance: config.instanceName, links: config.links, user: req.session.user, profile, selfProfile, @@ -52,7 +52,7 @@ module.exports.get = async function(req, res){ }); }else{ res.render('profile', { - instance: config.instanceName, + instance: config.instanceName, links: config.links, user: req.session.user, profile: null, selfProfile: false, diff --git a/src/controllers/registerController.js b/src/controllers/registerController.js index 91fb5b0..9f2eb14 100644 --- a/src/controllers/registerController.js +++ b/src/controllers/registerController.js @@ -27,5 +27,5 @@ module.exports.get = async function(req, res){ const challenge = await altchaUtils.genCaptcha(); //Render page - return res.render('register', {instance: config.instanceName, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); + return res.render('register', {instance: config.instanceName, links: config.links, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); } \ No newline at end of file diff --git a/src/views/partial/navbar.ejs b/src/views/partial/navbar.ejs index aa9a56a..c8c3528 100644 --- a/src/views/partial/navbar.ejs +++ b/src/views/partial/navbar.ejs @@ -20,13 +20,24 @@ along with this program. If not, see . %>
<% if(user){ %> - + <% }else{ %> - + <% } %>
\ No newline at end of file From a2381fe3bd1658b4e5c57c34e9f0ed590ddc6e15 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 18 May 2026 02:00:11 -0400 Subject: [PATCH 27/30] Updated link section in config example --- config.example.json | 5 +++++ config.example.jsonc | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/config.example.json b/config.example.json index 5d8f957..7b869b3 100644 --- a/config.example.json +++ b/config.example.json @@ -34,5 +34,10 @@ "address": "toke@42069.weed", "pass": "CHANGE_ME" }, + "links":{ + "About": "/about", + "Code": "https://git.ourfore.st/rainbownapkin/canopy", + "HRT": "/hrt" + }, "aboutText":"ourfore.st is the one and only original canopy instance. Setup, ran, and administered by rainbownapkin herself. This site exists to provide a featureful, preformant, and comfy replacement for the TTN community." } \ No newline at end of file diff --git a/config.example.jsonc b/config.example.jsonc index f315d9a..3669d09 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -65,6 +65,12 @@ "address": "toke@42069.weed", "pass": "CHANGE_ME" }, + //Provides customizable links for navbar + "links":{ + "About": "/about", + "Code": "https://git.ourfore.st/rainbownapkin/canopy", + "HRT": "/hrt" + }, //Fills the 'about ${instanceName}' section on the /about page, lets users know about your specific instance "aboutText":"ourfore.st is the one and only original canopy instance. Setup, ran, and administered by rainbownapkin herself. This site exists to provide a featureful, preformant, and comfy replacement for the TTN community." } \ No newline at end of file From dbc27aa87454c8b6526ae499362e2ec728b8380a Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 20 May 2026 23:18:18 -0400 Subject: [PATCH 28/30] Updated package.json --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index c66dc18..54af4f0 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "license": "AGPL-3.0-only", "dependencies": { "@braintree/sanitize-url": "^7.1.1", - "altcha": "^1.0.7", + "altcha": "^2.3.0", "altcha-lib": "^1.2.0", "argon2": "^0.44.0", - "bcrypt": "^5.1.1", + "bcrypt": "^6.0.0", "bootstrap-icons": "^1.11.3", "connect-mongo": "^5.1.0", "cookie-parser": "^1.4.7", @@ -20,8 +20,8 @@ "hls.js": "^1.6.2", "mongoose": "^8.4.3", "node-cron": "^3.0.3", - "nodemailer": "^7.0.9", - "socket.io": "^4.8.1", + "nodemailer": "^8.0.7", + "socket.io": "^4.2.0", "youtube-dl-exec": "^3.0.20" }, "scripts": { From 13e2b9fe11c2ef46865422eb76f3cfe91a0df3b9 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 20 May 2026 09:50:00 -0400 Subject: [PATCH 29/30] Fixed multi-day livestream rendering. --- .../channel/panels/queuePanel/queuePanel.js | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/www/js/channel/panels/queuePanel/queuePanel.js b/www/js/channel/panels/queuePanel/queuePanel.js index 7eefc46..2bfd1c7 100644 --- a/www/js/channel/panels/queuePanel/queuePanel.js +++ b/www/js/channel/panels/queuePanel/queuePanel.js @@ -1233,6 +1233,11 @@ class queuePanel extends panelObj{ //Convert start epoch to JS date object const started = new Date(nowPlaying.startTime); + //If the date the scheduler is set to isn't within the livestream + if(!utils.isSameDate(started, this.day) && !utils.dateWithinRange(started, new Date(), this.day)){ + return; + } + //If this started today if(utils.isSameDate(this.day, started)){ //Set entryDiv top-border location based on start time @@ -1246,15 +1251,29 @@ class queuePanel extends panelObj{ entryDiv.style.top = `${this.offsetByDate(dawn)}px`; //Apply rest of the styling rules for items that started yestarday - entryDiv.classList.add('started-yesterday') + entryDiv.classList.add('started-yesterday'); } //Create entry title const entryTitle = document.createElement('p'); entryTitle.textContent = utils.unescapeEntities(nowPlaying.title); - //Set entry div bottom-border location based on current time, round to match time marker - entryDiv.style.bottom = `${Math.round(this.offsetByDate(date, true))}px` + + //If we're looking at today + if(utils.isSameDate(this.day, new Date())){ + //Set entry div bottom-border location based on current time, round to match time marker + entryDiv.style.bottom = `${Math.round(this.offsetByDate(date, true))}px`; + }else{ + //Get midnight + const dusk = new Date(); + dusk.setHours(23,59,59,999); + + //Set stream to continue to run into the next morning + entryDiv.style.bottom = `${Math.round(this.offsetByDate(dusk, true))}px`; + + //Apply rest of the styling rules for items that end after today + entryDiv.classList.add('ends-tomorrow'); + } //Assembly entryDiv entryDiv.appendChild(entryTitle); @@ -1285,8 +1304,11 @@ class queuePanel extends panelObj{ //Append entry div to queue container this.queueContainer.appendChild(entryDiv); }else{ - //Update existing entry, round offset to match time marker - staleEntry.style.bottom = `${Math.round(this.offsetByDate(date, true))}px` + //If we're looking at today + if(utils.isSameDate(this.day, new Date())){ + //Update existing entry, round offset to match time marker + staleEntry.style.bottom = `${Math.round(this.offsetByDate(date, true))}px` + } } //Keep tooltip date seperate so it re-calculates live duration properly From 3b113df86a7cf65309cbf26e7bafc5eec21eb479 Mon Sep 17 00:00:00 2001 From: rainbownapkin Date: Thu, 21 May 2026 03:15:16 -0400 Subject: [PATCH 30/30] Fixed link-handling for self-referential links in chat --- src/utils/linkUtils.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/utils/linkUtils.js b/src/utils/linkUtils.js index 9e85870..76b8e45 100644 --- a/src/utils/linkUtils.js +++ b/src/utils/linkUtils.js @@ -14,6 +14,9 @@ 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 .*/ +//Config +const config = require('../../config.json'); + //NPM Imports const validator = require('validator');//No express here, so regular validator it is! const {sanitizeUrl} = require("@braintree/sanitize-url"); @@ -32,6 +35,15 @@ module.exports.cache = new Map(); module.exports.markLink = async function(dirtyLink){ const link = sanitizeUrl(dirtyLink); + //If this link is referencing this web server + if(link.match(new RegExp(`^${config.protocol}://${config.domain}`)) != null){ + //Lazily return it as a good link, since we know it'll at least return a good 404 page XP + return { + link, + type: "link" + } + } + //Check link cache for the requested link const cachedLink = module.exports.cache.get(link); @@ -105,4 +117,4 @@ module.exports.markLink = async function(dirtyLink){ //return the link return linkObj; -} \ No newline at end of file +}