diff --git a/README.md b/README.md index d0b3738..9d7a0c9 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ POST /api/v1/statuses/{status_id}/unfavourite POST /api/v1/statuses/{status_id}/reblog POST /api/v1/statuses/{status_id}/unreblog GET /api/v1/timelines/home +GET /api/v1/timelines/tag/{hashtag} ``` Additional methods: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 6189c1c..b3cd274 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -18,7 +18,7 @@ paths: description: Post does not belong to user 404: description: Post not found - /api/v1/{status_id}/make_permanent: + /api/v1/statuses/{status_id}/make_permanent: post: summary: Save post to IPFS parameters: @@ -38,7 +38,7 @@ paths: description: IPFS integration is not enabled 422: description: Post already saved to IPFS - /api/v1/{status_id}/signature: + /api/v1/statuses/{status_id}/signature: get: summary: Sign post data with instance key parameters: @@ -69,7 +69,7 @@ paths: description: Ethereum integration is not enabled 422: description: Post is not saved to IPFS - /api/v1/{status_id}/token_minted: + /api/v1/statuses/{status_id}/token_minted: post: summary: Register transaction that mints a token parameters: @@ -95,6 +95,40 @@ paths: description: Post not found 422: description: Transaction already registered + /api/v1/timelines/tag/{hashtag}: + get: + summary: View public posts containing the given hashtag + parameters: + - name: hashtag + in: path + description: Hashtag name + required: true + schema: + type: string + - name: max_id + in: query + description: Return results older than this ID. + required: false + schema: + type: string + format: uuid + - name: limit + in: query + description: Maximum number of results to return. + required: false + schema: + type: integer + default: 20 + responses: + 200: + description: Successful operation + content: + application/json: + schema: + description: Post list + type: array + items: + $ref: '#/components/schemas/Status' components: parameters: @@ -113,6 +147,14 @@ components: id: type: string format: uuid + content: + description: HTML-encoded status content. + type: string + tags: + description: Hashtags used within the status content. + type: array + items: + $ref: '#/components/schemas/Tag' ipfs_cid: type: string nullable: true @@ -121,3 +163,11 @@ components: type: string nullable: true example: '0x5fe80cdea7f...' + Tag: + type: object + properties: + name: + description: 'The value of the hashtag after the # sign.' + type: string + url: + description: A link to the hashtag on the instance. diff --git a/src/mastodon_api/timelines/views.rs b/src/mastodon_api/timelines/views.rs index 0d7b601..6560697 100644 --- a/src/mastodon_api/timelines/views.rs +++ b/src/mastodon_api/timelines/views.rs @@ -11,7 +11,7 @@ use crate::models::posts::helpers::{ get_actions_for_posts, get_reposted_posts, }; -use crate::models::posts::queries::get_home_timeline; +use crate::models::posts::queries::{get_home_timeline, get_posts_by_tag}; use super::types::TimelineQueryParams; #[get("/home")] @@ -42,7 +42,42 @@ async fn home_timeline( Ok(HttpResponse::Ok().json(statuses)) } +#[get("/tag/{hashtag}")] +async fn hashtag_timeline( + auth: Option, + config: web::Data, + db_pool: web::Data, + web::Path(hashtag): web::Path, + query_params: web::Query, +) -> Result { + let db_client = &**get_database_client(&db_pool).await?; + let maybe_current_user = match auth { + Some(auth) => Some(get_current_user(db_client, auth.token()).await?), + None => None, + }; + let mut posts = get_posts_by_tag( + db_client, + &hashtag, + query_params.max_id, + query_params.limit, + ).await?; + get_reposted_posts(db_client, posts.iter_mut().collect()).await?; + if let Some(user) = maybe_current_user { + get_actions_for_posts( + db_client, + &user.id, + posts.iter_mut().collect(), + ).await?; + }; + let statuses: Vec = posts + .into_iter() + .map(|post| Status::from_post(post, &config.instance_url())) + .collect(); + Ok(HttpResponse::Ok().json(statuses)) +} + pub fn timeline_api_scope() -> Scope { web::scope("/api/v1/timelines") .service(home_timeline) + .service(hashtag_timeline) } diff --git a/src/models/posts/queries.rs b/src/models/posts/queries.rs index 5a76562..cf9af5b 100644 --- a/src/models/posts/queries.rs +++ b/src/models/posts/queries.rs @@ -329,6 +329,46 @@ pub async fn get_posts_by_author( Ok(posts) } +pub async fn get_posts_by_tag( + db_client: &impl GenericClient, + tag_name: &str, + max_post_id: Option, + limit: i64, +) -> Result, DatabaseError> { + let statement = format!( + " + SELECT + post, actor_profile, + {related_attachments}, + {related_mentions}, + {related_tags} + FROM post + JOIN actor_profile ON post.author_id = actor_profile.id + WHERE + post.visibility = {visibility_public} + AND EXISTS ( + SELECT 1 FROM post_tag JOIN tag ON post_tag.tag_id = tag.id + WHERE post_tag.post_id = post.id AND tag.tag_name = $1 + ) + AND ($2::uuid IS NULL OR post.id < $2) + ORDER BY post.id DESC + LIMIT $3 + ", + related_attachments=RELATED_ATTACHMENTS, + related_mentions=RELATED_MENTIONS, + related_tags=RELATED_TAGS, + visibility_public=i16::from(&Visibility::Public), + ); + let rows = db_client.query( + statement.as_str(), + &[&tag_name.to_lowercase(), &max_post_id, &limit], + ).await?; + let posts: Vec = rows.iter() + .map(Post::try_from) + .collect::>()?; + Ok(posts) +} + pub async fn get_post_by_id( db_client: &impl GenericClient, post_id: &Uuid,