Compare commits

...

14 commits

Author SHA1 Message Date
3e4b036ab5 Merge branch 'dev' 2026-05-20 12:51:55 -04:00
rainbownapkin
3b113df86a Fixed link-handling for self-referential links in chat 2026-05-21 03:15:16 -04:00
13e2b9fe11 Fixed multi-day livestream rendering. 2026-05-20 09:50:00 -04:00
dbc27aa874 Updated package.json 2026-05-20 23:18:18 -04:00
a2381fe3bd Updated link section in config example 2026-05-18 02:00:11 -04:00
c82299f94b Added configurable link section to Navbar 2026-05-18 01:54:43 -04:00
d41e9d1df9 Added portrait-mode toggle 2026-05-18 01:21:56 -04:00
f9a6321b7b Fixed 'Move To...' Option in scheduler context menu when right-clicking scheduled item. 2026-05-17 23:32:57 -04:00
42bfdd834f Fixed archive.org backend pulling multiple files when queueing a link to an individual file. 2026-05-17 22:13:43 -04:00
fb226a306c Fixed strikethrough .svg filter for chromium compatibility 2026-05-17 21:01:48 -04:00
258e71323d 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 2026-05-17 19:22:18 -04:00
d7749d3f57 Corrected invisible whitespace on chromium-based browsers for line-breaks in long words. 2026-05-17 19:21:54 -04:00
2905fa21ac Added portrait orientation mode to channel UX. 2026-05-17 19:21:35 -04:00
86d16f1933 Updated Version Number 2026-05-17 19:21:13 -04:00
31 changed files with 221 additions and 67 deletions

View file

@ -9,7 +9,7 @@ Canopy
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/closed.svg"></a> <a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/closed.svg"></a>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank"><img src="https://img.shields.io/badge/License-AGPL_v3-663366.svg"></a> <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank"><img src="https://img.shields.io/badge/License-AGPL_v3-663366.svg"></a>
0.1-Alpha (Panama Red) - Hotfix 2 0.1-Alpha (Panama Red) - Hotfix 3
========= =========
Canopy - /ˈkæ.nə.pi/: Canopy - /ˈkæ.nə.pi/:

View file

@ -34,5 +34,10 @@
"address": "toke@42069.weed", "address": "toke@42069.weed",
"pass": "CHANGE_ME" "pass": "CHANGE_ME"
}, },
"links":{
"About": "/about",
"Code": "https://git.ourfore.st/rainbownapkin/canopy",
"HRT": "/hrt"
},
"aboutText":"<a href=\"https://ourfore.st/\">ourfore.st</a> 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." "aboutText":"<a href=\"https://ourfore.st/\">ourfore.st</a> 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."
} }

View file

@ -65,6 +65,12 @@
"address": "toke@42069.weed", "address": "toke@42069.weed",
"pass": "CHANGE_ME" "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 //Fills the 'about ${instanceName}' section on the /about page, lets users know about your specific instance
"aboutText":"<a href=\"https://ourfore.st/\">ourfore.st</a> 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." "aboutText":"<a href=\"https://ourfore.st/\">ourfore.st</a> 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."
} }

View file

@ -1,14 +1,14 @@
{ {
"name": "canopy-of-alpha", "name": "canopy-of-alpha",
"version": "0.1.2", "version": "0.1.3",
"canopyDisplayVersion": "0.1-Alpha (Panama Red) - Hotfix 2", "canopyDisplayVersion": "0.1-Alpha (Panama Red) - Hotfix 3",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"altcha": "^1.0.7", "altcha": "^2.3.0",
"altcha-lib": "^1.2.0", "altcha-lib": "^1.2.0",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"bcrypt": "^5.1.1", "bcrypt": "^6.0.0",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"connect-mongo": "^5.1.0", "connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@ -20,8 +20,8 @@
"hls.js": "^1.6.2", "hls.js": "^1.6.2",
"mongoose": "^8.4.3", "mongoose": "^8.4.3",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^7.0.9", "nodemailer": "^8.0.7",
"socket.io": "^4.8.1", "socket.io": "^4.2.0",
"youtube-dl-exec": "^3.0.20" "youtube-dl-exec": "^3.0.20"
}, },
"scripts": { "scripts": {

View file

@ -57,7 +57,7 @@ class chatPreprocessor{
//If we don't pass sanatization/validation turn this car around //If we don't pass sanatization/validation turn this car around
if(!this.sanatizeCommand(commandObj)){ if(!this.sanatizeCommand(commandObj)){
return; return false;
} }
//split the command //split the command

View file

@ -26,5 +26,5 @@ module.exports = async function(req, res){
res.status(404); res.status(404);
//Render page //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)});
} }

View file

@ -24,5 +24,5 @@ const csrfUtils = require('../utils/csrfUtils');
//register page functions //register page functions
module.exports.get = async function(req, res){ module.exports.get = async function(req, res){
//Render page //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)});
} }

View file

@ -42,7 +42,7 @@ module.exports.get = async function(req, res){
//Render out the page //Render out the page
return res.render('adminPanel', { return res.render('adminPanel', {
instance: config.instanceName, instance: config.instanceName, links: config.links,
user: req.session.user, user: req.session.user,
rankEnum: permissionModel.rankEnum, rankEnum: permissionModel.rankEnum,
chanGuide: chanGuide, chanGuide: chanGuide,

View file

@ -22,5 +22,5 @@ const csrfUtils = require('../utils/csrfUtils');
//channel functions //channel functions
module.exports.get = function(req, res){ 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)});
} }

View file

@ -42,7 +42,7 @@ module.exports.get = async function(req, res){
throw loggerUtils.exceptionSmith("Channel not found.", "queue"); 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){ }catch(err){
return exceptionHandler(res, err); return exceptionHandler(res, err);
} }

View file

@ -40,18 +40,18 @@ module.exports.get = async function(req, res){
//If we have an invalid request //If we have an invalid request
if(requestDB == null){ 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) //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 //Consume the request
await requestDB.consume(); await requestDB.consume();
}else{ }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){ }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});
} }
} }

View file

@ -24,5 +24,5 @@ const csrfUtils = require('../utils/csrfUtils');
//register page functions //register page functions
module.exports.get = async function(req, res){ module.exports.get = async function(req, res){
//Render page //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)});
} }

View file

@ -29,7 +29,7 @@ const {exceptionHandler, errorHandler} = require('../utils/loggerUtils');
module.exports.get = async function(req, res){ module.exports.get = async function(req, res){
try{ try{
const chanGuide = await channelModel.getChannelList(); 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){ }catch(err){
return exceptionHandler(res, err); return exceptionHandler(res, err);
} }

View file

@ -45,7 +45,7 @@ module.exports.get = async function(req, res){
//if we have previous attempts for this user //if we have previous attempts for this user
if(attempts != null){ if(attempts != null){
if(attempts.count > sessionUtils.maxAttempts){ 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 //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); const challenge = await altchaUtils.genCaptcha(difficulty, user);
//Render page //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 //otherwise
}else{ }else{
//Render generic page //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 //if we received invalid input
}else{ }else{
//Render pretend nothing happened, send out a generic page //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)});
} }
} }

View file

@ -27,5 +27,5 @@ module.exports.get = async function(req, res){
const challenge = await altchaUtils.genCaptcha(); const challenge = await altchaUtils.genCaptcha();
//Render page //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)});
} }

View file

@ -27,5 +27,5 @@ module.exports.get = async function(req, res){
const challenge = await altchaUtils.genCaptcha(); const challenge = await altchaUtils.genCaptcha();
//render the page //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)});
} }

View file

@ -19,5 +19,5 @@ const config = require('../../../config.json');
//popout panel container functions //popout panel container functions
module.exports.get = async function(req, res){ module.exports.get = async function(req, res){
res.render('popoutContainer', {instance: config.instanceName}); res.render('popoutContainer', {instance: config.instanceName, links: config.links});
} }

View file

@ -47,11 +47,11 @@ module.exports.get = async function(req, res){
*/ */
//Render page //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 //If we didn't get a valid token
}else{ }else{
//otherwise render generic page //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){ }catch(err){
return exceptionHandler(res, err); return exceptionHandler(res, err);

View file

@ -42,7 +42,7 @@ module.exports.get = async function(req, res){
const presence = await presenceUtils.getPresence(profile.user); const presence = await presenceUtils.getPresence(profile.user);
res.render('profile', { res.render('profile', {
instance: config.instanceName, instance: config.instanceName, links: config.links,
user: req.session.user, user: req.session.user,
profile, profile,
selfProfile, selfProfile,
@ -52,7 +52,7 @@ module.exports.get = async function(req, res){
}); });
}else{ }else{
res.render('profile', { res.render('profile', {
instance: config.instanceName, instance: config.instanceName, links: config.links,
user: req.session.user, user: req.session.user,
profile: null, profile: null,
selfProfile: false, selfProfile: false,

View file

@ -27,5 +27,5 @@ module.exports.get = async function(req, res){
const challenge = await altchaUtils.genCaptcha(); const challenge = await altchaUtils.genCaptcha();
//Render page //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)});
} }

View file

@ -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 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/>.*/ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//Config
const config = require('../../config.json');
//NPM Imports //NPM Imports
const validator = require('validator');//No express here, so regular validator it is! const validator = require('validator');//No express here, so regular validator it is!
const {sanitizeUrl} = require("@braintree/sanitize-url"); const {sanitizeUrl} = require("@braintree/sanitize-url");
@ -32,6 +35,15 @@ module.exports.cache = new Map();
module.exports.markLink = async function(dirtyLink){ module.exports.markLink = async function(dirtyLink){
const link = sanitizeUrl(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 //Check link cache for the requested link
const cachedLink = module.exports.cache.get(link); const cachedLink = module.exports.cache.get(link);

View file

@ -131,7 +131,7 @@ module.exports.getMediaType = async function(dirtyURL){
} }
//If we have link to a resource from archive.org //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 internet archive upload id and filepath
return { return {
type: "ia", type: "ia",

View file

@ -20,13 +20,24 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
</span> </span>
<span class="navbar-item" id="right-controls"> <span class="navbar-item" id="right-controls">
<% if(user){ %> <% if(user){ %>
<p class="navbar-item">Welcome, <a class="navbar-item" id="username" href="/profile"><%= user.user %></a> - <% if(user.rank == "admin"){ %><a href="/adminPanel" title="Admin Panel" class="bi bi-server navbar-item"></a> - <% } %> <a class="navbar-item" href="/about">About</a> - <a class="navbar-item" href="javascript:" id="logout-button">Logout</a></p> <p class="navbar-item">Welcome, <a class="navbar-item" id="username" href="/profile"><%= user.user %></a> - <% if(user.rank == "admin"){ %>
<a href="/adminPanel" title="Admin Panel" class="bi bi-server navbar-item"></a> -
<% } %>
<% for(link of Object.keys(links)){ %>
<a target="_blank" class="navbar-item" href="<%- links[link] %>"><%= link %></a> -
<% } %>
<a class="navbar-item" href="javascript:" id="logout-button">Logout</a></p>
<% }else{ %> <% }else{ %>
<p class="navbar-item">Remember Me:</p> <p class="navbar-item">Remember Me:</p>
<input class="navbar-item login-prompt" id="remember-me" type="checkbox"> <input class="navbar-item login-prompt" id="remember-me" type="checkbox">
<input class="navbar-item login-prompt" id="username-prompt" placeholder="username"> <input class="navbar-item login-prompt" id="username-prompt" placeholder="username">
<input class="navbar-item login-prompt" id="password-prompt" placeholder="password" type="password"> <input class="navbar-item login-prompt" id="password-prompt" placeholder="password" type="password">
<p class="navbar-item"><a class="navbar-item" href="javascript:" id="login-button">Login</a> - <a class="navbar-item" href="/passwordReset">Forgot Password</a> - <a class="navbar-item" href="/register">Register</a> - <a class="navbar-item" href="/about">About</a></p> <p class="navbar-item"><a class="navbar-item" href="javascript:" id="login-button">Login</a> - <a class="navbar-item" href="/passwordReset">Forgot Password</a> - <a class="navbar-item" href="/register">Register</a>
<% for(link of Object.keys(links)){ %>
- <a target="_blank" class="navbar-item" href="<%- links[link] %>"><%= link %></a>
<% } %></p>
<% } %> <% } %>
</span> </span>
</div> </div>

View file

@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<link rel="stylesheet" type="text/css" href="/css/panel/settings.css"> <link rel="stylesheet" type="text/css" href="/css/panel/settings.css">
<div id="settings-panel"> <div id="settings-panel">
<h2>Client Settings</h2> <h2>Client Settings</h2>
<h4>Player Settings</h4> <h4>Playeback Settings</h4>
<span id="settings-panel-youtube-source" class="settings-panel-setting"> <span id="settings-panel-youtube-source" class="settings-panel-setting">
<p>Youtube Player Type: </p> <p>Youtube Player Type: </p>
<select> <select>
@ -40,11 +40,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<p>Syncronization Delta: </p> <p>Syncronization Delta: </p>
<input type="number"> <input type="number">
</span> </span>
<h4>Chat Settings</h4> <h4>Display Settings</h4>
<span id="settings-panel-min-chat-width" class="settings-panel-setting"> <span id="settings-panel-min-chat-width" class="settings-panel-setting">
<p>Aspect-Ratio Lock Chat Width Minimum: </p> <p>Chat Width Minimum While Locked to Aspect Ratio: </p>
<input type="number"> <input type="number">
</span> </span>
<span id="settings-panel-disable-portrait" class="settings-panel-setting">
<p>Disable Portrait/Mobile Layout: </p>
<input type="checkbox">
</span>
<h4>Notification Settings</h4> <h4>Notification Settings</h4>
<span id="settings-panel-ping-on-pm-rx" class="settings-panel-setting"> <span id="settings-panel-ping-on-pm-rx" class="settings-panel-setting">
<p>Play Sound for received PMs: </p> <p>Play Sound for received PMs: </p>

View file

@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<filter id="strikethroughFilter"> <filter id="strikethroughFilter" primitiveUnits="objectBoundingBox">
<feFlood <feFlood
result="floodFill" result="floodFill"
x="0" height="2%"
y="50%"
width="100%" width="100%"
height="1" x="0"
y="49%"
flood-color="black" flood-color="black"
flood-opacity="1" flood-opacity="1"
/> />

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 413 B

Before After
Before After

View file

@ -288,6 +288,16 @@ class channel{
//Set Chat Box Width minimum while Locked to Aspect-Ratio //Set Chat Box Width minimum while Locked to Aspect-Ratio
this.chatBox.chatWidthMinimum = value / 100; this.chatBox.chatWidthMinimum = value / 100;
return; 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': case 'userlistHidden':
//If the userlist class isn't loaded in yet //If the userlist class isn't loaded in yet
if(this.userList == null){ if(this.userList == null){
@ -326,7 +336,8 @@ class channel{
["rxPMSound", 'unread'], ["rxPMSound", 'unread'],
["txPMSound", false], ["txPMSound", false],
["newSeshSound", true], ["newSeshSound", true],
["endSeshSound", true] ["endSeshSound", true],
["disablePortrait", false]
]); ]);
} }

View file

@ -38,6 +38,11 @@ class chatBox{
*/ */
this.autoScroll = true; 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 * Chat-Width Minimum while sized to media Aspect-Ratio
*/ */
@ -74,6 +79,11 @@ class chatBox{
this.chatPostprocessor = new chatPostprocessor(client); this.chatPostprocessor = new chatPostprocessor(client);
//Element Nodes //Element Nodes
/**
* Channel Div
*/
this.channelDiv = document.querySelector("#channel-flexbox");
/** /**
* Chat Panel Container Div * Chat Panel Container Div
*/ */
@ -473,8 +483,14 @@ class chatBox{
resizeAspect(event){ resizeAspect(event){
const playerHidden = this.client.player.playerDiv.style.display == "none"; const playerHidden = this.client.player.playerDiv.style.display == "none";
//If the aspect is locked and the player is hidden //If window is taller than wide and not in portrait mode, or vice-versa
if(this.aspectLock && !playerHidden){ 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(); this.sizeToAspect();
//Otherwise //Otherwise
}else{ }else{
@ -490,18 +506,34 @@ class chatBox{
* Re-sizes chat box relative to media aspect ratio * Re-sizes chat box relative to media aspect ratio
*/ */
sizeToAspect(){ sizeToAspect(){
//If the chat panel is visible
if(this.chatPanel.style.display != "none"){ if(this.chatPanel.style.display != "none"){
var targetVidWidth = this.client.player.getRatio() * this.chatPanel.getBoundingClientRect().height; //If our window width is more than or equal to window height (not portrait mode)
const targetChatWidth = window.innerWidth - targetVidWidth; if(!this.portrait){
//This should be changeable in settings later on, for now it defaults to 20% //Get target video width by multiplying media ratio by window height
const limit = window.innerWidth * this.chatWidthMinimum; 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 //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`; 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 //This sometimes gets called before userList ahs been initiated :p
if(this.client.userList != null){ if(this.client.userList != null){
this.client.userList.clickDragger.fixCutoff(); this.client.userList.clickDragger.fixCutoff();
@ -509,6 +541,38 @@ class chatBox{
} }
} }
togglePortrait(){
//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;
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();
//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;
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 * Toggles Chat Box UX
* @param {Boolean} show - Whether or not to show Chat Box UX * @param {Boolean} show - Whether or not to show Chat Box UX

View file

@ -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 //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 //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. //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|(?!-|[\u00C0-\u017F])(?<=\w)\b|(?=\s)\B|(?<=\s)\B|/g); const splitString = utils.unescapeEntities(this.rawData.msg).split(/(?<!-)(?<!␜)(?=\w)\b|(?!-|[\u00C0-\u017F])(?<=\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) => {
@ -474,7 +474,7 @@ class chatPostprocessor{
//After eight characters //After eight characters
if(charIndex > 8){ if(charIndex > 8){
//Push an invisible line-break character between every character //Push an invisible line-break character between every character
wordArray.push(""); wordArray.push("");
} }
}); });

View file

@ -116,14 +116,14 @@ class commandPreprocessor{
*/ */
processEmotes(){ processEmotes(){
//inject invisible whitespace in-between emotes to prevent from mushing links together //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 //For each list of emotes
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, pad with invisible whitespace to keep link from getting mushed //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 //Strip out file seperators in-case the user is being a smart-ass
this.message = this.message.replaceAll('␜',''); this.message = this.message.replaceAll('␜','');
//Split message by links //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 //Create an empty array to hold links
this.links = []; this.links = [];
splitMessage.forEach((chunk, chunkIndex) => { splitMessage.forEach((chunk, chunkIndex) => {
//For each chunk that is a link //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 //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) //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 //Replace link with indexed placeholder

View file

@ -1233,6 +1233,11 @@ class queuePanel extends panelObj{
//Convert start epoch to JS date object //Convert start epoch to JS date object
const started = new Date(nowPlaying.startTime); 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 this started today
if(utils.isSameDate(this.day, started)){ if(utils.isSameDate(this.day, started)){
//Set entryDiv top-border location based on start time //Set entryDiv top-border location based on start time
@ -1246,15 +1251,29 @@ class queuePanel extends panelObj{
entryDiv.style.top = `${this.offsetByDate(dawn)}px`; entryDiv.style.top = `${this.offsetByDate(dawn)}px`;
//Apply rest of the styling rules for items that started yestarday //Apply rest of the styling rules for items that started yestarday
entryDiv.classList.add('started-yesterday') entryDiv.classList.add('started-yesterday');
} }
//Create entry title //Create entry title
const entryTitle = document.createElement('p'); const entryTitle = document.createElement('p');
entryTitle.textContent = utils.unescapeEntities(nowPlaying.title); 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 //Assembly entryDiv
entryDiv.appendChild(entryTitle); entryDiv.appendChild(entryTitle);
@ -1285,8 +1304,11 @@ class queuePanel extends panelObj{
//Append entry div to queue container //Append entry div to queue container
this.queueContainer.appendChild(entryDiv); this.queueContainer.appendChild(entryDiv);
}else{ }else{
//Update existing entry, round offset to match time marker //If we're looking at today
staleEntry.style.bottom = `${Math.round(this.offsetByDate(date, true))}px` 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 //Keep tooltip date seperate so it re-calculates live duration properly
@ -1541,7 +1563,7 @@ class reschedulePopup extends schedulePopup{
this.media = media; this.media = media;
} }
startSesh(event){ schedule(event){
//If we clicked or hit enter //If we clicked or hit enter
if(event.key == null || event.key == "Enter"){ if(event.key == null || event.key == "Enter"){
//Get localized input date //Get localized input date

View file

@ -62,6 +62,11 @@ class settingsPanel extends panelObj{
*/ */
this.chatWidthMinimum = this.panelDocument.querySelector("#settings-panel-min-chat-width input"); 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 * Audible Ping on PM Recieved
*/ */
@ -99,6 +104,7 @@ class settingsPanel extends panelObj{
this.liveSyncTolerance.addEventListener('change', this.updateLiveSyncTolerance.bind(this)); this.liveSyncTolerance.addEventListener('change', this.updateLiveSyncTolerance.bind(this));
this.syncDelta.addEventListener('change', this.updateSyncDelta.bind(this)); this.syncDelta.addEventListener('change', this.updateSyncDelta.bind(this));
this.chatWidthMinimum.addEventListener('change', this.updateChatWidthMinimum.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.rxPMSound.addEventListener('change', this.updateRXPMSound.bind(this));
this.txPMSound.addEventListener('change', this.updateTXPMSound.bind(this)); this.txPMSound.addEventListener('change', this.updateTXPMSound.bind(this));
this.newSeshSound.addEventListener('change', this.updateNewPMSeshSound.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.liveSyncTolerance.value = localStorage.getItem("liveSyncTolerance");
this.syncDelta.value = localStorage.getItem("syncDelta"); this.syncDelta.value = localStorage.getItem("syncDelta");
this.chatWidthMinimum.value = localStorage.getItem("chatWidthMin"); this.chatWidthMinimum.value = localStorage.getItem("chatWidthMin");
this.disablePortrait.checked = localStorage.getItem("disablePortrait") == 'true';
this.rxPMSound.value = localStorage.getItem('rxPMSound'); this.rxPMSound.value = localStorage.getItem('rxPMSound');
this.txPMSound.checked = localStorage.getItem('txPMSound') == 'true'; this.txPMSound.checked = localStorage.getItem('txPMSound') == 'true';
this.newSeshSound.checked = localStorage.getItem('newSeshSound') == 'true'; this.newSeshSound.checked = localStorage.getItem('newSeshSound') == 'true';
@ -218,11 +225,20 @@ class settingsPanel extends panelObj{
client.processConfig("chatWidthMin", this.chatWidthMinimum.value); 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 * Handles changes to RX PM Sound setting
*/ */
updateRXPMSound(){ updateRXPMSound(){
localStorage.setItem('rxPMSound', this.rxPMSound.value); localStorage.setItem('rxPMSound', this.rxPMSound.value);
client.processConfig("rxPMSound", this.rxPMSound.value);
} }
/** /**
@ -230,6 +246,7 @@ class settingsPanel extends panelObj{
*/ */
updateTXPMSound(){ updateTXPMSound(){
localStorage.setItem('txPMSound', this.txPMSound.checked); localStorage.setItem('txPMSound', this.txPMSound.checked);
client.processConfig("txPMSound", this.txPMSound.checked);
} }
/** /**
@ -237,6 +254,7 @@ class settingsPanel extends panelObj{
*/ */
updateNewPMSeshSound(){ updateNewPMSeshSound(){
localStorage.setItem('newSeshSound', this.newSeshSound.checked); localStorage.setItem('newSeshSound', this.newSeshSound.checked);
client.processConfig("newSeshSound", this.newSeshSound.checked);
} }
/** /**
@ -244,5 +262,6 @@ class settingsPanel extends panelObj{
*/ */
updateEndPMSeshSound(){ updateEndPMSeshSound(){
localStorage.setItem('endSeshSound', this.endSeshSound.checked); localStorage.setItem('endSeshSound', this.endSeshSound.checked);
client.processConfig("endSeshSound", this.endSeshSound.checked);
} }
} }