diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bf51c59..4bc421c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -249,6 +249,16 @@ paths: - tokenAuth: [] parameters: - $ref: '#/components/parameters/account_id' + requestBody: + content: + application/json: + schema: + type: object + properties: + reblogs: + description: Receive this actor's reposts in home timeline? + type: boolean + default: true responses: 200: description: Successfully followed, or actor was already followed @@ -687,18 +697,27 @@ components: following: description: Are you following this user? type: boolean + default: false followed_by: description: Are you followed by this user? type: boolean + default: false requested: description: Do you have a pending follow request for this user? type: boolean + default: false subscription_to: description: Are you sending subscription payments to this user? type: boolean + default: false subscription_from: description: Are you receiving subscription payments from this user? type: boolean + default: false + showing_reblogs: + description: Are you receiving this user's boosts in your home timeline? + type: boolean + default: true Signature: type: object properties: diff --git a/src/mastodon_api/accounts/helpers.rs b/src/mastodon_api/accounts/helpers.rs index bba79fb..9eda178 100644 --- a/src/mastodon_api/accounts/helpers.rs +++ b/src/mastodon_api/accounts/helpers.rs @@ -35,6 +35,11 @@ pub async fn get_relationship( relationship_map.subscription_from = true; }; }, + RelationshipType::HideReposts => { + if relationship.is_direct(source_id, target_id)? { + relationship_map.showing_reblogs = false; + }; + }, }; }; Ok(relationship_map) @@ -48,6 +53,8 @@ mod tests { create_follow_request, follow, follow_request_accepted, + hide_reposts, + show_reposts, subscribe, unfollow, unsubscribe, @@ -85,6 +92,7 @@ mod tests { assert_eq!(relationship.requested, false); assert_eq!(relationship.subscription_to, false); assert_eq!(relationship.subscription_from, false); + assert_eq!(relationship.showing_reblogs, true); // Follow request let follow_request = create_follow_request(db_client, &user_1.id, &user_2.id).await.unwrap(); let relationship = get_relationship(db_client, &user_1.id, &user_2.id).await.unwrap(); @@ -122,4 +130,25 @@ mod tests { assert_eq!(relationship.subscription_to, false); assert_eq!(relationship.subscription_from, false); } + + #[tokio::test] + #[serial] + async fn test_hide_reblogs() { + let db_client = &mut create_test_database().await; + let (user_1, user_2) = create_users(db_client).await.unwrap(); + follow(db_client, &user_1.id, &user_2.id).await.unwrap(); + let relationship = get_relationship(db_client, &user_1.id, &user_2.id).await.unwrap(); + assert_eq!(relationship.following, true); + assert_eq!(relationship.showing_reblogs, true); + + hide_reposts(db_client, &user_1.id, &user_2.id).await.unwrap(); + let relationship = get_relationship(db_client, &user_1.id, &user_2.id).await.unwrap(); + assert_eq!(relationship.following, true); + assert_eq!(relationship.showing_reblogs, false); + + show_reposts(db_client, &user_1.id, &user_2.id).await.unwrap(); + let relationship = get_relationship(db_client, &user_1.id, &user_2.id).await.unwrap(); + assert_eq!(relationship.following, true); + assert_eq!(relationship.showing_reblogs, true); + } } diff --git a/src/mastodon_api/accounts/types.rs b/src/mastodon_api/accounts/types.rs index f33133f..21f77ec 100644 --- a/src/mastodon_api/accounts/types.rs +++ b/src/mastodon_api/accounts/types.rs @@ -193,7 +193,7 @@ pub struct RelationshipQueryParams { pub id: Uuid, } -#[derive(Default, Serialize)] +#[derive(Serialize)] pub struct RelationshipMap { pub id: Uuid, // target ID pub following: bool, @@ -201,6 +201,29 @@ pub struct RelationshipMap { pub requested: bool, pub subscription_to: bool, pub subscription_from: bool, + pub showing_reblogs: bool, +} + +fn default_showing_reblogs() -> bool { true } + +impl Default for RelationshipMap { + fn default() -> Self { + Self { + id: Default::default(), + following: false, + followed_by: false, + requested: false, + subscription_to: false, + subscription_from: false, + showing_reblogs: default_showing_reblogs(), + } + } +} + +#[derive(Deserialize)] +pub struct FollowData { + #[serde(default = "default_showing_reblogs")] + pub reblogs: bool, } fn default_page_size() -> i64 { 20 } @@ -216,7 +239,6 @@ pub struct StatusListQueryParams { pub limit: i64, } - fn default_follow_list_page_size() -> i64 { 40 } #[derive(Deserialize)] diff --git a/src/mastodon_api/accounts/views.rs b/src/mastodon_api/accounts/views.rs index 4f5ae51..a27973f 100644 --- a/src/mastodon_api/accounts/views.rs +++ b/src/mastodon_api/accounts/views.rs @@ -33,6 +33,8 @@ use crate::models::relationships::queries::{ get_follow_request_by_path, get_followers, get_following, + hide_reposts, + show_reposts, unfollow, }; use crate::models::users::queries::{ @@ -51,6 +53,7 @@ use super::types::{ Account, AccountCreateData, AccountUpdateData, + FollowData, FollowListQueryParams, RelationshipQueryParams, StatusListQueryParams, @@ -249,12 +252,13 @@ async fn follow_account( config: web::Data, db_pool: web::Data, web::Path(account_id): web::Path, + data: web::Json, ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let target = get_profile_by_id(db_client, &account_id).await?; if let Some(remote_actor) = target.actor_json { - // Remote follow + // Create follow request if target is remote match create_follow_request(db_client, ¤t_user.id, &target.id).await { Ok(request) => { let activity = create_activity_follow( @@ -275,6 +279,11 @@ async fn follow_account( Err(other_error) => return Err(other_error.into()), }; }; + if data.reblogs { + show_reposts(db_client, ¤t_user.id, &target.id).await?; + } else { + hide_reposts(db_client, ¤t_user.id, &target.id).await?; + }; let relationship = get_relationship( db_client, ¤t_user.id, @@ -294,7 +303,7 @@ async fn unfollow_account( let current_user = get_current_user(db_client, auth.token()).await?; let target = get_profile_by_id(db_client, &account_id).await?; if let Some(remote_actor) = target.actor_json { - // Remote follow + // Get follow request ID then unfollow and delete it match get_follow_request_by_path( db_client, ¤t_user.id, diff --git a/src/models/posts/queries.rs b/src/models/posts/queries.rs index e497324..30be705 100644 --- a/src/models/posts/queries.rs +++ b/src/models/posts/queries.rs @@ -248,6 +248,7 @@ pub async fn get_home_timeline( // Select posts from follows, subscriptions, // posts where current user is mentioned // and user's own posts. + // Select reposts if they are not hidden. let statement = format!( " SELECT @@ -260,11 +261,29 @@ pub async fn get_home_timeline( WHERE ( post.author_id = $current_user_id - OR EXISTS ( - SELECT 1 FROM relationship - WHERE - source_id = $current_user_id AND target_id = post.author_id - AND relationship_type IN ({relationship_follow}, {relationship_subscription}) + OR ( + EXISTS ( + SELECT 1 FROM relationship + WHERE + source_id = $current_user_id + AND target_id = post.author_id + AND relationship_type IN ({relationship_follow}, {relationship_subscription}) + ) + AND ( + post.repost_of_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM relationship + WHERE + source_id = $current_user_id + AND target_id = post.author_id + AND relationship_type = {relationship_hide_reposts} + ) + OR EXISTS ( + SELECT 1 FROM post AS repost_of + WHERE repost_of.id = post.repost_of_id + AND repost_of.author_id = $current_user_id + ) + ) ) OR EXISTS ( SELECT 1 FROM mention @@ -281,6 +300,7 @@ pub async fn get_home_timeline( related_tags=RELATED_TAGS, relationship_follow=i16::from(&RelationshipType::Follow), relationship_subscription=i16::from(&RelationshipType::Subscription), + relationship_hide_reposts=i16::from(&RelationshipType::HideReposts), visibility_filter=build_visibility_filter(), ); let query = query!( @@ -890,7 +910,11 @@ mod tests { use crate::database::test_utils::create_test_database; use crate::models::profiles::queries::create_profile; use crate::models::profiles::types::ProfileCreateData; - use crate::models::relationships::queries::{follow, subscribe}; + use crate::models::relationships::queries::{ + follow, + hide_reposts, + subscribe, + }; use crate::models::users::queries::create_user; use crate::models::users::types::UserCreateData; use super::*; @@ -973,28 +997,34 @@ mod tests { ..Default::default() }; let post_6 = create_post(db_client, &user_2.id, post_data_6).await.unwrap(); - // Direct message from followed user sent to another user + // Followed user's repost let post_data_7 = PostCreateData { + repost_of_id: Some(post_3.id), + ..Default::default() + }; + let post_7 = create_post(db_client, &user_2.id, post_data_7).await.unwrap(); + // Direct message from followed user sent to another user + let post_data_8 = PostCreateData { content: "test post".to_string(), visibility: Visibility::Direct, mentions: vec![user_1.id], ..Default::default() }; - let post_7 = create_post(db_client, &user_2.id, post_data_7).await.unwrap(); + let post_8 = create_post(db_client, &user_2.id, post_data_8).await.unwrap(); // Followers-only post from followed user - let post_data_8 = PostCreateData { + let post_data_9 = PostCreateData { content: "followers only".to_string(), visibility: Visibility::Followers, ..Default::default() }; - let post_8 = create_post(db_client, &user_2.id, post_data_8).await.unwrap(); + let post_9 = create_post(db_client, &user_2.id, post_data_9).await.unwrap(); // Subscribers-only post by followed user - let post_data_9 = PostCreateData { + let post_data_10 = PostCreateData { content: "subscribers only".to_string(), visibility: Visibility::Subscribers, ..Default::default() }; - let post_9 = create_post(db_client, &user_2.id, post_data_9).await.unwrap(); + let post_10 = create_post(db_client, &user_2.id, post_data_10).await.unwrap(); // Subscribers-only post by subscription let user_data_3 = UserCreateData { username: "subscription".to_string(), @@ -1002,25 +1032,40 @@ mod tests { }; let user_3 = create_user(db_client, user_data_3).await.unwrap(); subscribe(db_client, ¤t_user.id, &user_3.id).await.unwrap(); - let post_data_10 = PostCreateData { + let post_data_11 = PostCreateData { content: "subscribers only".to_string(), visibility: Visibility::Subscribers, ..Default::default() }; - let post_10 = create_post(db_client, &user_3.id, post_data_10).await.unwrap(); + let post_11 = create_post(db_client, &user_3.id, post_data_11).await.unwrap(); + // Repost from followed user if hiding reposts + let user_data_4 = UserCreateData { + username: "hide reposts".to_string(), + ..Default::default() + }; + let user_4 = create_user(db_client, user_data_4).await.unwrap(); + follow(db_client, ¤t_user.id, &user_4.id).await.unwrap(); + hide_reposts(db_client, ¤t_user.id, &user_4.id).await.unwrap(); + let post_data_12 = PostCreateData { + repost_of_id: Some(post_3.id), + ..Default::default() + }; + let post_12 = create_post(db_client, &user_4.id, post_data_12).await.unwrap(); let timeline = get_home_timeline(db_client, ¤t_user.id, None, 20).await.unwrap(); - assert_eq!(timeline.len(), 6); + assert_eq!(timeline.len(), 7); assert_eq!(timeline.iter().any(|post| post.id == post_1.id), true); assert_eq!(timeline.iter().any(|post| post.id == post_2.id), true); assert_eq!(timeline.iter().any(|post| post.id == post_3.id), false); assert_eq!(timeline.iter().any(|post| post.id == post_4.id), true); assert_eq!(timeline.iter().any(|post| post.id == post_5.id), false); assert_eq!(timeline.iter().any(|post| post.id == post_6.id), true); - assert_eq!(timeline.iter().any(|post| post.id == post_7.id), false); - assert_eq!(timeline.iter().any(|post| post.id == post_8.id), true); - assert_eq!(timeline.iter().any(|post| post.id == post_9.id), false); - assert_eq!(timeline.iter().any(|post| post.id == post_10.id), true); + assert_eq!(timeline.iter().any(|post| post.id == post_7.id), true); + assert_eq!(timeline.iter().any(|post| post.id == post_8.id), false); + assert_eq!(timeline.iter().any(|post| post.id == post_9.id), true); + assert_eq!(timeline.iter().any(|post| post.id == post_10.id), false); + assert_eq!(timeline.iter().any(|post| post.id == post_11.id), true); + assert_eq!(timeline.iter().any(|post| post.id == post_12.id), false); } #[tokio::test] diff --git a/src/models/relationships/queries.rs b/src/models/relationships/queries.rs index d6e2b89..f07cd44 100644 --- a/src/models/relationships/queries.rs +++ b/src/models/relationships/queries.rs @@ -377,3 +377,37 @@ pub async fn get_subscribers( .collect::>()?; Ok(profiles) } + +pub async fn hide_reposts( + db_client: &impl GenericClient, + source_id: &Uuid, + target_id: &Uuid, +) -> Result<(), DatabaseError> { + db_client.execute( + " + INSERT INTO relationship (source_id, target_id, relationship_type) + VALUES ($1, $2, $3) + ON CONFLICT (source_id, target_id, relationship_type) DO NOTHING + ", + &[&source_id, &target_id, &RelationshipType::HideReposts], + ).await?; + Ok(()) +} + +pub async fn show_reposts( + db_client: &impl GenericClient, + source_id: &Uuid, + target_id: &Uuid, +) -> Result<(), DatabaseError> { + // Does not return NotFound error + db_client.execute( + " + DELETE FROM relationship + WHERE + source_id = $1 AND target_id = $2 + AND relationship_type = $3 + ", + &[&source_id, &target_id, &RelationshipType::HideReposts], + ).await?; + Ok(()) +} diff --git a/src/models/relationships/types.rs b/src/models/relationships/types.rs index 7caeb67..6a4aa30 100644 --- a/src/models/relationships/types.rs +++ b/src/models/relationships/types.rs @@ -12,6 +12,7 @@ pub enum RelationshipType { Follow, FollowRequest, Subscription, + HideReposts, } impl From<&RelationshipType> for i16 { @@ -20,6 +21,7 @@ impl From<&RelationshipType> for i16 { RelationshipType::Follow => 1, RelationshipType::FollowRequest => 2, RelationshipType::Subscription => 3, + RelationshipType::HideReposts => 4, } } } @@ -32,6 +34,7 @@ impl TryFrom for RelationshipType { 1 => Self::Follow, 2 => Self::FollowRequest, 3 => Self::Subscription, + 4 => Self::HideReposts, _ => return Err(ConversionError), }; Ok(relationship_type)