feat(backend): wire FeedRequest/FeedOptions sort+filter through all feed layers
This commit is contained in:
@@ -9,7 +9,7 @@ use domain::{
|
|||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{FeedQuery, FeedRepository, FeedScope},
|
ports::{FeedFilter, FeedRepository, FeedRequest, FeedScope, FeedSort},
|
||||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -151,28 +151,62 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn order_by_clause(sort: &FeedSort, scope: &FeedScope) -> &'static str {
|
||||||
|
if matches!(scope, FeedScope::Search { .. }) {
|
||||||
|
return "ORDER BY similarity(t.content, $1) DESC";
|
||||||
|
}
|
||||||
|
match sort {
|
||||||
|
FeedSort::Newest => "ORDER BY t.created_at DESC",
|
||||||
|
FeedSort::Oldest => "ORDER BY t.created_at ASC",
|
||||||
|
FeedSort::MostLiked => "ORDER BY like_count DESC, t.created_at DESC",
|
||||||
|
FeedSort::MostBoosted => "ORDER BY boost_count DESC, t.created_at DESC",
|
||||||
|
FeedSort::MostDiscussed => "ORDER BY reply_count DESC, t.created_at DESC",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_clauses(f: &FeedFilter) -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
if f.originals_only {
|
||||||
|
s += " AND t.in_reply_to_id IS NULL";
|
||||||
|
}
|
||||||
|
if f.replies_only {
|
||||||
|
s += " AND t.in_reply_to_id IS NOT NULL";
|
||||||
|
}
|
||||||
|
if f.local_only {
|
||||||
|
s += " AND t.local = true";
|
||||||
|
}
|
||||||
|
if f.hide_sensitive {
|
||||||
|
s += " AND t.sensitive = false";
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FeedRepository for PgFeedRepository {
|
impl FeedRepository for PgFeedRepository {
|
||||||
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid());
|
let viewer = req.query.viewer_id.as_ref().map(|v| v.as_uuid());
|
||||||
let page = &q.page;
|
let page = &req.query.page;
|
||||||
|
let filter = filter_clauses(&req.options.filter);
|
||||||
|
let order = order_by_clause(&req.options.sort, &req.query.scope);
|
||||||
|
|
||||||
match &q.scope {
|
match &req.query.scope {
|
||||||
FeedScope::Home { following_ids } => {
|
FeedScope::Home { following_ids } => {
|
||||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||||
let fed_clause = federation_following_clause(viewer);
|
let fed_clause = federation_following_clause(viewer);
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
|
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{}",
|
||||||
fed_clause
|
fed_clause, filter
|
||||||
);
|
);
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.bind(&ids)
|
.bind(&ids)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
let sel = feed_select(viewer);
|
let sel = feed_select(viewer);
|
||||||
let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);
|
let sql = format!(
|
||||||
|
"{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{} {} LIMIT $2 OFFSET $3",
|
||||||
|
fed_clause, filter, order
|
||||||
|
);
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(&ids)
|
.bind(&ids)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
@@ -180,7 +214,6 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -193,22 +226,25 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FeedScope::Public => {
|
FeedScope::Public => {
|
||||||
let total: i64 = sqlx::query_scalar(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
|
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'{}",
|
||||||
)
|
filter
|
||||||
.fetch_one(&self.pool)
|
);
|
||||||
.await
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.into_domain()?;
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
let sel = feed_select(viewer);
|
let sel = feed_select(viewer);
|
||||||
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
let sql = format!(
|
||||||
|
"{sel} WHERE t.local=true AND t.visibility='public'{} {} LIMIT $1 OFFSET $2",
|
||||||
|
filter, order
|
||||||
|
);
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -221,16 +257,20 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FeedScope::Search { query } => {
|
FeedScope::Search { query } => {
|
||||||
let total: i64 = sqlx::query_scalar(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
|
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'{}",
|
||||||
)
|
filter
|
||||||
.bind(query)
|
);
|
||||||
.fetch_one(&self.pool)
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.await
|
.bind(query)
|
||||||
.into_domain()?;
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
let sel = feed_select(viewer);
|
let sel = feed_select(viewer);
|
||||||
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
|
let sql = format!(
|
||||||
|
"{sel} WHERE t.content % $1 AND t.visibility='public'{} {} LIMIT $2 OFFSET $3",
|
||||||
|
filter, order
|
||||||
|
);
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(query)
|
.bind(query)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
@@ -238,7 +278,6 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -251,24 +290,25 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FeedScope::Tag { tag_name } => {
|
FeedScope::Tag { tag_name } => {
|
||||||
let total: i64 = sqlx::query_scalar(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM thoughts t
|
"SELECT COUNT(*) FROM thoughts t
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
WHERE tg.name = $1 AND t.visibility = 'public'",
|
WHERE tg.name = $1 AND t.visibility = 'public'{}",
|
||||||
)
|
filter
|
||||||
.bind(tag_name)
|
);
|
||||||
.fetch_one(&self.pool)
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.await
|
.bind(tag_name)
|
||||||
.into_domain()?;
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()?;
|
||||||
let sel = feed_select(viewer);
|
let sel = feed_select(viewer);
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"{sel}
|
"{sel}
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
WHERE tg.name = $1 AND t.visibility = 'public'
|
WHERE tg.name = $1 AND t.visibility = 'public'{} {} LIMIT $2 OFFSET $3",
|
||||||
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
|
filter, order
|
||||||
);
|
);
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(tag_name)
|
.bind(tag_name)
|
||||||
@@ -277,7 +317,6 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -291,20 +330,22 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
|
|
||||||
FeedScope::User { user_id } => {
|
FeedScope::User { user_id } => {
|
||||||
let uid = user_id.as_uuid();
|
let uid = user_id.as_uuid();
|
||||||
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
|
|
||||||
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
||||||
|
let count_sql = format!(
|
||||||
let total: i64 = sqlx::query_scalar(
|
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted'))))){}",
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
|
filter
|
||||||
)
|
);
|
||||||
.bind(uid)
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.bind(viewer_uuid)
|
.bind(uid)
|
||||||
.fetch_one(&self.pool)
|
.bind(viewer_uuid)
|
||||||
.await
|
.fetch_one(&self.pool)
|
||||||
.into_domain()?;
|
.await
|
||||||
|
.into_domain()?;
|
||||||
let sel = feed_select(viewer);
|
let sel = feed_select(viewer);
|
||||||
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
let sql = format!(
|
||||||
|
"{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))){} {} LIMIT $2 OFFSET $3",
|
||||||
|
filter, order
|
||||||
|
);
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(uid)
|
.bind(uid)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
@@ -313,7 +354,6 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use domain::{
|
|||||||
thought::{NewThought, Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{FeedQuery, ThoughtRepository, UserWriter},
|
ports::{FeedOptions, FeedQuery, FeedRequest, ThoughtRepository, UserWriter},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,13 +38,16 @@ async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
|||||||
let (_, _) = seed(&pool, "alice", "hello").await;
|
let (_, _) = seed(&pool, "alice", "hello").await;
|
||||||
let repo = PgFeedRepository::new(pool);
|
let repo = PgFeedRepository::new(pool);
|
||||||
let result = repo
|
let result = repo
|
||||||
.query(&FeedQuery::public(
|
.query(&FeedRequest {
|
||||||
PageParams {
|
query: FeedQuery::public(
|
||||||
page: 1,
|
PageParams {
|
||||||
per_page: 20,
|
page: 1,
|
||||||
},
|
per_page: 20,
|
||||||
None,
|
},
|
||||||
))
|
None,
|
||||||
|
),
|
||||||
|
options: FeedOptions::default(),
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.total, 1);
|
assert_eq!(result.total, 1);
|
||||||
@@ -57,14 +60,17 @@ async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
|||||||
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
||||||
let repo = PgFeedRepository::new(pool);
|
let repo = PgFeedRepository::new(pool);
|
||||||
let result = repo
|
let result = repo
|
||||||
.query(&FeedQuery::search(
|
.query(&FeedRequest {
|
||||||
"hello world",
|
query: FeedQuery::search(
|
||||||
PageParams {
|
"hello world",
|
||||||
page: 1,
|
PageParams {
|
||||||
per_page: 20,
|
page: 1,
|
||||||
},
|
per_page: 20,
|
||||||
None,
|
},
|
||||||
))
|
None,
|
||||||
|
),
|
||||||
|
options: FeedOptions::default(),
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(result.total >= 1);
|
assert!(result.total >= 1);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use domain::{
|
|||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
|
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
|
||||||
FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
|
FederationSchedulerPort, FeedOptions, FeedQuery, FeedRepository, FeedRequest,
|
||||||
RemoteActorConnectionRepository, UserReader,
|
FollowRepository, RemoteActorConnectionRepository, UserReader,
|
||||||
},
|
},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
@@ -136,11 +136,10 @@ pub async fn get_remote_actor_posts(
|
|||||||
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
||||||
};
|
};
|
||||||
let result = feed
|
let result = feed
|
||||||
.query(&FeedQuery::user(
|
.query(&FeedRequest {
|
||||||
author_id,
|
query: FeedQuery::user(author_id, page.clone(), viewer_id.cloned()),
|
||||||
page.clone(),
|
options: FeedOptions::default(),
|
||||||
viewer_id.cloned(),
|
})
|
||||||
))
|
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(outbox_url) = actor.outbox_url {
|
if let Some(outbox_url) = actor.outbox_url {
|
||||||
let _ = scheduler
|
let _ = scheduler
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::feed::{FeedEntry, PageParams, Paginated},
|
models::feed::{FeedEntry, PageParams, Paginated},
|
||||||
ports::{FeedQuery, FeedRepository, FollowRepository},
|
ports::{FeedOptions, FeedQuery, FeedRepository, FeedRequest, FollowRepository},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -10,9 +10,13 @@ pub async fn get_home_feed(
|
|||||||
follows: &dyn FollowRepository,
|
follows: &dyn FollowRepository,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
page: PageParams,
|
page: PageParams,
|
||||||
|
opts: FeedOptions,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
||||||
following_ids.push(user_id.clone());
|
following_ids.push(user_id.clone());
|
||||||
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page))
|
feed.query(&FeedRequest {
|
||||||
.await
|
query: FeedQuery::home(user_id.clone(), following_ids, page),
|
||||||
|
options: opts,
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -882,7 +882,7 @@ impl RemoteActorConnectionRepository for TestStore {
|
|||||||
impl FeedRepository for TestStore {
|
impl FeedRepository for TestStore {
|
||||||
async fn query(
|
async fn query(
|
||||||
&self,
|
&self,
|
||||||
_q: &crate::ports::FeedQuery,
|
_req: &crate::ports::FeedRequest,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: vec![],
|
items: vec![],
|
||||||
|
|||||||
@@ -17,11 +17,53 @@ use axum::{
|
|||||||
use domain::{
|
use domain::{
|
||||||
models::feed::PageParams,
|
models::feed::PageParams,
|
||||||
ports::{
|
ports::{
|
||||||
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort,
|
FederationActionPort, FeedFilter, FeedOptions, FeedQuery, FeedRepository, FeedRequest,
|
||||||
TagRepository, UserRepository,
|
FeedSort, FollowRepository, SearchPort, TagRepository, UserRepository,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
pub struct FeedOptionsQuery {
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub originals_only: Option<bool>,
|
||||||
|
pub replies_only: Option<bool>,
|
||||||
|
pub local_only: Option<bool>,
|
||||||
|
pub hide_sensitive: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<FeedOptionsQuery> for FeedOptions {
|
||||||
|
type Error = crate::errors::ApiError;
|
||||||
|
|
||||||
|
fn try_from(q: FeedOptionsQuery) -> Result<Self, Self::Error> {
|
||||||
|
if q.originals_only.unwrap_or(false) && q.replies_only.unwrap_or(false) {
|
||||||
|
return Err(crate::errors::ApiError::BadRequest(
|
||||||
|
"originals_only and replies_only are mutually exclusive".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let sort = match q.sort.as_deref() {
|
||||||
|
None | Some("newest") => FeedSort::Newest,
|
||||||
|
Some("oldest") => FeedSort::Oldest,
|
||||||
|
Some("most_liked") => FeedSort::MostLiked,
|
||||||
|
Some("most_boosted") => FeedSort::MostBoosted,
|
||||||
|
Some("most_discussed") => FeedSort::MostDiscussed,
|
||||||
|
Some(other) => {
|
||||||
|
return Err(crate::errors::ApiError::BadRequest(format!(
|
||||||
|
"unknown sort value: {other}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(FeedOptions {
|
||||||
|
sort,
|
||||||
|
filter: FeedFilter {
|
||||||
|
originals_only: q.originals_only.unwrap_or(false),
|
||||||
|
replies_only: q.replies_only.unwrap_or(false),
|
||||||
|
local_only: q.local_only.unwrap_or(false),
|
||||||
|
hide_sensitive: q.hide_sensitive.unwrap_or(false),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deps_struct!(FeedDeps {
|
deps_struct!(FeedDeps {
|
||||||
feed: FeedRepository,
|
feed: FeedRepository,
|
||||||
follows: FollowRepository,
|
follows: FollowRepository,
|
||||||
@@ -62,12 +104,14 @@ pub async fn home_feed(
|
|||||||
Deps(d): Deps<FeedDeps>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
|
Query(opts_q): Query<FeedOptionsQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page).await?;
|
let opts = FeedOptions::try_from(opts_q)?;
|
||||||
|
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page, opts).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
@@ -85,12 +129,20 @@ pub async fn public_feed(
|
|||||||
Deps(d): Deps<FeedDeps>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
|
Query(opts_q): Query<FeedOptionsQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = d.feed.query(&FeedQuery::public(page, viewer)).await?;
|
let opts = FeedOptions::try_from(opts_q)?;
|
||||||
|
let result = d
|
||||||
|
.feed
|
||||||
|
.query(&FeedRequest {
|
||||||
|
query: FeedQuery::public(page, viewer),
|
||||||
|
options: opts,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
@@ -222,15 +274,20 @@ pub async fn user_thoughts_handler(
|
|||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
|
Query(opts_q): Query<FeedOptionsQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let user = get_user_by_username(&*d.users, &username).await?;
|
let user = get_user_by_username(&*d.users, &username).await?;
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
|
let opts = FeedOptions::try_from(opts_q)?;
|
||||||
let result = d
|
let result = d
|
||||||
.feed
|
.feed
|
||||||
.query(&FeedQuery::user(user.id.clone(), page, viewer))
|
.query(&FeedRequest {
|
||||||
|
query: FeedQuery::user(user.id.clone(), page, viewer),
|
||||||
|
options: opts,
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
@@ -273,14 +330,19 @@ pub async fn tag_thoughts_handler(
|
|||||||
Path(tag_name): Path<String>,
|
Path(tag_name): Path<String>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
|
Query(opts_q): Query<FeedOptionsQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
|
let opts = FeedOptions::try_from(opts_q)?;
|
||||||
let result = d
|
let result = d
|
||||||
.feed
|
.feed
|
||||||
.query(&FeedQuery::tag(&tag_name, page, viewer))
|
.query(&FeedRequest {
|
||||||
|
query: FeedQuery::tag(&tag_name, page, viewer),
|
||||||
|
options: opts,
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"tag": tag_name,
|
"tag": tag_name,
|
||||||
|
|||||||
Reference in New Issue
Block a user