feat: implement federated profile handling with support for remote actor URLs across multiple components
All checks were successful
CI / Check / Test (push) Successful in 1h7m53s
All checks were successful
CI / Check / Test (push) Successful in 1h7m53s
This commit is contained in:
58
crates/adapters/postgres-federation/src/federated_profile.rs
Normal file
58
crates/adapters/postgres-federation/src/federated_profile.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use super::PostgresFederationRepository;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederatedProfileQuery for PostgresFederationRepository {
|
||||||
|
async fn get_federated_profile(
|
||||||
|
&self,
|
||||||
|
synthetic_user_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||||
|
let uid = synthetic_user_id.to_string();
|
||||||
|
|
||||||
|
let actor_url: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT remote_actor_url FROM reviews
|
||||||
|
WHERE user_id = $1 AND remote_actor_url IS NOT NULL
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
let actor_url = match actor_url {
|
||||||
|
Some(url) => url,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT handle, display_name, bio, avatar_url, banner_url
|
||||||
|
FROM ap_remote_actors WHERE url = $1",
|
||||||
|
)
|
||||||
|
.bind(&actor_url)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(FederatedProfile {
|
||||||
|
actor_url,
|
||||||
|
handle: r.get("handle"),
|
||||||
|
display_name: r.try_get("display_name").ok().flatten(),
|
||||||
|
bio: r.try_get("bio").ok().flatten(),
|
||||||
|
avatar_url: r.try_get("avatar_url").ok().flatten(),
|
||||||
|
banner_url: r.try_get("banner_url").ok().flatten(),
|
||||||
|
})),
|
||||||
|
None => Ok(Some(FederatedProfile {
|
||||||
|
handle: actor_url.clone(),
|
||||||
|
actor_url,
|
||||||
|
display_name: None,
|
||||||
|
bio: None,
|
||||||
|
avatar_url: None,
|
||||||
|
banner_url: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ mod activity;
|
|||||||
mod actor;
|
mod actor;
|
||||||
pub mod ap_content;
|
pub mod ap_content;
|
||||||
mod blocklist;
|
mod blocklist;
|
||||||
|
mod federated_profile;
|
||||||
mod follow;
|
mod follow;
|
||||||
pub mod remote_goals;
|
pub mod remote_goals;
|
||||||
mod review;
|
mod review;
|
||||||
@@ -75,6 +76,12 @@ impl PostgresFederationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_federated_profile_query(
|
||||||
|
pool: PgPool,
|
||||||
|
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
|
||||||
|
std::sync::Arc::new(PostgresFederationRepository::new(pool))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn wire(pool: PgPool) -> activitypub::FederationRepos {
|
pub fn wire(pool: PgPool) -> activitypub::FederationRepos {
|
||||||
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
|
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -114,19 +114,24 @@ impl PostgresDiaryRepository {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
|
include_remote: bool,
|
||||||
) -> Result<i64, DomainError> {
|
) -> Result<i64, DomainError> {
|
||||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||||
let sql = if has_search {
|
let remote_clause = if include_remote {
|
||||||
"SELECT COUNT(*) FROM reviews r
|
""
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
|
||||||
WHERE r.user_id = $1 AND m.title ILIKE '%' || $2 || '%'"
|
|
||||||
.to_string()
|
|
||||||
} else {
|
} else {
|
||||||
|
" AND r.remote_actor_url IS NULL"
|
||||||
|
};
|
||||||
|
let search_clause = if has_search {
|
||||||
|
" AND m.title ILIKE '%' || $2 || '%'"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let sql = format!(
|
||||||
"SELECT COUNT(*) FROM reviews r
|
"SELECT COUNT(*) FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1"
|
WHERE r.user_id = $1{remote_clause}{search_clause}"
|
||||||
.to_string()
|
);
|
||||||
};
|
|
||||||
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
|
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
|
||||||
if has_search {
|
if has_search {
|
||||||
q = q.bind(search.unwrap());
|
q = q.bind(search.unwrap());
|
||||||
@@ -139,6 +144,7 @@ impl PostgresDiaryRepository {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
sort: &SortDirection,
|
sort: &SortDirection,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
|
include_remote: bool,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
@@ -149,9 +155,13 @@ impl PostgresDiaryRepository {
|
|||||||
SortDirection::Ascending => "r.watched_at ASC",
|
SortDirection::Ascending => "r.watched_at ASC",
|
||||||
SortDirection::Descending => "r.watched_at DESC",
|
SortDirection::Descending => "r.watched_at DESC",
|
||||||
};
|
};
|
||||||
|
let remote_clause = if include_remote {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" AND r.remote_actor_url IS NULL"
|
||||||
|
};
|
||||||
|
|
||||||
// Build param counter: user_id=$1, optional search=$2, limit=$N-1, offset=$N
|
let mut p: i32 = 1;
|
||||||
let mut p: i32 = 1; // $1 is user_id
|
|
||||||
let search_clause = if has_search {
|
let search_clause = if has_search {
|
||||||
p += 1;
|
p += 1;
|
||||||
format!(" AND m.title ILIKE '%' || ${} || '%'", p)
|
format!(" AND m.title ILIKE '%' || ${} || '%'", p)
|
||||||
@@ -171,10 +181,9 @@ impl PostgresDiaryRepository {
|
|||||||
r.remote_actor_url
|
r.remote_actor_url
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL{}
|
WHERE r.user_id = $1{remote_clause}{search_clause}
|
||||||
ORDER BY {}
|
ORDER BY {order_clause}
|
||||||
LIMIT {} OFFSET {}",
|
LIMIT {limit_param} OFFSET {offset_param}",
|
||||||
search_clause, order_clause, limit_param, offset_param
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
|
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
|
||||||
@@ -213,9 +222,17 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
(None, Some(uid)) => {
|
(None, Some(uid)) => {
|
||||||
let uid_str = uid.value().to_string();
|
let uid_str = uid.value().to_string();
|
||||||
let search = filter.search.as_deref();
|
let search = filter.search.as_deref();
|
||||||
|
let inc = filter.include_remote;
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
self.count_user_diary_entries(&uid_str, search),
|
self.count_user_diary_entries(&uid_str, search, inc),
|
||||||
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
|
self.fetch_user_diary_rows(
|
||||||
|
&uid_str,
|
||||||
|
&filter.sort_by,
|
||||||
|
search,
|
||||||
|
inc,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => {
|
||||||
|
|||||||
58
crates/adapters/sqlite-federation/src/federated_profile.rs
Normal file
58
crates/adapters/sqlite-federation/src/federated_profile.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use super::SqliteFederationRepository;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederatedProfileQuery for SqliteFederationRepository {
|
||||||
|
async fn get_federated_profile(
|
||||||
|
&self,
|
||||||
|
synthetic_user_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||||
|
let uid = synthetic_user_id.to_string();
|
||||||
|
|
||||||
|
let actor_url: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT remote_actor_url FROM reviews
|
||||||
|
WHERE user_id = ? AND remote_actor_url IS NOT NULL
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
let actor_url = match actor_url {
|
||||||
|
Some(url) => url,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT handle, display_name, bio, avatar_url, banner_url
|
||||||
|
FROM ap_remote_actors WHERE url = ?",
|
||||||
|
)
|
||||||
|
.bind(&actor_url)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(FederatedProfile {
|
||||||
|
actor_url,
|
||||||
|
handle: r.get("handle"),
|
||||||
|
display_name: r.try_get("display_name").ok().flatten(),
|
||||||
|
bio: r.try_get("bio").ok().flatten(),
|
||||||
|
avatar_url: r.try_get("avatar_url").ok().flatten(),
|
||||||
|
banner_url: r.try_get("banner_url").ok().flatten(),
|
||||||
|
})),
|
||||||
|
None => Ok(Some(FederatedProfile {
|
||||||
|
handle: actor_url.clone(),
|
||||||
|
actor_url,
|
||||||
|
display_name: None,
|
||||||
|
bio: None,
|
||||||
|
avatar_url: None,
|
||||||
|
banner_url: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
mod activity;
|
mod activity;
|
||||||
mod actor;
|
mod actor;
|
||||||
pub mod ap_content;
|
|
||||||
mod blocklist;
|
mod blocklist;
|
||||||
|
mod federated_profile;
|
||||||
mod follow;
|
mod follow;
|
||||||
pub mod remote_goals;
|
|
||||||
mod review;
|
mod review;
|
||||||
mod social;
|
mod social;
|
||||||
mod watchlist;
|
mod watchlist;
|
||||||
|
|
||||||
|
pub mod ap_content;
|
||||||
|
pub mod remote_goals;
|
||||||
|
|
||||||
pub use ap_content::SqliteApContentQuery;
|
pub use ap_content::SqliteApContentQuery;
|
||||||
pub use remote_goals::SqliteRemoteGoalRepository;
|
pub use remote_goals::SqliteRemoteGoalRepository;
|
||||||
|
|
||||||
@@ -86,6 +88,12 @@ impl SqliteFederationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_federated_profile_query(
|
||||||
|
pool: SqlitePool,
|
||||||
|
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
|
||||||
|
std::sync::Arc::new(SqliteFederationRepository::new(pool))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
|
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
|
||||||
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
|
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -108,19 +108,24 @@ impl SqliteDiaryRepository {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
|
include_remote: bool,
|
||||||
) -> Result<i64, DomainError> {
|
) -> Result<i64, DomainError> {
|
||||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||||
let sql = if has_search {
|
let remote_clause = if include_remote {
|
||||||
"SELECT COUNT(*) FROM reviews r
|
""
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
|
||||||
WHERE r.user_id = ? AND m.title LIKE '%' || ? || '%'"
|
|
||||||
.to_string()
|
|
||||||
} else {
|
} else {
|
||||||
|
" AND r.remote_actor_url IS NULL"
|
||||||
|
};
|
||||||
|
let search_clause = if has_search {
|
||||||
|
" AND m.title LIKE '%' || ? || '%'"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let sql = format!(
|
||||||
"SELECT COUNT(*) FROM reviews r
|
"SELECT COUNT(*) FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = ?"
|
WHERE r.user_id = ?{remote_clause}{search_clause}"
|
||||||
.to_string()
|
);
|
||||||
};
|
|
||||||
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
|
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
|
||||||
if has_search {
|
if has_search {
|
||||||
q = q.bind(search.unwrap());
|
q = q.bind(search.unwrap());
|
||||||
@@ -133,10 +138,16 @@ impl SqliteDiaryRepository {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
sort: &SortDirection,
|
sort: &SortDirection,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
|
include_remote: bool,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||||
|
let remote_clause = if include_remote {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" AND r.remote_actor_url IS NULL"
|
||||||
|
};
|
||||||
let search_clause = if has_search {
|
let search_clause = if has_search {
|
||||||
" AND m.title LIKE '%' || ? || '%'"
|
" AND m.title LIKE '%' || ? || '%'"
|
||||||
} else {
|
} else {
|
||||||
@@ -153,10 +164,9 @@ impl SqliteDiaryRepository {
|
|||||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL{}
|
WHERE r.user_id = ?{remote_clause}{search_clause}
|
||||||
ORDER BY {}
|
ORDER BY {order_clause}
|
||||||
LIMIT ? OFFSET ?",
|
LIMIT ? OFFSET ?",
|
||||||
search_clause, order_clause
|
|
||||||
);
|
);
|
||||||
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
|
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
|
||||||
if has_search {
|
if has_search {
|
||||||
@@ -194,9 +204,17 @@ impl DiaryRepository for SqliteDiaryRepository {
|
|||||||
(None, Some(uid)) => {
|
(None, Some(uid)) => {
|
||||||
let uid_str = uid.value().to_string();
|
let uid_str = uid.value().to_string();
|
||||||
let search = filter.search.as_deref();
|
let search = filter.search.as_deref();
|
||||||
|
let inc = filter.include_remote;
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
self.count_user_diary_entries(&uid_str, search),
|
self.count_user_diary_entries(&uid_str, search, inc),
|
||||||
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
|
self.fetch_user_diary_rows(
|
||||||
|
&uid_str,
|
||||||
|
&filter.sort_by,
|
||||||
|
search,
|
||||||
|
inc,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => {
|
||||||
|
|||||||
@@ -85,6 +85,16 @@ pub struct UserProfileResponse {
|
|||||||
pub trends: Option<UserTrendsDto>,
|
pub trends: Option<UserTrendsDto>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub goals: Option<Vec<GoalDto>>,
|
pub goals: Option<Vec<GoalDto>>,
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub is_federated: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub handle: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bio: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub actor_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub async fn execute(
|
|||||||
movie_id,
|
movie_id,
|
||||||
user_id,
|
user_id,
|
||||||
search: None,
|
search: None,
|
||||||
|
include_remote: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
diary.query_diary(&filter).await
|
diary.query_diary(&filter).await
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub async fn execute(
|
|||||||
query.limit,
|
query.limit,
|
||||||
query.offset,
|
query.offset,
|
||||||
query.search.clone(),
|
query.search.clone(),
|
||||||
|
query.include_remote,
|
||||||
)?;
|
)?;
|
||||||
let entries = deps.diary.query_diary(&filter).await?;
|
let entries = deps.diary.query_diary(&filter).await?;
|
||||||
Ok(base(Some(entries), None, None))
|
Ok(base(Some(entries), None, None))
|
||||||
@@ -122,6 +123,7 @@ fn paged_user_filter(
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
offset: Option<u32>,
|
offset: Option<u32>,
|
||||||
search: Option<String>,
|
search: Option<String>,
|
||||||
|
include_remote: bool,
|
||||||
) -> Result<DiaryFilter, DomainError> {
|
) -> Result<DiaryFilter, DomainError> {
|
||||||
let page = PageParams::new(limit, offset)?;
|
let page = PageParams::new(limit, offset)?;
|
||||||
Ok(DiaryFilter {
|
Ok(DiaryFilter {
|
||||||
@@ -130,6 +132,7 @@ fn paged_user_filter(
|
|||||||
movie_id: None,
|
movie_id: None,
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
search,
|
search,
|
||||||
|
include_remote,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +174,7 @@ mod helper_tests {
|
|||||||
Some(20),
|
Some(20),
|
||||||
Some(5),
|
Some(5),
|
||||||
Some("blade".into()),
|
Some("blade".into()),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub struct GetUserProfileQuery {
|
|||||||
pub sort_by: domain::models::FeedSortBy,
|
pub sort_by: domain::models::FeedSortBy,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
pub is_own_profile: bool,
|
pub is_own_profile: bool,
|
||||||
|
pub include_remote: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetCurrentProfileQuery {
|
pub struct GetCurrentProfileQuery {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ async fn returns_profile_with_empty_stats() {
|
|||||||
sort_by: domain::models::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
|
include_remote: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -88,6 +89,7 @@ async fn returns_history_view() {
|
|||||||
sort_by: domain::models::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
|
include_remote: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -124,6 +126,7 @@ async fn returns_trends_view() {
|
|||||||
sort_by: domain::models::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
|
include_remote: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -160,6 +163,7 @@ async fn returns_ratings_view() {
|
|||||||
sort_by: domain::models::FeedSortBy::Rating,
|
sort_by: domain::models::FeedSortBy::Rating,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
|
include_remote: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -194,6 +198,7 @@ async fn returns_recent_with_search() {
|
|||||||
sort_by: domain::models::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: Some("blade".into()),
|
search: Some("blade".into()),
|
||||||
is_own_profile: true,
|
is_own_profile: true,
|
||||||
|
include_remote: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -228,6 +233,7 @@ async fn non_own_profile_skips_pending_followers() {
|
|||||||
sort_by: domain::models::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
is_own_profile: false,
|
is_own_profile: false,
|
||||||
|
include_remote: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -18,3 +18,13 @@ pub struct FederationFlags {
|
|||||||
pub reviews: bool,
|
pub reviews: bool,
|
||||||
pub watchlist: bool,
|
pub watchlist: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FederatedProfile {
|
||||||
|
pub actor_url: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub struct DiaryFilter {
|
|||||||
pub movie_id: Option<MovieId>,
|
pub movie_id: Option<MovieId>,
|
||||||
pub user_id: Option<UserId>,
|
pub user_id: Option<UserId>,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
|
pub include_remote: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
11
crates/domain/src/ports/federated_profile.rs
Normal file
11
crates/domain/src/ports/federated_profile.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{errors::DomainError, models::FederatedProfile};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FederatedProfileQuery: Send + Sync {
|
||||||
|
async fn get_federated_profile(
|
||||||
|
&self,
|
||||||
|
synthetic_user_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<FederatedProfile>, DomainError>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod diary;
|
pub mod diary;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod federated_profile;
|
||||||
pub mod goals;
|
pub mod goals;
|
||||||
pub mod images;
|
pub mod images;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
@@ -16,6 +17,7 @@ pub mod wrapup;
|
|||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use diary::*;
|
pub use diary::*;
|
||||||
pub use events::*;
|
pub use events::*;
|
||||||
|
pub use federated_profile::*;
|
||||||
pub use goals::*;
|
pub use goals::*;
|
||||||
pub use images::*;
|
pub use images::*;
|
||||||
pub use import::*;
|
pub use import::*;
|
||||||
|
|||||||
@@ -347,6 +347,18 @@ impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PanicFederatedProfileQuery;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl crate::ports::FederatedProfileQuery for PanicFederatedProfileQuery {
|
||||||
|
async fn get_federated_profile(
|
||||||
|
&self,
|
||||||
|
_: uuid::Uuid,
|
||||||
|
) -> Result<Option<crate::models::FederatedProfile>, DomainError> {
|
||||||
|
panic!("PanicFederatedProfileQuery called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PanicWatchEventRepository;
|
pub struct PanicWatchEventRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository,
|
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
||||||
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
FederatedProfileQuery, GoalRepository, ImportProfileRepository, ImportSessionRepository,
|
||||||
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient,
|
MetadataClient, MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher,
|
||||||
PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository,
|
PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient,
|
||||||
RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort,
|
RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
|
||||||
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
|
||||||
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
|
||||||
WrapUpStatsQuery,
|
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
use application::config::AppConfig;
|
use application::config::AppConfig;
|
||||||
@@ -40,6 +40,7 @@ pub struct Repositories {
|
|||||||
pub user_settings: Arc<dyn UserSettingsRepository>,
|
pub user_settings: Arc<dyn UserSettingsRepository>,
|
||||||
pub remote_goal: Arc<dyn RemoteGoalRepository>,
|
pub remote_goal: Arc<dyn RemoteGoalRepository>,
|
||||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||||
|
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@@ -265,20 +265,30 @@ pub async fn get_user_profile(
|
|||||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let user = match state
|
let local_user = match state
|
||||||
.app_ctx
|
.app_ctx
|
||||||
.repos
|
.repos
|
||||||
.user
|
.user
|
||||||
.find_by_id(&UserId::from_uuid(user_id))
|
.find_by_id(&UserId::from_uuid(user_id))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Some(u)) => u,
|
Ok(u) => u,
|
||||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return crate::errors::domain_error_response(e);
|
return crate::errors::domain_error_response(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if local_user.is_none() {
|
||||||
|
if let Some(ref fed_query) = state.app_ctx.repos.federated_profile
|
||||||
|
&& let Ok(Some(fed)) = fed_query.get_federated_profile(user_id).await
|
||||||
|
{
|
||||||
|
return build_federated_profile_response(&state, user_id, fed, profile_view, ¶ms)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
let user = local_user.unwrap();
|
||||||
|
|
||||||
let get_profile_deps = GetProfileDeps {
|
let get_profile_deps = GetProfileDeps {
|
||||||
stats: state.app_ctx.repos.stats.clone(),
|
stats: state.app_ctx.repos.stats.clone(),
|
||||||
diary: state.app_ctx.repos.diary.clone(),
|
diary: state.app_ctx.repos.diary.clone(),
|
||||||
@@ -294,6 +304,7 @@ pub async fn get_user_profile(
|
|||||||
sort_by: domain::models::FeedSortBy::Date,
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
search: params.search,
|
search: params.search,
|
||||||
is_own_profile: viewer_id.value() == user_id,
|
is_own_profile: viewer_id.value() == user_id,
|
||||||
|
include_remote: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -384,6 +395,106 @@ pub async fn get_user_profile(
|
|||||||
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
|
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
is_federated: false,
|
||||||
|
handle: None,
|
||||||
|
display_name: None,
|
||||||
|
bio: None,
|
||||||
|
actor_url: None,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_federated_profile_response(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: Uuid,
|
||||||
|
fed: domain::models::FederatedProfile,
|
||||||
|
profile_view: application::users::queries::ProfileView,
|
||||||
|
params: &UserProfileQueryParams,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
let get_profile_deps = GetProfileDeps {
|
||||||
|
stats: state.app_ctx.repos.stats.clone(),
|
||||||
|
diary: state.app_ctx.repos.diary.clone(),
|
||||||
|
social_query: state.app_ctx.repos.social_query.clone(),
|
||||||
|
};
|
||||||
|
let profile = match get_user_profile_uc::execute(
|
||||||
|
&get_profile_deps,
|
||||||
|
GetUserProfileQuery {
|
||||||
|
user_id,
|
||||||
|
view: profile_view,
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
sort_by: domain::models::FeedSortBy::Date,
|
||||||
|
search: params.search.clone(),
|
||||||
|
is_own_profile: false,
|
||||||
|
include_remote: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return crate::errors::domain_error_response(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = profile.entries.map(|p| DiaryResponse {
|
||||||
|
items: p
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(crate::mappers::movies::entry_to_dto)
|
||||||
|
.collect(),
|
||||||
|
total_count: p.total_count,
|
||||||
|
limit: p.limit,
|
||||||
|
offset: p.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
let trends = profile.trends.map(|t| UserTrendsDto {
|
||||||
|
monthly_ratings: t
|
||||||
|
.monthly_ratings
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| MonthlyRatingDto {
|
||||||
|
year_month: r.year_month,
|
||||||
|
month_label: r.month_label,
|
||||||
|
avg_rating: r.avg_rating,
|
||||||
|
count: r.count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
top_directors: t
|
||||||
|
.top_directors
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| DirectorStatDto {
|
||||||
|
director: d.director,
|
||||||
|
count: d.count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
max_director_count: t.max_director_count,
|
||||||
|
});
|
||||||
|
|
||||||
|
let username = fed
|
||||||
|
.display_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| fed.handle.clone());
|
||||||
|
|
||||||
|
Json(UserProfileResponse {
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
avatar_url: fed.avatar_url,
|
||||||
|
banner_url: fed.banner_url,
|
||||||
|
stats: UserStatsDto {
|
||||||
|
total_movies: profile.stats.total_movies,
|
||||||
|
avg_rating: profile.stats.avg_rating,
|
||||||
|
favorite_director: profile.stats.favorite_director,
|
||||||
|
most_active_month: profile.stats.most_active_month,
|
||||||
|
},
|
||||||
|
following_count: 0,
|
||||||
|
followers_count: 0,
|
||||||
|
entries,
|
||||||
|
history: None,
|
||||||
|
trends,
|
||||||
|
goals: None,
|
||||||
|
is_federated: true,
|
||||||
|
handle: Some(fed.handle),
|
||||||
|
display_name: fed.display_name,
|
||||||
|
bio: fed.bio,
|
||||||
|
actor_url: Some(fed.actor_url),
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -538,6 +649,7 @@ pub async fn get_user_profile_html(
|
|||||||
Some(params.search.clone())
|
Some(params.search.clone())
|
||||||
},
|
},
|
||||||
is_own_profile,
|
is_own_profile,
|
||||||
|
include_remote: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let html_profile_deps = GetProfileDeps {
|
let html_profile_deps = GetProfileDeps {
|
||||||
|
|||||||
@@ -207,6 +207,23 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
user_settings: db.user_settings,
|
user_settings: db.user_settings,
|
||||||
remote_goal: db.remote_goal,
|
remote_goal: db.remote_goal,
|
||||||
refresh_session: db.refresh_session,
|
refresh_session: db.refresh_session,
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
federated_profile: Some({
|
||||||
|
match &db_pool {
|
||||||
|
#[cfg(feature = "sqlite-federation")]
|
||||||
|
factory::DbPool::Sqlite(pool) => {
|
||||||
|
sqlite_federation::create_federated_profile_query(pool.clone())
|
||||||
|
}
|
||||||
|
#[cfg(feature = "postgres-federation")]
|
||||||
|
factory::DbPool::Postgres(pool) => {
|
||||||
|
postgres_federation::create_federated_profile_query(pool.clone())
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "sqlite-federation"))]
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
#[cfg(not(feature = "federation"))]
|
||||||
|
federated_profile: None,
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
auth: auth_service,
|
auth: auth_service,
|
||||||
|
|||||||
@@ -807,6 +807,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
|||||||
user_settings: Arc::clone(&repo) as _,
|
user_settings: Arc::clone(&repo) as _,
|
||||||
remote_goal: Arc::clone(&repo) as _,
|
remote_goal: Arc::clone(&repo) as _,
|
||||||
refresh_session: Arc::clone(&repo) as _,
|
refresh_session: Arc::clone(&repo) as _,
|
||||||
|
federated_profile: None,
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
auth: auth_service,
|
auth: auth_service,
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ async fn test_app() -> Router {
|
|||||||
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
|
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
|
||||||
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
||||||
refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository),
|
refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository),
|
||||||
|
federated_profile: None,
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
auth: Arc::new(PanicAuth),
|
auth: Arc::new(PanicAuth),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router"
|
|||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
||||||
import { Search, User } from "lucide-react"
|
import { Globe, Search, User } from "lucide-react"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -23,6 +23,10 @@ type ProfileViewProps = {
|
|||||||
userId?: string
|
userId?: string
|
||||||
search?: string
|
search?: string
|
||||||
onSearchChange?: (value: string) => void
|
onSearchChange?: (value: string) => void
|
||||||
|
isFederated?: boolean
|
||||||
|
bio?: string
|
||||||
|
handle?: string
|
||||||
|
actorUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileView({
|
export function ProfileView({
|
||||||
@@ -32,6 +36,9 @@ export function ProfileView({
|
|||||||
userId,
|
userId,
|
||||||
search,
|
search,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
|
isFederated,
|
||||||
|
bio,
|
||||||
|
handle,
|
||||||
}: ProfileViewProps) {
|
}: ProfileViewProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const initial = (data.username || "?")[0]?.toUpperCase() ?? "?"
|
const initial = (data.username || "?")[0]?.toUpperCase() ?? "?"
|
||||||
@@ -50,8 +57,15 @@ export function ProfileView({
|
|||||||
{avatar && <AvatarImage src={avatar} />}
|
{avatar && <AvatarImage src={avatar} />}
|
||||||
<AvatarFallback>{initial}</AvatarFallback>
|
<AvatarFallback>{initial}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-semibold">{data.username}</p>
|
<p className="font-semibold">{data.username}</p>
|
||||||
|
{isFederated && handle && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Globe className="size-3" />
|
||||||
|
<span>{handle}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bio && <p className="mt-1 text-sm text-muted-foreground">{bio}</p>}
|
||||||
</div>
|
</div>
|
||||||
{headerRight}
|
{headerRight}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ export const userProfileResponseSchema = z.object({
|
|||||||
history: z.array(monthActivityDtoSchema).optional(),
|
history: z.array(monthActivityDtoSchema).optional(),
|
||||||
trends: userTrendsDtoSchema.optional(),
|
trends: userTrendsDtoSchema.optional(),
|
||||||
goals: z.array(goalDtoSchema).optional(),
|
goals: z.array(goalDtoSchema).optional(),
|
||||||
|
is_federated: z.boolean().optional().default(false),
|
||||||
|
handle: z.string().optional(),
|
||||||
|
display_name: z.string().optional(),
|
||||||
|
bio: z.string().optional(),
|
||||||
|
actor_url: z.string().optional(),
|
||||||
})
|
})
|
||||||
export type UserProfileResponse = z.infer<typeof userProfileResponseSchema>
|
export type UserProfileResponse = z.infer<typeof userProfileResponseSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function FeedTab() {
|
|||||||
movie={entry.movie}
|
movie={entry.movie}
|
||||||
review={entry.review}
|
review={entry.review}
|
||||||
userName={entry.user_display_name}
|
userName={entry.user_display_name}
|
||||||
userId={entry.is_federated ? undefined : entry.user_id}
|
userId={entry.user_id}
|
||||||
isFederated={entry.is_federated}
|
isFederated={entry.is_federated}
|
||||||
actorUrl={entry.actor_url}
|
actorUrl={entry.actor_url}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { UserCheck, UserPlus } from "lucide-react"
|
import { ExternalLink, UserCheck, UserPlus } from "lucide-react"
|
||||||
import { BackButton } from "@/components/back-button"
|
import { BackButton } from "@/components/back-button"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
||||||
@@ -39,10 +39,14 @@ function UserProfilePage() {
|
|||||||
<ProfileView
|
<ProfileView
|
||||||
data={data}
|
data={data}
|
||||||
userId={id}
|
userId={id}
|
||||||
search={search}
|
search={data.is_federated ? undefined : search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={data.is_federated ? undefined : setSearch}
|
||||||
|
isFederated={data.is_federated}
|
||||||
|
bio={data.bio}
|
||||||
|
handle={data.handle}
|
||||||
|
actorUrl={data.actor_url}
|
||||||
actions={
|
actions={
|
||||||
data.goals?.length ? (
|
!data.is_federated && data.goals?.length ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.goals.map((g) => (
|
{data.goals.map((g) => (
|
||||||
<GoalCard key={g.year} goal={g} />
|
<GoalCard key={g.year} goal={g} />
|
||||||
@@ -51,7 +55,7 @@ function UserProfilePage() {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
headerRight={
|
headerRight={
|
||||||
!isSelf ? (
|
!isSelf && !data.is_federated ? (
|
||||||
isFollowing ? (
|
isFollowing ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -72,6 +76,13 @@ function UserProfilePage() {
|
|||||||
{t("common.follow")}
|
{t("common.follow")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
) : data.is_federated && data.actor_url ? (
|
||||||
|
<a href={data.actor_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<ExternalLink className="mr-1 size-3.5" />
|
||||||
|
{t("common.viewOnRemote", { defaultValue: "Remote" })}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user