7.5 KiB
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
- User opens the Followers or Following tab on a remote actor profile
- Frontend calls
GET /federation/actors/{handle}/followers-list?page=1 - Backend returns cached data immediately (may be empty on first visit)
- If cache is empty OR older than 1 hour: publish
FetchActorConnectionsevent fire-and-forget - Worker receives event → fetches remote collection page → concurrently resolves each actor URL to a profile → stores results
- Next visit / tab re-open shows populated data
Domain Changes
New models (domain/src/models/)
connection_type.rs:
#[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:
#[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)
FetchActorConnections {
actor_ap_url: String,
collection_url: String,
connection_type: String, // "followers" | "following"
page: u32,
},
New port (domain/src/ports.rs)
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
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
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_handleconnection_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:
- For each URL: spawn
tokio::time::timeout(5s, fetch_actor_profile(url)) fetch_actor_profile:GET {url}withAccept: application/activity+json→ parsepreferred_username,name,icon.url,id- Collect
Okresults → return asVec<ActorConnectionSummary> - 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 }:
- Fetch
collection_url(as AP JSON) → extractorderedItemsarray as Vec of URL strings - If empty: return Ok(()) — nothing to store
federation_action.resolve_actor_profiles(urls).await— concurrent, partial success OKremote_actor_connections.upsert_connections(actor_ap_url, connection_type, page, &results).await- 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):
{ "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
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
RemoteUserCardcomponents (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.