From 1384b02f4dd6911f4e27e66291de0a3fc993768e Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 18 Sep 2025 02:43:43 -0400 Subject: [PATCH] Profile pages now display user status. --- README.md | 6 +-- src/controllers/panel/profileController.js | 6 ++- src/controllers/profileController.js | 6 +++ src/schemas/user/userSchema.js | 3 +- src/utils/presenceUtils.js | 59 ++++++++++++++++++++++ src/views/partial/panels/profile.ejs | 1 + src/views/partial/profile/status.ejs | 22 ++++++++ src/views/profile.ejs | 1 + www/css/panel/profile.css | 6 +++ www/css/profile.css | 5 ++ www/css/theme/movie-night.css | 9 ++++ 11 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 src/views/partial/profile/status.ejs diff --git a/README.md b/README.md index 34a3c2e..2f346eb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -Canopy - 0.3-INDEV - Hotfix 1 +Canopy - 0.4-INDEV ====== Canopy - /ˈkæ.nə.pi/: - The upper layer of foliage and branches of a forest, containing the majority of animal life. - - An honest attempt at an freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be. + - An honest attempt at a freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be. Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st. This new codebase intends to solve the following issues with the current CyTube based software: @@ -15,7 +15,7 @@ This new codebase intends to solve the following issues with the current CyTube - General Clunk - Less Unique Community Identity -Canopy intends to be a simple node/express.js app. It leverages yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such. +Canopy is a simple node/express.js app, leveraging yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such. The Canopy codebase does not, nor will it ever contain: - Advertisements (targetted or otherwise) diff --git a/src/controllers/panel/profileController.js b/src/controllers/panel/profileController.js index ac58d44..4a58c7d 100644 --- a/src/controllers/panel/profileController.js +++ b/src/controllers/panel/profileController.js @@ -18,6 +18,7 @@ along with this program. If not, see .*/ const {validationResult, matchedData} = require('express-validator'); //local imports +const presenceUtils = require('../../utils/presenceUtils'); const {userModel} = require('../../schemas/user/userSchema'); const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils'); @@ -30,7 +31,10 @@ module.exports.get = async function(req, res){ const data = matchedData(req); const profile = await userModel.findProfile({user: data.user}); - return res.render('partial/panels/profile', {profile}); + //Pull presence (should be quick since everyone whos been on since last startup will be backed in RAM) + const presence = await presenceUtils.getPresence(profile.user); + + return res.render('partial/panels/profile', {profile, presence}); }else{ res.status(400); return res.send({errors: validResult.array()}) diff --git a/src/controllers/profileController.js b/src/controllers/profileController.js index c37fdf9..fd13cac 100644 --- a/src/controllers/profileController.js +++ b/src/controllers/profileController.js @@ -17,6 +17,7 @@ along with this program. If not, see .*/ //Local Imports const {userModel} = require('../schemas/user/userSchema'); const csrfUtils = require('../utils/csrfUtils'); +const presenceUtils = require('../utils/presenceUtils'); const {exceptionHandler, errorHandler} = require('../utils/loggerUtils'); //Config @@ -34,11 +35,15 @@ module.exports.get = async function(req, res){ //If we have a user, check if the is looking at their own profile const selfProfile = req.session.user ? profile.user == req.session.user.user : false; + //Pull presence (should be quick since everyone whos been on since last startup will be backed in RAM) + const presence = await presenceUtils.getPresence(profile.user); + res.render('profile', { instance: config.instanceName, user: req.session.user, profile, selfProfile, + presence, csrfToken: csrfUtils.generateToken(req) }); }else{ @@ -47,6 +52,7 @@ module.exports.get = async function(req, res){ user: req.session.user, profile: null, selfProfile: false, + presence: null, csrfToken: csrfUtils.generateToken(req) }); } diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 5f718ed..434447c 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -63,7 +63,7 @@ const userSchema = new mongoose.Schema({ lastActive: { type: mongoose.SchemaTypes.Date, required: true, - default: new Date() + default: new Date(0) }, rank: { type: mongoose.SchemaTypes.String, @@ -505,6 +505,7 @@ userSchema.methods.getProfile = function(includeEmail = false){ id: this.id, user: this.user, date: this.date, + lastActive: this.lastActive, tokes: this.tokes, tokeCount: this.getTokeCount(), img: this.img, diff --git a/src/utils/presenceUtils.js b/src/utils/presenceUtils.js index 603b5c3..0096b69 100644 --- a/src/utils/presenceUtils.js +++ b/src/utils/presenceUtils.js @@ -15,6 +15,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see .*/ //local includes +const server = require('../server'); const userSchema = require('../schemas/user/userSchema'); //User activity map to keep us from constantly reading off of the DB @@ -23,6 +24,64 @@ let activityMap = new Map(); //How much difference between last write and now until we hit the DB again (in millis) //Defaults to two minutes const tolerance = 2 * (60 * 1000); +//How long a user has to be in-active to be considered offline +//Defaults to five minutes +const offlineTimeout = 5 * (60 * 1000); + +module.exports.getPresence = async function(user, userDB){ + //If we don't have a user + if(user == null || user == '' || user == 'Tokebot'){ + //Drop that shit + return; + } + + //Set status as offline + let status = "Offline" + //Attempt to pull from activity map to save on DB pull + let activity = activityMap.get(user); + //Pull current epoch in millis + const now = new Date().getTime(); + + //If we couldn't find anything in RAM + if(activity == null){ + //If we wheren't handed a free user doc + if(userDB == null){ + //Pull one from the username + userDB = await userSchema.userModel.findOne({user: user}); + } + + //If for some reason we can't find a user doc + if(userDB == null){ + //Bail with empty status object + return { + status, + activeConnections: [], + lastActive: 0 + } + } + + //Pull last active date from userDB + activity = userDB.lastActive.getTime(); + } + + //Pull active connections for user from the channel manager + const activeConnections = server.channelManager.getConnections(user); + + //If the user is connected to at least one channel + if(activeConnections != null && activeConnections.length > 0){ + status = "Streaming"; + //Otherwise, if it's been five minutes + }else if(now - activity < offlineTimeout){ + status = "Recently Active"; + } + + //Assemble and return status object + return { + status, + activeConnections, + lastActive: activity + } +} module.exports.presenceMiddleware = function(req, res, next){ //Pull user from session diff --git a/src/views/partial/panels/profile.ejs b/src/views/partial/panels/profile.ejs index c2ca2db..b5728c6 100644 --- a/src/views/partial/panels/profile.ejs +++ b/src/views/partial/panels/profile.ejs @@ -20,6 +20,7 @@ along with this program. If not, see . %> <% }else{ %> View Full Profile

<%- profile.user %>

+ <%- include('../profile/status', {profile, presence, auxClass:"panel"}); %>

Toke Count: <%- profile.tokeCount %>

<% if(profile.pronouns != '' && profile.pronouns != null){ %> diff --git a/src/views/partial/profile/status.ejs b/src/views/partial/profile/status.ejs new file mode 100644 index 0000000..d2bdff5 --- /dev/null +++ b/src/views/partial/profile/status.ejs @@ -0,0 +1,22 @@ +<%# 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 . %> +<% if(profile.user == "Tokebot"){ %> +

Perma-Couched

+<% }else{ %> + <% const statusClass = (presence.status == "Streaming") ? "positive" : ((presence.status == "Offline") ? "inactive" : "positive-low");%> + <% const curChan = (presence.activeConnections == null || presence.activeConnections.length <= 0) ? '' : (presence.activeConnections.length == 1 ? ` - /c/${presence.activeConnections[0].channel.name}` : " - Multiple Channels"); %> +

<%- presence.status %><%-curChan%>

+<% } %> \ No newline at end of file diff --git a/src/views/profile.ejs b/src/views/profile.ejs index ffb964b..3c956f0 100644 --- a/src/views/profile.ejs +++ b/src/views/profile.ejs @@ -33,6 +33,7 @@ along with this program. If not, see . %>

<%- profile.user %>

+ <%- include('partial/profile/status', {profile, presence, auxClass: ""}); %> <%- include('partial/profile/image', {profile, selfProfile}); %> <%- include('partial/profile/pronouns', {profile, selfProfile}); %> <%- include('partial/profile/signature', {profile, selfProfile}); %> diff --git a/www/css/panel/profile.css b/www/css/panel/profile.css index 299a2de..5ee912c 100644 --- a/www/css/panel/profile.css +++ b/www/css/panel/profile.css @@ -24,6 +24,12 @@ along with this program. If not, see .*/ .panel.profile-name{ text-align: center; + margin-bottom: 0; +} + +.panel.profile-status{ + text-align: center; + margin-bottom: 1em; } .panel.profile-img{ diff --git a/www/css/profile.css b/www/css/profile.css index 55c00fb..84a4088 100644 --- a/www/css/profile.css +++ b/www/css/profile.css @@ -56,6 +56,11 @@ along with this program. If not, see .*/ margin: 0; } +#profile-status{ + margin: 0; + text-wrap: nowrap; +} + #profile-img{ position: relative; display: flex; diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index d7443ae..fee6c3e 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -31,6 +31,7 @@ along with this program. If not, see .*/ --accent0-alt1: rgb(70, 70, 70); --accent1: rgb(245, 245, 245); --accent1-alt0: rgb(185, 185, 185); + --accent1-alt1: rgb(124, 124, 124); --accent2: var(--accent0-alt0); --focus0: rgb(51, 153, 51); @@ -157,6 +158,14 @@ textarea{ text-shadow: var(--focus-glow0); } +.positive-low{ + color: var(--focus0); +} + +.inactive{ + color: var(--accent1-alt1); +} + .danger-button{ background-color: var(--danger0); color: var(--accent1);