From e0a6072cc41904148d98fe0c5fae94cef402306e Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 2 Apr 2025 14:11:01 -0400 Subject: [PATCH] add: last_online and online indicators --- crates/app/src/public/css/style.css | 2 + crates/app/src/public/html/components.html | 53 +++++++++++++++++-- crates/app/src/public/html/profile/base.html | 8 +++ .../app/src/public/html/profile/settings.html | 5 ++ crates/app/src/public/html/root.html | 9 ++++ crates/app/src/public/js/atto.js | 37 +++++++++++++ crates/app/src/public/js/me.js | 12 +++++ crates/app/src/routes/api/v1/auth/profile.rs | 18 +++++++ crates/app/src/routes/api/v1/mod.rs | 1 + crates/core/src/database/auth.rs | 32 +++++++++-- .../src/database/drivers/sql/create_users.sql | 4 +- crates/core/src/model/auth.rs | 5 ++ 12 files changed, 177 insertions(+), 9 deletions(-) diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 3c8f2de..93a1c19 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -24,6 +24,7 @@ --color-shadow: rgba(0, 0, 0, 0.08); --color-red: hsl(0, 84%, 40%); --color-green: hsl(100, 84%, 20%); + --color-yellow: hsl(41, 63%, 75%); --radius: 6px; --circle: 360px; --shadow-x-offset: 0; @@ -54,6 +55,7 @@ --color-link: #93c5fd; --color-red: hsl(0, 94%, 82%); --color-green: hsl(100, 94%, 82%); + --color-yellow: hsl(41, 63%, 65%); } * { diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html index 128a76d..47a7589 100644 --- a/crates/app/src/public/html/components.html +++ b/crates/app/src/public/html/components.html @@ -111,9 +111,13 @@ show_community=true) -%} {% if community and show_community %}
- {{ components::username(user=owner) }} +
+ {{ components::username(user=owner) }} + + {{ components::online_indicator(user=owner) }} +
{{ post.created }} @@ -274,4 +278,45 @@ show_community=true) -%} {% if community and show_community %} {% endif %}
-{%- endmacro %} +{%- endmacro %} {% macro online_indicator(user) -%} {% if not +user.settings.private_last_online or is_helper %} +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+{% endif %} {%- endmacro %} diff --git a/crates/app/src/public/html/profile/base.html b/crates/app/src/public/html/profile/base.html index f82603b..036ed2a 100644 --- a/crates/app/src/public/html/profile/base.html +++ b/crates/app/src/public/html/profile/base.html @@ -72,6 +72,14 @@ Joined {{ profile.created }}
+ + {% if not profile.settings.private_last_seen or is_self + or is_helper %} +
+ Last seen + {{ profile.last_seen }} +
+ {% endif %} diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html index f275647..5b4d1fe 100644 --- a/crates/app/src/public/html/profile/settings.html +++ b/crates/app/src/public/html/profile/settings.html @@ -493,6 +493,11 @@ "{{ profile.settings.private_communities }}", "checkbox", ], + [ + ["private_last_seen", "Keep my last seen time private"], + "{{ profile.settings.private_last_seen }}", + "checkbox", + ], ], settings, ); diff --git a/crates/app/src/public/html/root.html b/crates/app/src/public/html/root.html index c237b06..19dc934 100644 --- a/crates/app/src/public/html/root.html +++ b/crates/app/src/public/html/root.html @@ -103,6 +103,7 @@ atto["hooks::character_counter.init"](); atto["hooks::long_text.init"](); atto["hooks::alt"](); + atto["hooks::online_indicator"](); // atto["hooks::ips"](); atto["hooks::check_reactions"](); atto["hooks::tabs"](); @@ -110,6 +111,14 @@ }); + {% if user %} + + {% endif %} +
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index b03549e..ccf7757 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -371,6 +371,43 @@ media_theme_pref(); } }); + self.define("last_seen_just_now", (_, last_seen) => { + const now = new Date().getTime(); + const maximum_time_to_be_considered_online = 60000 * 2; // 2 minutes + return now - last_seen <= maximum_time_to_be_considered_online; + }); + + self.define("last_seen_recently", (_, last_seen) => { + const now = new Date().getTime(); + const maximum_time_to_be_considered_idle = 60000 * 5; // 5 minutes + return now - last_seen <= maximum_time_to_be_considered_idle; + }); + + self.define("hooks::online_indicator", ({ $ }) => { + for (const element of Array.from( + document.querySelectorAll("[hook=online_indicator]") || [], + )) { + const last_seen = Number.parseInt( + element.getAttribute("hook-arg:last_seen"), + ); + + const is_online = $.last_seen_just_now(last_seen); + const is_idle = $.last_seen_recently(last_seen); + + const offline = element.querySelector("[hook_ui_ident=offline]"); + const online = element.querySelector("[hook_ui_ident=online]"); + const idle = element.querySelector("[hook_ui_ident=idle]"); + + if (is_online) { + online.style.display = "contents"; + } else if (is_idle) { + idle.style.display = "contents"; + } else { + offline.style.display = "contents"; + } + } + }); + self.define( "hooks::attach_to_partial", ({ $ }, partial, full, attach, wrapper, page, run_on_load) => { diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 04c3fe1..db25c2b 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -150,4 +150,16 @@ `/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`, ); }); + + self.define("seen", () => { + fetch("/api/v1/auth/profile/me/seen", { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + trigger("atto::toast", ["error", res.message]); + } + }); + }); })(); diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 57c9113..c2d5006 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -196,6 +196,24 @@ pub async fn update_user_role_request( } } +/// Update the current user's last seen value. +pub async fn seen_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.seen_user(&user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Delete the given user. pub async fn delete_user_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 63e0c25..b787717 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -147,6 +147,7 @@ pub fn routes() -> Router { "/auth/profile/{id}/verified", post(auth::profile::update_user_is_verified_request), ) + .route("/auth/profile/me/seen", post(auth::profile::seen_request)) .route( "/auth/profile/find/{id}", get(auth::profile::redirect_from_id), diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 8f1938b..18754bd 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -10,6 +10,7 @@ use crate::{auto_method, execute, get, query_row}; use pathbufd::PathBufD; use std::fs::{exists, remove_file}; use tetratto_shared::hash::{hash_salted, salt}; +use tetratto_shared::unix_epoch_timestamp; #[cfg(feature = "sqlite")] use rusqlite::Row; @@ -33,10 +34,10 @@ impl DataManager { tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(), permissions: FinePermission::from_bits(get!(x->7(u32))).unwrap(), is_verified: if get!(x->8(i8)) == 1 { true } else { false }, - // counts notification_count: get!(x->9(isize)) as usize, follower_count: get!(x->10(isize)) as usize, following_count: get!(x->11(isize)) as usize, + last_seen: get!(x->12(isize)) as usize, } } @@ -104,7 +105,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", &[ &data.id.to_string().as_str(), &data.created.to_string().as_str(), @@ -117,7 +118,8 @@ impl DataManager { &(if data.is_verified { 1 } else { 0 }).to_string().as_str(), &0.to_string().as_str(), &0.to_string().as_str(), - &0.to_string().as_str() + &0.to_string().as_str(), + &data.last_seen.to_string().as_str(), ] ); @@ -399,6 +401,30 @@ impl DataManager { Ok(()) } + pub async fn seen_user(&self, user: &User) -> Result<()> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET last_seen = $1 WHERE id = $2", + &[ + &unix_epoch_timestamp().to_string().as_str(), + &user.id.to_string().as_str() + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_user(&user).await; + + Ok(()) + } + pub async fn cache_clear_user(&self, user: &User) { self.2.remove(format!("atto.user:{}", user.id)).await; self.2.remove(format!("atto.user:{}", user.username)).await; diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 752ba68..e69677a 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -8,8 +8,8 @@ CREATE TABLE IF NOT EXISTS users ( tokens TEXT NOT NULL, permissions INTEGER NOT NULL, verified INTEGER NOT NULL, - -- counts notification_count INTEGER NOT NULL, follower_count INTEGER NOT NULL, - following_count INTEGER NOT NULL + following_count INTEGER NOT NULL, + last_seen INTEGER NOT NULL ) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index bd1e2d7..dae827d 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -23,6 +23,7 @@ pub struct User { pub notification_count: usize, pub follower_count: usize, pub following_count: usize, + pub last_seen: usize, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -50,6 +51,8 @@ pub struct UserSettings { pub private_communities: bool, #[serde(default)] pub theme_preference: ThemePreference, + #[serde(default)] + pub private_last_seen: bool, } impl Default for UserSettings { @@ -60,6 +63,7 @@ impl Default for UserSettings { private_profile: false, private_communities: false, theme_preference: ThemePreference::default(), + private_last_seen: false, } } } @@ -92,6 +96,7 @@ impl User { notification_count: 0, follower_count: 0, following_count: 0, + last_seen: unix_epoch_timestamp() as usize, } }