9.3 KiB
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
- User navigates to
/users/@gabrielkaszewski@mastodon.social - Frontend detects
@user@domainformat, calls in parallel:GET /users/lookup?handle=@user@instance→ enriched profile metadataGET /federation/actors/{handle}/posts?page=1→ cached posts (empty on first visit)
- Posts endpoint: looks up interned local
UserId, queriesfeed.user_feed, then publishesDomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url }fire-and-forget - Worker receives event → fetches remote outbox page via HTTP → stores public notes via
ap_repo.accept_note - On next visit/refresh posts are populated
Domain Changes
Extend domain/src/models/remote_actor.rs
Add fields:
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
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)
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
}
New FederationActionPort method (domain/src/ports.rs)
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:
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
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:
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
event-payload
Add to EventPayload enum:
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):
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:
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:
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
// 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 →
urlin new tab - Follow button (reuse
followUser(handle, token)) - Posts list using
ThoughtListor similar, with empty state "Posts are loading, check back soon" - Pagination controls