feat: feed ux improvements

This commit is contained in:
2026-05-10 00:16:29 +02:00
parent f4e7d4e359
commit 9f894ebdf2
20 changed files with 1186 additions and 161 deletions

View File

@@ -67,6 +67,18 @@ pub struct ErrorQuery {
pub error: Option<String>,
}
#[derive(serde::Deserialize, Default)]
pub struct FeedQueryParams {
#[serde(default)]
pub filter: String,
#[serde(default)]
pub sort_by: String,
#[serde(default)]
pub search: String,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Deserialize, Default)]
pub struct DeleteRedirectForm {
#[serde(default)]

View File

@@ -177,6 +177,15 @@ mod tests {
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!()
}
async fn query_activity_feed_filtered(
&self,
_: &PageParams,
_: &domain::ports::FeedSortBy,
_: Option<&str>,
_: Option<&domain::ports::FollowingFilter>,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!()
}
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!()
}
@@ -185,6 +194,20 @@ mod tests {
}
}
#[async_trait::async_trait]
impl domain::ports::SocialQueryPort for Panic {
async fn get_accepted_following_urls(
&self,
_: uuid::Uuid,
) -> Result<Vec<String>, DomainError> {
panic!()
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl StatsRepository for Panic {
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
panic!()
@@ -386,6 +409,7 @@ mod tests {
html_renderer: Arc::new(Panic),
rss_renderer: Arc::new(Panic),
ap_service: Arc::new(activitypub::NoopActivityPubService),
social_query: Arc::new(Panic),
}
}

View File

@@ -30,7 +30,7 @@ pub mod html {
use crate::{
csrf::CsrfToken,
dtos::{
DiaryQueryParams, ErrorQuery, FollowForm, FollowerActionForm, LogReviewData,
ErrorQuery, FeedQueryParams, FollowForm, FollowerActionForm, LogReviewData,
LogReviewForm, LoginForm, RegisterForm, UnfollowForm,
},
extractors::{OptionalCookieUser, RequiredCookieUser},
@@ -338,29 +338,87 @@ pub mod html {
pub async fn get_activity_feed(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Query(params): Query<DiaryQueryParams>,
Query(params): Query<FeedQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id, csrf.0).await;
let query = application::queries::GetActivityFeedQuery {
limit: params.limit,
offset: params.offset,
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let filter_str = if params.filter == "following" && user_id.is_some() {
"following"
} else {
"all"
};
let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc",
"rating" => "rating",
"rating_asc" => "rating_asc",
_ => "date",
};
let following = if filter_str == "following" {
if let Some(uid) = user_id {
let urls = state.social_query
.get_accepted_following_urls(uid.value())
.await
.unwrap_or_default();
let base_url = &state.app_ctx.config.base_url;
let mut local_ids = vec![uid.value()];
let mut remote_urls = Vec::new();
for url in urls {
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) {
if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
local_ids.push(parsed_id);
continue;
}
}
remote_urls.push(url);
}
Some(domain::ports::FollowingFilter {
local_user_ids: local_ids,
remote_actor_urls: remote_urls,
})
} else {
None
}
} else {
None
};
let search_opt = if params.search.is_empty() {
None
} else {
Some(params.search.clone())
};
let query = application::queries::GetActivityFeedQuery {
limit,
offset,
sort_by: domain::ports::FeedSortBy::from_str(sort_by_str),
search: search_opt,
following,
};
match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await {
Ok(entries) => {
let limit = entries.limit;
let offset = entries.offset;
let has_more = (offset as u64).saturating_add(limit as u64) < entries.total_count;
let entry_limit = entries.limit;
let entry_offset = entries.offset;
let has_more = (entry_offset as u64).saturating_add(entry_limit as u64)
< entries.total_count;
let data = application::ports::ActivityFeedPageData {
ctx,
current_offset: offset,
current_offset: entry_offset,
has_more,
limit,
limit: entry_limit,
entries,
filter: filter_str.to_string(),
sort_by: sort_by_str.to_string(),
search: params.search,
};
match state.html_renderer.render_activity_feed_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
@@ -375,20 +433,37 @@ pub mod html {
let mut ctx = build_page_context(&state, user_id, csrf.0).await;
ctx.page_title = "Members — Movies Diary".to_string();
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
match application::use_cases::get_users::execute(
&state.app_ctx,
application::queries::GetUsersQuery,
)
.await
{
Ok(users) => {
let data = application::ports::UsersPageData { ctx, users };
let (users_result, actors_result) = tokio::join!(
application::use_cases::get_users::execute(
&state.app_ctx,
application::queries::GetUsersQuery,
),
state.social_query.list_all_followed_remote_actors()
);
match (users_result, actors_result) {
(Ok(users), Ok(remote_actors)) => {
let actor_views = remote_actors
.into_iter()
.map(|a| application::ports::RemoteActorView {
handle: a.handle,
display_name: a.display_name,
url: a.url,
})
.collect();
let data = application::ports::UsersPageData {
ctx,
users,
remote_actors: actor_views,
};
match state.html_renderer.render_users_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
(Err(e), _) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
(_, Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@@ -1352,7 +1427,13 @@ pub mod api {
) -> Result<Json<ActivityFeedResponse>, ApiError> {
let page = get_feed_uc::execute(
&state.app_ctx,
GetActivityFeedQuery { limit: params.limit, offset: params.offset },
GetActivityFeedQuery {
limit: params.limit.unwrap_or(20),
offset: params.offset.unwrap_or(0),
sort_by: domain::ports::FeedSortBy::Date,
search: None,
following: None,
},
)
.await?;
Ok(Json(ActivityFeedResponse {

View File

@@ -114,6 +114,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
// Federation
let federation_repo = Arc::new(SqliteFederationRepository::new(pool));
let social_query: Arc<dyn domain::ports::SocialQueryPort> = Arc::clone(&federation_repo) as _;
let user_repo_adapter = Arc::new(DomainUserRepoAdapter(Arc::clone(&user_repository)));
let review_handler = Arc::new(ReviewObjectHandler {
movie_repository: Arc::clone(&movie_repository),
@@ -170,6 +171,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
)),
ap_service,
social_query,
};
Ok((state, ap_router))
}

View File

@@ -1,4 +1,3 @@
use std::net::SocketAddr;
use std::num::NonZeroU32;
use axum::{Router, routing};
@@ -130,17 +129,38 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route("/auth/login", routing::post(handlers::api::login))
.route("/auth/register", routing::post(handlers::api::register))
.route("/diary/export", routing::get(handlers::api::export_diary))
.route("/activity-feed", routing::get(handlers::api::get_activity_feed))
.route(
"/activity-feed",
routing::get(handlers::api::get_activity_feed),
)
.route("/users", routing::get(handlers::api::list_users))
.route("/users/{id}", routing::get(handlers::api::get_user_profile))
.route("/social/following", routing::get(handlers::api::get_following))
.route("/social/followers", routing::get(handlers::api::get_followers))
.route("/social/followers/pending", routing::get(handlers::api::get_pending_followers))
.route(
"/social/following",
routing::get(handlers::api::get_following),
)
.route(
"/social/followers",
routing::get(handlers::api::get_followers),
)
.route(
"/social/followers/pending",
routing::get(handlers::api::get_pending_followers),
)
.route("/social/follow", routing::post(handlers::api::follow))
.route("/social/unfollow", routing::post(handlers::api::unfollow))
.route("/social/followers/accept", routing::post(handlers::api::accept_follower))
.route("/social/followers/reject", routing::post(handlers::api::reject_follower))
.route("/social/followers/remove", routing::post(handlers::api::remove_follower))
.route(
"/social/followers/accept",
routing::post(handlers::api::accept_follower),
)
.route(
"/social/followers/reject",
routing::post(handlers::api::reject_follower),
)
.route(
"/social/followers/remove",
routing::post(handlers::api::remove_follower),
)
.layer(GovernorLayer::new(cfg)),
)
}

View File

@@ -11,4 +11,5 @@ pub struct AppState {
pub html_renderer: Arc<dyn HtmlRenderer>,
pub rss_renderer: Arc<dyn RssFeedRenderer>,
pub ap_service: Arc<dyn ActivityPubPort>,
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
}

View File

@@ -125,6 +125,22 @@ impl domain::ports::DiaryExporter for PanicExporter {
}
}
struct PanicSocialQuery;
#[async_trait::async_trait]
impl domain::ports::SocialQueryPort for PanicSocialQuery {
async fn get_accepted_following_urls(
&self,
_: uuid::Uuid,
) -> Result<Vec<String>, DomainError> {
panic!()
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
panic!()
}
}
async fn test_app() -> Router {
let pool = SqlitePool::connect("sqlite::memory:")
.await
@@ -156,6 +172,7 @@ async fn test_app() -> Router {
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
ap_service: Arc::new(activitypub::NoopActivityPubService),
social_query: Arc::new(PanicSocialQuery),
};
routes::build_router(state, axum::Router::new())