docs: remote actor connections (followers/following) design spec
This commit is contained in:
213
docs/superpowers/specs/2026-05-15-actor-connections-design.md
Normal file
213
docs/superpowers/specs/2026-05-15-actor-connections-design.md
Normal file
@@ -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<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<Vec<ActorConnectionSummary>, DomainError>;
|
||||||
|
|
||||||
|
async fn connection_page_age(
|
||||||
|
&self,
|
||||||
|
actor_url: &str,
|
||||||
|
connection_type: &str,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New `FederationActionPort` method
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn resolve_actor_profiles(
|
||||||
|
&self,
|
||||||
|
urls: Vec<String>,
|
||||||
|
) -> Vec<ActorConnectionSummary>;
|
||||||
|
```
|
||||||
|
|
||||||
|
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<ActorConnectionSummary>`
|
||||||
|
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<EventPayload>`, and uniqueness test.
|
||||||
|
|
||||||
|
## Worker
|
||||||
|
|
||||||
|
`FederationEventService` gains `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>`.
|
||||||
|
|
||||||
|
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<dyn RemoteActorConnectionRepository>` 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<typeof ActorConnectionSchema>;
|
||||||
|
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user