From 98d6f21e6edcba130c82aa4c14a7f294f2715c8f Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 2 May 2025 20:08:35 -0400 Subject: [PATCH] add: live browser notifications --- README.md | 2 +- crates/app/src/avif.rs | 1 + crates/app/src/macros.rs | 1 + crates/app/src/public/html/macros.html | 16 ++- .../app/src/public/html/profile/settings.html | 18 +++ crates/app/src/public/html/root.html | 4 + crates/app/src/public/js/me.js | 116 +++++++++++++++++- crates/app/src/public/js/streams.js | 1 - crates/app/src/routes/api/v1/auth/mod.rs | 1 + .../src/routes/api/v1/auth/subscriptions.rs | 58 +++++++++ crates/app/src/routes/api/v1/mod.rs | 9 ++ crates/app/src/routes/pages/chats.rs | 17 ++- crates/core/src/database/auth.rs | 6 +- .../src/database/drivers/sql/create_users.sql | 3 +- crates/core/src/database/notifications.rs | 32 +++++ crates/core/src/model/auth.rs | 9 +- crates/core/src/model/socket.rs | 10 ++ sql_changes/users_subscriptions.sql | 2 + 18 files changed, 291 insertions(+), 15 deletions(-) create mode 100644 crates/app/src/routes/api/v1/auth/subscriptions.rs create mode 100644 sql_changes/users_subscriptions.sql diff --git a/README.md b/README.md index 54663f5..6c4de21 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Everything Tetratto needs will be built into the main binary. You can build Tetr cargo build -r --no-default-features --features=redis,sqlite ``` -You can replace `sqlite` in the above command with `postgres`, if you'd like. It's also acceptable to remove the `redis` part if you don't want to use a cache. I wouldn't recomment removing cache, though +You can replace `sqlite` in the above command with `postgres`, if you'd like. Redis (or a Redis fork) is required for features such as chats and (realtime) notifications! You can then take the binary and place it somewhere else (highly recommended; the binary will create a fair number of files!). You can do this to move it to a directory just called "tetratto" in the parent directory: diff --git a/crates/app/src/avif.rs b/crates/app/src/avif.rs index f92ea62..524c1d9 100644 --- a/crates/app/src/avif.rs +++ b/crates/app/src/avif.rs @@ -44,6 +44,7 @@ where | (content_type == "image/jpeg") | (content_type == "image/png") | (content_type == "image/webp") + | (content_type == "image/gif") { Bytes::from_request(req, state) .await diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index e16c061..721314d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -105,6 +105,7 @@ macro_rules! check_user_blocked_or_private { .get_userblock_by_initiator_receiver($other_user.id, ua.id) .await .is_ok() + && !ua.permissions.check(FinePermission::MANAGE_USERS) { let lang = get_lang!($jar, $data.0); let mut context = initial_context(&$data.0.0, lang, &$user).await; diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html index 8458e99..95a651b 100644 --- a/crates/app/src/public/html/macros.html +++ b/crates/app/src/public/html/macros.html @@ -51,9 +51,12 @@ class="button {% if selected == 'requests' %}active{% endif %}" title="Requests" > - {{ icon "inbox" }} {% if user.request_count > 0 %} - {{ user.request_count }} - {% endif %} + {{ icon "inbox" }} + {{ user.request_count }} - {% if user.notification_count > 0 %} {{ icon "bell-dot" }} - {{ user.notification_count }} - {% else %} {{ icon "bell" }} {% endif %} +
+
+ Notifications +
+ +
+ + Notifications require you to keep {{ config.name }} open in your browser for real-time updates. This setting does not sync across browsers. +
+
+ + +
{{ text "settings:label.change_password" }} @@ -880,6 +897,7 @@ ui.refresh_container(account_settings, [ "home_timeline", + "notifications", "change_password", "change_username", "two_factor_authentication", diff --git a/crates/app/src/public/html/root.html b/crates/app/src/public/html/root.html index 331b8c2..2e4b46e 100644 --- a/crates/app/src/public/html/root.html +++ b/crates/app/src/public/html/root.html @@ -118,6 +118,10 @@ macros -%} document.getElementById("tokens"), ]); } + + setTimeout(() => { + trigger("me::notifications_stream"); + }, 250); }); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 1e62c1f..7814de2 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -1,5 +1,5 @@ (() => { - const self = reg_ns("me"); + const self = reg_ns("me", ["streams"]); self.LOGIN_ACCOUNT_TOKENS = JSON.parse( window.localStorage.getItem("atto:login_account_tokens") || "{}", @@ -235,6 +235,120 @@ }); }); + self.define("notifications_stream", ({ _, streams }) => { + const element = document.getElementById("notifications_span"); + + streams.subscribe("notifs"); + streams.event("notifs", "message", (data) => { + if (data === "Ping") { + return; + } + + const json = JSON.parse(data); + if (!json.method.Packet) { + console.warn("notifications stream cannot read this message"); + return; + } + + const inner_data = JSON.parse(json.data); + if (json.method.Packet.Crud === "Create") { + const current = Number.parseInt(element.innerText || "0"); + + if (current <= 0) { + element.classList.remove("hidden"); + } + + element.innerText = current + 1; + + // check if we're already connected + const connected = + window.sessionStorage.getItem("atto:connected/notifs") === + "true"; + + if (connected) { + return; + } + + window.sessionStorage.setItem("atto:connected/notifs", "true"); + + // send notification + const enabled = + window.localStorage.getItem("atto:notifs_enabled") === + "true"; + + if (Notification.permission === "granted" && enabled) { + // try to pull notification user + const matches = /\/api\/v1\/auth\/user\/find\/(\d*)/.exec( + inner_data.content, + ); + + // ... + new Notification(inner_data.title, { + body: inner_data.content, + icon: matches[1] + ? `/api/v1/auth/user/${matches[1]}/avatar?selector_type=id` + : "/public/favicon.svg", + lang: "en-US", + }); + + console.info("notification created"); + } + } else if (json.method.Packet.Crud === "Delete") { + const current = Number.parseInt(element.innerText || "0"); + + if (current - 1 <= 0) { + element.classList.add("hidden"); + } + + element.innerText = current - 1; + } else { + console.warn("correct packet type but with wrong data"); + } + }); + }); + + self.define("notifications_button", (_, element) => { + if (Notification.permission === "granted") { + let enabled = + window.localStorage.getItem("atto:notifs_enabled") === "true"; + + function text() { + if (!enabled) { + element.innerText = "Enable notifications"; + } else { + element.innerText = "Disable notifications"; + } + } + + element.addEventListener("click", () => { + enabled = !enabled; + window.localStorage.setItem("atto:notifs_enabled", enabled); + + text(); + }); + + text(); + } else if (Notification.permission !== "denied") { + element.innerText = "Enable notifications"; + element.addEventListener("click", () => { + Notification.requestPermission().then((permission) => { + if (permission === "granted") { + window.localStorage.setItem( + "atto:notifs_enabled", + "true", + ); + + window.location.reload(); + } else { + alert( + "Permission denied! You must allow this permission for browser notifications.", + ); + } + }); + }); + } + }); + // token switcher self.define( "set_login_account_tokens", diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js index e0d9106..add4dd4 100644 --- a/crates/app/src/public/js/streams.js +++ b/crates/app/src/public/js/streams.js @@ -65,7 +65,6 @@ } socket.events[event] = handler; - socket.socket.addEventListener(event, handler); }); self.define("send_packet", async ({ $ }, stream, method, data) => { diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index 3d2067e..bee9b62 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -3,6 +3,7 @@ pub mod images; pub mod ipbans; pub mod profile; pub mod social; +pub mod subscriptions; pub mod user_warnings; use super::{LoginProps, RegisterProps}; diff --git a/crates/app/src/routes/api/v1/auth/subscriptions.rs b/crates/app/src/routes/api/v1/auth/subscriptions.rs new file mode 100644 index 0000000..43e4398 --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/subscriptions.rs @@ -0,0 +1,58 @@ +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{ApiReturn, Error}; +use crate::{get_user_from_token, State}; + +pub async fn update_last_message_request( + jar: CookieJar, + Extension(data): Extension, + Json((channel_id, message_id)): Json<(usize, usize)>, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + user.subscriptions.insert(channel_id, message_id); + + if let Err(e) = data + .update_user_subscriptions(user.id, user.subscriptions) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Subscription connection".to_string(), + payload: (), + }) +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(channel): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + user.subscriptions.remove(&channel); + + if let Err(e) = data + .update_user_subscriptions(user.id, user.subscriptions) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Subscription removed".to_string(), + payload: (), + }) +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index a26d9e9..3d43103 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -303,6 +303,15 @@ pub fn routes() -> Router { ) .route("/messages", post(channels::messages::create_request)) .route("/messages/{id}", delete(channels::messages::delete_request)) + // subscriptions + .route( + "/auth/user/me/subscriptions/{channel_id}/{message_id}", + post(auth::subscriptions::update_last_message_request), + ) + .route( + "/auth/user/me/subscriptions/{channel_id}", + delete(auth::subscriptions::delete_request), + ) } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/chats.rs b/crates/app/src/routes/pages/chats.rs index 56ed917..89e0e96 100644 --- a/crates/app/src/routes/pages/chats.rs +++ b/crates/app/src/routes/pages/chats.rs @@ -146,7 +146,7 @@ pub async fn stream_request( Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; - let user = match get_user_from_token!(jar, data.0) { + let mut user = match get_user_from_token!(jar, data.0) { Some(ua) => ua, None => { return Err(Html( @@ -168,6 +168,21 @@ pub async fn stream_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; + if props.page == 0 { + if let Some(ref last_message) = messages.get(messages.len() - 1) { + user.subscriptions.insert(channel, last_message.0.id); + + // maybe make update_user_subscriptions take a reference to avoid cloning + if let Err(e) = data + .0 + .update_user_subscriptions(user.id, user.subscriptions.clone()) + .await + { + return Err(Html(render_error(e, &jar, &data, &Some(user)).await)); + } + } + } + let membership = match data .0 .get_membership_by_owner_community(user.id, community) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 102a04c..48cca69 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -9,6 +9,7 @@ use crate::model::{ }; use crate::{auto_method, execute, get, query_row, params}; use pathbufd::PathBufD; +use std::collections::HashMap; use std::fs::{exists, remove_file}; use tetratto_shared::hash::{hash_salted, salt}; use tetratto_shared::unix_epoch_timestamp; @@ -44,6 +45,7 @@ impl DataManager { post_count: get!(x->15(i32)) as usize, request_count: get!(x->16(i32)) as usize, connections: serde_json::from_str(&get!(x->17(String)).to_string()).unwrap(), + subscriptions: serde_json::from_str(&get!(x->18(String)).to_string()).unwrap(), } } @@ -138,7 +140,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)", params![ &(data.id as i64), &(data.created as i64), @@ -158,6 +160,7 @@ impl DataManager { &0_i32, &0_i32, &serde_json::to_string(&data.connections).unwrap(), + &serde_json::to_string(&data.subscriptions).unwrap(), ] ); @@ -634,6 +637,7 @@ impl DataManager { auto_method!(update_user_tokens(Vec)@get_user_by_id -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_subscriptions(HashMap)@get_user_by_id -> "UPDATE users SET subscriptions = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(incr_user_notifications()@get_user_by_id -> "UPDATE users SET notification_count = notification_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr); auto_method!(decr_user_notifications()@get_user_by_id -> "UPDATE users SET notification_count = notification_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 767fe73..af853bd 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -16,5 +16,6 @@ CREATE TABLE IF NOT EXISTS users ( recovery_codes TEXT NOT NULL, post_count INT NOT NULL, request_count INT NOT NULL, - connections TEXT NOT NULL + connections TEXT NOT NULL, + subscriptions TEXT NOT NULL ) diff --git a/crates/core/src/database/notifications.rs b/crates/core/src/database/notifications.rs index d38a90f..9afd6fe 100644 --- a/crates/core/src/database/notifications.rs +++ b/crates/core/src/database/notifications.rs @@ -1,8 +1,12 @@ use super::*; use crate::cache::Cache; +use crate::model::socket::{CrudMessageType, PacketType, SocketMessage, SocketMethod}; use crate::model::{Error, Result, auth::Notification, auth::User, permissions::FinePermission}; use crate::{auto_method, execute, get, query_row, query_rows, params}; +#[cfg(feature = "redis")] +use redis::Commands; + #[cfg(feature = "sqlite")] use rusqlite::Row; @@ -78,6 +82,20 @@ impl DataManager { // incr notification count self.incr_user_notifications(data.owner).await.unwrap(); + // post event + let mut con = self.2.get_con().await; + + if let Err(e) = con.publish::( + format!("{}/notifs", data.owner), + serde_json::to_string(&SocketMessage { + method: SocketMethod::Packet(PacketType::Crud(CrudMessageType::Create)), + data: serde_json::to_string(&data).unwrap(), + }) + .unwrap(), + ) { + return Err(Error::MiscError(e.to_string())); + } + // return Ok(()) } @@ -115,6 +133,20 @@ impl DataManager { .unwrap(); } + // post event + let mut con = self.2.get_con().await; + + if let Err(e) = con.publish::( + format!("{}/notifs", notification.owner), + serde_json::to_string(&SocketMessage { + method: SocketMethod::Packet(PacketType::Crud(CrudMessageType::Delete)), + data: notification.id.to_string(), + }) + .unwrap(), + ) { + return Err(Error::MiscError(e.to_string())); + } + // return Ok(()) } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index b42a996..501f2cc 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -40,6 +40,10 @@ pub struct User { /// External service connection details. #[serde(default)] pub connections: UserConnections, + /// Subscribed channels data. Each entry is a channel ID, as well as the ID of + /// the last message the user saw in that channel. + #[serde(default)] + pub subscriptions: HashMap, } pub type UserConnections = @@ -232,6 +236,7 @@ impl User { post_count: 0, request_count: 0, connections: HashMap::new(), + subscriptions: HashMap::new(), } } @@ -370,14 +375,12 @@ pub struct ExternalConnectionInfo { pub show_on_profile: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] -#[derive(Default)] +#[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct ExternalConnectionData { pub external_urls: HashMap, pub data: HashMap, } - #[derive(Debug, Serialize, Deserialize)] pub struct Notification { pub id: usize, diff --git a/crates/core/src/model/socket.rs b/crates/core/src/model/socket.rs index 3b57124..f526aa8 100644 --- a/crates/core/src/model/socket.rs +++ b/crates/core/src/model/socket.rs @@ -1,11 +1,19 @@ use serde::{Serialize, Deserialize, de::DeserializeOwned}; +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum CrudMessageType { + Create, + Delete, +} + #[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum PacketType { /// A regular check to ensure the connection is still alive. Ping, /// General text which can be ignored. Text, + /// A CRUD operation. + Crud(CrudMessageType), } #[derive(Serialize, Deserialize, PartialEq, Eq)] @@ -20,6 +28,8 @@ pub enum SocketMethod { Forward(PacketType), /// A general packet from client to server. (ws to Redis pubsub) Misc(PacketType), + /// A general packet from client to server. (ws to Redis pubsub) + Packet(PacketType), } #[derive(Serialize, Deserialize)] diff --git a/sql_changes/users_subscriptions.sql b/sql_changes/users_subscriptions.sql new file mode 100644 index 0000000..eee4301 --- /dev/null +++ b/sql_changes/users_subscriptions.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN subscriptions TEXT NOT NULL DEFAULT '{}';