# Remote Actor Connections 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:** Show a remote actor's followers and following as browseable lists within the thoughts UI, backed by a worker cache with concurrent AP profile resolution. **Architecture:** New domain models (`ConnectionType`, `ActorConnectionSummary`) + new port (`RemoteActorConnectionRepository`) + new `FederationActionPort` methods. REST endpoints return cached data and fire a `FetchActorConnections` event fire-and-forget. Worker fetches the AP collection, concurrently resolves each actor URL to a profile (5s timeout per actor, partial failures silently skipped), and upserts results. Frontend adds Followers/Following tabs to `RemoteUserProfile` using existing `RemoteUserCard`. **Tech Stack:** Rust (axum, sqlx, tokio, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod. --- ## File Map | Action | Path | Change | |--------|------|--------| | Create | `crates/domain/src/models/connection_type.rs` | `ConnectionType` enum | | Create | `crates/domain/src/models/actor_connection_summary.rs` | `ActorConnectionSummary` struct | | Modify | `crates/domain/src/models/mod.rs` | expose new modules | | Modify | `crates/domain/src/events.rs` | `FetchActorConnections` variant | | Modify | `crates/domain/src/ports.rs` | `RemoteActorConnectionRepository` port; 2 new `FederationActionPort` methods | | Modify | `crates/domain/src/testing.rs` | stubs + test | | Create | `crates/adapters/postgres/migrations/006_remote_actor_connections.sql` | new table | | Create | `crates/adapters/postgres/src/remote_actor_connections.rs` | postgres impl | | Modify | `crates/adapters/postgres/src/lib.rs` | expose module, export type | | Modify | `crates/adapters/activitypub-base/src/service.rs` | impl 2 new port methods | | Modify | `crates/adapters/event-payload/src/lib.rs` | `FetchActorConnections` variant | | Modify | `crates/application/src/services/federation_event.rs` | new dep + handler | | Modify | `crates/worker/src/factory.rs` | wire `remote_actor_connections` | | Modify | `crates/api-types/src/responses.rs` | `ActorConnectionResponse` | | Modify | `crates/presentation/src/state.rs` | add `remote_actor_connections` field | | Modify | `crates/bootstrap/src/factory.rs` | wire new repo | | Modify | `crates/presentation/src/handlers/federation_actors.rs` | 2 new handlers | | Modify | `crates/presentation/src/handlers/*.rs` (tests) | add `remote_actor_connections` to `make_state()` | | Modify | `crates/presentation/src/routes.rs` | mount 2 new routes | | Modify | `thoughts-frontend/lib/api.ts` | new schema + 2 fetch functions | | Modify | `thoughts-frontend/components/remote-user-profile.tsx` | replace links with tabs | --- ## Task 1: Domain — models, port, event, stubs **Files:** - Create: `crates/domain/src/models/connection_type.rs` - Create: `crates/domain/src/models/actor_connection_summary.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: Create `connection_type.rs`** ```rust #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConnectionType { Followers, Following, } impl ConnectionType { pub fn as_str(&self) -> &'static str { match self { Self::Followers => "followers", Self::Following => "following", } } } ``` - [ ] **Step 2: Create `actor_connection_summary.rs`** ```rust #[derive(Debug, Clone)] pub struct ActorConnectionSummary { pub url: String, pub handle: String, pub display_name: Option, pub avatar_url: Option, } ``` - [ ] **Step 3: Register in `models/mod.rs`** Add: ```rust pub mod actor_connection_summary; pub mod connection_type; ``` - [ ] **Step 4: Add `FetchActorConnections` to `DomainEvent`** Read `crates/domain/src/events.rs`. Add before the closing brace: ```rust FetchActorConnections { actor_ap_url: String, collection_url: String, connection_type: String, page: u32, }, ``` - [ ] **Step 5: Write failing domain test** At the bottom of `crates/domain/src/testing.rs`, in the `federation_port_tests` module, add: ```rust #[tokio::test] async fn test_store_resolve_actor_profiles_returns_empty() { let store = TestStore::default(); let result = store.resolve_actor_profiles(vec!["https://example.com/users/alice".into()]).await; assert!(result.is_empty()); } #[tokio::test] async fn test_store_fetch_collection_urls_returns_empty() { let store = TestStore::default(); let urls = store.fetch_actor_urls_from_collection("https://example.com/users/alice/followers").await.unwrap(); assert!(urls.is_empty()); } ``` - [ ] **Step 6: Run to confirm compile failure** ```bash cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 ``` Expected: compile error — new port methods and `RemoteActorConnectionRepository` not defined. - [ ] **Step 7: Add `RemoteActorConnectionRepository` to `ports.rs`** Read `crates/domain/src/ports.rs`. Add after `RemoteActorRepository`: ```rust #[async_trait] pub trait RemoteActorConnectionRepository: Send + Sync { async fn upsert_connections( &self, actor_url: &str, connection_type: &str, page: u32, actors: &[crate::models::actor_connection_summary::ActorConnectionSummary], ) -> Result<(), DomainError>; async fn list_connections( &self, actor_url: &str, connection_type: &str, page: u32, ) -> Result, DomainError>; async fn connection_page_age( &self, actor_url: &str, connection_type: &str, page: u32, ) -> Result>, DomainError>; } ``` Then in `FederationActionPort`, add two new methods: ```rust async fn fetch_actor_urls_from_collection( &self, collection_url: &str, ) -> Result, DomainError>; async fn resolve_actor_profiles( &self, urls: Vec, ) -> Vec; ``` - [ ] **Step 8: Add stubs to `TestStore`** In `crates/domain/src/testing.rs`, add after the existing `impl FederationActionPort for TestStore` block: ```rust #[async_trait] impl RemoteActorConnectionRepository for TestStore { async fn upsert_connections( &self, _actor_url: &str, _connection_type: &str, _page: u32, _actors: &[crate::models::actor_connection_summary::ActorConnectionSummary], ) -> Result<(), DomainError> { Ok(()) } async fn list_connections( &self, _actor_url: &str, _connection_type: &str, _page: u32, ) -> Result, DomainError> { Ok(vec![]) } async fn connection_page_age( &self, _actor_url: &str, _connection_type: &str, _page: u32, ) -> Result>, DomainError> { Ok(None) } } ``` Inside `impl FederationActionPort for TestStore`, add the two new methods: ```rust async fn fetch_actor_urls_from_collection( &self, _collection_url: &str, ) -> Result, DomainError> { Ok(vec![]) } async fn resolve_actor_profiles( &self, _urls: Vec, ) -> Vec { vec![] } ``` - [ ] **Step 9: 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 10: Full compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5 ``` - [ ] **Step 11: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/domain/src/models/connection_type.rs \ crates/domain/src/models/actor_connection_summary.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): ActorConnectionSummary, ConnectionType, RemoteActorConnectionRepository, FetchActorConnections event" ``` --- ## Task 2: PostgreSQL adapter — migration + repository **Files:** - Create: `crates/adapters/postgres/migrations/006_remote_actor_connections.sql` - Create: `crates/adapters/postgres/src/remote_actor_connections.rs` - Modify: `crates/adapters/postgres/src/lib.rs` - [ ] **Step 1: Create migration** Create `crates/adapters/postgres/migrations/006_remote_actor_connections.sql`: ```sql CREATE TABLE remote_actor_connections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), actor_url TEXT NOT NULL, connection_type TEXT NOT NULL, page INT NOT NULL, connected_actor_url TEXT NOT NULL, connected_handle TEXT NOT NULL, connected_display_name TEXT, connected_avatar_url TEXT, fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(actor_url, connection_type, page, connected_actor_url) ); CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at); ``` - [ ] **Step 2: Create `remote_actor_connections.rs`** Create `crates/adapters/postgres/src/remote_actor_connections.rs`: ```rust use async_trait::async_trait; use domain::{ errors::DomainError, models::actor_connection_summary::ActorConnectionSummary, ports::RemoteActorConnectionRepository, }; use sqlx::PgPool; pub struct PgRemoteActorConnectionRepository { pool: PgPool, } impl PgRemoteActorConnectionRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl RemoteActorConnectionRepository for PgRemoteActorConnectionRepository { async fn upsert_connections( &self, actor_url: &str, connection_type: &str, page: u32, actors: &[ActorConnectionSummary], ) -> Result<(), DomainError> { for actor in actors { sqlx::query( "INSERT INTO remote_actor_connections (actor_url, connection_type, page, connected_actor_url, connected_handle, connected_display_name, connected_avatar_url, fetched_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT(actor_url, connection_type, page, connected_actor_url) DO UPDATE SET connected_handle = EXCLUDED.connected_handle, connected_display_name = EXCLUDED.connected_display_name, connected_avatar_url = EXCLUDED.connected_avatar_url, fetched_at = NOW()", ) .bind(actor_url) .bind(connection_type) .bind(page as i32) .bind(&actor.url) .bind(&actor.handle) .bind(&actor.display_name) .bind(&actor.avatar_url) .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; } Ok(()) } async fn list_connections( &self, actor_url: &str, connection_type: &str, page: u32, ) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { connected_actor_url: String, connected_handle: String, connected_display_name: Option, connected_avatar_url: Option, } let rows = sqlx::query_as::<_, Row>( "SELECT connected_actor_url, connected_handle, connected_display_name, connected_avatar_url FROM remote_actor_connections WHERE actor_url = $1 AND connection_type = $2 AND page = $3 ORDER BY connected_handle", ) .bind(actor_url) .bind(connection_type) .bind(page as i32) .fetch_all(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(rows .into_iter() .map(|r| ActorConnectionSummary { url: r.connected_actor_url, handle: r.connected_handle, display_name: r.connected_display_name, avatar_url: r.connected_avatar_url, }) .collect()) } async fn connection_page_age( &self, actor_url: &str, connection_type: &str, page: u32, ) -> Result>, DomainError> { let row: Option<(Option>,)> = sqlx::query_as( "SELECT MAX(fetched_at) FROM remote_actor_connections WHERE actor_url = $1 AND connection_type = $2 AND page = $3", ) .bind(actor_url) .bind(connection_type) .bind(page as i32) .fetch_optional(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(row.and_then(|(ts,)| ts)) } } ``` - [ ] **Step 3: Expose in `postgres/src/lib.rs`** Read `crates/adapters/postgres/src/lib.rs`. Add: ```rust pub mod remote_actor_connections; ``` - [ ] **Step 4: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p postgres 2>&1 | tail -10 ``` Expected: no errors. - [ ] **Step 5: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/adapters/postgres/migrations/006_remote_actor_connections.sql \ crates/adapters/postgres/src/remote_actor_connections.rs \ crates/adapters/postgres/src/lib.rs git commit -m "feat(postgres): remote_actor_connections table + PgRemoteActorConnectionRepository" ``` --- ## Task 3: activitypub-base — implement `fetch_actor_urls_from_collection` + `resolve_actor_profiles` **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 -5 ``` Expected: error — `fetch_actor_urls_from_collection` and `resolve_actor_profiles` not implemented. - [ ] **Step 2: Implement both methods in the `FederationActionPort` impl block** Read the file. At the bottom of `impl domain::ports::FederationActionPort for ActivityPubService`, after `fetch_outbox_page`, add: ```rust async fn fetch_actor_urls_from_collection( &self, collection_url: &str, ) -> Result, domain::errors::DomainError> { let resp: serde_json::Value = reqwest::Client::new() .get(collection_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); Ok(items .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect()) } async fn resolve_actor_profiles( &self, urls: Vec, ) -> Vec { use futures::future; async fn fetch_one( url: String, ) -> Option { let resp: serde_json::Value = tokio::time::timeout( std::time::Duration::from_secs(5), reqwest::Client::new() .get(&url) .header("Accept", "application/activity+json") .send(), ) .await .ok()? .ok()? .json() .await .ok()?; let ap_url = resp["id"].as_str()?.to_string(); let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string(); let domain_str = url::Url::parse(&ap_url) .ok() .and_then(|u| u.host_str().map(|s| s.to_string())) .unwrap_or_default(); let handle = format!("{}@{}", preferred_username, domain_str); let display_name = resp["name"].as_str().map(|s| s.to_string()); let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string()); Some(domain::models::actor_connection_summary::ActorConnectionSummary { url: ap_url, handle, display_name, avatar_url, }) } let futs: Vec<_> = urls.into_iter().map(fetch_one).collect(); let results = future::join_all(futs).await; results .into_iter() .filter_map(|r| { if r.is_none() { tracing::warn!("failed to resolve actor profile (timeout or parse error)"); } r }) .collect() } ``` - [ ] **Step 3: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 ``` - [ ] **Step 4: Run all tests** ```bash cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 ``` Expected: all pass. - [ ] **Step 5: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/adapters/activitypub-base/src/service.rs git commit -m "feat(activitypub-base): impl fetch_actor_urls_from_collection + resolve_actor_profiles (concurrent, 5s timeout)" ``` --- ## Task 4: event-payload — `FetchActorConnections` **Files:** - Modify: `crates/adapters/event-payload/src/lib.rs` - [ ] **Step 1: Add variant to `EventPayload` enum** Read the file. Add at the end of the enum: ```rust FetchActorConnections { actor_ap_url: String, collection_url: String, connection_type: String, page: u32, }, ``` - [ ] **Step 2: Add subject** In `subject()`: ```rust Self::FetchActorConnections { .. } => "federation.fetch_actor_connections", ``` - [ ] **Step 3: Add `From<&DomainEvent>` arm** ```rust DomainEvent::FetchActorConnections { actor_ap_url, collection_url, connection_type, page, } => Self::FetchActorConnections { actor_ap_url: actor_ap_url.clone(), collection_url: collection_url.clone(), connection_type: connection_type.clone(), page: *page, }, ``` - [ ] **Step 4: Add `TryFrom` arm** ```rust EventPayload::FetchActorConnections { actor_ap_url, collection_url, connection_type, page, } => DomainEvent::FetchActorConnections { actor_ap_url, collection_url, connection_type, page, }, ``` - [ ] **Step 5: Add to uniqueness test sample array** ```rust EventPayload::FetchActorConnections { actor_ap_url: "https://mastodon.social/users/alice".into(), collection_url: "https://mastodon.social/users/alice/followers".into(), connection_type: "followers".into(), page: 1, }, ``` - [ ] **Step 6: Test** ```bash cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -5 ``` Expected: all pass (uniqueness test includes 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): FetchActorConnections event" ``` --- ## Task 5: Worker — handle `FetchActorConnections` + wire repo **Files:** - Modify: `crates/application/src/services/federation_event.rs` - Modify: `crates/worker/src/factory.rs` - [ ] **Step 1: Add `remote_actor_connections` to `FederationEventService`** Read `crates/application/src/services/federation_event.rs`. Add to the struct: ```rust pub remote_actor_connections: Arc, ``` - [ ] **Step 2: Handle `FetchActorConnections` in `process()`** Before the `_ => Ok(())` arm, add: ```rust DomainEvent::FetchActorConnections { actor_ap_url, collection_url, connection_type, page, } => { let urls = match self .federation_action .fetch_actor_urls_from_collection(collection_url) .await { Ok(u) => u, Err(e) => { tracing::warn!( collection_url, error = %e, "failed to fetch actor connections collection" ); return Ok(()); } }; if urls.is_empty() { return Ok(()); } let summaries = self .federation_action .resolve_actor_profiles(urls) .await; if summaries.is_empty() { return Ok(()); } tracing::info!( count = summaries.len(), connection_type, actor = actor_ap_url, "caching actor connections" ); self.remote_actor_connections .upsert_connections(actor_ap_url, connection_type, *page, &summaries) .await?; Ok(()) } ``` - [ ] **Step 3: Add test** In the `#[cfg(test)]` block, add `remote_actor_connections: Arc::new(store.clone())` to the `svc()` helper, then add: ```rust #[tokio::test] async fn fetch_actor_connections_is_noop_when_collection_empty() { let store = TestStore::default(); let spy = Arc::new(SpyPort::default()); svc(&store, spy.clone()) .process(&DomainEvent::FetchActorConnections { actor_ap_url: "https://mastodon.social/users/alice".into(), collection_url: "https://mastodon.social/users/alice/followers".into(), connection_type: "followers".into(), page: 1, }) .await .unwrap(); } ``` - [ ] **Step 4: Run tests** ```bash cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -10 ``` Expected: all pass. - [ ] **Step 5: Wire `remote_actor_connections` in `worker/src/factory.rs`** Read the file. Add import: ```rust use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; ``` Add the repo: ```rust let actor_connections = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())) as Arc; ``` Add to `FederationEventService` construction: ```rust remote_actor_connections: actor_connections, ``` - [ ] **Step 6: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p worker 2>&1 | tail -10 ``` - [ ] **Step 7: Run all tests** ```bash cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5 ``` - [ ] **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 FetchActorConnections — resolve and cache remote actor connections" ``` --- ## Task 6: AppState + bootstrap + REST endpoints **Files:** - Modify: `crates/presentation/src/state.rs` - Modify: `crates/bootstrap/src/factory.rs` - Modify: `crates/api-types/src/responses.rs` - Modify: `crates/presentation/src/handlers/federation_actors.rs` - Modify: `crates/presentation/src/handlers/` (test make_state() helpers) - Modify: `crates/presentation/src/routes.rs` - [ ] **Step 1: Add `remote_actor_connections` to `AppState`** Read `crates/presentation/src/state.rs`. Add field: ```rust pub remote_actor_connections: Arc, ``` `RemoteActorConnectionRepository` is in `domain::ports::*`, already imported. - [ ] **Step 2: Wire in `bootstrap/src/factory.rs`** Read the file. Add import: ```rust use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; ``` Add to `AppState { ... }`: ```rust remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), ``` - [ ] **Step 3: Add `ActorConnectionResponse` to api-types** Read `crates/api-types/src/responses.rs`. Add: ```rust #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ActorConnectionResponse { pub handle: String, pub display_name: Option, pub avatar_url: Option, pub url: String, } #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ActorConnectionPageResponse { pub items: Vec, pub page: u32, pub has_more: bool, } ``` - [ ] **Step 4: Fix broken test `make_state()` helpers** Find all handlers with `make_state()` that construct `AppState` — they will now be missing `remote_actor_connections`. Run: ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "missing field" | head -5 ``` For each affected test module, add `remote_actor_connections: store.clone()` to the `AppState` construction. - [ ] **Step 5: Add two new handlers to `federation_actors.rs`** Read the file. Add imports at the top: ```rust use api_types::responses::{ActorConnectionPageResponse, ActorConnectionResponse}; use domain::events::DomainEvent; ``` Add after `remote_actor_posts_handler`: ```rust const CACHE_TTL_SECS: i64 = 3600; pub async fn actor_followers_handler( State(s): State, Path(handle): Path, Query(q): Query, ) -> Result, ApiError> { actor_connections_handler(s, handle, "followers", q.page() as u32).await } pub async fn actor_following_handler( State(s): State, Path(handle): Path, Query(q): Query, ) -> Result, ApiError> { actor_connections_handler(s, handle, "following", q.page() as u32).await } async fn actor_connections_handler( s: AppState, handle: String, connection_type: &str, page: u32, ) -> Result, ApiError> { const PAGE_SIZE: usize = 20; let actor = s.federation.lookup_actor(&handle).await?; let collection_url = match connection_type { "followers" => actor .followers_url .ok_or_else(|| ApiError::BadRequest("actor has no followers URL".into()))?, _ => actor .following_url .ok_or_else(|| ApiError::BadRequest("actor has no following URL".into()))?, }; let items = s .remote_actor_connections .list_connections(&actor.url, connection_type, page) .await?; // Fire fetch if cache is missing or stale let stale = match s .remote_actor_connections .connection_page_age(&actor.url, connection_type, page) .await? { None => true, Some(age) => { chrono::Utc::now() .signed_duration_since(age) .num_seconds() > CACHE_TTL_SECS } }; if stale { let _ = s .events .publish(&DomainEvent::FetchActorConnections { actor_ap_url: actor.url.clone(), collection_url, connection_type: connection_type.to_string(), page, }) .await; } let has_more = items.len() >= PAGE_SIZE; Ok(Json(ActorConnectionPageResponse { items: items .into_iter() .map(|a| ActorConnectionResponse { handle: a.handle, display_name: a.display_name, avatar_url: a.avatar_url, url: a.url, }) .collect(), page, has_more, })) } ``` - [ ] **Step 6: Mount routes** Read `crates/presentation/src/routes.rs`. After the existing `/federation/actors/{handle}/posts` route, add: ```rust .route( "/federation/actors/{handle}/followers-list", get(federation_actors::actor_followers_handler), ) .route( "/federation/actors/{handle}/following-list", get(federation_actors::actor_following_handler), ) ``` - [ ] **Step 7: Run all tests** ```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/presentation/src/state.rs \ crates/bootstrap/src/factory.rs \ crates/api-types/src/responses.rs \ crates/presentation/src/handlers/federation_actors.rs \ crates/presentation/src/routes.rs # Also add any handler files with updated make_state() git commit -m "feat(presentation): followers/following list endpoints for remote actors" ``` --- ## Task 7: Frontend — API + tabs in `RemoteUserProfile` **Files:** - Modify: `thoughts-frontend/lib/api.ts` - Modify: `thoughts-frontend/components/remote-user-profile.tsx` - [ ] **Step 1: Add schema + fetch functions to `api.ts`** Read the file. After `getActorFollowing`/`getActorFollowers` (or after `getRemoteActorPosts`), add: ```typescript export const ActorConnectionSchema = z.object({ handle: z.string(), displayName: z.string().nullable(), avatarUrl: z.string().nullable(), url: z.string(), }); export type ActorConnection = z.infer; const ActorConnectionPageSchema = z.object({ items: z.array(ActorConnectionSchema), page: z.number(), hasMore: z.boolean(), }); export const getActorFollowers = ( handle: string, page: number, token: string | null ) => apiFetch( `/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, {}, ActorConnectionPageSchema, token ); export const getActorFollowing = ( handle: string, page: number, token: string | null ) => apiFetch( `/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, {}, ActorConnectionPageSchema, token ); ``` - [ ] **Step 2: Update `remote-user-profile.tsx`** Read the full file. Replace the existing followers/following links section AND add tab state + lazy loading. The component is already `"use client"`. Add imports at the top: ```typescript import { getActorFollowers, getActorFollowing, ActorConnection } from "@/lib/api"; import { RemoteUserCard } from "@/components/remote-user-card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; ``` Add state inside the component (after existing state): ```typescript type Tab = "posts" | "followers" | "following"; const [activeTab, setActiveTab] = useState("posts"); const [followers, setFollowers] = useState([]); const [following, setFollowing] = useState([]); const [followersPage, setFollowersPage] = useState(1); const [followingPage, setFollowingPage] = useState(1); const [followersHasMore, setFollowersHasMore] = useState(false); const [followingHasMore, setFollowingHasMore] = useState(false); const [followersLoaded, setFollowersLoaded] = useState(false); const [followingLoaded, setFollowingLoaded] = useState(false); ``` Add tab handlers: ```typescript const loadFollowers = async (page: number) => { const result = await getActorFollowers(actor.handle, page, token).catch(() => null); if (!result) return; setFollowers((prev) => page === 1 ? result.items : [...prev, ...result.items]); setFollowersHasMore(result.hasMore); setFollowersLoaded(true); setFollowersPage(page); }; const loadFollowing = async (page: number) => { const result = await getActorFollowing(actor.handle, page, token).catch(() => null); if (!result) return; setFollowing((prev) => page === 1 ? result.items : [...prev, ...result.items]); setFollowingHasMore(result.hasMore); setFollowingLoaded(true); setFollowingPage(page); }; const handleTabChange = (tab: string) => { setActiveTab(tab as Tab); if (tab === "followers" && !followersLoaded) loadFollowers(1); if (tab === "following" && !followingLoaded) loadFollowing(1); }; ``` Replace the posts section (`
...`) with: ```tsx
Posts Followers Following {initialPosts.length > 0 ? ( ) : (

Posts are being fetched — check back soon.

)}
{!followersLoaded ? (

Loading followers…

) : followers.length === 0 ? (

No followers cached yet — check back soon.

) : (
{followers.map((f) => ( ))} {followersHasMore && ( )}
)}
{!followingLoaded ? (

Loading following…

) : following.length === 0 ? (

No following cached yet — check back soon.

) : (
{following.map((f) => ( ))} {followingHasMore && ( )}
)}
``` Also remove the old `{(actor.followersUrl || actor.followingUrl) && ...}` plain links section from the sidebar — replaced by tabs. - [ ] **Step 3: Type-check** ```bash cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -10 ``` Fix any type errors. Common issue: `RemoteUserCard` expects `RemoteActor` but we're passing `ActorConnection` — both have the same shape (`handle`, `displayName`, `avatarUrl`, `url`) so you may need a cast or to widen the prop type on `RemoteUserCard`. If `RemoteUserCard` is typed as `actor: RemoteActor`, change its prop to `actor: { handle: string; displayName: string | null; avatarUrl: string | null; url: string }` or union type. Alternatively, cast: `actor={f as RemoteActor}`. - [ ] **Step 4: Commit** ```bash cd /mnt/drive/dev/thoughts git add thoughts-frontend/lib/api.ts \ thoughts-frontend/components/remote-user-profile.tsx git commit -m "feat(frontend): followers/following tabs on remote actor profile with lazy loading + pagination" ``` --- ## Self-Review **Spec coverage:** - ✅ `ConnectionType` enum — Task 1 - ✅ `ActorConnectionSummary` model — Task 1 - ✅ `RemoteActorConnectionRepository` port — Task 1 - ✅ `fetch_actor_urls_from_collection` on `FederationActionPort` — Tasks 1 + 3 - ✅ `resolve_actor_profiles` on `FederationActionPort` (concurrent, 5s timeout, partial) — Tasks 1 + 3 - ✅ `FetchActorConnections` domain event — Task 1 - ✅ Migration + `PgRemoteActorConnectionRepository` — Task 2 - ✅ activitypub-base implements both new methods — Task 3 - ✅ event-payload wired — Task 4 - ✅ Worker handles event (fetch collection → resolve profiles → upsert) — Task 5 - ✅ 1-hour TTL cache logic in endpoint — Task 6 - ✅ `AppState` + bootstrap wired — Task 6 - ✅ `ActorConnectionResponse` + `ActorConnectionPageResponse` — Task 6 - ✅ Two REST endpoints + routes — Task 6 - ✅ Frontend: schema, fetch fns, tabs with lazy load + pagination — Task 7 - ✅ Failure handling: partial resolution, warn log, skip — Task 3 **Placeholder scan:** None found. **Type consistency:** - `ActorConnectionSummary.url` (domain) → `ActorConnectionResponse.url` (api-types) → `ActorConnection.url` (frontend schema) ✅ - `connection_type: &str` in port matches `connection_type: String` in event (converted via `.as_str()` when needed) ✅ - `page: u32` in port, event, endpoint, frontend ✅ - `RemoteUserCard` prop type — noted in Task 7 step 3 ✅