diff --git a/README.md b/README.md index 75f85f25d..524b7cf69 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ Each Lemmy server can set its own moderation policy; appointing site-wide admins Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project. +Lemmy is made possible by a generous grant from the [NLnet foundation](https://nlnet.nl/). + - [Support on Liberapay](https://liberapay.com/Lemmy). - [Support on Patreon](https://www.patreon.com/dessalines). - [Support on OpenCollective](https://opencollective.com/lemmy). diff --git a/crates/api/src/post/hide.rs b/crates/api/src/post/hide.rs new file mode 100644 index 000000000..1adfa110d --- /dev/null +++ b/crates/api/src/post/hide.rs @@ -0,0 +1,34 @@ +use actix_web::web::{Data, Json}; +use lemmy_api_common::{context::LemmyContext, post::HidePost, SuccessResponse}; +use lemmy_db_schema::source::post::PostHide; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::{LemmyError, LemmyErrorExt, LemmyErrorType, MAX_API_PARAM_ELEMENTS}; +use std::collections::HashSet; + +#[tracing::instrument(skip(context))] +pub async fn hide_post( + data: Json, + context: Data, + local_user_view: LocalUserView, +) -> Result, LemmyError> { + let post_ids = HashSet::from_iter(data.post_ids.clone()); + + if post_ids.len() > MAX_API_PARAM_ELEMENTS { + Err(LemmyErrorType::TooManyItems)?; + } + + let person_id = local_user_view.person.id; + + // Mark the post as hidden / unhidden + if data.hide { + PostHide::hide(&mut context.pool(), post_ids, person_id) + .await + .with_lemmy_type(LemmyErrorType::CouldntHidePost)?; + } else { + PostHide::unhide(&mut context.pool(), post_ids, person_id) + .await + .with_lemmy_type(LemmyErrorType::CouldntHidePost)?; + } + + Ok(Json(SuccessResponse::default())) +} diff --git a/crates/api/src/post/mark_read.rs b/crates/api/src/post/mark_read.rs index a46e949fa..bfc455f4f 100644 --- a/crates/api/src/post/mark_read.rs +++ b/crates/api/src/post/mark_read.rs @@ -11,14 +11,7 @@ pub async fn mark_post_as_read( context: Data, local_user_view: LocalUserView, ) -> Result, LemmyError> { - let mut post_ids = HashSet::new(); - if let Some(post_ids_) = &data.post_ids { - post_ids.extend(post_ids_.iter().cloned()); - } - - if let Some(post_id) = data.post_id { - post_ids.insert(post_id); - } + let post_ids = HashSet::from_iter(data.post_ids.clone()); if post_ids.len() > MAX_API_PARAM_ELEMENTS { Err(LemmyErrorType::TooManyItems)?; diff --git a/crates/api/src/post/mod.rs b/crates/api/src/post/mod.rs index 6a6ed9d21..7287010f7 100644 --- a/crates/api/src/post/mod.rs +++ b/crates/api/src/post/mod.rs @@ -1,5 +1,6 @@ pub mod feature; pub mod get_link_metadata; +pub mod hide; pub mod like; pub mod list_post_likes; pub mod lock; diff --git a/crates/api_common/src/post.rs b/crates/api_common/src/post.rs index 1db07e451..69d1258e3 100644 --- a/crates/api_common/src/post.rs +++ b/crates/api_common/src/post.rs @@ -79,6 +79,7 @@ pub struct GetPosts { pub saved_only: Option, pub liked_only: Option, pub disliked_only: Option, + pub show_hidden: Option, pub page_cursor: Option, } @@ -148,12 +149,20 @@ pub struct RemovePost { #[cfg_attr(feature = "full", ts(export))] /// Mark a post as read. pub struct MarkPostAsRead { - /// TODO: deprecated, send `post_ids` instead - pub post_id: Option, - pub post_ids: Option>, + pub post_ids: Vec, pub read: bool, } +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Hide a post from list views +pub struct HidePost { + pub post_ids: Vec, + pub hide: bool, +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] diff --git a/crates/apub/src/api/list_posts.rs b/crates/apub/src/api/list_posts.rs index 5285acaa7..b2ca95648 100644 --- a/crates/apub/src/api/list_posts.rs +++ b/crates/apub/src/api/list_posts.rs @@ -36,6 +36,7 @@ pub async fn list_posts( data.community_id }; let saved_only = data.saved_only.unwrap_or_default(); + let show_hidden = data.show_hidden.unwrap_or_default(); let liked_only = data.liked_only.unwrap_or_default(); let disliked_only = data.disliked_only.unwrap_or_default(); @@ -75,6 +76,7 @@ pub async fn list_posts( page, page_after, limit, + show_hidden, ..Default::default() } .list(&local_site.site, &mut context.pool()) diff --git a/crates/db_schema/src/impls/post.rs b/crates/db_schema/src/impls/post.rs index 82f82d1b7..7e2eec22b 100644 --- a/crates/db_schema/src/impls/post.rs +++ b/crates/db_schema/src/impls/post.rs @@ -1,8 +1,10 @@ use crate::{ newtypes::{CommunityId, DbUrl, PersonId, PostId}, - schema::post, + schema::{post, post_hide, post_like, post_read, post_saved}, source::post::{ Post, + PostHide, + PostHideForm, PostInsertForm, PostLike, PostLikeForm, @@ -243,11 +245,10 @@ impl Likeable for PostLike { type Form = PostLikeForm; type IdType = PostId; async fn like(pool: &mut DbPool<'_>, post_like_form: &PostLikeForm) -> Result { - use crate::schema::post_like::dsl::{person_id, post_id, post_like}; let conn = &mut get_conn(pool).await?; - insert_into(post_like) + insert_into(post_like::table) .values(post_like_form) - .on_conflict((post_id, person_id)) + .on_conflict((post_like::post_id, post_like::person_id)) .do_update() .set(post_like_form) .get_result::(conn) @@ -258,9 +259,8 @@ impl Likeable for PostLike { person_id: PersonId, post_id: PostId, ) -> Result { - use crate::schema::post_like::dsl; let conn = &mut get_conn(pool).await?; - diesel::delete(dsl::post_like.find((person_id, post_id))) + diesel::delete(post_like::table.find((person_id, post_id))) .execute(conn) .await } @@ -270,20 +270,18 @@ impl Likeable for PostLike { impl Saveable for PostSaved { type Form = PostSavedForm; async fn save(pool: &mut DbPool<'_>, post_saved_form: &PostSavedForm) -> Result { - use crate::schema::post_saved::dsl::{person_id, post_id, post_saved}; let conn = &mut get_conn(pool).await?; - insert_into(post_saved) + insert_into(post_saved::table) .values(post_saved_form) - .on_conflict((post_id, person_id)) + .on_conflict((post_saved::post_id, post_saved::person_id)) .do_update() .set(post_saved_form) .get_result::(conn) .await } async fn unsave(pool: &mut DbPool<'_>, post_saved_form: &PostSavedForm) -> Result { - use crate::schema::post_saved::dsl::post_saved; let conn = &mut get_conn(pool).await?; - diesel::delete(post_saved.find((post_saved_form.person_id, post_saved_form.post_id))) + diesel::delete(post_saved::table.find((post_saved_form.person_id, post_saved_form.post_id))) .execute(conn) .await } @@ -295,14 +293,13 @@ impl PostRead { post_ids: HashSet, person_id: PersonId, ) -> Result { - use crate::schema::post_read::dsl::post_read; let conn = &mut get_conn(pool).await?; let forms = post_ids .into_iter() .map(|post_id| PostReadForm { post_id, person_id }) .collect::>(); - insert_into(post_read) + insert_into(post_read::table) .values(forms) .on_conflict_do_nothing() .execute(conn) @@ -314,13 +311,48 @@ impl PostRead { post_id_: HashSet, person_id_: PersonId, ) -> Result { - use crate::schema::post_read::dsl::{person_id, post_id, post_read}; let conn = &mut get_conn(pool).await?; diesel::delete( - post_read - .filter(post_id.eq_any(post_id_)) - .filter(person_id.eq(person_id_)), + post_read::table + .filter(post_read::post_id.eq_any(post_id_)) + .filter(post_read::person_id.eq(person_id_)), + ) + .execute(conn) + .await + } +} + +impl PostHide { + pub async fn hide( + pool: &mut DbPool<'_>, + post_ids: HashSet, + person_id: PersonId, + ) -> Result { + let conn = &mut get_conn(pool).await?; + + let forms = post_ids + .into_iter() + .map(|post_id| PostHideForm { post_id, person_id }) + .collect::>(); + insert_into(post_hide::table) + .values(forms) + .on_conflict_do_nothing() + .execute(conn) + .await + } + + pub async fn unhide( + pool: &mut DbPool<'_>, + post_id_: HashSet, + person_id_: PersonId, + ) -> Result { + let conn = &mut get_conn(pool).await?; + + diesel::delete( + post_hide::table + .filter(post_hide::post_id.eq_any(post_id_)) + .filter(post_hide::person_id.eq(person_id_)), ) .execute(conn) .await diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 19f9183a5..bdde25566 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -730,6 +730,14 @@ diesel::table! { } } +diesel::table! { + post_hide (person_id, post_id) { + post_id -> Int4, + person_id -> Int4, + published -> Timestamptz, + } +} + diesel::table! { post_like (person_id, post_id) { post_id -> Int4, @@ -983,6 +991,8 @@ diesel::joinable!(post_aggregates -> community (community_id)); diesel::joinable!(post_aggregates -> instance (instance_id)); diesel::joinable!(post_aggregates -> person (creator_id)); diesel::joinable!(post_aggregates -> post (post_id)); +diesel::joinable!(post_hide -> person (person_id)); +diesel::joinable!(post_hide -> post (post_id)); diesel::joinable!(post_like -> person (person_id)); diesel::joinable!(post_like -> post (post_id)); diesel::joinable!(post_read -> person (person_id)); @@ -1054,6 +1064,7 @@ diesel::allow_tables_to_appear_in_same_query!( person_post_aggregates, post, post_aggregates, + post_hide, post_like, post_read, post_report, diff --git a/crates/db_schema/src/source/post.rs b/crates/db_schema/src/source/post.rs index 4ac3e2a65..115c90eef 100644 --- a/crates/db_schema/src/source/post.rs +++ b/crates/db_schema/src/source/post.rs @@ -1,6 +1,6 @@ use crate::newtypes::{CommunityId, DbUrl, LanguageId, PersonId, PostId}; #[cfg(feature = "full")] -use crate::schema::{post, post_like, post_read, post_saved}; +use crate::schema::{post, post_hide, post_like, post_read, post_saved}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; @@ -182,3 +182,25 @@ pub(crate) struct PostReadForm { pub post_id: PostId, pub person_id: PersonId, } + +#[derive(PartialEq, Eq, Debug)] +#[cfg_attr( + feature = "full", + derive(Identifiable, Queryable, Selectable, Associations) +)] +#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))] +#[cfg_attr(feature = "full", diesel(table_name = post_hide))] +#[cfg_attr(feature = "full", diesel(primary_key(post_id, person_id)))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +pub struct PostHide { + pub post_id: PostId, + pub person_id: PersonId, + pub published: DateTime, +} + +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = post_hide))] +pub(crate) struct PostHideForm { + pub post_id: PostId, + pub person_id: PersonId, +} diff --git a/crates/db_views/src/post_view.rs b/crates/db_views/src/post_view.rs index 6e15d1678..04e3e4d3c 100644 --- a/crates/db_views/src/post_view.rs +++ b/crates/db_views/src/post_view.rs @@ -35,6 +35,7 @@ use lemmy_db_schema::{ person_post_aggregates, post, post_aggregates, + post_hide, post_like, post_read, post_saved, @@ -107,6 +108,16 @@ fn queries<'a>() -> Queries< ) }; + let is_hidden = |person_id| { + exists( + post_hide::table.filter( + post_aggregates::post_id + .eq(post_hide::post_id) + .and(post_hide::person_id.eq(person_id)), + ), + ) + }; + let is_creator_blocked = |person_id| { exists( person_block::table.filter( @@ -147,6 +158,13 @@ fn queries<'a>() -> Queries< Box::new(false.into_sql::()) }; + let is_hidden_selection: Box> = + if let Some(person_id) = my_person_id { + Box::new(is_hidden(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)) @@ -211,6 +229,7 @@ fn queries<'a>() -> Queries< subscribed_type_selection, is_saved_selection, is_read_selection, + is_hidden_selection, is_creator_blocked_selection, score_selection, coalesce( @@ -406,6 +425,13 @@ fn queries<'a>() -> Queries< } } + if !options.show_hidden { + // If a creator id isn't given (IE its on home or community pages), hide the hidden posts + if let (None, Some(person_id)) = (options.creator_id, my_person_id) { + query = query.filter(not(is_hidden(person_id))); + } + } + if let Some(person_id) = my_person_id { if options.liked_only { query = query.filter(score(person_id).eq(1)); @@ -593,6 +619,7 @@ pub struct PostQuery<'a> { pub page_after: Option, pub page_before_or_equal: Option, pub page_back: bool, + pub show_hidden: bool, } impl<'a> PostQuery<'a> { @@ -726,7 +753,7 @@ mod tests { local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, person::{Person, PersonInsertForm}, person_block::{PersonBlock, PersonBlockForm}, - post::{Post, PostInsertForm, PostLike, PostLikeForm, PostRead, PostUpdateForm}, + post::{Post, PostHide, PostInsertForm, PostLike, PostLikeForm, PostRead, PostUpdateForm}, site::Site, }, traits::{Blockable, Crud, Joinable, Likeable}, @@ -1463,6 +1490,47 @@ mod tests { cleanup(data, pool).await } + #[tokio::test] + #[serial] + async fn post_listings_hide_hidden() -> LemmyResult<()> { + let pool = &build_db_pool().await?; + let pool = &mut pool.into(); + let data = init_data(pool).await?; + + // Mark a post as hidden + PostHide::hide( + pool, + HashSet::from([data.inserted_bot_post.id]), + data.local_user_view.person.id, + ) + .await?; + + // Make sure you don't see the hidden post in the results + let post_listings_hide_hidden = data.default_post_query().list(&data.site, pool).await?; + assert_eq!(vec![POST], names(&post_listings_hide_hidden)); + + // Make sure it does come back with the show_hidden option + let post_listings_show_hidden = PostQuery { + sort: Some(SortType::New), + local_user: Some(&data.local_user_view), + show_hidden: true, + ..Default::default() + } + .list(&data.site, pool) + .await?; + assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_show_hidden)); + + // Make sure that hidden field is true. + assert!( + &post_listings_show_hidden + .first() + .expect("first post should exist") + .hidden + ); + + cleanup(data, pool).await + } + async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { let num_deleted = Post::delete(pool, data.inserted_post.id).await?; Community::delete(pool, data.inserted_community.id).await?; @@ -1584,6 +1652,7 @@ mod tests { }, subscribed: SubscribedType::NotSubscribed, read: false, + hidden: false, saved: false, creator_blocked: false, }) diff --git a/crates/db_views/src/structs.rs b/crates/db_views/src/structs.rs index e05d33242..9b2d8d602 100644 --- a/crates/db_views/src/structs.rs +++ b/crates/db_views/src/structs.rs @@ -120,6 +120,7 @@ pub struct PostView { pub subscribed: SubscribedType, pub saved: bool, pub read: bool, + pub hidden: bool, pub creator_blocked: bool, pub my_vote: Option, pub unread_comments: i64, diff --git a/crates/utils/src/error.rs b/crates/utils/src/error.rs index ad3f4371f..31f0707be 100644 --- a/crates/utils/src/error.rs +++ b/crates/utils/src/error.rs @@ -122,6 +122,7 @@ pub enum LemmyErrorType { CouldntLikePost, CouldntSavePost, CouldntMarkPostAsRead, + CouldntHidePost, CouldntUpdateCommunity, CouldntUpdateReplies, CouldntUpdatePersonMentions, diff --git a/migrations/2024-02-28-144211_hide_posts/down.sql b/migrations/2024-02-28-144211_hide_posts/down.sql new file mode 100644 index 000000000..72729838c --- /dev/null +++ b/migrations/2024-02-28-144211_hide_posts/down.sql @@ -0,0 +1,2 @@ +DROP TABLE post_hide; + diff --git a/migrations/2024-02-28-144211_hide_posts/up.sql b/migrations/2024-02-28-144211_hide_posts/up.sql new file mode 100644 index 000000000..922dddd66 --- /dev/null +++ b/migrations/2024-02-28-144211_hide_posts/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE post_hide ( + post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + published timestamp with time zone NOT NULL DEFAULT now(), + PRIMARY KEY (person_id, post_id) +); + diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index 912dcfbf9..966862fa5 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -49,6 +49,7 @@ use lemmy_api::{ post::{ feature::feature_post, get_link_metadata::get_link_metadata, + hide::hide_post, like::like_post, list_post_likes::list_post_likes, lock::lock_post, @@ -206,6 +207,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { .route("/delete", web::post().to(delete_post)) .route("/remove", web::post().to(remove_post)) .route("/mark_as_read", web::post().to(mark_post_as_read)) + .route("/hide", web::post().to(hide_post)) .route("/lock", web::post().to(lock_post)) .route("/feature", web::post().to(feature_post)) .route("/list", web::get().to(list_posts))