diff --git a/Cargo.lock b/Cargo.lock index cdaeba2..b77ba10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3381,7 +3381,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "2.0.0" +version = "2.1.0" dependencies = [ "ammonia", "axum", @@ -3410,7 +3410,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "2.0.0" +version = "2.1.0" dependencies = [ "async-recursion", "base16ct", @@ -3433,7 +3433,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "2.0.0" +version = "2.1.0" dependencies = [ "pathbufd", "serde", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "2.0.0" +version = "2.1.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 689fb17..6538f33 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "2.0.0" +version = "2.1.0" edition = "2024" [features] @@ -35,5 +35,8 @@ cf-turnstile = "0.2.0" contrasted = "0.1.2" futures-util = "0.3.31" -redis = { version = "0.30.0", features = ["aio", "tokio-comp"], optional = true } +redis = { version = "0.30.0", features = [ + "aio", + "tokio-comp", +], optional = true } tower_governor = "0.7.0" diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index c3e7e7a..e370617 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -22,6 +22,7 @@ version = "1.0.0" "general:action.report" = "Report" "general:action.manage" = "Manage" "general:action.open" = "Open" +"general:action.copy_link" = "Copy link" "general:label.safety" = "Safety" "general:label.share" = "Share" "general:action.add_account" = "Add account" @@ -157,3 +158,4 @@ version = "1.0.0" "chats:label.viewing_old_messages" = "You're viewing old messages!" "chats:label.go_back" = "Go back" "chats:action.leave" = "Leave" +"chats:label.viewing_single_message" = "You're viewing a single message!" diff --git a/crates/app/src/public/html/chats/app.html b/crates/app/src/public/html/chats/app.html index 0f8e61e..92a1ed9 100644 --- a/crates/app/src/public/html/chats/app.html +++ b/crates/app/src/public/html/chats/app.html @@ -109,7 +109,7 @@ hide_user_menu=true) }}
window.CURRENT_PAGE = Number.parseInt("{{ page }}"); + window.VIEWING_SINGLE = "{{ message }}".length > 0; window.CHAT_PROPS = { selected_community: "{{ selected_community }}", selected_channel: "{{ selected_channel }}", @@ -457,6 +458,26 @@ hide_user_menu=true) }} window.socket_id = window.CHAT_PROPS.selected_channel; } + if (window.CHANNEL_NOTIFS_INTERVAL) { + window.clearInterval(window.CHANNEL_NOTIFS_INTERVAL); + } + + window.CHANNEL_NOTIFS_INTERVAL = setInterval(() => { + if (!window.CHAT_PROPS.selected_channel) { + return; + } + + if (!window.location.href.includes("{{ selected_channel }}")) { + window.clearInterval(window.CHANNEL_NOTIFS_INTERVAL); + return; + } + + fetch( + `/api/v1/notifications/tag/chats/${window.CHAT_PROPS.selected_channel}`, + { method: "DELETE" }, + ); + }, 10000); + window.socket.addEventListener("open", () => { // auth window.socket.send( @@ -480,7 +501,11 @@ hide_user_menu=true) }} const msg = JSON.parse(event.data); const [channel_id, data] = JSON.parse(msg.data); - if (msg.method === "Message" && window.CURRENT_PAGE === 0) { + if ( + msg.method === "Message" && + window.CURRENT_PAGE === 0 && + window.VIEWING_SINGLE + ) { if (channel_id !== window.CHAT_PROPS.selected_channel) { // message not for us... maybe send notification later // something like /api/v1/messages/{id}/mark_unread diff --git a/crates/app/src/public/html/chats/stream.html b/crates/app/src/public/html/chats/stream.html index 52fc220..b392d54 100644 --- a/crates/app/src/public/html/chats/stream.html +++ b/crates/app/src/public/html/chats/stream.html @@ -11,9 +11,20 @@
{% endif %} - {% for message in messages %} - {{ components::message(user=message[1], message=message[0], grouped=message[2]) }} - {% endfor %} + {% if message %} +
+ {{ text "chats:label.viewing_single_message" }} + + {{ text "chats:label.go_back" }} + +
+ + {{ components::message(user=message_owner, message=message, grouped=false) }} + {% else %} + {% for message in messages %} + {{ components::message(user=message[1], message=message[0], grouped=message[2]) }} + {% endfor %} + {% endif %} {% if messages|length > 0 %}
diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html index 16da4bd..dcf3bf8 100644 --- a/crates/app/src/public/html/components.html +++ b/crates/app/src/public/html/components.html @@ -910,8 +910,7 @@ if state and state.data %} {%- endmacro %} {% macro connection_url(key, value) -%} {% if value[0].data.url %} {{ value[0].data.url }} {% elif key == "LastFm" %} https://last.fm/user/{{ value[0].data.name }} {% endif %} {%- endmacro %} {% macro -message_actions(can_manage_message, user, message) -%} {% if can_manage_message -or (user and user.id == message.owner) %} +message_actions(can_manage_message, user, message) -%}
-{% endif %} {%- endmacro %} {% macro message(user, message, -can_manage_message=false, grouped=false) -%} +{%- endmacro %} {% macro message(user, message, can_manage_message=false, +grouped=false) -%}
, - 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 3d43103..8f70147 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -210,6 +210,10 @@ pub fn routes() -> Router { "/notifications/my", delete(notifications::delete_all_request), ) + .route( + "/notifications/tag/{*tag}", + delete(notifications::delete_all_by_tag_request), + ) .route("/notifications/{id}", delete(notifications::delete_request)) .route( "/notifications/{id}/read_status", @@ -303,15 +307,6 @@ 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/api/v1/notifications.rs b/crates/app/src/routes/api/v1/notifications.rs index 1f6747c..41a4e3c 100644 --- a/crates/app/src/routes/api/v1/notifications.rs +++ b/crates/app/src/routes/api/v1/notifications.rs @@ -45,6 +45,27 @@ pub async fn delete_all_request( } } +pub async fn delete_all_by_tag_request( + jar: CookieJar, + Extension(data): Extension, + Path(tag): Path, +) -> 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.delete_all_notifications_by_tag(&user, &tag).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Notifications cleared".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn update_read_status_request( jar: CookieJar, Extension(data): Extension, diff --git a/crates/app/src/routes/pages/chats.rs b/crates/app/src/routes/pages/chats.rs index 89e0e96..4b6f73a 100644 --- a/crates/app/src/routes/pages/chats.rs +++ b/crates/app/src/routes/pages/chats.rs @@ -107,6 +107,7 @@ pub async fn app_request( context.insert("selected_channel", &selected_channel); context.insert("membership_role", &membership.role.bits()); context.insert("page", &props.page); + context.insert("message", &props.message); context.insert( "can_manage_channels", @@ -143,10 +144,10 @@ pub async fn stream_request( jar: CookieJar, Extension(data): Extension, Path((community, channel)): Path<(usize, usize)>, - Query(props): Query, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; - let mut user = match get_user_from_token!(jar, data.0) { + let user = match get_user_from_token!(jar, data.0) { Some(ua) => ua, None => { return Err(Html( @@ -156,32 +157,6 @@ pub async fn stream_request( }; let ignore_users = data.0.get_userblocks_receivers(user.id).await; - let messages = match data - .0 - .get_messages_by_channel(channel, 24, props.page) - .await - { - Ok(p) => match data.0.fill_messages(p, &ignore_users).await { - Ok(p) => p, - Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), - }, - 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 @@ -195,10 +170,48 @@ pub async fn stream_request( let can_manage_messages = membership.role.check(CommunityPermission::MANAGE_MESSAGES) | user.permissions.check(FinePermission::MANAGE_MESSAGES); + let messages = if props.message == 0 { + match data + .0 + .get_messages_by_channel(channel, 24, props.page) + .await + { + Ok(p) => match data.0.fill_messages(p, &ignore_users).await { + Ok(p) => p, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &Some(user)).await)); + } + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + Vec::new() + }; + + let message = if props.message == 0 { + None + } else { + Some(match data.0.get_message_by_id(props.message).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }) + }; + + let message_owner = if let Some(ref message) = message { + Some(match data.0.get_user_by_id(message.owner).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }) + } else { + None + }; + let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &Some(user)).await; context.insert("messages", &messages); + context.insert("message", &message); + context.insert("message_owner", &message_owner); context.insert("can_manage_messages", &can_manage_messages); context.insert("page", &props.page); @@ -213,7 +226,7 @@ pub async fn stream_request( pub async fn message_request( jar: CookieJar, Extension(data): Extension, - Path((community, _)): Path<(usize, usize)>, + Path((community, channel)): Path<(usize, usize)>, Json(req): Json, ) -> impl IntoResponse { let data = data.read().await; @@ -261,6 +274,9 @@ pub async fn message_request( context.insert("message", &message); context.insert("user", &owner); + context.insert("channel", &channel); + context.insert("community", &community); + // return Ok(Html(data.1.render("chats/message.html", &context).unwrap())) } diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 2334f2e..bfcfafa 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -130,6 +130,8 @@ pub struct ChatsAppQuery { pub page: usize, #[serde(default)] pub nav: bool, + #[serde(default)] + pub message: usize, } #[derive(Deserialize)] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index c8f170e..fe4c626 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "2.0.0" +version = "2.1.0" edition = "2024" [features] @@ -23,7 +23,10 @@ async-recursion = "1.1.1" md-5 = "0.10.6" base16ct = { version = "0.2.0", features = ["alloc"] } -redis = { version = "0.30.0", features = ["aio", "tokio-comp"], optional = true } +redis = { version = "0.30.0", features = [ + "aio", + "tokio-comp", +], optional = true } rusqlite = { version = "0.35.0", optional = true } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 48cca69..b55b8bb 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -45,7 +45,6 @@ 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(), } } @@ -140,7 +139,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, $19)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)", params![ &(data.id as i64), &(data.created as i64), @@ -160,7 +159,6 @@ impl DataManager { &0_i32, &0_i32, &serde_json::to_string(&data.connections).unwrap(), - &serde_json::to_string(&data.subscriptions).unwrap(), ] ); diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs index 65e818e..98378bc 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use super::*; use crate::cache::Cache; +use crate::model::auth::Notification; use crate::model::moderation::AuditLogEntry; use crate::model::socket::{SocketMessage, SocketMethod}; use crate::model::{ @@ -116,7 +117,7 @@ impl DataManager { /// /// # Arguments /// * `data` - a mock [`Message`] object to insert - pub async fn create_message(&self, data: Message) -> Result<()> { + pub async fn create_message(&self, mut data: Message) -> Result<()> { if data.content.len() < 2 { return Err(Error::DataTooLong("content".to_string())); } @@ -125,19 +126,78 @@ impl DataManager { return Err(Error::DataTooLong("content".to_string())); } - let user = self.get_user_by_id(data.owner).await?; + let owner = self.get_user_by_id(data.owner).await?; let channel = self.get_channel_by_id(data.channel).await?; // check user permission in community let membership = self - .get_membership_by_owner_community(user.id, channel.community) + .get_membership_by_owner_community(owner.id, channel.community) .await?; // check user permission to post in channel - if !channel.check_post(user.id, Some(membership.role)) { + if !channel.check_post(owner.id, Some(membership.role)) { return Err(Error::NotAllowed); } + // send mention notifications + let mut already_notified: HashMap = HashMap::new(); + for username in User::parse_mentions(&data.content) { + let user = { + if let Some(ua) = already_notified.get(&username) { + ua.to_owned() + } else { + let user = self.get_user_by_username(&username).await?; + self.create_notification(Notification::new( + "You've been mentioned in a message!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has mentioned you in their [message](/chats/{}/{}?message={}).", + owner.username, owner.id, channel.community, data.channel, data.id + ), + user.id, + )) + .await?; + already_notified.insert(username.to_owned(), user.clone()); + user + } + }; + + data.content = data.content.replace( + &format!("@{username}"), + &format!( + "@{username}", + user.id + ), + ); + } + + // send notifs to members (if this message isn't associated with a channel) + if channel.community == 0 { + for member in [channel.members, vec![channel.owner]].concat() { + if member == owner.id { + continue; + } + + let mut notif = Notification::new( + "You've received a new message!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has sent a [message](/chats/{}/{}?message={}) in [{}](/chats/{}/{}).", + owner.username, + owner.id, + channel.community, + data.channel, + data.id, + channel.title, + channel.community, + data.channel + ), + member, + ); + + notif.tag = format!("chats/{}", channel.id); + self.create_notification(notif).await?; + } + } + // ... let conn = match self.connect().await { Ok(c) => c, diff --git a/crates/core/src/database/notifications.rs b/crates/core/src/database/notifications.rs index 9afd6fe..14e2806 100644 --- a/crates/core/src/database/notifications.rs +++ b/crates/core/src/database/notifications.rs @@ -26,6 +26,7 @@ impl DataManager { content: get!(x->3(String)), owner: get!(x->4(i64)) as usize, read: get!(x->5(i32)) as i8 == 1, + tag: get!(x->6(String)), } } @@ -52,6 +53,27 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all notifications by `tag`. + pub async fn get_notifications_by_tag(&self, tag: &str) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM notifications WHERE tag = $1 ORDER BY created DESC", + &[&tag], + |x| { Self::get_notification_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("notification".to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new notification in the database. /// /// # Arguments @@ -64,14 +86,15 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO notifications VALUES ($1, $2, $3, $4, $5, $6)", + "INSERT INTO notifications VALUES ($1, $2, $3, $4, $5, $6, $7)", params![ &(data.id as i64), &(data.created as i64), &data.title, &data.content, &(data.owner as i64), - &{ if data.read { 1 } else { 0 } } + &{ if data.read { 1 } else { 0 } }, + &data.tag ] ); @@ -167,6 +190,22 @@ impl DataManager { Ok(()) } + pub async fn delete_all_notifications_by_tag(&self, user: &User, tag: &str) -> Result<()> { + let notifications = self.get_notifications_by_tag(tag).await?; + + for notification in notifications { + if user.id != notification.owner + && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) + { + return Err(Error::NotAllowed); + } + + self.delete_notification(notification.id, user).await? + } + + Ok(()) + } + pub async fn update_notification_read( &self, id: usize, diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index c4335a7..6f4103f 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -40,10 +40,6 @@ 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 = @@ -239,7 +235,6 @@ impl User { post_count: 0, request_count: 0, connections: HashMap::new(), - subscriptions: HashMap::new(), } } @@ -392,6 +387,7 @@ pub struct Notification { pub content: String, pub owner: usize, pub read: bool, + pub tag: String, } impl Notification { @@ -407,6 +403,7 @@ impl Notification { content, owner, read: false, + tag: String::new(), } } } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index f157860..7f96ff0 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "2.0.0" +version = "2.1.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 9f91dfc..9c4c940 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "2.0.0" +version = "2.1.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index f107d13..8bd7031 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -28,15 +28,13 @@ pub fn render_markdown(input: &str) -> String { allowed_attributes.insert("align"); allowed_attributes.insert("src"); - allowed_attributes.insert("data-color"); - allowed_attributes.insert("data-font-family"); - Builder::default() .generic_attributes(allowed_attributes) .add_tags(&[ - "video", "source", "img", "b", "span", "p", "i", "strong", "em", + "video", "source", "img", "b", "span", "p", "i", "strong", "em", "a", ]) .rm_tags(&["script", "style", "link", "canvas"]) + .add_tag_attributes("a", &["href", "target"]) .clean(&html) .to_string() .replace("src=\"", "loading=\"lazy\" src=\"/api/v1/util/proxy?url=") diff --git a/sql_changes/notifications_tag.sql b/sql_changes/notifications_tag.sql new file mode 100644 index 0000000..529451c --- /dev/null +++ b/sql_changes/notifications_tag.sql @@ -0,0 +1,7 @@ +-- remove users subscriptions +ALTER TABLE users +DROP COLUMN subscriptions; + +-- add notifications tag +ALTER TABLE notifications +ADD COLUMN tag TEXT NOT NULL DEFAULT ''; diff --git a/sql_changes/users_subscriptions.sql b/sql_changes/users_subscriptions.sql deleted file mode 100644 index eee4301..0000000 --- a/sql_changes/users_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users -ADD COLUMN subscriptions TEXT NOT NULL DEFAULT '{}';