docs: remote actor connections (followers/following) design spec

This commit is contained in:
2026-05-15 00:17:21 +02:00
parent 8b3dfffd3b
commit 75f59a1f40

View 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.