# Remote Actor Profile Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Display full remote actor profiles at `/users/@user@instance` — avatar, banner, bio, profile fields, and their public posts fetched in the background by the NATS worker. **Architecture:** New `DomainEvent::FetchRemoteActorPosts` triggers the worker to fetch a remote outbox page and store notes via `ActivityPubRepository::accept_note`. A new REST endpoint returns cached posts + fires the event. The frontend detects the `@user@domain` URL format and renders a dedicated `RemoteUserProfile` component. **Tech Stack:** Rust (axum, domain ports, activitypub_federation, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod, shadcn/ui. --- ## File Map | Action | Path | Change | |--------|------|--------| | Modify | `crates/domain/src/models/remote_actor.rs` | Add 5 new fields | | Create | `crates/domain/src/models/remote_note.rs` | New model | | Modify | `crates/domain/src/models/mod.rs` | `pub mod remote_note` | | Modify | `crates/domain/src/events.rs` | Add `FetchRemoteActorPosts` variant | | Modify | `crates/domain/src/ports.rs` | Add `fetch_outbox_page` to `FederationActionPort` | | Modify | `crates/domain/src/testing.rs` | Stub `fetch_outbox_page` on `TestStore` | | Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `fetch_outbox_page`; populate new `RemoteActor` fields | | Modify | `crates/adapters/event-payload/src/lib.rs` | Add `FetchRemoteActorPosts` to all 4 impls + test | | Modify | `crates/presentation/src/state.rs` | Add `ap_repo` field | | Modify | `crates/bootstrap/src/factory.rs` | Wire `ap_repo` into `AppState` | | Modify | `crates/api-types/src/responses.rs` | Add `ProfileField`, extend `RemoteActorResponse` | | Modify | `crates/presentation/src/handlers/feed.rs` | Make `to_thought_response` pub | | Modify | `crates/presentation/src/handlers/users.rs` | Populate new `RemoteActorResponse` fields in `lookup_handler` | | Create | `crates/presentation/src/handlers/federation_actors.rs` | `remote_actor_posts_handler` | | Modify | `crates/presentation/src/handlers/mod.rs` | `pub mod federation_actors` | | Modify | `crates/presentation/src/routes.rs` | Mount `GET /federation/actors/{handle}/posts` | | Modify | `crates/application/src/services/federation_event.rs` | Handle `FetchRemoteActorPosts`; add new deps | | Modify | `crates/worker/src/factory.rs` | Wire `federation_action` + `ap_repo` into `FederationEventService` | | Modify | `thoughts-frontend/lib/api.ts` | Extend `RemoteActorSchema`; add `getRemoteActorPosts` | | Create | `thoughts-frontend/components/remote-user-profile.tsx` | Full remote profile component | | Modify | `thoughts-frontend/app/users/[username]/page.tsx` | Handle detection + remote profile branch | --- ## Task 1: Domain — extend `RemoteActor`, add `RemoteNote`, new event, new port method **Files:** - Modify: `crates/domain/src/models/remote_actor.rs` - Create: `crates/domain/src/models/remote_note.rs` - Modify: `crates/domain/src/models/mod.rs` - Modify: `crates/domain/src/events.rs` - Modify: `crates/domain/src/ports.rs` - Modify: `crates/domain/src/testing.rs` - [ ] **Step 1: Extend `RemoteActor` with new fields** Replace the full content of `crates/domain/src/models/remote_actor.rs`: ```rust use chrono::{DateTime, Utc}; #[derive(Debug, Clone)] 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, pub bio: Option, pub banner_url: Option, pub also_known_as: Option, pub outbox_url: Option, pub attachment: Vec<(String, String)>, } ``` - [ ] **Step 2: Create `RemoteNote`** Create `crates/domain/src/models/remote_note.rs`: ```rust use chrono::{DateTime, Utc}; #[derive(Debug, Clone)] pub struct RemoteNote { pub ap_id: String, pub content: String, pub published: DateTime, pub sensitive: bool, pub content_warning: Option, } ``` - [ ] **Step 3: Register in `mod.rs`** In `crates/domain/src/models/mod.rs`, add: ```rust pub mod remote_note; ``` - [ ] **Step 4: Add `FetchRemoteActorPosts` to `DomainEvent`** Read `crates/domain/src/events.rs`. Add the new variant at the end of the enum (before the closing brace): ```rust FetchRemoteActorPosts { actor_ap_url: String, outbox_url: String, }, ``` - [ ] **Step 5: Write failing test** In `crates/domain/src/testing.rs`, find the `federation_port_tests` module. Add: ```rust #[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()); } ``` - [ ] **Step 6: Run to see compile failure** ```bash cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests::test_store_fetch_outbox 2>&1 | tail -10 ``` Expected: compile error — `fetch_outbox_page` not in trait. - [ ] **Step 7: Add `fetch_outbox_page` to `FederationActionPort`** Read `crates/domain/src/ports.rs`. In the `FederationActionPort` trait, add after `following_collection_json`: ```rust async fn fetch_outbox_page( &self, outbox_url: &str, page: u32, ) -> Result, DomainError>; ``` Note: you need to import or reference `RemoteNote`. Since it's in the same crate, use the full path `crate::models::remote_note::RemoteNote` or add it to the use block at the top of the trait impl. Check what's currently imported and add `use crate::models::remote_note::RemoteNote;` to the imports if not present. - [ ] **Step 8: Add stub to `TestStore`** In `crates/domain/src/testing.rs`, inside `impl FederationActionPort for TestStore`, add: ```rust async fn fetch_outbox_page( &self, _outbox_url: &str, _page: u32, ) -> Result, DomainError> { Ok(vec![]) } ``` - [ ] **Step 9: Fix `RemoteActor` construction sites** Adding new fields to `RemoteActor` will break all existing construction sites. Find them: ```bash cd /mnt/drive/dev/thoughts && grep -rn "RemoteActor {" --include="*.rs" | grep -v "target/" ``` For each construction site (likely in `activitypub-base/src/actors.rs`, `activitypub-base/src/service.rs`, `adapters/postgres/src/remote_actor.rs`), add the new fields with default `None`/`vec![]` values: ```rust bio: None, banner_url: None, also_known_as: None, outbox_url: None, attachment: vec![], ``` - [ ] **Step 10: Run tests to confirm pass** ```bash cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 ``` Expected: all tests pass. - [ ] **Step 11: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5 ``` - [ ] **Step 12: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/domain/src/models/remote_actor.rs \ crates/domain/src/models/remote_note.rs \ crates/domain/src/models/mod.rs \ crates/domain/src/events.rs \ crates/domain/src/ports.rs \ crates/domain/src/testing.rs git commit -m "feat(domain): RemoteActor fields, RemoteNote model, FetchRemoteActorPosts event, fetch_outbox_page port" ``` --- ## Task 2: activitypub-base — implement `fetch_outbox_page` + populate new `RemoteActor` fields **Files:** - Modify: `crates/adapters/activitypub-base/src/service.rs` - [ ] **Step 1: Confirm compile failure** ```bash cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -10 ``` Expected: error — `fetch_outbox_page` not implemented on `ActivityPubService`. - [ ] **Step 2: Update `lookup_actor` to populate new `RemoteActor` fields** Read `crates/adapters/activitypub-base/src/service.rs`. Find the `lookup_actor` impl. The current `Ok(domain::models::remote_actor::RemoteActor { ... })` block sets `handle: full_handle` and `avatar_url`. Extend it with the new fields: ```rust let domain_str = actor.ap_id.host_str().unwrap_or(""); let full_handle = format!("{}@{}", actor.username, domain_str); Ok(domain::models::remote_actor::RemoteActor { url: actor.ap_id.to_string(), handle: full_handle, display_name: Some(actor.username.clone()), inbox_url: actor.inbox_url.to_string(), shared_inbox_url: None, 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(), }) ``` - [ ] **Step 3: Implement `fetch_outbox_page`** In the `impl domain::ports::FederationActionPort for ActivityPubService` block, after `following_collection_json`, add: ```rust async fn fetch_outbox_page( &self, outbox_url: &str, page: u32, ) -> Result, 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) } ``` - [ ] **Step 4: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 ``` - [ ] **Step 5: Run all tests** ```bash cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 ``` Expected: all pass. - [ ] **Step 6: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/adapters/activitypub-base/src/service.rs git commit -m "feat(activitypub-base): impl fetch_outbox_page; populate all RemoteActor fields in lookup_actor" ``` --- ## Task 3: event-payload — add `FetchRemoteActorPosts` **Files:** - Modify: `crates/adapters/event-payload/src/lib.rs` - [ ] **Step 1: Add variant to `EventPayload` enum** Read the file. In the `EventPayload` enum, add at the end (before the closing brace): ```rust FetchRemoteActorPosts { actor_ap_url: String, outbox_url: String, }, ``` - [ ] **Step 2: Add subject** In `impl EventPayload { pub fn subject(&self) -> &'static str { match self { ... } } }`, add: ```rust Self::FetchRemoteActorPosts { .. } => "federation.fetch_actor_posts", ``` - [ ] **Step 3: Add `From<&DomainEvent>` arm** In `impl From<&DomainEvent> for EventPayload { fn from(e: &DomainEvent) -> Self { match e { ... } } }`, add: ```rust DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url, } => Self::FetchRemoteActorPosts { actor_ap_url: actor_ap_url.clone(), outbox_url: outbox_url.clone(), }, ``` - [ ] **Step 4: Add `TryFrom` arm** In `impl TryFrom for DomainEvent { fn try_from(p: EventPayload) -> Result { Ok(match p { ... }) } }`, add: ```rust EventPayload::FetchRemoteActorPosts { actor_ap_url, outbox_url, } => DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url, }, ``` - [ ] **Step 5: Add to the uniqueness test sample array** Find the test that asserts each event has a unique subject (look for a `let samples: Vec = vec![...]` in the `#[cfg(test)]` block). Add to the array: ```rust EventPayload::FetchRemoteActorPosts { actor_ap_url: "https://mastodon.social/users/alice".into(), outbox_url: "https://mastodon.social/users/alice/outbox".into(), }, ``` - [ ] **Step 6: Compile and test** ```bash cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -10 ``` Expected: all tests pass (uniqueness test passes with the new variant). - [ ] **Step 7: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/adapters/event-payload/src/lib.rs git commit -m "feat(event-payload): add FetchRemoteActorPosts event" ``` --- ## Task 4: AppState + bootstrap — add `ap_repo` **Files:** - Modify: `crates/presentation/src/state.rs` - Modify: `crates/bootstrap/src/factory.rs` - [ ] **Step 1: Add `ap_repo` to `AppState`** Read `crates/presentation/src/state.rs`. Add the new field: ```rust pub ap_repo: Arc, ``` `ActivityPubRepository` is in `domain::ports::*` which is already imported via `use domain::ports::*`. - [ ] **Step 2: Wire in `factory.rs`** Read `crates/bootstrap/src/factory.rs`. Add the import at the top if not present: ```rust use postgres::activitypub::PgActivityPubRepository; ``` In the `AppState { ... }` construction block, add: ```rust ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), ``` - [ ] **Step 3: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p bootstrap 2>&1 | tail -10 ``` Expected: no errors. (Presentation tests may fail with missing `ap_repo` in `make_state()` — they will be fixed in Task 5.) - [ ] **Step 4: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/presentation/src/state.rs crates/bootstrap/src/factory.rs git commit -m "feat(bootstrap): add ap_repo to AppState" ``` --- ## Task 5: REST endpoint — extend `RemoteActorResponse`, new handler, update `lookup_handler` **Files:** - Modify: `crates/api-types/src/responses.rs` - Modify: `crates/presentation/src/handlers/feed.rs` - Modify: `crates/presentation/src/handlers/users.rs` - Create: `crates/presentation/src/handlers/federation_actors.rs` - Modify: `crates/presentation/src/handlers/mod.rs` - Modify: `crates/presentation/src/routes.rs` - [ ] **Step 1: Add `ProfileField` + extend `RemoteActorResponse` in api-types** Read `crates/api-types/src/responses.rs`. Add a new struct and extend `RemoteActorResponse`: ```rust #[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 { pub handle: String, pub display_name: Option, pub avatar_url: Option, pub url: String, pub bio: Option, pub banner_url: Option, pub also_known_as: Option, pub outbox_url: Option, pub attachment: Vec, } ``` - [ ] **Step 2: Make `to_thought_response` pub in `feed.rs`** Read `crates/presentation/src/handlers/feed.rs`. Find `fn to_thought_response` (currently private) and change it to: ```rust pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { ``` - [ ] **Step 3: Update `lookup_handler` in `users.rs` to populate new fields** Read `crates/presentation/src/handlers/users.rs`. Find `lookup_handler`. Update the `Ok(Json(RemoteActorResponse { ... }))` return to include all new fields: ```rust pub async fn lookup_handler( State(s): State, Query(q): Query, ) -> Result, ApiError> { let actor = s.federation.lookup_actor(&q.handle).await?; Ok(Json(RemoteActorResponse { handle: actor.handle, 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)| api_types::responses::ProfileField { name, value }) .collect(), })) } ``` - [ ] **Step 4: Write failing tests for the new handler** Create `crates/presentation/src/handlers/federation_actors.rs` with tests first: ```rust 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, Path(_handle): Path, Query(_q): Query, OptionalAuthUser(_viewer): OptionalAuthUser, ) -> Result, ApiError> { todo!() } #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::Request, routing::get, Router, }; use domain::testing::TestStore; use std::sync::Arc; use tower::ServiceExt; // Copy NoOpAuth and NoOpHasher structs from another handler test module // (e.g. crates/presentation/src/handlers/notifications.rs tests section). // They implement AuthService and PasswordHasher minimally for tests. 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() { // TestStore.lookup_actor returns NotFound, so unknown handle → 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); } } ``` Note: `TestStore` must implement `ActivityPubRepository` for `make_state()` to compile. Check `crates/domain/src/testing.rs` — `TestStore` already implements it (look for `impl ActivityPubRepository for TestStore`). If the `ap_repo` field expects `Arc`, pass `store.clone()`. - [ ] **Step 5: Add `pub mod federation_actors` to `mod.rs`** In `crates/presentation/src/handlers/mod.rs`, add: ```rust pub mod federation_actors; ``` - [ ] **Step 6: Run tests to see compile/fail state** ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::federation_actors::tests 2>&1 | tail -20 ``` Expected: compile error or panic from `todo!()`. - [ ] **Step 7: Implement `remote_actor_posts_handler`** Replace the `todo!()` body with: ```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 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 — ignore publish errors) 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::>(), }))) } ``` Add the missing import at the top: ```rust use application::use_cases::feed::get_user_feed; use domain::{events::DomainEvent, models::feed::PageParams}; use url; ``` - [ ] **Step 8: Mount the route** Read `crates/presentation/src/routes.rs`. After the `/search` route, add: ```rust .route( "/federation/actors/{handle}/posts", get(federation_actors::remote_actor_posts_handler), ) ``` - [ ] **Step 9: Run tests to confirm pass** ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::federation_actors::tests 2>&1 | tail -10 ``` Expected: `unknown_actor_returns_404` passes. - [ ] **Step 10: Fix any broken tests caused by `ap_repo` in `make_state()`** Other test modules (notifications, social, users) also build `AppState` via `make_state()`. They will fail to compile because `AppState` now has `ap_repo`. Find them with: ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "error" | head -20 ``` For each test module that constructs `AppState`, add `ap_repo: store.clone()` to the struct literal. - [ ] **Step 11: Full compile + test** ```bash cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 ``` Expected: all pass. - [ ] **Step 12: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/api-types/src/responses.rs \ crates/presentation/src/handlers/feed.rs \ crates/presentation/src/handlers/users.rs \ crates/presentation/src/handlers/federation_actors.rs \ crates/presentation/src/handlers/mod.rs \ crates/presentation/src/routes.rs git commit -m "feat(presentation): remote actor posts endpoint + extended RemoteActorResponse" ``` --- ## Task 6: Worker — handle `FetchRemoteActorPosts` + wire deps **Files:** - Modify: `crates/application/src/services/federation_event.rs` - Modify: `crates/worker/src/factory.rs` - [ ] **Step 1: Add new deps to `FederationEventService`** Read `crates/application/src/services/federation_event.rs`. Add two new fields to the struct: ```rust pub struct FederationEventService { pub thoughts: Arc, pub users: Arc, pub ap: Arc, pub base_url: String, pub federation_action: Arc, pub ap_repo: Arc, } ``` - [ ] **Step 2: Handle `FetchRemoteActorPosts` in `process()`** In the `match event { ... }` block in `process()`, add a new arm after `DomainEvent::BoostRemoved`: ```rust 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, }; // accept_note is idempotent — duplicate ap_ids are ignored let _ = self .ap_repo .accept_note( &ap_id, &author_id, ¬e.content, note.published, note.sensitive, note.content_warning, "public", ) .await; } Ok(()) } ``` Add `url` to the imports at the top of the file if not already imported: ```rust use url; ``` - [ ] **Step 3: Fix the `FederationEventService` construction in `worker/factory.rs`** Read `crates/worker/src/factory.rs`. Currently it creates `ap_service` as `Arc`. Change to create it as a concrete `Arc` first, then cast: ```rust use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort}; ``` Replace the current `let ap_service: Arc = Arc::new(ActivityPubService::new(...).await.expect("..."))` with: ```rust let ap_service = Arc::new( ActivityPubService::new( Arc::new(PostgresFederationRepository::new(pool.clone())), Arc::new(PostgresApUserRepository::new( pool.clone(), base_url.to_string(), )), Arc::new(ThoughtsObjectHandler::new( Arc::new(PgActivityPubRepository::new(pool.clone())), base_url, )), base_url.to_string(), false, "thoughts".to_string(), false, None, ) .await .expect("ActivityPubService build failed"), ); let ap_outbound = ap_service.clone() as Arc; let ap_federation = ap_service.clone() as Arc; let ap_repo_worker = Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc; ``` Update the `FederationEventService` construction: ```rust let federation_svc = Arc::new(FederationEventService { thoughts, users, ap: ap_outbound, base_url: base_url.to_string(), federation_action: ap_federation, ap_repo: ap_repo_worker, }); ``` - [ ] **Step 4: Fix existing tests in `federation_event.rs`** The `svc()` helper in tests constructs `FederationEventService` and will now fail because of missing new fields. Find the helper and add: ```rust fn svc(store: &TestStore, spy: Arc) -> FederationEventService { FederationEventService { thoughts: Arc::new(store.clone()), users: Arc::new(store.clone()), ap: spy, base_url: "https://example.com".to_string(), federation_action: Arc::new(store.clone()), // TestStore implements FederationActionPort ap_repo: Arc::new(store.clone()), // TestStore implements ActivityPubRepository } } ``` - [ ] **Step 5: Write a test for `FetchRemoteActorPosts`** In the `#[cfg(test)]` block of `federation_event.rs`, add after the existing tests: ```rust #[tokio::test] async fn fetch_remote_actor_posts_is_noop_when_outbox_empty() { // TestStore.fetch_outbox_page returns Ok(vec![]) — no notes to store 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(); // No assertions needed — just confirm it doesn't panic or error } ``` - [ ] **Step 6: Run tests** ```bash cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -15 ``` Expected: all existing federation_event tests pass + new test passes. - [ ] **Step 7: Full compile + test suite** ```bash cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 ``` Expected: all pass. - [ ] **Step 8: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/application/src/services/federation_event.rs \ crates/worker/src/factory.rs git commit -m "feat(worker): handle FetchRemoteActorPosts — fetch and store remote outbox notes" ``` --- ## Task 7: Frontend — API + `RemoteUserProfile` component + page routing **Files:** - Modify: `thoughts-frontend/lib/api.ts` - Create: `thoughts-frontend/components/remote-user-profile.tsx` - Modify: `thoughts-frontend/app/users/[username]/page.tsx` - [ ] **Step 1: Extend `RemoteActorSchema` and add `getRemoteActorPosts` in `api.ts`** Read `thoughts-frontend/lib/api.ts`. Replace `RemoteActorSchema` with the enriched version: ```typescript export const ProfileFieldSchema = z.object({ name: z.string(), value: z.string(), }); export type ProfileField = z.infer; 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; ``` After `lookupRemoteActor`, add: ```typescript 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 ); ``` - [ ] **Step 2: Create `RemoteUserProfile` component** Create `thoughts-frontend/components/remote-user-profile.tsx`: ```typescript "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; // Build authorDetails for ThoughtList const authorDetails = new Map(); initialPosts.forEach((t) => { authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl }); }); return (
{/* Banner */}
{/* Left sidebar */} {/* Posts */}
{initialPosts.length > 0 ? ( ) : (

Posts are being fetched — check back soon.

)}
); } ``` Note: `dangerouslySetInnerHTML` on `field.value` is needed because Mastodon returns HTML in profile field values (e.g. links). This is safe because the data comes from a trusted AP fetch, not user input. - [ ] **Step 3: Update `app/users/[username]/page.tsx` to handle remote actors** Read the full file. Add a handle-detection branch at the top of `ProfilePage`, before the existing promise setup: ```typescript import { getFollowersList, getFollowingList, getMe, getTopFriends, getUserProfile, getUserThoughts, lookupRemoteActor, getRemoteActorPosts, Me, } from "@/lib/api"; import { RemoteUserProfile } from "@/components/remote-user-profile"; // ... existing imports unchanged ``` After `const { username } = await params;` and `const token = ...`, add the branch: ```typescript 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; const posts = postsResult.status === "fulfilled" ? postsResult.value.items : []; const me = meResult.status === "fulfilled" ? (meResult.value as Me | null) : null; return ; } ``` Place this block immediately before the existing `const userProfilePromise = ...` line. The rest of the file continues unchanged. - [ ] **Step 4: Type-check** ```bash cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20 ``` Expected: no errors. If `ThoughtList` props don't match, check its interface and adjust. - [ ] **Step 5: Commit** ```bash cd /mnt/drive/dev/thoughts git add thoughts-frontend/lib/api.ts \ thoughts-frontend/components/remote-user-profile.tsx \ thoughts-frontend/app/users/[username]/page.tsx git commit -m "feat(frontend): remote actor profile page with bio, fields, and posts" ``` --- ## Self-Review **Spec coverage:** - ✅ `RemoteActor` extended with bio, banner_url, also_known_as, outbox_url, attachment — Task 1 + 2 - ✅ `RemoteNote` domain model — Task 1 - ✅ `FetchRemoteActorPosts` domain event — Task 1 - ✅ `fetch_outbox_page` port method — Task 1 + 2 - ✅ `fetch_outbox_page` impl (HTTPS, Create/Note both handled, public-only filter) — Task 2 - ✅ `lookup_actor` populates new fields — Task 2 - ✅ `EventPayload::FetchRemoteActorPosts` (enum, subject, From, TryFrom, test) — Task 3 - ✅ `AppState.ap_repo` wired — Task 4 - ✅ `ProfileField` + extended `RemoteActorResponse` — Task 5 - ✅ `to_thought_response` made pub — Task 5 - ✅ `lookup_handler` updated to return new fields — Task 5 - ✅ `GET /federation/actors/{handle}/posts` endpoint — Task 5 - ✅ Worker handles `FetchRemoteActorPosts` — Task 6 - ✅ Worker factory wires new deps — Task 6 - ✅ `RemoteActorSchema` extended + `getRemoteActorPosts` — Task 7 - ✅ `RemoteUserProfile` component (banner, avatar, bio, fields, alsoKnownAs, external link, follow, posts) — Task 7 - ✅ Handle detection in profile page — Task 7 **Placeholder scan:** None found. **Type consistency:** - `RemoteNote { ap_id, content, published, sensitive, content_warning }` defined Task 1, used in Task 2 impl and Task 6 worker ✅ - `actor.outbox_url: Option` returned by `lookup_actor` (Task 2), used in handler (Task 5) and event payload (Task 3) ✅ - `RemoteActorResponse.attachment: Vec` defined Task 5, mapped from `actor.attachment: Vec<(String, String)>` in Task 2 ✅ - `FederationEventService { federation_action, ap_repo }` — new fields added Task 6 step 1, wired in factory Task 6 step 3, test helper updated Task 6 step 4 ✅ - `ap_repo: Arc` in `AppState` added Task 4, used in Task 5 handler, used in test `make_state()` Task 5 step 4 ✅ - `getRemoteActorPosts` returns `{ items: ThoughtSchema[] }` — `ThoughtSchema` already imported in `api.ts` ✅