From 75f59a1f40f1b31c4e1ad4f3645a7026b6279c7b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 00:17:21 +0200 Subject: [PATCH] docs: remote actor connections (followers/following) design spec --- .../2026-05-15-actor-connections-design.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-actor-connections-design.md diff --git a/docs/superpowers/specs/2026-05-15-actor-connections-design.md b/docs/superpowers/specs/2026-05-15-actor-connections-design.md new file mode 100644 index 0000000..ebc697a --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-actor-connections-design.md @@ -0,0 +1,213 @@ +# 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.