# Remote Actor Connections (Followers/Following) Design Display a remote actor's followers and following lists in the thoughts UI, with worker-backed caching and concurrent AP profile resolution. ## Data Flow 1. User opens the Followers or Following tab on a remote actor profile 2. Frontend calls `GET /federation/actors/{handle}/followers-list?page=1` 3. Backend returns cached data immediately (may be empty on first visit) 4. If cache is empty OR older than 1 hour: publish `FetchActorConnections` event fire-and-forget 5. Worker receives event → fetches remote collection page → concurrently resolves each actor URL to a profile → stores results 6. Next visit / tab re-open shows populated data ## Domain Changes ### New models (`domain/src/models/`) **`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" } } } ``` **`actor_connection_summary.rs`**: ```rust #[derive(Debug, Clone)] pub struct ActorConnectionSummary { pub url: String, // AP URL of the connected actor pub handle: String, pub display_name: Option, pub avatar_url: Option, } ``` ### New `DomainEvent` variant (`domain/src/events.rs`) ```rust FetchActorConnections { actor_ap_url: String, collection_url: String, connection_type: String, // "followers" | "following" page: u32, }, ``` ### New port (`domain/src/ports.rs`) ```rust pub trait RemoteActorConnectionRepository: Send + Sync { async fn upsert_connections( &self, actor_url: &str, connection_type: &str, page: u32, actors: &[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>; } ``` ### New `FederationActionPort` method ```rust async fn resolve_actor_profiles( &self, urls: Vec, ) -> Vec; ``` Returns only successful resolutions. Per-actor timeout: 5 seconds. Concurrent. No error propagation — failures are silently skipped (warn logged). ## Storage ### Migration: `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); ``` ### `PgRemoteActorConnectionRepository` - `upsert_connections`: `INSERT ... ON CONFLICT 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()` - `list_connections`: `SELECT * WHERE actor_url=$1 AND connection_type=$2 AND page=$3 ORDER BY connected_handle` - `connection_page_age`: `SELECT MAX(fetched_at) WHERE actor_url=$1 AND connection_type=$2 AND page=$3` ## activitypub-base: `resolve_actor_profiles` `ActivityPubService` implements `FederationActionPort::resolve_actor_profiles`: 1. For each URL: spawn `tokio::time::timeout(5s, fetch_actor_profile(url))` 2. `fetch_actor_profile`: `GET {url}` with `Accept: application/activity+json` → parse `preferred_username`, `name`, `icon.url`, `id` 3. Collect `Ok` results → return as `Vec` 4. Failed/timed-out actors: `tracing::warn!` and skip ## event-payload Add `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }` to `EventPayload` — subject: `"federation.fetch_actor_connections"`. Add to `From<&DomainEvent>`, `TryFrom`, and uniqueness test. ## Worker `FederationEventService` gains `remote_actor_connections: Arc`. Handler for `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }`: 1. Fetch `collection_url` (as AP JSON) → extract `orderedItems` array as Vec of URL strings 2. If empty: return Ok(()) — nothing to store 3. `federation_action.resolve_actor_profiles(urls).await` — concurrent, partial success OK 4. `remote_actor_connections.upsert_connections(actor_ap_url, connection_type, page, &results).await` 5. Log: `tracing::info!(count = results.len(), "actor connections cached")` Wire `remote_actor_connections` in `worker/src/factory.rs`. ## AppState + Bootstrap Add `remote_actor_connections: Arc` to `AppState`. Wire `PgRemoteActorConnectionRepository` in `bootstrap/src/factory.rs`. ## REST Endpoints **`GET /federation/actors/{handle}/followers-list?page=1`** ``` 1. lookup_actor(handle) → get actor_ap_url + followers_url 2. list_connections(actor_ap_url, "followers", page) → cached items 3. connection_page_age(...) → if None or > 1 hour: publish FetchActorConnections (fire-and-forget) 4. Return { items: [...], page, has_more: items.len() == PAGE_SIZE } ``` `PAGE_SIZE = 20`. `has_more` tells the frontend whether to show a "next" button. **`GET /federation/actors/{handle}/following-list?page=1`** — identical, uses `following_url` and `"following"`. Response item shape (reuses `RemoteActorResponse` minus `bio`/`banner`/`attachment`/`outbox_url`): ```json { "handle": "...", "displayName": "...", "avatarUrl": "...", "url": "..." } ``` Define as a new `ActorConnectionResponse` in api-types. Mount both routes in `routes.rs`. Add new handler file `federation_actors.rs` (already exists — add to it). ## Frontend ### `lib/api.ts` ```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 ConnectionPageSchema = z.object({ items: z.array(ActorConnectionSchema), page: z.number(), hasMore: z.boolean(), }); export const getActorFollowers = (handle, page, token) => apiFetch(`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, {}, ConnectionPageSchema, token); export const getActorFollowing = (handle, page, token) => apiFetch(`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, {}, ConnectionPageSchema, token); ``` ### `RemoteUserProfile` changes Replace the plain "Followers / Following" link section with two client-side tabs. Each tab: - Shows a list of `RemoteUserCard` components (reuse existing) - "Load more" button if `hasMore` - Empty state: "Loading — check back soon." - Tab is lazy: only fetches when first opened (not on profile load) Use the existing `RemoteUserCard` component — it already handles follow button and linking. ### `remote-user-profile.tsx` note The component is already a client component (`"use client"`), so React state for tab selection and paginated data works fine. Each tab fetches via `getActorFollowers`/`getActorFollowing` when first activated.