Compare commits
9 Commits
ed6996e350
...
8ef7c93970
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef7c93970 | |||
| dc3afeca26 | |||
| f3c3637ade | |||
| 00b369c6ad | |||
| 8c931c9b98 | |||
| 1ddb6a3954 | |||
| 70fc4fbcd0 | |||
| cbfaeb95ac | |||
| 2e64e196b5 |
@@ -1394,6 +1394,15 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
||||
public_key: actor.public_key_pem.clone(),
|
||||
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
||||
last_fetched_at: actor.last_refreshed_at,
|
||||
bio: actor.bio.clone(),
|
||||
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||
also_known_as: actor.also_known_as.clone(),
|
||||
outbox_url: Some(actor.outbox_url.to_string()),
|
||||
attachment: actor
|
||||
.attachment
|
||||
.iter()
|
||||
.map(|f| (f.name.clone(), f.value.clone()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1511,6 +1520,65 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
||||
serde_json::to_string(&obj)
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_outbox_page(
|
||||
&self,
|
||||
outbox_url: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<domain::models::remote_note::RemoteNote>, domain::errors::DomainError> {
|
||||
use chrono::DateTime;
|
||||
|
||||
let url = format!("{}?page={}", outbox_url, page);
|
||||
let resp: serde_json::Value = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
let empty = vec![];
|
||||
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
|
||||
|
||||
let notes = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
// Items are Create activities wrapping a Note, or Notes directly
|
||||
let note = if item["type"].as_str() == Some("Create") {
|
||||
&item["object"]
|
||||
} else if item["type"].as_str() == Some("Note") {
|
||||
item
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Only public notes
|
||||
let to = note["to"].as_array()?;
|
||||
let is_public = to
|
||||
.iter()
|
||||
.any(|t| t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public"));
|
||||
if !is_public {
|
||||
return None;
|
||||
}
|
||||
|
||||
let published = DateTime::parse_from_rfc3339(note["published"].as_str()?)
|
||||
.ok()?
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
Some(domain::models::remote_note::RemoteNote {
|
||||
ap_id: note["id"].as_str()?.to_string(),
|
||||
content: note["content"].as_str().unwrap_or("").to_string(),
|
||||
published,
|
||||
sensitive: note["sensitive"].as_bool().unwrap_or(false),
|
||||
content_warning: note["summary"].as_str().map(|s| s.to_string()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(notes)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -68,6 +68,10 @@ pub enum EventPayload {
|
||||
UserRegistered {
|
||||
user_id: String,
|
||||
},
|
||||
FetchRemoteActorPosts {
|
||||
actor_ap_url: String,
|
||||
outbox_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -88,6 +92,7 @@ impl EventPayload {
|
||||
Self::UserBlocked { .. } => "users.blocked",
|
||||
Self::UserUnblocked { .. } => "users.unblocked",
|
||||
Self::UserRegistered { .. } => "users.registered",
|
||||
Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +202,13 @@ impl From<&DomainEvent> for EventPayload {
|
||||
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
||||
user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::FetchRemoteActorPosts {
|
||||
actor_ap_url,
|
||||
outbox_url,
|
||||
} => Self::FetchRemoteActorPosts {
|
||||
actor_ap_url: actor_ap_url.clone(),
|
||||
outbox_url: outbox_url.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,6 +327,13 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::FetchRemoteActorPosts {
|
||||
actor_ap_url,
|
||||
outbox_url,
|
||||
} => DomainEvent::FetchRemoteActorPosts {
|
||||
actor_ap_url,
|
||||
outbox_url,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -389,6 +408,17 @@ mod tests {
|
||||
blocker_id: "a".into(),
|
||||
blocked_id: "b".into(),
|
||||
},
|
||||
EventPayload::UserUnblocked {
|
||||
blocker_id: "a".into(),
|
||||
blocked_id: "b".into(),
|
||||
},
|
||||
EventPayload::UserRegistered {
|
||||
user_id: "a".into(),
|
||||
},
|
||||
EventPayload::FetchRemoteActorPosts {
|
||||
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
||||
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
|
||||
},
|
||||
];
|
||||
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
||||
subjects.sort();
|
||||
|
||||
@@ -45,6 +45,6 @@ impl RemoteActorRepository for PgRemoteActorRepository {
|
||||
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||
).bind(url).fetch_optional(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, avatar_url: r.avatar_url, last_fetched_at: r.last_fetched_at }))
|
||||
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, avatar_url: r.avatar_url, last_fetched_at: r.last_fetched_at, bio: None, banner_url: None, also_known_as: None, outbox_url: None, attachment: vec![] }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@ pub struct CreatedApiKeyResponse {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileField {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteActorResponse {
|
||||
@@ -95,4 +102,9 @@ pub struct RemoteActorResponse {
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub url: String,
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
pub attachment: Vec<ProfileField>,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -2,7 +2,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::{Thought, Visibility},
|
||||
ports::{OutboundFederationPort, ThoughtRepository, UserRepository},
|
||||
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserRepository},
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -12,6 +12,8 @@ pub struct FederationEventService {
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub ap: Arc<dyn OutboundFederationPort>,
|
||||
pub base_url: String,
|
||||
pub federation_action: Arc<dyn domain::ports::FederationActionPort>,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
}
|
||||
|
||||
impl FederationEventService {
|
||||
@@ -112,6 +114,49 @@ impl FederationEventService {
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::FetchRemoteActorPosts {
|
||||
actor_ap_url,
|
||||
outbox_url,
|
||||
} => {
|
||||
let notes = match self
|
||||
.federation_action
|
||||
.fetch_outbox_page(outbox_url, 1)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(outbox_url, error = %e, "failed to fetch remote outbox");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let actor_url = url::Url::parse(actor_ap_url)
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
|
||||
|
||||
for note in notes {
|
||||
let ap_id = match url::Url::parse(¬e.ap_id) {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let _ = self
|
||||
.ap_repo
|
||||
.accept_note(
|
||||
&ap_id,
|
||||
&author_id,
|
||||
¬e.content,
|
||||
note.published,
|
||||
note.sensitive,
|
||||
note.content_warning,
|
||||
"public",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -126,7 +171,7 @@ mod tests {
|
||||
events::DomainEvent,
|
||||
models::thought::{Thought, Visibility},
|
||||
models::user::User,
|
||||
ports::OutboundFederationPort,
|
||||
ports::{ActivityPubRepository, OutboundFederationPort},
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
@@ -208,6 +253,8 @@ mod tests {
|
||||
users: Arc::new(store.clone()),
|
||||
ap: spy,
|
||||
base_url: "https://example.com".to_string(),
|
||||
federation_action: Arc::new(store.clone()),
|
||||
ap_repo: Arc::new(store.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,4 +578,18 @@ mod tests {
|
||||
|
||||
assert!(spy.updated.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_actor_posts_is_noop_when_outbox_empty() {
|
||||
let store = TestStore::default();
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::FetchRemoteActorPosts {
|
||||
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
||||
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// TestStore.fetch_outbox_page returns Ok(vec![]) — no notes, no error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
||||
hasher: Arc::new(auth::Argon2PasswordHasher),
|
||||
events: event_publisher,
|
||||
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
|
||||
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
};
|
||||
|
||||
Infrastructure { state, ap_service }
|
||||
|
||||
@@ -60,6 +60,10 @@ pub enum DomainEvent {
|
||||
UserRegistered {
|
||||
user_id: UserId,
|
||||
},
|
||||
FetchRemoteActorPosts {
|
||||
actor_ap_url: String,
|
||||
outbox_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct EventEnvelope {
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod api_key;
|
||||
pub mod feed;
|
||||
pub mod notification;
|
||||
pub mod remote_actor;
|
||||
pub mod remote_note;
|
||||
pub mod social;
|
||||
pub mod tag;
|
||||
pub mod thought;
|
||||
|
||||
@@ -10,4 +10,9 @@ pub struct RemoteActor {
|
||||
pub public_key: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub last_fetched_at: DateTime<Utc>,
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
pub attachment: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
10
crates/domain/src/models/remote_note.rs
Normal file
10
crates/domain/src/models/remote_note.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteNote {
|
||||
pub ap_id: String,
|
||||
pub content: String,
|
||||
pub published: DateTime<Utc>,
|
||||
pub sensitive: bool,
|
||||
pub content_warning: Option<String>,
|
||||
}
|
||||
@@ -209,6 +209,11 @@ pub trait FederationActionPort: Send + Sync {
|
||||
user_id: &UserId,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError>;
|
||||
async fn fetch_outbox_page(
|
||||
&self,
|
||||
outbox_url: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -567,6 +567,14 @@ impl FederationActionPort for TestStore {
|
||||
) -> Result<String, DomainError> {
|
||||
Err(DomainError::NotFound)
|
||||
}
|
||||
|
||||
async fn fetch_outbox_page(
|
||||
&self,
|
||||
_outbox_url: &str,
|
||||
_page: u32,
|
||||
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -833,6 +841,16 @@ mod federation_port_tests {
|
||||
let err = store.actor_json(&UserId::new()).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_fetch_outbox_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let notes = store
|
||||
.fetch_outbox_page("https://example.com/outbox", 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
136
crates/presentation/src/handlers/federation_actors.rs
Normal file
136
crates/presentation/src/handlers/federation_actors.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use crate::{
|
||||
errors::ApiError, extractors::OptionalAuthUser, handlers::feed::to_thought_response,
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::requests::PaginationQuery;
|
||||
use application::use_cases::feed::get_user_feed;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use domain::{events::DomainEvent, models::feed::PageParams};
|
||||
|
||||
pub async fn remote_actor_posts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let actor = s.federation.lookup_actor(&handle).await?;
|
||||
|
||||
let ap_url = url::Url::parse(&actor.url).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
|
||||
// Get or create interned local UserId for this remote actor
|
||||
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
|
||||
Some(id) => id,
|
||||
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
|
||||
};
|
||||
|
||||
// Return cached posts from DB
|
||||
let page = PageParams {
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_user_feed(&*s.feed, &author_id, page, viewer.as_ref()).await?;
|
||||
|
||||
// Trigger background outbox fetch (fire and forget)
|
||||
if let Some(outbox_url) = &actor.outbox_url {
|
||||
let _ = s
|
||||
.events
|
||||
.publish(&DomainEvent::FetchRemoteActorPosts {
|
||||
actor_ap_url: actor.url.clone(),
|
||||
outbox_url: outbox_url.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use axum::{body::Body, http::Request, routing::get, Router};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
testing::TestStore,
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NoOpAuth;
|
||||
impl AuthService for NoOpAuth {
|
||||
fn generate_token(&self, _uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoOpHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for NoOpHasher {
|
||||
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_state() -> crate::state::AppState {
|
||||
let store = Arc::new(TestStore::default());
|
||||
crate::state::AppState {
|
||||
users: store.clone(),
|
||||
thoughts: store.clone(),
|
||||
likes: store.clone(),
|
||||
boosts: store.clone(),
|
||||
follows: store.clone(),
|
||||
blocks: store.clone(),
|
||||
tags: store.clone(),
|
||||
api_keys: store.clone(),
|
||||
top_friends: store.clone(),
|
||||
notifications: store.clone(),
|
||||
remote_actors: store.clone(),
|
||||
feed: store.clone(),
|
||||
search: store.clone(),
|
||||
auth: Arc::new(NoOpAuth),
|
||||
hasher: Arc::new(NoOpHasher),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
ap_repo: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/federation/actors/{handle}/posts",
|
||||
get(remote_actor_posts_handler),
|
||||
)
|
||||
.with_state(make_state())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_actor_returns_404() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/federation/actors/%40alice%40example.com/posts")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use axum::{
|
||||
use domain::models::feed::PageParams;
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||
pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||
ThoughtResponse {
|
||||
id: e.thought.id.as_uuid(),
|
||||
content: e.thought.content.as_str().to_string(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod api_keys;
|
||||
pub mod auth;
|
||||
pub mod federation_actors;
|
||||
pub mod feed;
|
||||
pub mod health;
|
||||
pub mod notifications;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
};
|
||||
use api_types::{
|
||||
requests::{PaginationQuery, UpdateProfileRequest},
|
||||
responses::{ErrorResponse, RemoteActorResponse, UserResponse},
|
||||
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
|
||||
};
|
||||
use application::use_cases::feed::list_users;
|
||||
use application::use_cases::profile::{get_user_by_username, update_profile};
|
||||
@@ -200,6 +200,15 @@ pub async fn lookup_handler(
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
url: actor.url,
|
||||
bio: actor.bio,
|
||||
banner_url: actor.banner_url,
|
||||
also_known_as: actor.also_known_as,
|
||||
outbox_url: actor.outbox_url,
|
||||
attachment: actor
|
||||
.attachment
|
||||
.into_iter()
|
||||
.map(|(name, value)| ProfileField { name, value })
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -263,6 +272,7 @@ mod tests {
|
||||
hasher: Arc::new(NoOpHasher),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
ap_repo: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/feed", get(feed::home_feed))
|
||||
.route("/feed/public", get(feed::public_feed))
|
||||
.route("/search", get(feed::search_handler))
|
||||
.route(
|
||||
"/federation/actors/{handle}/posts",
|
||||
get(federation_actors::remote_actor_posts_handler),
|
||||
)
|
||||
.route("/tags/popular", get(feed::get_popular_tags))
|
||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||
// notifications
|
||||
|
||||
@@ -20,4 +20,5 @@ pub struct AppState {
|
||||
pub hasher: Arc<dyn PasswordHasher>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use activitypub::ThoughtsObjectHandler;
|
||||
use activitypub_base::ActivityPubService;
|
||||
use application::services::{FederationEventService, NotificationEventService};
|
||||
use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort};
|
||||
use postgres::activitypub::PgActivityPubRepository;
|
||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||
|
||||
@@ -34,7 +35,7 @@ pub async fn build(
|
||||
));
|
||||
|
||||
// ActivityPub service (for federation fan-out)
|
||||
let ap_service: Arc<dyn domain::ports::OutboundFederationPort> = Arc::new(
|
||||
let ap_service = Arc::new(
|
||||
ActivityPubService::new(
|
||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||
Arc::new(PostgresApUserRepository::new(
|
||||
@@ -54,6 +55,10 @@ pub async fn build(
|
||||
.await
|
||||
.expect("ActivityPubService build failed"),
|
||||
);
|
||||
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
|
||||
let ap_federation = ap_service.clone() as Arc<dyn FederationActionPort>;
|
||||
let ap_repo_worker =
|
||||
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
|
||||
|
||||
// Application services
|
||||
let notification_svc = Arc::new(NotificationEventService {
|
||||
@@ -63,8 +68,10 @@ pub async fn build(
|
||||
let federation_svc = Arc::new(FederationEventService {
|
||||
thoughts,
|
||||
users,
|
||||
ap: ap_service,
|
||||
ap: ap_outbound,
|
||||
base_url: base_url.to_string(),
|
||||
federation_action: ap_federation,
|
||||
ap_repo: ap_repo_worker,
|
||||
});
|
||||
|
||||
// Thin handlers
|
||||
|
||||
1288
docs/superpowers/plans/2026-05-14-remote-actor-profile.md
Normal file
1288
docs/superpowers/plans/2026-05-14-remote-actor-profile.md
Normal file
File diff suppressed because it is too large
Load Diff
300
docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md
Normal file
300
docs/superpowers/specs/2026-05-14-remote-actor-profile-design.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# Remote Actor Profile Design
|
||||
|
||||
Display full profiles for remote ActivityPub actors: metadata (avatar, bio, banner, profile fields) plus their public posts, fetched in the background via the NATS worker.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User navigates to `/users/@gabrielkaszewski@mastodon.social`
|
||||
2. Frontend detects `@user@domain` format, calls in parallel:
|
||||
- `GET /users/lookup?handle=@user@instance` → enriched profile metadata
|
||||
- `GET /federation/actors/{handle}/posts?page=1` → cached posts (empty on first visit)
|
||||
3. Posts endpoint: looks up interned local `UserId`, queries `feed.user_feed`, **then** publishes `DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url }` fire-and-forget
|
||||
4. Worker receives event → fetches remote outbox page via HTTP → stores public notes via `ap_repo.accept_note`
|
||||
5. On next visit/refresh posts are populated
|
||||
|
||||
## Domain Changes
|
||||
|
||||
### Extend `domain/src/models/remote_actor.rs`
|
||||
|
||||
Add fields:
|
||||
```rust
|
||||
pub struct RemoteActor {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub inbox_url: String,
|
||||
pub shared_inbox_url: Option<String>,
|
||||
pub public_key: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub last_fetched_at: DateTime<Utc>,
|
||||
// new:
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
pub attachment: Vec<(String, String)>, // (name, value)
|
||||
}
|
||||
```
|
||||
|
||||
### New `domain/src/models/remote_note.rs`
|
||||
|
||||
```rust
|
||||
pub struct RemoteNote {
|
||||
pub ap_id: String,
|
||||
pub content: String,
|
||||
pub published: chrono::DateTime<chrono::Utc>,
|
||||
pub sensitive: bool,
|
||||
pub content_warning: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### New `DomainEvent` variant (`domain/src/events.rs`)
|
||||
|
||||
```rust
|
||||
FetchRemoteActorPosts {
|
||||
actor_ap_url: String,
|
||||
outbox_url: String,
|
||||
}
|
||||
```
|
||||
|
||||
### New `FederationActionPort` method (`domain/src/ports.rs`)
|
||||
|
||||
```rust
|
||||
async fn fetch_outbox_page(
|
||||
&self,
|
||||
outbox_url: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<RemoteNote>, DomainError>;
|
||||
```
|
||||
|
||||
`TestStore` stub returns `Ok(vec![])`.
|
||||
|
||||
## activitypub-base Implementation
|
||||
|
||||
### `lookup_actor` — populate new `RemoteActor` fields
|
||||
|
||||
Map from `DbActor`:
|
||||
```rust
|
||||
bio: actor.bio.clone(),
|
||||
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||
also_known_as: actor.also_known_as.clone(),
|
||||
outbox_url: Some(actor.outbox_url.to_string()),
|
||||
attachment: actor.attachment.iter().map(|f| (f.name.clone(), f.value.clone())).collect(),
|
||||
```
|
||||
|
||||
### `fetch_outbox_page` impl on `ActivityPubService`
|
||||
|
||||
```rust
|
||||
async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<RemoteNote>, DomainError> {
|
||||
let url = format!("{}?page={}", outbox_url, page);
|
||||
let resp: serde_json::Value = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send().await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json().await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
let items = resp["orderedItems"].as_array().cloned().unwrap_or_default();
|
||||
Ok(items.iter().filter_map(|item| {
|
||||
// Items are Create activities or Notes directly
|
||||
let note = if item["type"].as_str() == Some("Create") {
|
||||
&item["object"]
|
||||
} else if item["type"].as_str() == Some("Note") {
|
||||
item
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// Only public notes
|
||||
let to = note["to"].as_array()?;
|
||||
let is_public = to.iter().any(|t| {
|
||||
t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public")
|
||||
});
|
||||
if !is_public { return None; }
|
||||
Some(RemoteNote {
|
||||
ap_id: note["id"].as_str()?.to_string(),
|
||||
content: note["content"].as_str().unwrap_or("").to_string(),
|
||||
published: chrono::DateTime::parse_from_rfc3339(
|
||||
note["published"].as_str()?
|
||||
).ok()?.with_timezone(&chrono::Utc),
|
||||
sensitive: note["sensitive"].as_bool().unwrap_or(false),
|
||||
content_warning: note["summary"].as_str().map(|s| s.to_string()),
|
||||
})
|
||||
}).collect())
|
||||
}
|
||||
```
|
||||
|
||||
## AppState + Bootstrap
|
||||
|
||||
Add `ap_repo: Arc<dyn ActivityPubRepository>` to `presentation/src/state.rs`.
|
||||
|
||||
Wire in `bootstrap/src/factory.rs`:
|
||||
```rust
|
||||
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
```
|
||||
|
||||
## event-payload
|
||||
|
||||
Add to `EventPayload` enum:
|
||||
```rust
|
||||
FetchRemoteActorPosts {
|
||||
actor_ap_url: String,
|
||||
outbox_url: String,
|
||||
}
|
||||
```
|
||||
|
||||
Add subject (`"fetch_remote_actor_posts"`), mapping from/to `DomainEvent`, and a sample in the uniqueness test.
|
||||
|
||||
## REST Endpoint
|
||||
|
||||
**`GET /federation/actors/{handle}/posts?page=1`** (new handler in `presentation/src/handlers/federation_actors.rs`):
|
||||
|
||||
```rust
|
||||
pub async fn remote_actor_posts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let actor = s.federation.lookup_actor(&handle).await?;
|
||||
let ap_url = url::Url::parse(&actor.url)
|
||||
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
|
||||
// Get or create interned local UserId for this remote actor
|
||||
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
|
||||
Some(id) => id,
|
||||
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
|
||||
};
|
||||
|
||||
// Return cached posts
|
||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||
let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?;
|
||||
|
||||
// Trigger background fetch (fire and forget)
|
||||
if let Some(outbox_url) = &actor.outbox_url {
|
||||
let _ = s.events.publish(&DomainEvent::FetchRemoteActorPosts {
|
||||
actor_ap_url: actor.url.clone(),
|
||||
outbox_url: outbox_url.clone(),
|
||||
}).await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
Mount at `GET /federation/actors/{handle}/posts` in `routes.rs`.
|
||||
|
||||
Add `pub mod federation_actors;` to `handlers/mod.rs`.
|
||||
|
||||
Make `to_thought_response` in `feed.rs` `pub` so `federation_actors.rs` can import it.
|
||||
|
||||
## api-types
|
||||
|
||||
Extend `RemoteActorResponse`:
|
||||
```rust
|
||||
pub struct RemoteActorResponse {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub url: String,
|
||||
// new:
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
pub attachment: Vec<ProfileField>,
|
||||
}
|
||||
|
||||
pub struct ProfileField {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
```
|
||||
|
||||
Update `lookup_handler` in `users.rs` to populate all new fields.
|
||||
|
||||
## Worker
|
||||
|
||||
### `FederationEventService` new deps
|
||||
|
||||
Add `federation: Arc<dyn FederationActionPort>` and `ap_repo: Arc<dyn ActivityPubRepository>` to `FederationEventService`. Handle the new event:
|
||||
|
||||
```rust
|
||||
DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } => {
|
||||
let notes = match self.federation.fetch_outbox_page(outbox_url, 1).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => { tracing::warn!("failed to fetch outbox: {e}"); return Ok(()); }
|
||||
};
|
||||
let actor_url = url::Url::parse(actor_ap_url)
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
|
||||
for note in notes {
|
||||
let ap_id = match url::Url::parse(¬e.ap_id) {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// accept_note is idempotent — ignore duplicate errors
|
||||
let _ = self.ap_repo.accept_note(
|
||||
&ap_id, &author_id, ¬e.content, note.published,
|
||||
note.sensitive, note.content_warning, "public",
|
||||
).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Wire new deps in `worker/src/factory.rs`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### `lib/api.ts`
|
||||
|
||||
```typescript
|
||||
// Enriched RemoteActorSchema (same endpoint, more fields)
|
||||
export const ProfileFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const RemoteActorSchema = z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
url: z.string(),
|
||||
bio: z.string().nullable(),
|
||||
bannerUrl: z.string().nullable(),
|
||||
alsoKnownAs: z.string().nullable(),
|
||||
outboxUrl: z.string().nullable(),
|
||||
attachment: z.array(ProfileFieldSchema),
|
||||
});
|
||||
|
||||
export const getRemoteActorPosts = (handle: string, page: number, token: string | null) =>
|
||||
apiFetch(
|
||||
`/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
|
||||
{},
|
||||
z.object({ total: z.number(), page: z.number(), per_page: z.number(), items: z.array(ThoughtSchema) }),
|
||||
token
|
||||
);
|
||||
```
|
||||
|
||||
### `app/users/[username]/page.tsx`
|
||||
|
||||
Detect `@user@domain` regex. If handle: call `lookupRemoteActor` + `getRemoteActorPosts` in parallel; render `<RemoteUserProfile>`. Otherwise: existing local profile.
|
||||
|
||||
### New `components/remote-user-profile.tsx`
|
||||
|
||||
Client component showing:
|
||||
- Banner (`bannerUrl`) — full-width image or placeholder
|
||||
- Avatar + display name + handle (`@user@instance`)
|
||||
- Bio (rendered as text)
|
||||
- Profile fields (`attachment`) — key-value table
|
||||
- "Also known as" link (if present)
|
||||
- External profile link button → `url` in new tab
|
||||
- Follow button (reuse `followUser(handle, token)`)
|
||||
- Posts list using `ThoughtList` or similar, with empty state "Posts are loading, check back soon"
|
||||
- Pagination controls
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
getTopFriends,
|
||||
getUserProfile,
|
||||
getUserThoughts,
|
||||
lookupRemoteActor,
|
||||
getRemoteActorPosts,
|
||||
Me,
|
||||
} from "@/lib/api";
|
||||
import { RemoteUserProfile } from "@/components/remote-user-profile";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { Calendar, Settings } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -27,6 +30,28 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
const { username } = await params;
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;
|
||||
|
||||
if (HANDLE_RE.test(username)) {
|
||||
const [actorResult, postsResult, meResult] = await Promise.allSettled([
|
||||
lookupRemoteActor(username, token),
|
||||
getRemoteActorPosts(username, 1, token),
|
||||
token ? getMe(token) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (actorResult.status === "rejected") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const actor = actorResult.value as Awaited<ReturnType<typeof lookupRemoteActor>>;
|
||||
const posts =
|
||||
postsResult.status === "fulfilled" ? postsResult.value.items : [];
|
||||
const me =
|
||||
meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;
|
||||
|
||||
return <RemoteUserProfile actor={actor} initialPosts={posts} me={me} />;
|
||||
}
|
||||
|
||||
const userProfilePromise = getUserProfile(username, token);
|
||||
const thoughtsPromise = getUserThoughts(username, token);
|
||||
const mePromise = token ? getMe(token) : Promise.resolve(null);
|
||||
|
||||
179
thoughts-frontend/components/remote-user-profile.tsx
Normal file
179
thoughts-frontend/components/remote-user-profile.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { ThoughtList } from "@/components/thought-list";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, UserPlus, UserMinus } from "lucide-react";
|
||||
import { followUser, unfollowUser, RemoteActor, Thought, Me } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
interface RemoteUserProfileProps {
|
||||
actor: RemoteActor;
|
||||
initialPosts: Thought[];
|
||||
me: Me | null;
|
||||
}
|
||||
|
||||
export function RemoteUserProfile({
|
||||
actor,
|
||||
initialPosts,
|
||||
me,
|
||||
}: RemoteUserProfileProps) {
|
||||
const [followed, setFollowed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { token } = useAuth();
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in to follow users.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
if (followed) {
|
||||
await unfollowUser(actor.handle, token);
|
||||
setFollowed(false);
|
||||
} else {
|
||||
await followUser(actor.handle, token);
|
||||
setFollowed(true);
|
||||
toast.success(`Follow request sent to ${actor.handle}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
followed ? "Failed to unfollow." : "Failed to send follow request."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isOwnProfile = me?.username === actor.handle;
|
||||
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||
initialPosts.forEach((t) => {
|
||||
authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl });
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="h-48 bg-muted bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: actor.bannerUrl
|
||||
? `url(${actor.bannerUrl})`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
<aside className="col-span-1 space-y-6">
|
||||
<div className="sticky top-20 space-y-6">
|
||||
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
||||
<UserAvatar
|
||||
src={actor.avatarUrl}
|
||||
alt={actor.displayName}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
{!isOwnProfile && token && (
|
||||
<Button
|
||||
onClick={handleFollow}
|
||||
disabled={loading}
|
||||
variant={followed ? "secondary" : "default"}
|
||||
size="sm"
|
||||
>
|
||||
{followed ? (
|
||||
<>
|
||||
<UserMinus className="mr-2 h-4 w-4" /> Unfollow
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" /> Follow
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{actor.displayName ?? actor.handle}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{actor.handle}</p>
|
||||
</div>
|
||||
|
||||
{actor.bio && (
|
||||
<p className="mt-4 text-sm whitespace-pre-wrap">{actor.bio}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
>
|
||||
<Link href={actor.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View on {new URL(actor.url).hostname}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{actor.alsoKnownAs && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Also known as:{" "}
|
||||
<Link
|
||||
href={actor.alsoKnownAs}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{actor.alsoKnownAs}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actor.attachment.length > 0 && (
|
||||
<table className="mt-4 w-full text-sm border-collapse">
|
||||
<tbody>
|
||||
{actor.attachment.map((field) => (
|
||||
<tr key={field.name} className="border-t">
|
||||
<td className="py-1 pr-2 font-medium text-muted-foreground">
|
||||
{field.name}
|
||||
</td>
|
||||
<td
|
||||
className="py-1"
|
||||
dangerouslySetInnerHTML={{ __html: field.value }}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="col-span-1 lg:col-span-3 space-y-4">
|
||||
{initialPosts.length > 0 ? (
|
||||
<ThoughtList
|
||||
thoughts={initialPosts}
|
||||
authorDetails={authorDetails}
|
||||
currentUser={me}
|
||||
/>
|
||||
) : (
|
||||
<Card className="flex items-center justify-center h-48">
|
||||
<p className="text-center text-muted-foreground">
|
||||
Posts are being fetched — check back soon.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,11 +15,22 @@ export const UserSchema = z.object({
|
||||
|
||||
export const MeSchema = UserSchema;
|
||||
|
||||
export const ProfileFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
export type ProfileField = z.infer<typeof ProfileFieldSchema>;
|
||||
|
||||
export const RemoteActorSchema = z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
url: z.string(),
|
||||
bio: z.string().nullable(),
|
||||
bannerUrl: z.string().nullable(),
|
||||
alsoKnownAs: z.string().nullable(),
|
||||
outboxUrl: z.string().nullable(),
|
||||
attachment: z.array(ProfileFieldSchema),
|
||||
});
|
||||
export type RemoteActor = z.infer<typeof RemoteActorSchema>;
|
||||
|
||||
@@ -240,6 +251,23 @@ export const lookupRemoteActor = (handle: string, token: string | null) =>
|
||||
token
|
||||
);
|
||||
|
||||
export const getRemoteActorPosts = (
|
||||
handle: string,
|
||||
page: number,
|
||||
token: string | null
|
||||
) =>
|
||||
apiFetch(
|
||||
`/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
|
||||
{},
|
||||
z.object({
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
per_page: z.number(),
|
||||
items: z.array(ThoughtSchema),
|
||||
}),
|
||||
token
|
||||
);
|
||||
|
||||
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
|
||||
apiFetch(
|
||||
`/users?page=${page}&per_page=${pageSize}`,
|
||||
|
||||
Reference in New Issue
Block a user