diff --git a/crates/api/src/comment/like.rs b/crates/api/src/comment/like.rs index e93b8513f..39e2e5338 100644 --- a/crates/api/src/comment/like.rs +++ b/crates/api/src/comment/like.rs @@ -5,10 +5,10 @@ use lemmy_api_common::{ comment::{CommentResponse, CreateCommentLike}, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, - utils::{check_bot_account, check_community_user_action, check_local_vote_mode, VoteItem}, + utils::{check_bot_account, check_community_user_action, check_local_vote_mode}, }; use lemmy_db_schema::{ - newtypes::LocalUserId, + newtypes::{LocalUserId, PostOrCommentId}, source::{ comment::{CommentLike, CommentLikeForm}, comment_reply::CommentReply, @@ -33,7 +33,7 @@ pub async fn like_comment( check_local_vote_mode( data.score, - VoteItem::Comment(comment_id), + PostOrCommentId::Comment(comment_id), &local_site, local_user_view.person.id, &mut context.pool(), diff --git a/crates/api/src/local_user/notifications/list_post_mentions.rs b/crates/api/src/local_user/notifications/list_post_mentions.rs new file mode 100644 index 000000000..e39dc59af --- /dev/null +++ b/crates/api/src/local_user/notifications/list_post_mentions.rs @@ -0,0 +1,36 @@ +use actix_web::web::{Data, Json, Query}; +use lemmy_api_common::{ + context::LemmyContext, + person::{GetPersonPostMentions, GetPersonPostMentionsResponse}, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_db_views_actor::person_post_mention_view::PersonPostMentionQuery; +use lemmy_utils::error::LemmyResult; + +#[tracing::instrument(skip(context))] +pub async fn list_post_mentions( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let sort = data.sort; + let page = data.page; + let limit = data.limit; + let unread_only = data.unread_only.unwrap_or_default(); + let person_id = Some(local_user_view.person.id); + let show_bot_accounts = local_user_view.local_user.show_bot_accounts; + + let post_mentions = PersonPostMentionQuery { + recipient_id: person_id, + my_person_id: person_id, + sort, + unread_only, + show_bot_accounts, + page, + limit, + } + .list(&mut context.pool()) + .await?; + + Ok(Json(GetPersonPostMentionsResponse { post_mentions })) +} diff --git a/crates/api/src/local_user/notifications/mark_post_mention_read.rs b/crates/api/src/local_user/notifications/mark_post_mention_read.rs new file mode 100644 index 000000000..35a12c65c --- /dev/null +++ b/crates/api/src/local_user/notifications/mark_post_mention_read.rs @@ -0,0 +1,47 @@ +use actix_web::web::{Data, Json}; +use lemmy_api_common::{ + context::LemmyContext, + person::{MarkPersonPostMentionAsRead, PersonPostMentionResponse}, +}; +use lemmy_db_schema::{ + source::person_post_mention::{PersonPostMention, PersonPostMentionUpdateForm}, + traits::Crud, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_db_views_actor::structs::PersonPostMentionView; +use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; + +#[tracing::instrument(skip(context))] +pub async fn mark_post_mention_as_read( + data: Json, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let person_post_mention_id = data.person_post_mention_id; + let read_person_post_mention = + PersonPostMention::read(&mut context.pool(), person_post_mention_id).await?; + + if local_user_view.person.id != read_person_post_mention.recipient_id { + Err(LemmyErrorType::CouldntUpdatePost)? + } + + let person_post_mention_id = read_person_post_mention.id; + let read = Some(data.read); + PersonPostMention::update( + &mut context.pool(), + person_post_mention_id, + &PersonPostMentionUpdateForm { read }, + ) + .await + .with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?; + + let person_post_mention_id = read_person_post_mention.id; + let person_id = local_user_view.person.id; + let person_post_mention_view = + PersonPostMentionView::read(&mut context.pool(), person_post_mention_id, Some(person_id)) + .await?; + + Ok(Json(PersonPostMentionResponse { + person_post_mention_view, + })) +} diff --git a/crates/api/src/local_user/notifications/mod.rs b/crates/api/src/local_user/notifications/mod.rs index a3580b930..e62905109 100644 --- a/crates/api/src/local_user/notifications/mod.rs +++ b/crates/api/src/local_user/notifications/mod.rs @@ -1,6 +1,8 @@ pub mod list_comment_mentions; +pub mod list_post_mentions; pub mod list_replies; pub mod mark_all_read; pub mod mark_comment_mention_read; +pub mod mark_post_mention_read; pub mod mark_reply_read; pub mod unread_count; diff --git a/crates/api/src/local_user/notifications/unread_count.rs b/crates/api/src/local_user/notifications/unread_count.rs index 90891f21e..62bf7a0c9 100644 --- a/crates/api/src/local_user/notifications/unread_count.rs +++ b/crates/api/src/local_user/notifications/unread_count.rs @@ -1,7 +1,11 @@ use actix_web::web::{Data, Json}; use lemmy_api_common::{context::LemmyContext, person::GetUnreadCountResponse}; use lemmy_db_views::structs::{LocalUserView, PrivateMessageView}; -use lemmy_db_views_actor::structs::{CommentReplyView, PersonCommentMentionView}; +use lemmy_db_views_actor::structs::{ + CommentReplyView, + PersonCommentMentionView, + PersonPostMentionView, +}; use lemmy_utils::error::LemmyResult; #[tracing::instrument(skip(context))] @@ -14,16 +18,21 @@ pub async fn unread_count( let replies = CommentReplyView::get_unread_count(&mut context.pool(), &local_user_view.local_user).await?; - let mentions = + let comment_mentions = PersonCommentMentionView::get_unread_count(&mut context.pool(), &local_user_view.local_user) .await?; + let post_mentions = + PersonPostMentionView::get_unread_count(&mut context.pool(), &local_user_view.local_user) + .await?; + let private_messages = PrivateMessageView::get_unread_count(&mut context.pool(), person_id).await?; Ok(Json(GetUnreadCountResponse { replies, - mentions, + comment_mentions, + post_mentions, private_messages, })) } diff --git a/crates/api/src/post/like.rs b/crates/api/src/post/like.rs index c81d9630a..e85523175 100644 --- a/crates/api/src/post/like.rs +++ b/crates/api/src/post/like.rs @@ -10,10 +10,10 @@ use lemmy_api_common::{ check_community_user_action, check_local_vote_mode, mark_post_as_read, - VoteItem, }, }; use lemmy_db_schema::{ + newtypes::PostOrCommentId, source::{ community::Community, local_site::LocalSite, @@ -36,7 +36,7 @@ pub async fn like_post( check_local_vote_mode( data.score, - VoteItem::Post(post_id), + PostOrCommentId::Post(post_id), &local_site, local_user_view.person.id, &mut context.pool(), diff --git a/crates/api_common/src/build_response.rs b/crates/api_common/src/build_response.rs index 8e79867f1..e5f5f2024 100644 --- a/crates/api_common/src/build_response.rs +++ b/crates/api_common/src/build_response.rs @@ -12,13 +12,14 @@ use crate::{ }; use actix_web::web::Json; use lemmy_db_schema::{ - newtypes::{CommentId, CommunityId, LocalUserId, PostId}, + newtypes::{CommentId, CommunityId, LocalUserId, PostId, PostOrCommentId}, source::{ actor_language::CommunityLanguage, comment::Comment, comment_reply::{CommentReply, CommentReplyInsertForm}, person::Person, person_comment_mention::{PersonCommentMention, PersonCommentMentionInsertForm}, + person_post_mention::{PersonPostMention, PersonPostMentionInsertForm}, }, traits::Crud, }; @@ -92,7 +93,7 @@ pub async fn build_post_response( #[tracing::instrument(skip_all)] pub async fn send_local_notifs( mentions: Vec, - comment_id: CommentId, + post_or_comment_id: PostOrCommentId, person: &Person, do_send_email: bool, context: &LemmyContext, @@ -103,15 +104,32 @@ pub async fn send_local_notifs( // let person = my_local_user.person; // Read the comment view to get extra info - let comment_view = CommentView::read( - &mut context.pool(), - comment_id, - local_user_view.map(|view| &view.local_user), - ) - .await?; - let comment = comment_view.comment; - let post = comment_view.post; - let community = comment_view.community; + + let (comment_opt, post, community) = match post_or_comment_id { + PostOrCommentId::Post(post_id) => { + let post_view = PostView::read( + &mut context.pool(), + post_id, + local_user_view.map(|view| &view.local_user), + false, + ) + .await?; + (None, post_view.post, post_view.community) + } + PostOrCommentId::Comment(comment_id) => { + let comment_view = CommentView::read( + &mut context.pool(), + comment_id, + local_user_view.map(|view| &view.local_user), + ) + .await?; + ( + Some(comment_view.comment), + comment_view.post, + comment_view.community, + ) + } + }; // Send the local mentions for mention in mentions @@ -127,22 +145,38 @@ pub async fn send_local_notifs( // below by checking recipient ids recipient_ids.push(mention_user_view.local_user.id); - let person_comment_mention_form = PersonCommentMentionInsertForm { - recipient_id: mention_user_view.person.id, - comment_id, - read: None, - }; + // Make the correct reply form depending on whether its a post or comment mention + let comment_content_or_post_body = if let Some(comment) = &comment_opt { + let person_comment_mention_form = PersonCommentMentionInsertForm { + recipient_id: mention_user_view.person.id, + comment_id: comment.id, + read: None, + }; - // Allow this to fail softly, since comment edits might re-update or replace it - // Let the uniqueness handle this fail - PersonCommentMention::create(&mut context.pool(), &person_comment_mention_form) - .await - .ok(); + // Allow this to fail softly, since comment edits might re-update or replace it + // Let the uniqueness handle this fail + PersonCommentMention::create(&mut context.pool(), &person_comment_mention_form) + .await + .ok(); + comment.content.clone() + } else { + let person_post_mention_form = PersonPostMentionInsertForm { + recipient_id: mention_user_view.person.id, + post_id: post.id, + read: None, + }; + + // Allow this to fail softly, since edits might re-update or replace it + PersonPostMention::create(&mut context.pool(), &person_post_mention_form) + .await + .ok(); + post.body.clone().unwrap_or_default() + }; // Send an email to those local users that have notifications on if do_send_email { let lang = get_interface_language(&mention_user_view); - let content = markdown_to_html(&comment.content); + let content = markdown_to_html(&comment_content_or_post_body); send_email_to_user( &mention_user_view, &lang.notification_mentioned_by_subject(&person.name), @@ -155,99 +189,101 @@ pub async fn send_local_notifs( } // Send comment_reply to the parent commenter / poster - if let Some(parent_comment_id) = comment.parent_comment_id() { - let parent_comment = Comment::read(&mut context.pool(), parent_comment_id).await?; + if let Some(comment) = &comment_opt { + if let Some(parent_comment_id) = comment.parent_comment_id() { + let parent_comment = Comment::read(&mut context.pool(), parent_comment_id).await?; - // Get the parent commenter local_user - let parent_creator_id = parent_comment.creator_id; + // Get the parent commenter local_user + let parent_creator_id = parent_comment.creator_id; - let check_blocks = check_person_instance_community_block( - person.id, - parent_creator_id, - // Only block from the community's instance_id - community.instance_id, - community.id, - &mut context.pool(), - ) - .await - .is_err(); + let check_blocks = check_person_instance_community_block( + person.id, + parent_creator_id, + // Only block from the community's instance_id + community.instance_id, + community.id, + &mut context.pool(), + ) + .await + .is_err(); - // Don't send a notif to yourself - if parent_comment.creator_id != person.id && !check_blocks { - let user_view = LocalUserView::read_person(&mut context.pool(), parent_creator_id).await; - if let Ok(parent_user_view) = user_view { - // Don't duplicate notif if already mentioned by checking recipient ids - if !recipient_ids.contains(&parent_user_view.local_user.id) { - recipient_ids.push(parent_user_view.local_user.id); + // Don't send a notif to yourself + if parent_comment.creator_id != person.id && !check_blocks { + let user_view = LocalUserView::read_person(&mut context.pool(), parent_creator_id).await; + if let Ok(parent_user_view) = user_view { + // Don't duplicate notif if already mentioned by checking recipient ids + if !recipient_ids.contains(&parent_user_view.local_user.id) { + recipient_ids.push(parent_user_view.local_user.id); - let comment_reply_form = CommentReplyInsertForm { - recipient_id: parent_user_view.person.id, - comment_id: comment.id, - read: None, - }; + let comment_reply_form = CommentReplyInsertForm { + recipient_id: parent_user_view.person.id, + comment_id: comment.id, + read: None, + }; - // Allow this to fail softly, since comment edits might re-update or replace it - // Let the uniqueness handle this fail - CommentReply::create(&mut context.pool(), &comment_reply_form) - .await - .ok(); + // Allow this to fail softly, since comment edits might re-update or replace it + // Let the uniqueness handle this fail + CommentReply::create(&mut context.pool(), &comment_reply_form) + .await + .ok(); - if do_send_email { - let lang = get_interface_language(&parent_user_view); - let content = markdown_to_html(&comment.content); - send_email_to_user( - &parent_user_view, - &lang.notification_comment_reply_subject(&person.name), - &lang.notification_comment_reply_body(&content, &inbox_link, &person.name), - context.settings(), - ) - .await + if do_send_email { + let lang = get_interface_language(&parent_user_view); + let content = markdown_to_html(&comment.content); + send_email_to_user( + &parent_user_view, + &lang.notification_comment_reply_subject(&person.name), + &lang.notification_comment_reply_body(&content, &inbox_link, &person.name), + context.settings(), + ) + .await + } } } } - } - } else { - // Use the post creator to check blocks - let check_blocks = check_person_instance_community_block( - person.id, - post.creator_id, - // Only block from the community's instance_id - community.instance_id, - community.id, - &mut context.pool(), - ) - .await - .is_err(); + } else { + // Use the post creator to check blocks + let check_blocks = check_person_instance_community_block( + person.id, + post.creator_id, + // Only block from the community's instance_id + community.instance_id, + community.id, + &mut context.pool(), + ) + .await + .is_err(); - if post.creator_id != person.id && !check_blocks { - let creator_id = post.creator_id; - let parent_user = LocalUserView::read_person(&mut context.pool(), creator_id).await; - if let Ok(parent_user_view) = parent_user { - if !recipient_ids.contains(&parent_user_view.local_user.id) { - recipient_ids.push(parent_user_view.local_user.id); + if post.creator_id != person.id && !check_blocks { + let creator_id = post.creator_id; + let parent_user = LocalUserView::read_person(&mut context.pool(), creator_id).await; + if let Ok(parent_user_view) = parent_user { + if !recipient_ids.contains(&parent_user_view.local_user.id) { + recipient_ids.push(parent_user_view.local_user.id); - let comment_reply_form = CommentReplyInsertForm { - recipient_id: parent_user_view.person.id, - comment_id: comment.id, - read: None, - }; + let comment_reply_form = CommentReplyInsertForm { + recipient_id: parent_user_view.person.id, + comment_id: comment.id, + read: None, + }; - // Allow this to fail softly, since comment edits might re-update or replace it - // Let the uniqueness handle this fail - CommentReply::create(&mut context.pool(), &comment_reply_form) - .await - .ok(); + // Allow this to fail softly, since comment edits might re-update or replace it + // Let the uniqueness handle this fail + CommentReply::create(&mut context.pool(), &comment_reply_form) + .await + .ok(); - if do_send_email { - let lang = get_interface_language(&parent_user_view); - let content = markdown_to_html(&comment.content); - send_email_to_user( - &parent_user_view, - &lang.notification_post_reply_subject(&person.name), - &lang.notification_post_reply_body(&content, &inbox_link, &person.name), - context.settings(), - ) - .await + if do_send_email { + let lang = get_interface_language(&parent_user_view); + let content = markdown_to_html(&comment.content); + send_email_to_user( + &parent_user_view, + &lang.notification_post_reply_subject(&person.name), + &lang.notification_post_reply_body(&content, &inbox_link, &person.name), + context.settings(), + ) + .await + } } } } diff --git a/crates/api_common/src/person.rs b/crates/api_common/src/person.rs index a4020f57e..cbe510e3c 100644 --- a/crates/api_common/src/person.rs +++ b/crates/api_common/src/person.rs @@ -1,5 +1,12 @@ use lemmy_db_schema::{ - newtypes::{CommentReplyId, CommunityId, LanguageId, PersonCommentMentionId, PersonId}, + newtypes::{ + CommentReplyId, + CommunityId, + LanguageId, + PersonCommentMentionId, + PersonId, + PersonPostMentionId, + }, sensitive::SensitiveString, source::{login_token::LoginToken, site::Site}, CommentSortType, @@ -12,6 +19,7 @@ use lemmy_db_views_actor::structs::{ CommentReplyView, CommunityModeratorView, PersonCommentMentionView, + PersonPostMentionView, PersonView, }; use serde::{Deserialize, Serialize}; @@ -325,6 +333,43 @@ pub struct PersonCommentMentionResponse { pub person_comment_mention_view: PersonCommentMentionView, } +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Get mentions for your user. +pub struct GetPersonPostMentions { + pub sort: Option, + pub page: Option, + pub limit: Option, + pub unread_only: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// The response of mentions for your user. +pub struct GetPersonPostMentionsResponse { + pub post_mentions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Mark a person mention as read. +pub struct MarkPersonPostMentionAsRead { + pub person_post_mention_id: PersonPostMentionId, + pub read: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// The response for a person mention action. +pub struct PersonPostMentionResponse { + pub person_post_mention_view: PersonPostMentionView, +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] @@ -396,7 +441,8 @@ pub struct GetReportCountResponse { /// A response containing counts for your notifications. pub struct GetUnreadCountResponse { pub replies: i64, - pub mentions: i64, + pub comment_mentions: i64, + pub post_mentions: i64, pub private_messages: i64, } diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index e358d483b..069523533 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Days, Local, TimeZone, Utc}; use enum_map::{enum_map, EnumMap}; use lemmy_db_schema::{ aggregates::structs::{PersonPostAggregates, PersonPostAggregatesForm}, - newtypes::{CommentId, CommunityId, DbUrl, InstanceId, PersonId, PostId}, + newtypes::{CommentId, CommunityId, DbUrl, InstanceId, PersonId, PostId, PostOrCommentId}, source::{ comment::{Comment, CommentLike, CommentUpdateForm}, community::{Community, CommunityModerator, CommunityUpdateForm}, @@ -301,23 +301,17 @@ pub async fn check_person_instance_community_block( Ok(()) } -/// A vote item type used to check the vote mode. -pub enum VoteItem { - Post(PostId), - Comment(CommentId), -} - #[tracing::instrument(skip_all)] pub async fn check_local_vote_mode( score: i16, - vote_item: VoteItem, + post_or_comment_id: PostOrCommentId, local_site: &LocalSite, person_id: PersonId, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { - let (downvote_setting, upvote_setting) = match vote_item { - VoteItem::Post(_) => (local_site.post_downvotes, local_site.post_upvotes), - VoteItem::Comment(_) => (local_site.comment_downvotes, local_site.comment_upvotes), + let (downvote_setting, upvote_setting) = match post_or_comment_id { + PostOrCommentId::Post(_) => (local_site.post_downvotes, local_site.post_upvotes), + PostOrCommentId::Comment(_) => (local_site.comment_downvotes, local_site.comment_upvotes), }; let downvote_fail = score == -1 && downvote_setting == FederationMode::Disable; @@ -325,9 +319,11 @@ pub async fn check_local_vote_mode( // Undo previous vote for item if new vote fails if downvote_fail || upvote_fail { - match vote_item { - VoteItem::Post(post_id) => PostLike::remove(pool, person_id, post_id).await?, - VoteItem::Comment(comment_id) => CommentLike::remove(pool, person_id, comment_id).await?, + match post_or_comment_id { + PostOrCommentId::Post(post_id) => PostLike::remove(pool, person_id, post_id).await?, + PostOrCommentId::Comment(comment_id) => { + CommentLike::remove(pool, person_id, comment_id).await? + } }; } Ok(()) diff --git a/crates/api_crud/src/comment/create.rs b/crates/api_crud/src/comment/create.rs index 1347918c2..b0d3f4d13 100644 --- a/crates/api_crud/src/comment/create.rs +++ b/crates/api_crud/src/comment/create.rs @@ -17,6 +17,7 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ impls::actor_language::default_post_language, + newtypes::PostOrCommentId, source::{ actor_language::CommunityLanguage, comment::{Comment, CommentInsertForm, CommentLike, CommentLikeForm}, @@ -121,7 +122,7 @@ pub async fn create_comment( let mentions = scrape_text_for_mentions(&content); let recipient_ids = send_local_notifs( mentions, - inserted_comment_id, + PostOrCommentId::Comment(inserted_comment_id), &local_user_view.person, true, &context, diff --git a/crates/api_crud/src/comment/delete.rs b/crates/api_crud/src/comment/delete.rs index 2b5f35827..7a864b4e7 100644 --- a/crates/api_crud/src/comment/delete.rs +++ b/crates/api_crud/src/comment/delete.rs @@ -8,6 +8,7 @@ use lemmy_api_common::{ utils::check_community_user_action, }; use lemmy_db_schema::{ + newtypes::PostOrCommentId, source::comment::{Comment, CommentUpdateForm}, traits::Crud, }; @@ -60,7 +61,7 @@ pub async fn delete_comment( let recipient_ids = send_local_notifs( vec![], - comment_id, + PostOrCommentId::Comment(comment_id), &local_user_view.person, false, &context, diff --git a/crates/api_crud/src/comment/remove.rs b/crates/api_crud/src/comment/remove.rs index 3c137a984..ae4386bdd 100644 --- a/crates/api_crud/src/comment/remove.rs +++ b/crates/api_crud/src/comment/remove.rs @@ -8,6 +8,7 @@ use lemmy_api_common::{ utils::check_community_mod_action, }; use lemmy_db_schema::{ + newtypes::PostOrCommentId, source::{ comment::{Comment, CommentUpdateForm}, comment_report::CommentReport, @@ -82,7 +83,7 @@ pub async fn remove_comment( let recipient_ids = send_local_notifs( vec![], - comment_id, + PostOrCommentId::Comment(comment_id), &local_user_view.person, false, &context, diff --git a/crates/api_crud/src/comment/update.rs b/crates/api_crud/src/comment/update.rs index 51f65aa67..ae9e1b99b 100644 --- a/crates/api_crud/src/comment/update.rs +++ b/crates/api_crud/src/comment/update.rs @@ -13,6 +13,7 @@ use lemmy_api_common::{ }, }; use lemmy_db_schema::{ + newtypes::PostOrCommentId, source::{ actor_language::CommunityLanguage, comment::{Comment, CommentUpdateForm}, @@ -87,7 +88,7 @@ pub async fn update_comment( let mentions = scrape_text_for_mentions(&updated_comment_content); let recipient_ids = send_local_notifs( mentions, - comment_id, + PostOrCommentId::Comment(comment_id), &local_user_view.person, false, &context, diff --git a/crates/api_crud/src/post/create.rs b/crates/api_crud/src/post/create.rs index 90c68bdbd..d9ee9cb7a 100644 --- a/crates/api_crud/src/post/create.rs +++ b/crates/api_crud/src/post/create.rs @@ -2,7 +2,7 @@ use super::convert_published_time; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_common::{ - build_response::build_post_response, + build_response::{build_post_response, send_local_notifs}, context::LemmyContext, post::{CreatePost, PostResponse}, request::generate_post_link_metadata, @@ -18,6 +18,7 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ impls::actor_language::default_post_language, + newtypes::PostOrCommentId, source::{ actor_language::CommunityLanguage, community::Community, @@ -34,6 +35,7 @@ use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, spawn_try_task, utils::{ + mention::scrape_text_for_mentions, slurs::check_slurs, validation::{ is_url_blocked, @@ -169,6 +171,18 @@ pub async fn create_post( .await .with_lemmy_type(LemmyErrorType::CouldntLikePost)?; + // Scan the post body for user mentions, add those rows + let mentions = scrape_text_for_mentions(&inserted_post.body.clone().unwrap_or_default()); + send_local_notifs( + mentions, + PostOrCommentId::Post(inserted_post.id), + &local_user_view.person, + true, + &context, + Some(&local_user_view), + ) + .await?; + mark_post_as_read(person_id, post_id, &mut context.pool()).await?; build_post_response(&context, community_id, local_user_view, post_id).await diff --git a/crates/api_crud/src/post/update.rs b/crates/api_crud/src/post/update.rs index cef8bfea8..7b1cb7629 100644 --- a/crates/api_crud/src/post/update.rs +++ b/crates/api_crud/src/post/update.rs @@ -2,7 +2,7 @@ use super::{convert_published_time, create::send_webmention}; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_common::{ - build_response::build_post_response, + build_response::{build_post_response, send_local_notifs}, context::LemmyContext, post::{EditPost, PostResponse}, request::generate_post_link_metadata, @@ -15,6 +15,7 @@ use lemmy_api_common::{ }, }; use lemmy_db_schema::{ + newtypes::PostOrCommentId, source::{ actor_language::CommunityLanguage, community::Community, @@ -28,6 +29,7 @@ use lemmy_db_views::structs::LocalUserView; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, utils::{ + mention::scrape_text_for_mentions, slurs::check_slurs, validation::{ is_url_blocked, @@ -142,6 +144,18 @@ pub async fn update_post( .await .with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?; + // Scan the post body for user mentions, add those rows + let mentions = scrape_text_for_mentions(&updated_post.body.clone().unwrap_or_default()); + send_local_notifs( + mentions, + PostOrCommentId::Post(updated_post.id), + &local_user_view.person, + false, + &context, + Some(&local_user_view), + ) + .await?; + // send out federation/webmention if necessary match ( orig_post.scheduled_publish_time, diff --git a/crates/apub/src/activities/create_or_update/comment.rs b/crates/apub/src/activities/create_or_update/comment.rs index 0a0737151..777b94f54 100644 --- a/crates/apub/src/activities/create_or_update/comment.rs +++ b/crates/apub/src/activities/create_or_update/comment.rs @@ -29,7 +29,7 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ aggregates::structs::CommentAggregates, - newtypes::PersonId, + newtypes::{PersonId, PostOrCommentId}, source::{ activity::ActivitySendTargets, comment::{Comment, CommentLike, CommentLikeForm}, @@ -171,7 +171,15 @@ impl ActivityHandler for CreateOrUpdateNote { // TODO: for compatibility with other projects, it would be much better to read this from cc or // tags let mentions = scrape_text_for_mentions(&comment.content); - send_local_notifs(mentions, comment.id, &actor, do_send_email, context, None).await?; + send_local_notifs( + mentions, + PostOrCommentId::Comment(comment.id), + &actor, + do_send_email, + context, + None, + ) + .await?; Ok(()) } } diff --git a/crates/apub/src/activities/create_or_update/post.rs b/crates/apub/src/activities/create_or_update/post.rs index fb53100f6..39fd6573c 100644 --- a/crates/apub/src/activities/create_or_update/post.rs +++ b/crates/apub/src/activities/create_or_update/post.rs @@ -20,10 +20,10 @@ use activitypub_federation::{ protocol::verification::{verify_domains_match, verify_urls_match}, traits::{ActivityHandler, Actor, Object}, }; -use lemmy_api_common::context::LemmyContext; +use lemmy_api_common::{build_response::send_local_notifs, context::LemmyContext}; use lemmy_db_schema::{ aggregates::structs::PostAggregates, - newtypes::PersonId, + newtypes::{PersonId, PostOrCommentId}, source::{ activity::ActivitySendTargets, community::Community, @@ -32,7 +32,10 @@ use lemmy_db_schema::{ }, traits::{Crud, Likeable}, }; -use lemmy_utils::error::{LemmyError, LemmyResult}; +use lemmy_utils::{ + error::{LemmyError, LemmyResult}, + utils::mention::scrape_text_for_mentions, +}; use url::Url; impl CreateOrUpdatePage { @@ -128,6 +131,21 @@ impl ActivityHandler for CreateOrUpdatePage { // Calculate initial hot_rank for post PostAggregates::update_ranks(&mut context.pool(), post.id).await?; + let do_send_email = self.kind == CreateOrUpdateType::Create; + let actor = self.actor.dereference(context).await?; + + // Send the post body mentions + let mentions = scrape_text_for_mentions(&post.body.clone().unwrap_or_default()); + send_local_notifs( + mentions, + PostOrCommentId::Post(post.id), + &actor, + do_send_email, + context, + None, + ) + .await?; + Ok(()) } } diff --git a/crates/db_schema/src/impls/person_post_mention.rs b/crates/db_schema/src/impls/person_post_mention.rs new file mode 100644 index 000000000..ef59b60e1 --- /dev/null +++ b/crates/db_schema/src/impls/person_post_mention.rs @@ -0,0 +1,83 @@ +use crate::{ + diesel::OptionalExtension, + newtypes::{PersonId, PersonPostMentionId, PostId}, + schema::person_post_mention, + source::person_post_mention::{ + PersonPostMention, + PersonPostMentionInsertForm, + PersonPostMentionUpdateForm, + }, + traits::Crud, + utils::{get_conn, DbPool}, +}; +use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; + +#[async_trait] +impl Crud for PersonPostMention { + type InsertForm = PersonPostMentionInsertForm; + type UpdateForm = PersonPostMentionUpdateForm; + type IdType = PersonPostMentionId; + + async fn create( + pool: &mut DbPool<'_>, + person_post_mention_form: &Self::InsertForm, + ) -> Result { + let conn = &mut get_conn(pool).await?; + // since the return here isnt utilized, we dont need to do an update + // but get_result doesn't return the existing row here + insert_into(person_post_mention::table) + .values(person_post_mention_form) + .on_conflict(( + person_post_mention::recipient_id, + person_post_mention::post_id, + )) + .do_update() + .set(person_post_mention_form) + .get_result::(conn) + .await + } + + async fn update( + pool: &mut DbPool<'_>, + person_post_mention_id: PersonPostMentionId, + person_post_mention_form: &Self::UpdateForm, + ) -> Result { + let conn = &mut get_conn(pool).await?; + diesel::update(person_post_mention::table.find(person_post_mention_id)) + .set(person_post_mention_form) + .get_result::(conn) + .await + } +} + +impl PersonPostMention { + pub async fn mark_all_as_read( + pool: &mut DbPool<'_>, + for_recipient_id: PersonId, + ) -> Result, Error> { + let conn = &mut get_conn(pool).await?; + diesel::update( + person_post_mention::table + .filter(person_post_mention::recipient_id.eq(for_recipient_id)) + .filter(person_post_mention::read.eq(false)), + ) + .set(person_post_mention::read.eq(true)) + .get_results::(conn) + .await + } + + pub async fn read_by_post_and_person( + pool: &mut DbPool<'_>, + for_post_id: PostId, + for_recipient_id: PersonId, + ) -> Result, Error> { + let conn = &mut get_conn(pool).await?; + person_post_mention::table + .filter(person_post_mention::post_id.eq(for_post_id)) + .filter(person_post_mention::recipient_id.eq(for_recipient_id)) + .first(conn) + .await + .optional() + } +} diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index 2041b0c48..c372aa255 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -55,6 +55,11 @@ impl fmt::Display for CommentId { } } +pub enum PostOrCommentId { + Post(PostId), + Comment(CommentId), +} + #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(DieselNewType, TS))] #[cfg_attr(feature = "full", ts(export))] diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 6dc863e48..65e013af6 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -752,7 +752,7 @@ diesel::table! { recipient_id -> Int4, post_id -> Int4, read -> Bool, - published -> Timestamp, + published -> Timestamptz, } } diff --git a/crates/db_schema/src/source/person_post_mention.rs b/crates/db_schema/src/source/person_post_mention.rs new file mode 100644 index 000000000..b1c00febf --- /dev/null +++ b/crates/db_schema/src/source/person_post_mention.rs @@ -0,0 +1,39 @@ +use crate::newtypes::{PersonId, PersonPostMentionId, PostId}; +#[cfg(feature = "full")] +use crate::schema::person_post_mention; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "full")] +use ts_rs::TS; + +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "full", + derive(Queryable, Selectable, Associations, Identifiable, TS) +)] +#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))] +#[cfg_attr(feature = "full", diesel(table_name = person_post_mention))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", ts(export))] +/// A person mention. +pub struct PersonPostMention { + pub id: PersonPostMentionId, + pub recipient_id: PersonId, + pub post_id: PostId, + pub read: bool, + pub published: DateTime, +} + +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = person_post_mention))] +pub struct PersonPostMentionInsertForm { + pub recipient_id: PersonId, + pub post_id: PostId, + pub read: Option, +} + +#[cfg_attr(feature = "full", derive(AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = person_post_mention))] +pub struct PersonPostMentionUpdateForm { + pub read: Option, +} diff --git a/crates/db_views_actor/src/lib.rs b/crates/db_views_actor/src/lib.rs index 7e1b96757..6a955aacb 100644 --- a/crates/db_views_actor/src/lib.rs +++ b/crates/db_views_actor/src/lib.rs @@ -10,8 +10,8 @@ pub mod community_person_ban_view; pub mod community_view; #[cfg(feature = "full")] pub mod person_comment_mention_view; -// #[cfg(feature = "full")] -// pub mod person_post_mention_view; +#[cfg(feature = "full")] +pub mod person_post_mention_view; #[cfg(feature = "full")] pub mod person_view; pub mod structs; diff --git a/crates/db_views_actor/src/person_comment_mention_view.rs b/crates/db_views_actor/src/person_comment_mention_view.rs index 9a61ff0d9..a4f7b4e85 100644 --- a/crates/db_views_actor/src/person_comment_mention_view.rs +++ b/crates/db_views_actor/src/person_comment_mention_view.rs @@ -216,7 +216,7 @@ fn queries<'a>() -> Queries< query = query.filter(not(person::bot_account)); }; - query = match options.sort.unwrap_or(CommentSortType::Hot) { + query = match options.sort.unwrap_or(CommentSortType::New) { CommentSortType::Hot => query.then_order_by(comment_aggregates::hot_rank.desc()), CommentSortType::Controversial => { query.then_order_by(comment_aggregates::controversy_rank.desc()) diff --git a/crates/db_views_actor/src/person_post_mention_view.rs b/crates/db_views_actor/src/person_post_mention_view.rs new file mode 100644 index 000000000..c7ab89d51 --- /dev/null +++ b/crates/db_views_actor/src/person_post_mention_view.rs @@ -0,0 +1,501 @@ +use crate::structs::PersonPostMentionView; +use diesel::{ + dsl::{exists, not, IntervalDsl}, + pg::Pg, + result::Error, + sql_types, + BoolExpressionMethods, + BoxableExpression, + ExpressionMethods, + IntoSql, + JoinOnDsl, + NullableExpressionMethods, + QueryDsl, +}; +use diesel_async::RunQueryDsl; +use lemmy_db_schema::{ + aliases, + newtypes::{PersonId, PersonPostMentionId}, + schema::{ + community, + community_follower, + community_moderator, + community_person_ban, + local_user, + person, + person_block, + person_post_mention, + post, + post_aggregates, + post_like, + post_saved, + }, + source::local_user::LocalUser, + utils::{get_conn, limit_and_offset, now, DbConn, DbPool, ListFn, Queries, ReadFn}, + PostSortType, +}; + +fn queries<'a>() -> Queries< + impl ReadFn<'a, PersonPostMentionView, (PersonPostMentionId, Option)>, + impl ListFn<'a, PersonPostMentionView, PersonPostMentionQuery>, +> { + let is_creator_banned_from_community = exists( + community_person_ban::table.filter( + community::id + .eq(community_person_ban::community_id) + .and(community_person_ban::person_id.eq(post::creator_id)), + ), + ); + + let is_local_user_banned_from_community = |person_id| { + exists( + community_person_ban::table.filter( + community::id + .eq(community_person_ban::community_id) + .and(community_person_ban::person_id.eq(person_id)), + ), + ) + }; + + let is_saved = |person_id| { + exists( + post_saved::table.filter( + post::id + .eq(post_saved::post_id) + .and(post_saved::person_id.eq(person_id)), + ), + ) + }; + + let is_community_followed = |person_id| { + community_follower::table + .filter( + post::community_id + .eq(community_follower::community_id) + .and(community_follower::person_id.eq(person_id)), + ) + .select(community_follower::pending.nullable()) + .single_value() + }; + + let is_creator_blocked = |person_id| { + exists( + person_block::table.filter( + post::creator_id + .eq(person_block::target_id) + .and(person_block::person_id.eq(person_id)), + ), + ) + }; + + let score = |person_id| { + post_like::table + .filter( + post::id + .eq(post_like::post_id) + .and(post_like::person_id.eq(person_id)), + ) + .select(post_like::score.nullable()) + .single_value() + }; + + let creator_is_moderator = exists( + community_moderator::table.filter( + community::id + .eq(community_moderator::community_id) + .and(community_moderator::person_id.eq(post::creator_id)), + ), + ); + + let creator_is_admin = exists( + local_user::table.filter( + post::creator_id + .eq(local_user::person_id) + .and(local_user::admin.eq(true)), + ), + ); + + let all_joins = move |query: person_post_mention::BoxedQuery<'a, Pg>, + my_person_id: Option| { + let is_local_user_banned_from_community_selection: Box< + dyn BoxableExpression<_, Pg, SqlType = sql_types::Bool>, + > = if let Some(person_id) = my_person_id { + Box::new(is_local_user_banned_from_community(person_id)) + } else { + Box::new(false.into_sql::()) + }; + let score_selection: Box< + dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable>, + > = if let Some(person_id) = my_person_id { + Box::new(score(person_id)) + } else { + Box::new(None::.into_sql::>()) + }; + + let subscribed_type_selection: Box< + dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable>, + > = if let Some(person_id) = my_person_id { + Box::new(is_community_followed(person_id)) + } else { + Box::new(None::.into_sql::>()) + }; + + let is_saved_selection: Box> = + if let Some(person_id) = my_person_id { + Box::new(is_saved(person_id)) + } else { + Box::new(false.into_sql::()) + }; + + let is_creator_blocked_selection: Box> = + if let Some(person_id) = my_person_id { + Box::new(is_creator_blocked(person_id)) + } else { + Box::new(false.into_sql::()) + }; + + query + .inner_join(post::table) + .inner_join(person::table.on(post::creator_id.eq(person::id))) + .inner_join(community::table.on(post::community_id.eq(community::id))) + .inner_join(aliases::person1) + .inner_join(post_aggregates::table.on(post::id.eq(post_aggregates::post_id))) + .select(( + person_post_mention::all_columns, + post::all_columns, + person::all_columns, + community::all_columns, + aliases::person1.fields(person::all_columns), + post_aggregates::all_columns, + is_creator_banned_from_community, + is_local_user_banned_from_community_selection, + creator_is_moderator, + creator_is_admin, + subscribed_type_selection, + is_saved_selection, + is_creator_blocked_selection, + score_selection, + )) + }; + + let read = move |mut conn: DbConn<'a>, + (person_post_mention_id, my_person_id): ( + PersonPostMentionId, + Option, + )| async move { + all_joins( + person_post_mention::table + .find(person_post_mention_id) + .into_boxed(), + my_person_id, + ) + .first(&mut conn) + .await + }; + + let list = move |mut conn: DbConn<'a>, options: PersonPostMentionQuery| async move { + // These filters need to be kept in sync with the filters in + // PersonPostMentionView::get_unread_mentions() + let mut query = all_joins( + person_post_mention::table.into_boxed(), + options.my_person_id, + ); + + if let Some(recipient_id) = options.recipient_id { + query = query.filter(person_post_mention::recipient_id.eq(recipient_id)); + } + + if options.unread_only { + query = query.filter(person_post_mention::read.eq(false)); + } + + if !options.show_bot_accounts { + query = query.filter(not(person::bot_account)); + }; + + let time = |interval| post_aggregates::published.gt(now() - interval); + + query = match options.sort.unwrap_or(PostSortType::New) { + PostSortType::Active => query.then_order_by(post_aggregates::hot_rank_active.desc()), + PostSortType::Hot => query.then_order_by(post_aggregates::hot_rank.desc()), + PostSortType::Controversial => query.then_order_by(post_aggregates::controversy_rank.desc()), + PostSortType::New => query.then_order_by(post_aggregates::published.desc()), + PostSortType::Old => query.then_order_by(post_aggregates::published.asc()), + PostSortType::TopAll => query.order_by(post_aggregates::score.desc()), + PostSortType::Scaled => query.then_order_by(post_aggregates::scaled_rank.desc()), + PostSortType::NewComments => query.then_order_by(post_aggregates::newest_comment_time.desc()), + PostSortType::MostComments => query.then_order_by(post_aggregates::comments.desc()), + PostSortType::TopYear => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(1.years())), + PostSortType::TopMonth => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(1.months())), + PostSortType::TopWeek => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(1.weeks())), + PostSortType::TopDay => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(1.days())), + PostSortType::TopHour => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(1.hours())), + PostSortType::TopSixHour => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(6.hours())), + PostSortType::TopTwelveHour => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(12.hours())), + PostSortType::TopThreeMonths => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(3.months())), + PostSortType::TopSixMonths => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(6.months())), + PostSortType::TopNineMonths => query + .then_order_by(post_aggregates::score.desc()) + .filter(time(9.months())), + }; + + // Don't show mentions from blocked persons + if let Some(my_person_id) = options.my_person_id { + query = query.filter(not(is_creator_blocked(my_person_id))); + } + + let (limit, offset) = limit_and_offset(options.page, options.limit)?; + + query + .limit(limit) + .offset(offset) + .load::(&mut conn) + .await + }; + + Queries::new(read, list) +} + +impl PersonPostMentionView { + pub async fn read( + pool: &mut DbPool<'_>, + person_post_mention_id: PersonPostMentionId, + my_person_id: Option, + ) -> Result { + queries() + .read(pool, (person_post_mention_id, my_person_id)) + .await + } + + /// Gets the number of unread mentions + pub async fn get_unread_count( + pool: &mut DbPool<'_>, + local_user: &LocalUser, + ) -> Result { + use diesel::dsl::count; + let conn = &mut get_conn(pool).await?; + + let mut query = person_post_mention::table + .inner_join(post::table) + .left_join( + person_block::table.on( + post::creator_id + .eq(person_block::target_id) + .and(person_block::person_id.eq(local_user.person_id)), + ), + ) + .inner_join(person::table.on(post::creator_id.eq(person::id))) + .into_boxed(); + + // These filters need to be kept in sync with the filters in queries().list() + if !local_user.show_bot_accounts { + query = query.filter(not(person::bot_account)); + } + + query + // Don't count replies from blocked users + .filter(person_block::person_id.is_null()) + .filter(person_post_mention::recipient_id.eq(local_user.person_id)) + .filter(person_post_mention::read.eq(false)) + .filter(post::deleted.eq(false)) + .filter(post::removed.eq(false)) + .select(count(person_post_mention::id)) + .first::(conn) + .await + } +} + +#[derive(Default, Clone)] +pub struct PersonPostMentionQuery { + pub my_person_id: Option, + pub recipient_id: Option, + pub sort: Option, + pub unread_only: bool, + pub show_bot_accounts: bool, + pub page: Option, + pub limit: Option, +} + +impl PersonPostMentionQuery { + pub async fn list(self, pool: &mut DbPool<'_>) -> Result, Error> { + queries().list(pool, self).await + } +} + +#[cfg(test)] +mod tests { + + use crate::{person_post_mention_view::PersonPostMentionQuery, structs::PersonPostMentionView}; + use lemmy_db_schema::{ + source::{ + community::{Community, CommunityInsertForm}, + instance::Instance, + local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, + person::{Person, PersonInsertForm, PersonUpdateForm}, + person_block::{PersonBlock, PersonBlockForm}, + person_post_mention::{ + PersonPostMention, + PersonPostMentionInsertForm, + PersonPostMentionUpdateForm, + }, + post::{Post, PostInsertForm}, + }, + traits::{Blockable, Crud}, + utils::build_db_pool_for_tests, + }; + use lemmy_db_views::structs::LocalUserView; + use lemmy_utils::error::LemmyResult; + use pretty_assertions::assert_eq; + use serial_test::serial; + + #[tokio::test] + #[serial] + async fn test_crud() -> LemmyResult<()> { + let pool = &build_db_pool_for_tests().await; + let pool = &mut pool.into(); + + let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; + + let new_person = PersonInsertForm::test_form(inserted_instance.id, "terrylake"); + + let inserted_person = Person::create(pool, &new_person).await?; + + let recipient_form = PersonInsertForm::test_form(inserted_instance.id, "terrylakes recipient"); + + let inserted_recipient = Person::create(pool, &recipient_form).await?; + let recipient_id = inserted_recipient.id; + + let recipient_local_user = + LocalUser::create(pool, &LocalUserInsertForm::test_form(recipient_id), vec![]).await?; + + let new_community = CommunityInsertForm::new( + inserted_instance.id, + "test community lake".to_string(), + "nada".to_owned(), + "pubkey".to_string(), + ); + let inserted_community = Community::create(pool, &new_community).await?; + + let new_post = PostInsertForm::new( + "A test post".into(), + inserted_person.id, + inserted_community.id, + ); + let inserted_post = Post::create(pool, &new_post).await?; + + let person_post_mention_form = PersonPostMentionInsertForm { + recipient_id: inserted_recipient.id, + post_id: inserted_post.id, + read: None, + }; + + let inserted_mention = PersonPostMention::create(pool, &person_post_mention_form).await?; + + let expected_mention = PersonPostMention { + id: inserted_mention.id, + recipient_id: inserted_mention.recipient_id, + post_id: inserted_mention.post_id, + read: false, + published: inserted_mention.published, + }; + + let read_mention = PersonPostMention::read(pool, inserted_mention.id).await?; + + let person_post_mention_update_form = PersonPostMentionUpdateForm { read: Some(false) }; + let updated_mention = + PersonPostMention::update(pool, inserted_mention.id, &person_post_mention_update_form) + .await?; + + // Test to make sure counts and blocks work correctly + let unread_mentions = + PersonPostMentionView::get_unread_count(pool, &recipient_local_user).await?; + + let query = PersonPostMentionQuery { + recipient_id: Some(recipient_id), + my_person_id: Some(recipient_id), + sort: None, + unread_only: false, + show_bot_accounts: true, + page: None, + limit: None, + }; + let mentions = query.clone().list(pool).await?; + assert_eq!(1, unread_mentions); + assert_eq!(1, mentions.len()); + + // Block the person, and make sure these counts are now empty + let block_form = PersonBlockForm { + person_id: recipient_id, + target_id: inserted_person.id, + }; + PersonBlock::block(pool, &block_form).await?; + + let unread_mentions_after_block = + PersonPostMentionView::get_unread_count(pool, &recipient_local_user).await?; + let mentions_after_block = query.clone().list(pool).await?; + assert_eq!(0, unread_mentions_after_block); + assert_eq!(0, mentions_after_block.len()); + + // Unblock user so we can reuse the same person + PersonBlock::unblock(pool, &block_form).await?; + + // Turn Terry into a bot account + let person_update_form = PersonUpdateForm { + bot_account: Some(true), + ..Default::default() + }; + Person::update(pool, inserted_person.id, &person_update_form).await?; + + let recipient_local_user_update_form = LocalUserUpdateForm { + show_bot_accounts: Some(false), + ..Default::default() + }; + LocalUser::update( + pool, + recipient_local_user.id, + &recipient_local_user_update_form, + ) + .await?; + let recipient_local_user_view = LocalUserView::read(pool, recipient_local_user.id).await?; + + let unread_mentions_after_hide_bots = + PersonPostMentionView::get_unread_count(pool, &recipient_local_user_view.local_user).await?; + + let mut query_without_bots = query.clone(); + query_without_bots.show_bot_accounts = false; + let replies_after_hide_bots = query_without_bots.list(pool).await?; + assert_eq!(0, unread_mentions_after_hide_bots); + assert_eq!(0, replies_after_hide_bots.len()); + + Post::delete(pool, inserted_post.id).await?; + Post::delete(pool, inserted_post.id).await?; + Community::delete(pool, inserted_community.id).await?; + Person::delete(pool, inserted_person.id).await?; + Person::delete(pool, inserted_recipient.id).await?; + Instance::delete(pool, inserted_instance.id).await?; + + assert_eq!(expected_mention, read_mention); + assert_eq!(expected_mention, inserted_mention); + assert_eq!(expected_mention, updated_mention); + + Ok(()) + } +} diff --git a/crates/db_views_actor/src/structs.rs b/crates/db_views_actor/src/structs.rs index 96013a8c7..a2f95695f 100644 --- a/crates/db_views_actor/src/structs.rs +++ b/crates/db_views_actor/src/structs.rs @@ -1,13 +1,14 @@ #[cfg(feature = "full")] use diesel::Queryable; use lemmy_db_schema::{ - aggregates::structs::{CommentAggregates, CommunityAggregates, PersonAggregates}, + aggregates::structs::{CommentAggregates, CommunityAggregates, PersonAggregates, PostAggregates}, source::{ comment::Comment, comment_reply::CommentReply, community::Community, person::Person, person_comment_mention::PersonCommentMention, + person_post_mention::PersonPostMention, post::Post, }, SubscribedType, @@ -93,7 +94,7 @@ pub enum CommunitySortType { #[cfg_attr(feature = "full", derive(TS, Queryable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", ts(export))] -/// A person mention view. +/// A person comment mention view. pub struct PersonCommentMentionView { pub person_comment_mention: PersonCommentMention, pub comment: Comment, @@ -112,28 +113,28 @@ pub struct PersonCommentMentionView { pub my_vote: Option, } -// #[skip_serializing_none] -// #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] -// #[cfg_attr(feature = "full", derive(TS, Queryable))] -// #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -// #[cfg_attr(feature = "full", ts(export))] -// /// A person mention view. -// pub struct PersonPostMentionView { -// pub person_post_mention: PersonPostMention, -// pub creator: Person, -// pub post: Post, -// pub community: Community, -// pub recipient: Person, -// pub counts: CommentAggregates, -// pub creator_banned_from_community: bool, -// pub banned_from_community: bool, -// pub creator_is_moderator: bool, -// pub creator_is_admin: bool, -// pub subscribed: SubscribedType, -// pub saved: bool, -// pub creator_blocked: bool, -// pub my_vote: Option, -// } +#[skip_serializing_none] +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS, Queryable))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", ts(export))] +/// A person post mention view. +pub struct PersonPostMentionView { + pub person_post_mention: PersonPostMention, + pub post: Post, + pub creator: Person, + pub community: Community, + pub recipient: Person, + pub counts: PostAggregates, + pub creator_banned_from_community: bool, + pub banned_from_community: bool, + pub creator_is_moderator: bool, + pub creator_is_admin: bool, + pub subscribed: SubscribedType, + pub saved: bool, + pub creator_blocked: bool, + pub my_vote: Option, +} #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] diff --git a/crates/routes/src/feeds.rs b/crates/routes/src/feeds.rs index 5aa55663f..211a459bf 100644 --- a/crates/routes/src/feeds.rs +++ b/crates/routes/src/feeds.rs @@ -18,7 +18,8 @@ use lemmy_db_views::{ use lemmy_db_views_actor::{ comment_reply_view::CommentReplyQuery, person_comment_mention_view::PersonCommentMentionQuery, - structs::{CommentReplyView, PersonCommentMentionView}, + person_post_mention_view::PersonPostMentionQuery, + structs::{CommentReplyView, PersonCommentMentionView, PersonPostMentionView}, }; use lemmy_utils::{ cache_header::cache_1hour, @@ -377,37 +378,53 @@ async fn get_feed_front( async fn get_feed_inbox(context: &LemmyContext, jwt: &str) -> LemmyResult { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_user = local_user_view_from_jwt(jwt, context).await?; - let person_id = local_user.local_user.person_id; + let my_person_id = Some(local_user.person.id); + let recipient_id = Some(local_user.local_user.person_id); let show_bot_accounts = local_user.local_user.show_bot_accounts; - - let sort = CommentSortType::New; + let limit = Some(RSS_FETCH_LIMIT); check_private_instance(&Some(local_user.clone()), &site_view.local_site)?; let replies = CommentReplyQuery { - recipient_id: (Some(person_id)), - my_person_id: (Some(person_id)), - show_bot_accounts: (show_bot_accounts), - sort: (Some(sort)), - limit: (Some(RSS_FETCH_LIMIT)), + recipient_id, + my_person_id, + show_bot_accounts, + sort: Some(CommentSortType::New), + limit, ..Default::default() } .list(&mut context.pool()) .await?; let comment_mentions = PersonCommentMentionQuery { - recipient_id: (Some(person_id)), - my_person_id: (Some(person_id)), - show_bot_accounts: (show_bot_accounts), - sort: (Some(sort)), - limit: (Some(RSS_FETCH_LIMIT)), + recipient_id, + my_person_id, + show_bot_accounts, + sort: Some(CommentSortType::New), + limit, + ..Default::default() + } + .list(&mut context.pool()) + .await?; + + let post_mentions = PersonPostMentionQuery { + recipient_id, + my_person_id, + show_bot_accounts, + sort: Some(PostSortType::New), + limit, ..Default::default() } .list(&mut context.pool()) .await?; let protocol_and_hostname = context.settings().get_protocol_and_hostname(); - let items = create_reply_and_mention_items(replies, comment_mentions, &protocol_and_hostname)?; + let items = create_reply_and_mention_items( + replies, + comment_mentions, + post_mentions, + &protocol_and_hostname, + )?; let mut channel = Channel { namespaces: RSS_NAMESPACE.clone(), @@ -428,6 +445,7 @@ async fn get_feed_inbox(context: &LemmyContext, jwt: &str) -> LemmyResult, comment_mentions: Vec, + post_mentions: Vec, protocol_and_hostname: &str, ) -> LemmyResult> { let mut reply_items: Vec = replies @@ -459,6 +477,23 @@ fn create_reply_and_mention_items( .collect::>>()?; reply_items.append(&mut comment_mention_items); + + let mut post_mention_items: Vec = post_mentions + .iter() + .map(|m| { + let mention_url = format!("{}/post/{}", protocol_and_hostname, m.post.id); + build_item( + &m.creator.name, + &m.post.published, + &mention_url, + &m.post.body.clone().unwrap_or_default(), + protocol_and_hostname, + ) + }) + .collect::>>()?; + + reply_items.append(&mut post_mention_items); + Ok(reply_items) } diff --git a/migrations/2024-11-02-161125_add_post_body_mention/up.sql b/migrations/2024-11-02-161125_add_post_body_mention/up.sql index dffecf9c2..ae8e0bcad 100644 --- a/migrations/2024-11-02-161125_add_post_body_mention/up.sql +++ b/migrations/2024-11-02-161125_add_post_body_mention/up.sql @@ -7,6 +7,6 @@ CREATE TABLE person_post_mention ( recipient_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, read boolean DEFAULT FALSE NOT NULL, - published timestamp NOT NULL DEFAULT now(), + published timestamptz NOT NULL DEFAULT now(), UNIQUE (recipient_id, post_id) ); diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index d24b907a8..18cf5bad3 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -35,9 +35,11 @@ use lemmy_api::{ logout::logout, notifications::{ list_comment_mentions::list_comment_mentions, + list_post_mentions::list_post_mentions, list_replies::list_replies, mark_all_read::mark_all_notifications_read, mark_comment_mention_read::mark_comment_mention_as_read, + mark_post_mention_read::mark_post_mention_as_read, mark_reply_read::mark_reply_as_read, unread_count::unread_count, }, @@ -334,6 +336,11 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { "/comment_mention/mark_as_read", web::post().to(mark_comment_mention_as_read), ) + .route("/post_mention", web::get().to(list_post_mentions)) + .route( + "/post_mention/mark_as_read", + web::post().to(mark_post_mention_as_read), + ) .route("/replies", web::get().to(list_replies)) // Admin action. I don't like that it's in /user .route("/ban", web::post().to(ban_from_site))