diff --git a/crates/api/src/site.rs b/crates/api/src/site.rs index 92dbdd64d..7baeae78d 100644 --- a/crates/api/src/site.rs +++ b/crates/api/src/site.rs @@ -50,6 +50,7 @@ use lemmy_db_views_moderator::{ mod_add_view::ModAddView, mod_ban_from_community_view::ModBanFromCommunityView, mod_ban_view::ModBanView, + mod_hide_community_view::ModHideCommunityView, mod_lock_post_view::ModLockPostView, mod_remove_comment_view::ModRemoveCommentView, mod_remove_community_view::ModRemoveCommunityView, @@ -117,6 +118,11 @@ impl Perform for GetModlog { }) .await??; + let hidden_communities = blocking(context.pool(), move |conn| { + ModHideCommunityView::list(conn, community_id, mod_person_id, page, limit) + }) + .await??; + // These arrays are only for the full modlog, when a community isn't given let (removed_communities, banned, added) = if data.community_id.is_none() { blocking(context.pool(), move |conn| { @@ -143,6 +149,7 @@ impl Perform for GetModlog { added_to_community, added, transferred_to_community, + hidden_communities, }) } } diff --git a/crates/api_common/src/community.rs b/crates/api_common/src/community.rs index 9686172ac..fbfe3c660 100644 --- a/crates/api_common/src/community.rs +++ b/crates/api_common/src/community.rs @@ -93,6 +93,14 @@ pub struct EditCommunity { pub auth: Sensitive, } +#[derive(Debug, Serialize, Deserialize)] +pub struct HideCommunity { + pub community_id: CommunityId, + pub hidden: bool, + pub reason: Option, + pub auth: Sensitive, +} + #[derive(Debug, Serialize, Deserialize)] pub struct DeleteCommunity { pub community_id: CommunityId, diff --git a/crates/api_common/src/site.rs b/crates/api_common/src/site.rs index e3bc52a2d..a707dd656 100644 --- a/crates/api_common/src/site.rs +++ b/crates/api_common/src/site.rs @@ -19,6 +19,7 @@ use lemmy_db_views_moderator::{ mod_add_view::ModAddView, mod_ban_from_community_view::ModBanFromCommunityView, mod_ban_view::ModBanView, + mod_hide_community_view::ModHideCommunityView, mod_lock_post_view::ModLockPostView, mod_remove_comment_view::ModRemoveCommentView, mod_remove_community_view::ModRemoveCommunityView, @@ -87,6 +88,7 @@ pub struct GetModlogResponse { pub added_to_community: Vec, pub transferred_to_community: Vec, pub added: Vec, + pub hidden_communities: Vec, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/api_crud/src/community/update.rs b/crates/api_crud/src/community/update.rs index 324b90eba..a0b1dd3b4 100644 --- a/crates/api_crud/src/community/update.rs +++ b/crates/api_crud/src/community/update.rs @@ -2,15 +2,19 @@ use crate::PerformCrud; use actix_web::web::Data; use lemmy_api_common::{ blocking, - community::{CommunityResponse, EditCommunity}, + community::{CommunityResponse, EditCommunity, HideCommunity}, get_local_user_view_from_jwt, + is_admin, }; use lemmy_apub::protocol::activities::community::update::UpdateCommunity; use lemmy_db_schema::{ diesel_option_overwrite_to_url, naive_now, newtypes::PersonId, - source::community::{Community, CommunityForm}, + source::{ + community::{Community, CommunityForm}, + moderator::{ModHideCommunity, ModHideCommunityForm}, + }, traits::Crud, }; use lemmy_db_views_actor::community_moderator_view::CommunityModeratorView; @@ -62,6 +66,7 @@ impl PerformCrud for EditCommunity { icon, banner, nsfw: data.nsfw, + hidden: Some(read_community.hidden), updated: Some(naive_now()), ..CommunityForm::default() }; @@ -85,3 +90,71 @@ impl PerformCrud for EditCommunity { send_community_ws_message(data.community_id, op, websocket_id, None, context).await } } + +#[async_trait::async_trait(?Send)] +impl PerformCrud for HideCommunity { + type Response = CommunityResponse; + + #[tracing::instrument(skip(context, websocket_id))] + async fn perform( + &self, + context: &Data, + websocket_id: Option, + ) -> Result { + let data: &HideCommunity = self; + + // Verify its a admin (only admin can hide or unhide it) + let local_user_view = + get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?; + is_admin(&local_user_view)?; + + let community_id = data.community_id; + let read_community = blocking(context.pool(), move |conn| { + Community::read(conn, community_id) + }) + .await??; + + let community_form = CommunityForm { + name: read_community.name, + title: read_community.title, + description: read_community.description.to_owned(), + public_key: read_community.public_key, + icon: Some(read_community.icon), + banner: Some(read_community.banner), + nsfw: Some(read_community.nsfw), + updated: Some(naive_now()), + hidden: Some(data.hidden), + ..CommunityForm::default() + }; + + let mod_hide_community_form = ModHideCommunityForm { + community_id: data.community_id, + mod_person_id: local_user_view.person.id, + reason: data.reason.clone(), + hidden: Some(data.hidden), + }; + + let community_id = data.community_id; + let updated_community = blocking(context.pool(), move |conn| { + Community::update(conn, community_id, &community_form) + }) + .await? + .map_err(LemmyError::from) + .map_err(|e| e.with_message("couldnt_update_community_hidden_status"))?; + + blocking(context.pool(), move |conn| { + ModHideCommunity::create(conn, &mod_hide_community_form) + }) + .await??; + + UpdateCommunity::send( + updated_community.into(), + &local_user_view.person.into(), + context, + ) + .await?; + + let op = UserOperationCrud::EditCommunity; + send_community_ws_message(data.community_id, op, websocket_id, None, context).await + } +} diff --git a/crates/apub/src/protocol/objects/group.rs b/crates/apub/src/protocol/objects/group.rs index 51e3a00ac..5b564ff88 100644 --- a/crates/apub/src/protocol/objects/group.rs +++ b/crates/apub/src/protocol/objects/group.rs @@ -80,6 +80,7 @@ impl Group { actor_id: Some(self.id.into()), local: Some(false), private_key: None, + hidden: Some(false), public_key: self.public_key.public_key_pem, last_refreshed_at: Some(naive_now()), icon: Some(self.icon.map(|i| i.url.into())), diff --git a/crates/db_schema/src/impls/community.rs b/crates/db_schema/src/impls/community.rs index b2b3a6d81..43b7db3e2 100644 --- a/crates/db_schema/src/impls/community.rs +++ b/crates/db_schema/src/impls/community.rs @@ -43,6 +43,7 @@ mod safe_type { local, icon, banner, + hidden, ); impl ToSafe for Community { @@ -62,6 +63,7 @@ mod safe_type { local, icon, banner, + hidden, ) } } @@ -372,6 +374,7 @@ mod tests { followers_url: inserted_community.followers_url.to_owned(), inbox_url: inserted_community.inbox_url.to_owned(), shared_inbox_url: None, + hidden: false, }; let community_follower_form = CommunityFollowerForm { diff --git a/crates/db_schema/src/impls/moderator.rs b/crates/db_schema/src/impls/moderator.rs index 952bab40e..696dbe0a4 100644 --- a/crates/db_schema/src/impls/moderator.rs +++ b/crates/db_schema/src/impls/moderator.rs @@ -168,6 +168,30 @@ impl Crud for ModBan { } } +impl Crud for ModHideCommunity { + type Form = ModHideCommunityForm; + type IdType = i32; + + fn read(conn: &PgConnection, from_id: i32) -> Result { + use crate::schema::mod_hide_community::dsl::*; + mod_hide_community.find(from_id).first::(conn) + } + + fn create(conn: &PgConnection, form: &ModHideCommunityForm) -> Result { + use crate::schema::mod_hide_community::dsl::*; + insert_into(mod_hide_community) + .values(form) + .get_result::(conn) + } + + fn update(conn: &PgConnection, from_id: i32, form: &ModHideCommunityForm) -> Result { + use crate::schema::mod_hide_community::dsl::*; + diesel::update(mod_hide_community.find(from_id)) + .set(form) + .get_result::(conn) + } +} + impl Crud for ModAddCommunity { type Form = ModAddCommunityForm; type IdType = i32; diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index b93cd4e72..811e8b09a 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -93,6 +93,7 @@ table! { followers_url -> Varchar, inbox_url -> Varchar, shared_inbox_url -> Nullable, + hidden -> Bool, } } @@ -593,6 +594,17 @@ table! { } } +table! { + mod_hide_community (id) { + id -> Int4, + community_id -> Int4, + mod_person_id -> Int4, + reason -> Nullable, + hidden -> Nullable, + when_ -> Timestamp, + } +} + joinable!(comment_alias_1 -> person_alias_1 (creator_id)); joinable!(comment -> comment_alias_1 (parent_id)); joinable!(person_mention -> person_alias_1 (recipient_id)); @@ -656,6 +668,8 @@ joinable!(site_aggregates -> site (site_id)); joinable!(email_verification -> local_user (local_user_id)); joinable!(registration_application -> local_user (local_user_id)); joinable!(registration_application -> person (admin_id)); +joinable!(mod_hide_community -> person (mod_person_id)); +joinable!(mod_hide_community -> community (community_id)); allow_tables_to_appear_in_same_query!( activity, @@ -681,6 +695,7 @@ allow_tables_to_appear_in_same_query!( mod_remove_community, mod_remove_post, mod_sticky_post, + mod_hide_community, password_reset_request, person, person_aggregates, diff --git a/crates/db_schema/src/source/community.rs b/crates/db_schema/src/source/community.rs index 986015e7d..35b695db0 100644 --- a/crates/db_schema/src/source/community.rs +++ b/crates/db_schema/src/source/community.rs @@ -26,6 +26,7 @@ pub struct Community { pub followers_url: DbUrl, pub inbox_url: DbUrl, pub shared_inbox_url: Option, + pub hidden: bool, } /// A safe representation of community, without the sensitive info @@ -45,6 +46,7 @@ pub struct CommunitySafe { pub local: bool, pub icon: Option, pub banner: Option, + pub hidden: bool, } #[derive(Insertable, AsChangeset, Debug, Default)] @@ -68,6 +70,7 @@ pub struct CommunityForm { pub followers_url: Option, pub inbox_url: Option, pub shared_inbox_url: Option>, + pub hidden: Option, } #[derive(Identifiable, Queryable, Associations, PartialEq, Debug)] diff --git a/crates/db_schema/src/source/moderator.rs b/crates/db_schema/src/source/moderator.rs index ee8bc693f..84121f9a4 100644 --- a/crates/db_schema/src/source/moderator.rs +++ b/crates/db_schema/src/source/moderator.rs @@ -5,6 +5,7 @@ use crate::{ mod_add_community, mod_ban, mod_ban_from_community, + mod_hide_community, mod_lock_post, mod_remove_comment, mod_remove_community, @@ -149,6 +150,25 @@ pub struct ModBan { pub when_: chrono::NaiveDateTime, } +#[derive(Insertable, AsChangeset)] +#[table_name = "mod_hide_community"] +pub struct ModHideCommunityForm { + pub community_id: CommunityId, + pub mod_person_id: PersonId, + pub hidden: Option, + pub reason: Option, +} +#[derive(Clone, Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize)] +#[table_name = "mod_hide_community"] +pub struct ModHideCommunity { + pub id: i32, + pub community_id: CommunityId, + pub mod_person_id: PersonId, + pub reason: Option, + pub hidden: Option, + pub when_: chrono::NaiveDateTime, +} + #[derive(Insertable, AsChangeset)] #[table_name = "mod_ban"] pub struct ModBanForm { diff --git a/crates/db_views/src/comment_report_view.rs b/crates/db_views/src/comment_report_view.rs index 52089ec57..68c000271 100644 --- a/crates/db_views/src/comment_report_view.rs +++ b/crates/db_views/src/comment_report_view.rs @@ -434,6 +434,7 @@ mod tests { description: None, updated: None, banner: None, + hidden: false, published: inserted_community.published, }, creator: PersonSafe { diff --git a/crates/db_views/src/comment_view.rs b/crates/db_views/src/comment_view.rs index 6dcda899b..4a7f96b6a 100644 --- a/crates/db_views/src/comment_view.rs +++ b/crates/db_views/src/comment_view.rs @@ -448,10 +448,25 @@ impl<'a> CommentQueryBuilder<'a> { }; if let Some(listing_type) = self.listing_type { - query = match listing_type { - ListingType::Subscribed => query.filter(community_follower::person_id.is_not_null()), // TODO could be this: and(community_follower::person_id.eq(person_id_join)), - ListingType::Local => query.filter(community::local.eq(true)), - _ => query, + match listing_type { + ListingType::Subscribed => { + query = query.filter(community_follower::person_id.is_not_null()) + } // TODO could be this: and(community_follower::person_id.eq(person_id_join)), + ListingType::Local => { + query = query.filter(community::local.eq(true)).filter( + community::hidden + .eq(false) + .or(community_follower::person_id.eq(person_id_join)), + ) + } + ListingType::All => { + query = query.filter( + community::hidden + .eq(false) + .or(community_follower::person_id.eq(person_id_join)), + ) + } + ListingType::Community => {} }; } @@ -693,6 +708,7 @@ mod tests { description: None, updated: None, banner: None, + hidden: false, published: inserted_community.published, }, counts: CommentAggregates { diff --git a/crates/db_views/src/post_report_view.rs b/crates/db_views/src/post_report_view.rs index e7ba0308a..e8532a916 100644 --- a/crates/db_views/src/post_report_view.rs +++ b/crates/db_views/src/post_report_view.rs @@ -412,6 +412,7 @@ mod tests { description: None, updated: None, banner: None, + hidden: false, published: inserted_community.published, }, creator: PersonSafe { diff --git a/crates/db_views/src/post_view.rs b/crates/db_views/src/post_view.rs index 14138374a..b9350f8fc 100644 --- a/crates/db_views/src/post_view.rs +++ b/crates/db_views/src/post_view.rs @@ -355,17 +355,32 @@ impl<'a> PostQueryBuilder<'a> { .into_boxed(); if let Some(listing_type) = self.listing_type { - query = match listing_type { - ListingType::Subscribed => query.filter(community_follower::person_id.is_not_null()), - ListingType::Local => query.filter(community::local.eq(true)), - _ => query, - }; - } - - if let Some(community_id) = self.community_id { - query = query - .filter(post::community_id.eq(community_id)) - .then_order_by(post_aggregates::stickied.desc()); + match listing_type { + ListingType::Subscribed => { + query = query.filter(community_follower::person_id.is_not_null()) + } + ListingType::Local => { + query = query.filter(community::local.eq(true)).filter( + community::hidden + .eq(false) + .or(community_follower::person_id.eq(person_id_join)), + ); + } + ListingType::All => { + query = query.filter( + community::hidden + .eq(false) + .or(community_follower::person_id.eq(person_id_join)), + ) + } + ListingType::Community => { + if let Some(community_id) = self.community_id { + query = query + .filter(post::community_id.eq(community_id)) + .then_order_by(post_aggregates::stickied.desc()); + } + } + } } if let Some(community_actor_id) = self.community_actor_id { @@ -679,6 +694,7 @@ mod tests { description: None, updated: None, banner: None, + hidden: false, published: inserted_community.published, }, counts: PostAggregates { diff --git a/crates/db_views_actor/src/community_view.rs b/crates/db_views_actor/src/community_view.rs index 48270c25c..50ca6f4dd 100644 --- a/crates/db_views_actor/src/community_view.rs +++ b/crates/db_views_actor/src/community_view.rs @@ -201,7 +201,24 @@ impl<'a> CommunityQueryBuilder<'a> { SortType::New => query = query.order_by(community::published.desc()), SortType::TopAll => query = query.order_by(community_aggregates::subscribers.desc()), SortType::TopMonth => query = query.order_by(community_aggregates::users_active_month.desc()), - // Covers all other sorts, including hot + SortType::Hot => { + query = query + .order_by( + hot_rank( + community_aggregates::subscribers, + community_aggregates::published, + ) + .desc(), + ) + .then_order_by(community_aggregates::published.desc()); + // Don't show hidden communities in Hot (trending) + query = query.filter( + community::hidden + .eq(false) + .or(community_follower::person_id.eq(person_id_join)), + ); + } + // Covers all other sorts _ => { query = query .order_by( diff --git a/crates/db_views_moderator/src/lib.rs b/crates/db_views_moderator/src/lib.rs index 354504c4a..ce51617ce 100644 --- a/crates/db_views_moderator/src/lib.rs +++ b/crates/db_views_moderator/src/lib.rs @@ -2,6 +2,7 @@ pub mod mod_add_community_view; pub mod mod_add_view; pub mod mod_ban_from_community_view; pub mod mod_ban_view; +pub mod mod_hide_community_view; pub mod mod_lock_post_view; pub mod mod_remove_comment_view; pub mod mod_remove_community_view; diff --git a/crates/db_views_moderator/src/mod_hide_community_view.rs b/crates/db_views_moderator/src/mod_hide_community_view.rs new file mode 100644 index 000000000..c7aba772d --- /dev/null +++ b/crates/db_views_moderator/src/mod_hide_community_view.rs @@ -0,0 +1,75 @@ +use diesel::{result::Error, *}; +use lemmy_db_schema::{ + limit_and_offset, + newtypes::{CommunityId, PersonId}, + schema::{community, mod_hide_community, person}, + source::{ + community::{Community, CommunitySafe}, + moderator::ModHideCommunity, + person::{Person, PersonSafe}, + }, + traits::{ToSafe, ViewToVec}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ModHideCommunityView { + pub mod_hide_community: ModHideCommunity, + pub admin: PersonSafe, + pub community: CommunitySafe, +} + +type ModHideCommunityViewTuple = (ModHideCommunity, PersonSafe, CommunitySafe); + +impl ModHideCommunityView { + // Pass in mod_id as admin_id because only admins can do this action + pub fn list( + conn: &PgConnection, + community_id: Option, + admin_id: Option, + page: Option, + limit: Option, + ) -> Result, Error> { + let mut query = mod_hide_community::table + .inner_join(person::table) + .inner_join(community::table.on(mod_hide_community::community_id.eq(community::id))) + .select(( + mod_hide_community::all_columns, + Person::safe_columns_tuple(), + Community::safe_columns_tuple(), + )) + .into_boxed(); + + if let Some(community_id) = community_id { + query = query.filter(mod_hide_community::community_id.eq(community_id)); + }; + + if let Some(admin_id) = admin_id { + query = query.filter(mod_hide_community::mod_person_id.eq(admin_id)); + }; + + let (limit, offset) = limit_and_offset(page, limit); + + let res = query + .limit(limit) + .offset(offset) + .order_by(mod_hide_community::when_.desc()) + .load::(conn)?; + + Ok(Self::from_tuple_to_vec(res)) + } +} + +impl ViewToVec for ModHideCommunityView { + type DbTuple = ModHideCommunityViewTuple; + fn from_tuple_to_vec(items: Vec) -> Vec { + items + .iter() + .map(|a| Self { + mod_hide_community: a.0.to_owned(), + admin: a.1.to_owned(), + community: a.2.to_owned(), + }) + .collect::>() + } +} diff --git a/migrations/2022-01-04-034553_add_hidden_column/down.sql b/migrations/2022-01-04-034553_add_hidden_column/down.sql new file mode 100644 index 000000000..55a054ae3 --- /dev/null +++ b/migrations/2022-01-04-034553_add_hidden_column/down.sql @@ -0,0 +1,3 @@ +alter table community drop column hidden; + +drop table mod_hide_community; diff --git a/migrations/2022-01-04-034553_add_hidden_column/up.sql b/migrations/2022-01-04-034553_add_hidden_column/up.sql new file mode 100644 index 000000000..b7436616d --- /dev/null +++ b/migrations/2022-01-04-034553_add_hidden_column/up.sql @@ -0,0 +1,13 @@ +alter table community add column hidden boolean default false; + + +create table mod_hide_community +( + id serial primary key, + community_id int references community on update cascade on delete cascade not null, + mod_person_id int references person on update cascade on delete cascade not null, + when_ timestamp not null default now(), + reason text, + hidden boolean default false +); + diff --git a/src/api_routes.rs b/src/api_routes.rs index 69bfe50bf..1af9f028f 100644 --- a/src/api_routes.rs +++ b/src/api_routes.rs @@ -49,6 +49,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) { .wrap(rate_limit.message()) .route("", web::get().to(route_get_crud::)) .route("", web::put().to(route_post_crud::)) + .route("/hide", web::put().to(route_post_crud::)) .route("/list", web::get().to(route_get_crud::)) .route("/follow", web::post().to(route_post::)) .route("/block", web::post().to(route_post::)) diff --git a/src/code_migrations.rs b/src/code_migrations.rs index 161dd3b60..4c0becfe9 100644 --- a/src/code_migrations.rs +++ b/src/code_migrations.rs @@ -111,6 +111,7 @@ fn community_updates_2020_04_02( deleted: None, nsfw: None, updated: None, + hidden: Some(false), actor_id: Some(community_actor_id.to_owned()), local: Some(ccommunity.local), private_key: Some(Some(keypair.private_key)),