Add "subscribers-only" post visibility setting

This commit is contained in:
silverpill 2022-01-15 23:21:13 +00:00
parent 71fc2d9dad
commit 9330038141
9 changed files with 194 additions and 29 deletions

View file

@ -351,11 +351,7 @@ paths:
format: uuid format: uuid
visibility: visibility:
description: Visibility of the post. description: Visibility of the post.
type: string $ref: '#/components/schemas/Visibility'
enum:
- public
- private
- direct
mentions: mentions:
description: Array of profile IDs to be mentioned description: Array of profile IDs to be mentioned
type: array type: array
@ -681,11 +677,7 @@ components:
type: string type: string
visibility: visibility:
description: Visibility of this post. description: Visibility of this post.
type: string $ref: '#/components/schemas/Visibility'
enum:
- public
- private
- direct
mentions: mentions:
description: Mentions of users within the post. description: Mentions of users within the post.
type: array type: array
@ -712,3 +704,10 @@ components:
type: string type: string
url: url:
description: A link to the hashtag on the instance. description: A link to the hashtag on the instance.
Visibility:
type: string
enum:
- public
- private
- subscribers
- direct

View file

@ -11,7 +11,12 @@ use crate::utils::files::get_file_url;
use crate::utils::id::new_uuid; use crate::utils::id::new_uuid;
use super::actor::{get_local_actor, ActorKeyError}; use super::actor::{get_local_actor, ActorKeyError};
use super::constants::{AP_CONTEXT, AP_PUBLIC}; use super::constants::{AP_CONTEXT, AP_PUBLIC};
use super::views::{get_actor_url, get_followers_url, get_object_url}; use super::views::{
get_actor_url,
get_followers_url,
get_subscribers_url,
get_object_url,
};
use super::vocabulary::*; use super::vocabulary::*;
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -187,13 +192,22 @@ pub fn create_note(
let mut secondary_audience = vec![]; let mut secondary_audience = vec![];
let followers_collection_url = let followers_collection_url =
get_followers_url(instance_url, &post.author.username); get_followers_url(instance_url, &post.author.username);
let mut tags = vec![]; let subscribers_collection_url =
if matches!(post.visibility, Visibility::Public) { get_subscribers_url(instance_url, &post.author.username);
match post.visibility {
Visibility::Public => {
primary_audience.push(AP_PUBLIC.to_string()); primary_audience.push(AP_PUBLIC.to_string());
secondary_audience.push(followers_collection_url); secondary_audience.push(followers_collection_url);
} else if matches!(post.visibility, Visibility::Followers) { },
Visibility::Followers => {
primary_audience.push(followers_collection_url); primary_audience.push(followers_collection_url);
},
Visibility::Subscribers => {
primary_audience.push(subscribers_collection_url);
},
Visibility::Direct => (),
}; };
let mut tags = vec![];
for profile in &post.mentions { for profile in &post.mentions {
let actor_id = profile.actor_id(instance_url); let actor_id = profile.actor_id(instance_url);
primary_audience.push(actor_id); primary_audience.push(actor_id);

View file

@ -44,6 +44,10 @@ pub fn get_following_url(instance_url: &str, username: &str) -> String {
format!("{}/users/{}/following", instance_url, username) format!("{}/users/{}/following", instance_url, username)
} }
pub fn get_subscribers_url(instance_url: &str, username: &str) -> String {
format!("{}/users/{}/subscribers", instance_url, username)
}
pub fn get_instance_actor_url(instance_url: &str) -> String { pub fn get_instance_actor_url(instance_url: &str) -> String {
format!("{}/actor", instance_url) format!("{}/actor", instance_url)
} }
@ -225,6 +229,24 @@ async fn following_collection(
Ok(response) Ok(response)
} }
#[get("/subscribers")]
async fn subscribers_collection(
config: web::Data<Config>,
web::Path(username): web::Path<String>,
query_params: web::Query<CollectionQueryParams>,
) -> Result<HttpResponse, HttpError> {
if query_params.page.is_some() {
// Subscriber list is hidden
return Err(HttpError::PermissionError);
}
let collection_id = get_subscribers_url(&config.instance_url(), &username);
let collection = OrderedCollection::new(collection_id, None);
let response = HttpResponse::Ok()
.content_type(ACTIVITY_CONTENT_TYPE)
.json(collection);
Ok(response)
}
pub fn actor_scope() -> Scope { pub fn actor_scope() -> Scope {
web::scope("/users/{username}") web::scope("/users/{username}")
.service(actor_view) .service(actor_view)
@ -232,6 +254,7 @@ pub fn actor_scope() -> Scope {
.service(outbox) .service(outbox)
.service(followers_collection) .service(followers_collection)
.service(following_collection) .service(following_collection)
.service(subscribers_collection)
} }
#[get("")] #[get("")]

View file

@ -4,7 +4,7 @@ use crate::activitypub::actor::Actor;
use crate::errors::DatabaseError; use crate::errors::DatabaseError;
use crate::models::posts::queries::get_post_author; use crate::models::posts::queries::get_post_author;
use crate::models::posts::types::{Post, Visibility}; use crate::models::posts::types::{Post, Visibility};
use crate::models::relationships::queries::get_followers; use crate::models::relationships::queries::{get_followers, get_subscribers};
use crate::models::users::types::User; use crate::models::users::types::User;
pub async fn get_note_recipients( pub async fn get_note_recipients(
@ -13,9 +13,16 @@ pub async fn get_note_recipients(
post: &Post, post: &Post,
) -> Result<Vec<Actor>, DatabaseError> { ) -> Result<Vec<Actor>, DatabaseError> {
let mut audience = vec![]; let mut audience = vec![];
if matches!(post.visibility, Visibility::Public | Visibility::Followers) { match post.visibility {
Visibility::Public | Visibility::Followers => {
let followers = get_followers(db_client, &current_user.id, None, None).await?; let followers = get_followers(db_client, &current_user.id, None, None).await?;
audience.extend(followers); audience.extend(followers);
},
Visibility::Subscribers => {
let subscribers = get_subscribers(db_client, &current_user.id).await?;
audience.extend(subscribers);
},
Visibility::Direct => (),
}; };
if let Some(in_reply_to_id) = post.in_reply_to_id { if let Some(in_reply_to_id) = post.in_reply_to_id {
// TODO: use post.in_reply_to ? // TODO: use post.in_reply_to ?

View file

@ -98,6 +98,7 @@ impl Status {
Visibility::Public => "public", Visibility::Public => "public",
Visibility::Direct => "direct", Visibility::Direct => "direct",
Visibility::Followers => "private", Visibility::Followers => "private",
Visibility::Subscribers => "subscribers",
}; };
Self { Self {
id: post.id, id: post.id,
@ -147,6 +148,7 @@ impl TryFrom<StatusData> for PostCreateData {
Some("public") => Visibility::Public, Some("public") => Visibility::Public,
Some("direct") => Visibility::Direct, Some("direct") => Visibility::Direct,
Some("private") => Visibility::Followers, Some("private") => Visibility::Followers,
Some("subscribers") => Visibility::Subscribers,
Some(_) => return Err(ValidationError("invalid visibility parameter")), Some(_) => return Err(ValidationError("invalid visibility parameter")),
None => Visibility::Public, None => Visibility::Public,
}; };

View file

@ -90,6 +90,20 @@ pub async fn can_view_post(
false false
} }
}, },
Visibility::Subscribers => {
if let Some(user) = user {
let relationship = get_relationship(
db_client,
&post.author.id,
&user.id,
).await?;
let is_mentioned = post.mentions.iter()
.any(|profile| profile.id == user.profile.id);
relationship.subscription_from || is_mentioned
} else {
false
}
},
}; };
Ok(result) Ok(result)
} }
@ -99,7 +113,7 @@ mod tests {
use serial_test::serial; use serial_test::serial;
use tokio_postgres::Client; use tokio_postgres::Client;
use crate::database::test_utils::create_test_database; use crate::database::test_utils::create_test_database;
use crate::models::relationships::queries::follow; use crate::models::relationships::queries::{follow, subscribe};
use crate::models::users::queries::create_user; use crate::models::users::queries::create_user;
use crate::models::users::types::UserCreateData; use crate::models::users::types::UserCreateData;
use super::*; use super::*;
@ -180,4 +194,32 @@ mod tests {
let result = can_view_post(db_client, Some(&follower), &post).await.unwrap(); let result = can_view_post(db_client, Some(&follower), &post).await.unwrap();
assert_eq!(result, true); assert_eq!(result, true);
} }
#[tokio::test]
#[serial]
async fn test_can_view_post_subscribers_only() {
let db_client = &mut create_test_database().await;
let author = create_test_user(db_client, "author").await;
let follower = create_test_user(db_client, "follower").await;
follow(db_client, &follower.id, &author.id).await.unwrap();
let subscriber = create_test_user(db_client, "subscriber").await;
subscribe(db_client, &subscriber.id, &author.id).await.unwrap();
let post = Post {
author: author.profile,
visibility: Visibility::Subscribers,
..Default::default()
};
assert_eq!(
can_view_post(db_client, None, &post).await.unwrap(),
false,
);
assert_eq!(
can_view_post(db_client, Some(&follower), &post).await.unwrap(),
false,
);
assert_eq!(
can_view_post(db_client, Some(&subscriber), &post).await.unwrap(),
true,
);
}
} }

View file

@ -222,11 +222,20 @@ fn build_visibility_filter() -> String {
AND target_id = post.author_id AND target_id = post.author_id
AND relationship_type = {relationship_follow} AND relationship_type = {relationship_follow}
) )
OR post.visibility = {visibility_subscribers} AND EXISTS (
SELECT 1 FROM relationship
WHERE
source_id = $current_user_id
AND target_id = post.author_id
AND relationship_type = {relationship_subscription}
)
)", )",
visibility_public=i16::from(&Visibility::Public), visibility_public=i16::from(&Visibility::Public),
visibility_direct=i16::from(&Visibility::Direct), visibility_direct=i16::from(&Visibility::Direct),
visibility_followers=i16::from(&Visibility::Followers), visibility_followers=i16::from(&Visibility::Followers),
visibility_subscribers=i16::from(&Visibility::Subscribers),
relationship_follow=i16::from(&RelationshipType::Follow), relationship_follow=i16::from(&RelationshipType::Follow),
relationship_subscription=i16::from(&RelationshipType::Subscription),
) )
} }
@ -236,7 +245,8 @@ pub async fn get_home_timeline(
max_post_id: Option<Uuid>, max_post_id: Option<Uuid>,
limit: i64, limit: i64,
) -> Result<Vec<Post>, DatabaseError> { ) -> Result<Vec<Post>, DatabaseError> {
// Select posts from follows, posts where current user is mentioned // Select posts from follows, subscriptions,
// posts where current user is mentioned
// and user's own posts. // and user's own posts.
let statement = format!( let statement = format!(
" "
@ -252,7 +262,9 @@ pub async fn get_home_timeline(
post.author_id = $current_user_id post.author_id = $current_user_id
OR EXISTS ( OR EXISTS (
SELECT 1 FROM relationship SELECT 1 FROM relationship
WHERE source_id = $current_user_id AND target_id = post.author_id WHERE
source_id = $current_user_id AND target_id = post.author_id
AND relationship_type IN ({relationship_follow}, {relationship_subscription})
) )
OR EXISTS ( OR EXISTS (
SELECT 1 FROM mention SELECT 1 FROM mention
@ -267,6 +279,8 @@ pub async fn get_home_timeline(
related_attachments=RELATED_ATTACHMENTS, related_attachments=RELATED_ATTACHMENTS,
related_mentions=RELATED_MENTIONS, related_mentions=RELATED_MENTIONS,
related_tags=RELATED_TAGS, related_tags=RELATED_TAGS,
relationship_follow=i16::from(&RelationshipType::Follow),
relationship_subscription=i16::from(&RelationshipType::Subscription),
visibility_filter=build_visibility_filter(), visibility_filter=build_visibility_filter(),
); );
let query = query!( let query = query!(
@ -780,7 +794,7 @@ mod tests {
use crate::database::test_utils::create_test_database; use crate::database::test_utils::create_test_database;
use crate::models::profiles::queries::create_profile; use crate::models::profiles::queries::create_profile;
use crate::models::profiles::types::ProfileCreateData; use crate::models::profiles::types::ProfileCreateData;
use crate::models::relationships::queries::follow; use crate::models::relationships::queries::{follow, subscribe};
use crate::models::users::queries::create_user; use crate::models::users::queries::create_user;
use crate::models::users::types::UserCreateData; use crate::models::users::types::UserCreateData;
use super::*; use super::*;
@ -878,9 +892,29 @@ mod tests {
..Default::default() ..Default::default()
}; };
let post_8 = create_post(db_client, &user_2.id, post_data_8).await.unwrap(); let post_8 = create_post(db_client, &user_2.id, post_data_8).await.unwrap();
// Subscribers-only post by followed user
let post_data_9 = 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();
// Subscribers-only post by subscription
let user_data_3 = UserCreateData {
username: "subscription".to_string(),
..Default::default()
};
let user_3 = create_user(db_client, user_data_3).await.unwrap();
subscribe(db_client, &current_user.id, &user_3.id).await.unwrap();
let post_data_10 = 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 timeline = get_home_timeline(db_client, &current_user.id, None, 10).await.unwrap(); let timeline = get_home_timeline(db_client, &current_user.id, None, 20).await.unwrap();
assert_eq!(timeline.len(), 5); assert_eq!(timeline.len(), 6);
assert_eq!(timeline.iter().any(|post| post.id == post_1.id), true); 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_2.id), true);
assert_eq!(timeline.iter().any(|post| post.id == post_3.id), false); assert_eq!(timeline.iter().any(|post| post.id == post_3.id), false);
@ -889,6 +923,8 @@ mod tests {
assert_eq!(timeline.iter().any(|post| post.id == post_6.id), true); 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_7.id), false);
assert_eq!(timeline.iter().any(|post| post.id == post_8.id), true); 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);
} }
#[tokio::test] #[tokio::test]
@ -906,13 +942,27 @@ mod tests {
..Default::default() ..Default::default()
}; };
let post_1 = create_post(db_client, &user.id, post_data_1).await.unwrap(); let post_1 = create_post(db_client, &user.id, post_data_1).await.unwrap();
// Direct message // Followers only post
let post_data_2 = PostCreateData { let post_data_2 = PostCreateData {
content: "my post".to_string(),
visibility: Visibility::Followers,
..Default::default()
};
let post_2 = create_post(db_client, &user.id, post_data_2).await.unwrap();
// Subscribers only post
let post_data_3 = PostCreateData {
content: "my post".to_string(),
visibility: Visibility::Subscribers,
..Default::default()
};
let post_3 = create_post(db_client, &user.id, post_data_3).await.unwrap();
// Direct message
let post_data_4 = PostCreateData {
content: "my post".to_string(), content: "my post".to_string(),
visibility: Visibility::Direct, visibility: Visibility::Direct,
..Default::default() ..Default::default()
}; };
let post_2 = create_post(db_client, &user.id, post_data_2).await.unwrap(); let post_4 = create_post(db_client, &user.id, post_data_4).await.unwrap();
// Anonymous viewer // Anonymous viewer
let timeline = get_posts_by_author( let timeline = get_posts_by_author(
@ -921,5 +971,7 @@ mod tests {
assert_eq!(timeline.len(), 1); assert_eq!(timeline.len(), 1);
assert_eq!(timeline.iter().any(|post| post.id == post_1.id), true); assert_eq!(timeline.iter().any(|post| post.id == post_1.id), true);
assert_eq!(timeline.iter().any(|post| post.id == post_2.id), false); assert_eq!(timeline.iter().any(|post| post.id == post_2.id), false);
assert_eq!(timeline.iter().any(|post| post.id == post_3.id), false);
assert_eq!(timeline.iter().any(|post| post.id == post_4.id), false);
} }
} }

View file

@ -17,6 +17,7 @@ pub enum Visibility {
Public, Public,
Direct, Direct,
Followers, Followers,
Subscribers,
} }
impl Default for Visibility { impl Default for Visibility {
@ -29,6 +30,7 @@ impl From<&Visibility> for i16 {
Visibility::Public => 1, Visibility::Public => 1,
Visibility::Direct => 2, Visibility::Direct => 2,
Visibility::Followers => 3, Visibility::Followers => 3,
Visibility::Subscribers => 4,
} }
} }
} }
@ -41,6 +43,7 @@ impl TryFrom<i16> for Visibility {
1 => Self::Public, 1 => Self::Public,
2 => Self::Direct, 2 => Self::Direct,
3 => Self::Followers, 3 => Self::Followers,
4 => Self::Subscribers,
_ => return Err(ConversionError), _ => return Err(ConversionError),
}; };
Ok(visibility) Ok(visibility)

View file

@ -367,6 +367,29 @@ pub async fn unsubscribe(
Ok(()) Ok(())
} }
pub async fn get_subscribers(
db_client: &impl GenericClient,
profile_id: &Uuid,
) -> Result<Vec<DbActorProfile>, DatabaseError> {
let rows = db_client.query(
"
SELECT actor_profile
FROM actor_profile
JOIN relationship
ON (actor_profile.id = relationship.source_id)
WHERE
relationship.target_id = $1
AND relationship.relationship_type = $2
ORDER BY relationship.id DESC
",
&[&profile_id, &RelationshipType::Subscription],
).await?;
let profiles = rows.iter()
.map(|row| row.try_get("actor_profile"))
.collect::<Result<_, _>>()?;
Ok(profiles)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use serial_test::serial; use serial_test::serial;