# 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, pub inbox_url: String, pub shared_inbox_url: Option, pub public_key: String, pub avatar_url: Option, pub last_fetched_at: DateTime, // new: pub bio: Option, pub banner_url: Option, pub also_known_as: Option, pub outbox_url: Option, 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, pub sensitive: bool, pub content_warning: Option, } ``` ### 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, 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, 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` 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, Path(handle): Path, Query(q): Query, OptionalAuthUser(viewer): OptionalAuthUser, ) -> Result, 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::>(), }))) } ``` 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, pub avatar_url: Option, pub url: String, // new: pub bio: Option, pub banner_url: Option, pub also_known_as: Option, pub outbox_url: Option, pub attachment: Vec, } 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` and `ap_repo: Arc` 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 ``. 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