36 KiB
Remote Actor Connections Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Show a remote actor's followers and following as browseable lists within the thoughts UI, backed by a worker cache with concurrent AP profile resolution.
Architecture: New domain models (ConnectionType, ActorConnectionSummary) + new port (RemoteActorConnectionRepository) + new FederationActionPort methods. REST endpoints return cached data and fire a FetchActorConnections event fire-and-forget. Worker fetches the AP collection, concurrently resolves each actor URL to a profile (5s timeout per actor, partial failures silently skipped), and upserts results. Frontend adds Followers/Following tabs to RemoteUserProfile using existing RemoteUserCard.
Tech Stack: Rust (axum, sqlx, tokio, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod.
File Map
| Action | Path | Change |
|---|---|---|
| Create | crates/domain/src/models/connection_type.rs |
ConnectionType enum |
| Create | crates/domain/src/models/actor_connection_summary.rs |
ActorConnectionSummary struct |
| Modify | crates/domain/src/models/mod.rs |
expose new modules |
| Modify | crates/domain/src/events.rs |
FetchActorConnections variant |
| Modify | crates/domain/src/ports.rs |
RemoteActorConnectionRepository port; 2 new FederationActionPort methods |
| Modify | crates/domain/src/testing.rs |
stubs + test |
| Create | crates/adapters/postgres/migrations/006_remote_actor_connections.sql |
new table |
| Create | crates/adapters/postgres/src/remote_actor_connections.rs |
postgres impl |
| Modify | crates/adapters/postgres/src/lib.rs |
expose module, export type |
| Modify | crates/adapters/activitypub-base/src/service.rs |
impl 2 new port methods |
| Modify | crates/adapters/event-payload/src/lib.rs |
FetchActorConnections variant |
| Modify | crates/application/src/services/federation_event.rs |
new dep + handler |
| Modify | crates/worker/src/factory.rs |
wire remote_actor_connections |
| Modify | crates/api-types/src/responses.rs |
ActorConnectionResponse |
| Modify | crates/presentation/src/state.rs |
add remote_actor_connections field |
| Modify | crates/bootstrap/src/factory.rs |
wire new repo |
| Modify | crates/presentation/src/handlers/federation_actors.rs |
2 new handlers |
| Modify | crates/presentation/src/handlers/*.rs (tests) |
add remote_actor_connections to make_state() |
| Modify | crates/presentation/src/routes.rs |
mount 2 new routes |
| Modify | thoughts-frontend/lib/api.ts |
new schema + 2 fetch functions |
| Modify | thoughts-frontend/components/remote-user-profile.tsx |
replace links with tabs |
Task 1: Domain — models, port, event, stubs
Files:
-
Create:
crates/domain/src/models/connection_type.rs -
Create:
crates/domain/src/models/actor_connection_summary.rs -
Modify:
crates/domain/src/models/mod.rs -
Modify:
crates/domain/src/events.rs -
Modify:
crates/domain/src/ports.rs -
Modify:
crates/domain/src/testing.rs -
Step 1: Create
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",
}
}
}
- Step 2: Create
actor_connection_summary.rs
#[derive(Debug, Clone)]
pub struct ActorConnectionSummary {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
- Step 3: Register in
models/mod.rs
Add:
pub mod actor_connection_summary;
pub mod connection_type;
- Step 4: Add
FetchActorConnectionstoDomainEvent
Read crates/domain/src/events.rs. Add before the closing brace:
FetchActorConnections {
actor_ap_url: String,
collection_url: String,
connection_type: String,
page: u32,
},
- Step 5: Write failing domain test
At the bottom of crates/domain/src/testing.rs, in the federation_port_tests module, add:
#[tokio::test]
async fn test_store_resolve_actor_profiles_returns_empty() {
let store = TestStore::default();
let result = store.resolve_actor_profiles(vec!["https://example.com/users/alice".into()]).await;
assert!(result.is_empty());
}
#[tokio::test]
async fn test_store_fetch_collection_urls_returns_empty() {
let store = TestStore::default();
let urls = store.fetch_actor_urls_from_collection("https://example.com/users/alice/followers").await.unwrap();
assert!(urls.is_empty());
}
- Step 6: Run to confirm compile failure
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10
Expected: compile error — new port methods and RemoteActorConnectionRepository not defined.
- Step 7: Add
RemoteActorConnectionRepositorytoports.rs
Read crates/domain/src/ports.rs. Add after RemoteActorRepository:
#[async_trait]
pub trait RemoteActorConnectionRepository: Send + Sync {
async fn upsert_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
actors: &[crate::models::actor_connection_summary::ActorConnectionSummary],
) -> Result<(), DomainError>;
async fn list_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Vec<crate::models::actor_connection_summary::ActorConnectionSummary>, DomainError>;
async fn connection_page_age(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
}
Then in FederationActionPort, add two new methods:
async fn fetch_actor_urls_from_collection(
&self,
collection_url: &str,
) -> Result<Vec<String>, DomainError>;
async fn resolve_actor_profiles(
&self,
urls: Vec<String>,
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
- Step 8: Add stubs to
TestStore
In crates/domain/src/testing.rs, add after the existing impl FederationActionPort for TestStore block:
#[async_trait]
impl RemoteActorConnectionRepository for TestStore {
async fn upsert_connections(
&self,
_actor_url: &str,
_connection_type: &str,
_page: u32,
_actors: &[crate::models::actor_connection_summary::ActorConnectionSummary],
) -> Result<(), DomainError> {
Ok(())
}
async fn list_connections(
&self,
_actor_url: &str,
_connection_type: &str,
_page: u32,
) -> Result<Vec<crate::models::actor_connection_summary::ActorConnectionSummary>, DomainError> {
Ok(vec![])
}
async fn connection_page_age(
&self,
_actor_url: &str,
_connection_type: &str,
_page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError> {
Ok(None)
}
}
Inside impl FederationActionPort for TestStore, add the two new methods:
async fn fetch_actor_urls_from_collection(
&self,
_collection_url: &str,
) -> Result<Vec<String>, DomainError> {
Ok(vec![])
}
async fn resolve_actor_profiles(
&self,
_urls: Vec<String>,
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary> {
vec![]
}
- Step 9: Run tests to confirm pass
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10
Expected: all tests pass.
- Step 10: Full compile check
cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5
- Step 11: Commit
cd /mnt/drive/dev/thoughts
git add crates/domain/src/models/connection_type.rs \
crates/domain/src/models/actor_connection_summary.rs \
crates/domain/src/models/mod.rs \
crates/domain/src/events.rs \
crates/domain/src/ports.rs \
crates/domain/src/testing.rs
git commit -m "feat(domain): ActorConnectionSummary, ConnectionType, RemoteActorConnectionRepository, FetchActorConnections event"
Task 2: PostgreSQL adapter — migration + repository
Files:
-
Create:
crates/adapters/postgres/migrations/006_remote_actor_connections.sql -
Create:
crates/adapters/postgres/src/remote_actor_connections.rs -
Modify:
crates/adapters/postgres/src/lib.rs -
Step 1: Create migration
Create crates/adapters/postgres/migrations/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);
- Step 2: Create
remote_actor_connections.rs
Create crates/adapters/postgres/src/remote_actor_connections.rs:
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::actor_connection_summary::ActorConnectionSummary,
ports::RemoteActorConnectionRepository,
};
use sqlx::PgPool;
pub struct PgRemoteActorConnectionRepository {
pool: PgPool,
}
impl PgRemoteActorConnectionRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl RemoteActorConnectionRepository for PgRemoteActorConnectionRepository {
async fn upsert_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
actors: &[ActorConnectionSummary],
) -> Result<(), DomainError> {
for actor in actors {
sqlx::query(
"INSERT INTO remote_actor_connections
(actor_url, connection_type, page, connected_actor_url,
connected_handle, connected_display_name, connected_avatar_url, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT(actor_url, connection_type, page, connected_actor_url)
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()",
)
.bind(actor_url)
.bind(connection_type)
.bind(page as i32)
.bind(&actor.url)
.bind(&actor.handle)
.bind(&actor.display_name)
.bind(&actor.avatar_url)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
}
Ok(())
}
async fn list_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Vec<ActorConnectionSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
connected_actor_url: String,
connected_handle: String,
connected_display_name: Option<String>,
connected_avatar_url: Option<String>,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT connected_actor_url, connected_handle, connected_display_name, connected_avatar_url
FROM remote_actor_connections
WHERE actor_url = $1 AND connection_type = $2 AND page = $3
ORDER BY connected_handle",
)
.bind(actor_url)
.bind(connection_type)
.bind(page as i32)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| ActorConnectionSummary {
url: r.connected_actor_url,
handle: r.connected_handle,
display_name: r.connected_display_name,
avatar_url: r.connected_avatar_url,
})
.collect())
}
async fn connection_page_age(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError> {
let row: Option<(Option<chrono::DateTime<chrono::Utc>>,)> = sqlx::query_as(
"SELECT MAX(fetched_at) FROM remote_actor_connections
WHERE actor_url = $1 AND connection_type = $2 AND page = $3",
)
.bind(actor_url)
.bind(connection_type)
.bind(page as i32)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(row.and_then(|(ts,)| ts))
}
}
- Step 3: Expose in
postgres/src/lib.rs
Read crates/adapters/postgres/src/lib.rs. Add:
pub mod remote_actor_connections;
- Step 4: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p postgres 2>&1 | tail -10
Expected: no errors.
- Step 5: Commit
cd /mnt/drive/dev/thoughts
git add crates/adapters/postgres/migrations/006_remote_actor_connections.sql \
crates/adapters/postgres/src/remote_actor_connections.rs \
crates/adapters/postgres/src/lib.rs
git commit -m "feat(postgres): remote_actor_connections table + PgRemoteActorConnectionRepository"
Task 3: activitypub-base — implement fetch_actor_urls_from_collection + resolve_actor_profiles
Files:
-
Modify:
crates/adapters/activitypub-base/src/service.rs -
Step 1: Confirm compile failure
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
Expected: error — fetch_actor_urls_from_collection and resolve_actor_profiles not implemented.
- Step 2: Implement both methods in the
FederationActionPortimpl block
Read the file. At the bottom of impl domain::ports::FederationActionPort for ActivityPubService, after fetch_outbox_page, add:
async fn fetch_actor_urls_from_collection(
&self,
collection_url: &str,
) -> Result<Vec<String>, domain::errors::DomainError> {
let resp: serde_json::Value = reqwest::Client::new()
.get(collection_url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
let empty = vec![];
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
Ok(items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect())
}
async fn resolve_actor_profiles(
&self,
urls: Vec<String>,
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
use futures::future;
async fn fetch_one(
url: String,
) -> Option<domain::models::actor_connection_summary::ActorConnectionSummary> {
let resp: serde_json::Value = tokio::time::timeout(
std::time::Duration::from_secs(5),
reqwest::Client::new()
.get(&url)
.header("Accept", "application/activity+json")
.send(),
)
.await
.ok()?
.ok()?
.json()
.await
.ok()?;
let ap_url = resp["id"].as_str()?.to_string();
let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string();
let domain_str = url::Url::parse(&ap_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
let handle = format!("{}@{}", preferred_username, domain_str);
let display_name = resp["name"].as_str().map(|s| s.to_string());
let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string());
Some(domain::models::actor_connection_summary::ActorConnectionSummary {
url: ap_url,
handle,
display_name,
avatar_url,
})
}
let futs: Vec<_> = urls.into_iter().map(fetch_one).collect();
let results = future::join_all(futs).await;
results
.into_iter()
.filter_map(|r| {
if r.is_none() {
tracing::warn!("failed to resolve actor profile (timeout or parse error)");
}
r
})
.collect()
}
- Step 3: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
- Step 4: Run all tests
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
Expected: all pass.
- Step 5: Commit
cd /mnt/drive/dev/thoughts
git add crates/adapters/activitypub-base/src/service.rs
git commit -m "feat(activitypub-base): impl fetch_actor_urls_from_collection + resolve_actor_profiles (concurrent, 5s timeout)"
Task 4: event-payload — FetchActorConnections
Files:
-
Modify:
crates/adapters/event-payload/src/lib.rs -
Step 1: Add variant to
EventPayloadenum
Read the file. Add at the end of the enum:
FetchActorConnections {
actor_ap_url: String,
collection_url: String,
connection_type: String,
page: u32,
},
- Step 2: Add subject
In subject():
Self::FetchActorConnections { .. } => "federation.fetch_actor_connections",
- Step 3: Add
From<&DomainEvent>arm
DomainEvent::FetchActorConnections {
actor_ap_url,
collection_url,
connection_type,
page,
} => Self::FetchActorConnections {
actor_ap_url: actor_ap_url.clone(),
collection_url: collection_url.clone(),
connection_type: connection_type.clone(),
page: *page,
},
- Step 4: Add
TryFrom<EventPayload>arm
EventPayload::FetchActorConnections {
actor_ap_url,
collection_url,
connection_type,
page,
} => DomainEvent::FetchActorConnections {
actor_ap_url,
collection_url,
connection_type,
page,
},
- Step 5: Add to uniqueness test sample array
EventPayload::FetchActorConnections {
actor_ap_url: "https://mastodon.social/users/alice".into(),
collection_url: "https://mastodon.social/users/alice/followers".into(),
connection_type: "followers".into(),
page: 1,
},
- Step 6: Test
cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -5
Expected: all pass (uniqueness test includes new variant).
- Step 7: Commit
cd /mnt/drive/dev/thoughts
git add crates/adapters/event-payload/src/lib.rs
git commit -m "feat(event-payload): FetchActorConnections event"
Task 5: Worker — handle FetchActorConnections + wire repo
Files:
-
Modify:
crates/application/src/services/federation_event.rs -
Modify:
crates/worker/src/factory.rs -
Step 1: Add
remote_actor_connectionstoFederationEventService
Read crates/application/src/services/federation_event.rs. Add to the struct:
pub remote_actor_connections: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
- Step 2: Handle
FetchActorConnectionsinprocess()
Before the _ => Ok(()) arm, add:
DomainEvent::FetchActorConnections {
actor_ap_url,
collection_url,
connection_type,
page,
} => {
let urls = match self
.federation_action
.fetch_actor_urls_from_collection(collection_url)
.await
{
Ok(u) => u,
Err(e) => {
tracing::warn!(
collection_url,
error = %e,
"failed to fetch actor connections collection"
);
return Ok(());
}
};
if urls.is_empty() {
return Ok(());
}
let summaries = self
.federation_action
.resolve_actor_profiles(urls)
.await;
if summaries.is_empty() {
return Ok(());
}
tracing::info!(
count = summaries.len(),
connection_type,
actor = actor_ap_url,
"caching actor connections"
);
self.remote_actor_connections
.upsert_connections(actor_ap_url, connection_type, *page, &summaries)
.await?;
Ok(())
}
- Step 3: Add test
In the #[cfg(test)] block, add remote_actor_connections: Arc::new(store.clone()) to the svc() helper, then add:
#[tokio::test]
async fn fetch_actor_connections_is_noop_when_collection_empty() {
let store = TestStore::default();
let spy = Arc::new(SpyPort::default());
svc(&store, spy.clone())
.process(&DomainEvent::FetchActorConnections {
actor_ap_url: "https://mastodon.social/users/alice".into(),
collection_url: "https://mastodon.social/users/alice/followers".into(),
connection_type: "followers".into(),
page: 1,
})
.await
.unwrap();
}
- Step 4: Run tests
cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -10
Expected: all pass.
- Step 5: Wire
remote_actor_connectionsinworker/src/factory.rs
Read the file. Add import:
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
Add the repo:
let actor_connections = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()))
as Arc<dyn domain::ports::RemoteActorConnectionRepository>;
Add to FederationEventService construction:
remote_actor_connections: actor_connections,
- Step 6: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p worker 2>&1 | tail -10
- Step 7: Run all tests
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
- Step 8: Commit
cd /mnt/drive/dev/thoughts
git add crates/application/src/services/federation_event.rs \
crates/worker/src/factory.rs
git commit -m "feat(worker): handle FetchActorConnections — resolve and cache remote actor connections"
Task 6: AppState + bootstrap + REST endpoints
Files:
-
Modify:
crates/presentation/src/state.rs -
Modify:
crates/bootstrap/src/factory.rs -
Modify:
crates/api-types/src/responses.rs -
Modify:
crates/presentation/src/handlers/federation_actors.rs -
Modify:
crates/presentation/src/handlers/(test make_state() helpers) -
Modify:
crates/presentation/src/routes.rs -
Step 1: Add
remote_actor_connectionstoAppState
Read crates/presentation/src/state.rs. Add field:
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
RemoteActorConnectionRepository is in domain::ports::*, already imported.
- Step 2: Wire in
bootstrap/src/factory.rs
Read the file. Add import:
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
Add to AppState { ... }:
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
- Step 3: Add
ActorConnectionResponseto api-types
Read crates/api-types/src/responses.rs. Add:
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ActorConnectionResponse {
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub url: String,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ActorConnectionPageResponse {
pub items: Vec<ActorConnectionResponse>,
pub page: u32,
pub has_more: bool,
}
- Step 4: Fix broken test
make_state()helpers
Find all handlers with make_state() that construct AppState — they will now be missing remote_actor_connections. Run:
cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "missing field" | head -5
For each affected test module, add remote_actor_connections: store.clone() to the AppState construction.
- Step 5: Add two new handlers to
federation_actors.rs
Read the file. Add imports at the top:
use api_types::responses::{ActorConnectionPageResponse, ActorConnectionResponse};
use domain::events::DomainEvent;
Add after remote_actor_posts_handler:
const CACHE_TTL_SECS: i64 = 3600;
pub async fn actor_followers_handler(
State(s): State<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
actor_connections_handler(s, handle, "followers", q.page() as u32).await
}
pub async fn actor_following_handler(
State(s): State<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
actor_connections_handler(s, handle, "following", q.page() as u32).await
}
async fn actor_connections_handler(
s: AppState,
handle: String,
connection_type: &str,
page: u32,
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
const PAGE_SIZE: usize = 20;
let actor = s.federation.lookup_actor(&handle).await?;
let collection_url = match connection_type {
"followers" => actor
.followers_url
.ok_or_else(|| ApiError::BadRequest("actor has no followers URL".into()))?,
_ => actor
.following_url
.ok_or_else(|| ApiError::BadRequest("actor has no following URL".into()))?,
};
let items = s
.remote_actor_connections
.list_connections(&actor.url, connection_type, page)
.await?;
// Fire fetch if cache is missing or stale
let stale = match s
.remote_actor_connections
.connection_page_age(&actor.url, connection_type, page)
.await?
{
None => true,
Some(age) => {
chrono::Utc::now()
.signed_duration_since(age)
.num_seconds()
> CACHE_TTL_SECS
}
};
if stale {
let _ = s
.events
.publish(&DomainEvent::FetchActorConnections {
actor_ap_url: actor.url.clone(),
collection_url,
connection_type: connection_type.to_string(),
page,
})
.await;
}
let has_more = items.len() >= PAGE_SIZE;
Ok(Json(ActorConnectionPageResponse {
items: items
.into_iter()
.map(|a| ActorConnectionResponse {
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
url: a.url,
})
.collect(),
page,
has_more,
}))
}
- Step 6: Mount routes
Read crates/presentation/src/routes.rs. After the existing /federation/actors/{handle}/posts route, add:
.route(
"/federation/actors/{handle}/followers-list",
get(federation_actors::actor_followers_handler),
)
.route(
"/federation/actors/{handle}/following-list",
get(federation_actors::actor_following_handler),
)
- Step 7: Run all tests
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
Expected: all pass.
- Step 8: Commit
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/state.rs \
crates/bootstrap/src/factory.rs \
crates/api-types/src/responses.rs \
crates/presentation/src/handlers/federation_actors.rs \
crates/presentation/src/routes.rs
# Also add any handler files with updated make_state()
git commit -m "feat(presentation): followers/following list endpoints for remote actors"
Task 7: Frontend — API + tabs in RemoteUserProfile
Files:
-
Modify:
thoughts-frontend/lib/api.ts -
Modify:
thoughts-frontend/components/remote-user-profile.tsx -
Step 1: Add schema + fetch functions to
api.ts
Read the file. After getActorFollowing/getActorFollowers (or after getRemoteActorPosts), add:
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 ActorConnectionPageSchema = z.object({
items: z.array(ActorConnectionSchema),
page: z.number(),
hasMore: z.boolean(),
});
export const getActorFollowers = (
handle: string,
page: number,
token: string | null
) =>
apiFetch(
`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`,
{},
ActorConnectionPageSchema,
token
);
export const getActorFollowing = (
handle: string,
page: number,
token: string | null
) =>
apiFetch(
`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`,
{},
ActorConnectionPageSchema,
token
);
- Step 2: Update
remote-user-profile.tsx
Read the full file. Replace the existing followers/following links section AND add tab state + lazy loading. The component is already "use client".
Add imports at the top:
import { getActorFollowers, getActorFollowing, ActorConnection } from "@/lib/api";
import { RemoteUserCard } from "@/components/remote-user-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Add state inside the component (after existing state):
type Tab = "posts" | "followers" | "following";
const [activeTab, setActiveTab] = useState<Tab>("posts");
const [followers, setFollowers] = useState<ActorConnection[]>([]);
const [following, setFollowing] = useState<ActorConnection[]>([]);
const [followersPage, setFollowersPage] = useState(1);
const [followingPage, setFollowingPage] = useState(1);
const [followersHasMore, setFollowersHasMore] = useState(false);
const [followingHasMore, setFollowingHasMore] = useState(false);
const [followersLoaded, setFollowersLoaded] = useState(false);
const [followingLoaded, setFollowingLoaded] = useState(false);
Add tab handlers:
const loadFollowers = async (page: number) => {
const result = await getActorFollowers(actor.handle, page, token).catch(() => null);
if (!result) return;
setFollowers((prev) => page === 1 ? result.items : [...prev, ...result.items]);
setFollowersHasMore(result.hasMore);
setFollowersLoaded(true);
setFollowersPage(page);
};
const loadFollowing = async (page: number) => {
const result = await getActorFollowing(actor.handle, page, token).catch(() => null);
if (!result) return;
setFollowing((prev) => page === 1 ? result.items : [...prev, ...result.items]);
setFollowingHasMore(result.hasMore);
setFollowingLoaded(true);
setFollowingPage(page);
};
const handleTabChange = (tab: string) => {
setActiveTab(tab as Tab);
if (tab === "followers" && !followersLoaded) loadFollowers(1);
if (tab === "following" && !followingLoaded) loadFollowing(1);
};
Replace the posts section (<div className="col-span-1 lg:col-span-3 space-y-4">...) with:
<div className="col-span-1 lg:col-span-3">
<Tabs defaultValue="posts" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="posts">Posts</TabsTrigger>
<TabsTrigger value="followers">Followers</TabsTrigger>
<TabsTrigger value="following">Following</TabsTrigger>
</TabsList>
<TabsContent value="posts" className="space-y-4 mt-4">
{initialPosts.length > 0 ? (
<ThoughtList
thoughts={initialPosts}
authorDetails={authorDetails}
currentUser={me}
/>
) : (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">
Posts are being fetched — check back soon.
</p>
</Card>
)}
</TabsContent>
<TabsContent value="followers" className="mt-4">
{!followersLoaded ? (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">Loading followers…</p>
</Card>
) : followers.length === 0 ? (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">
No followers cached yet — check back soon.
</p>
</Card>
) : (
<div className="space-y-2">
{followers.map((f) => (
<RemoteUserCard key={f.url} actor={f} />
))}
{followersHasMore && (
<button
onClick={() => loadFollowers(followersPage + 1)}
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
>
Load more
</button>
)}
</div>
)}
</TabsContent>
<TabsContent value="following" className="mt-4">
{!followingLoaded ? (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">Loading following…</p>
</Card>
) : following.length === 0 ? (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">
No following cached yet — check back soon.
</p>
</Card>
) : (
<div className="space-y-2">
{following.map((f) => (
<RemoteUserCard key={f.url} actor={f} />
))}
{followingHasMore && (
<button
onClick={() => loadFollowing(followingPage + 1)}
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
>
Load more
</button>
)}
</div>
)}
</TabsContent>
</Tabs>
</div>
Also remove the old {(actor.followersUrl || actor.followingUrl) && ...} plain links section from the sidebar — replaced by tabs.
- Step 3: Type-check
cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -10
Fix any type errors. Common issue: RemoteUserCard expects RemoteActor but we're passing ActorConnection — both have the same shape (handle, displayName, avatarUrl, url) so you may need a cast or to widen the prop type on RemoteUserCard.
If RemoteUserCard is typed as actor: RemoteActor, change its prop to actor: { handle: string; displayName: string | null; avatarUrl: string | null; url: string } or union type. Alternatively, cast: actor={f as RemoteActor}.
- Step 4: Commit
cd /mnt/drive/dev/thoughts
git add thoughts-frontend/lib/api.ts \
thoughts-frontend/components/remote-user-profile.tsx
git commit -m "feat(frontend): followers/following tabs on remote actor profile with lazy loading + pagination"
Self-Review
Spec coverage:
- ✅
ConnectionTypeenum — Task 1 - ✅
ActorConnectionSummarymodel — Task 1 - ✅
RemoteActorConnectionRepositoryport — Task 1 - ✅
fetch_actor_urls_from_collectiononFederationActionPort— Tasks 1 + 3 - ✅
resolve_actor_profilesonFederationActionPort(concurrent, 5s timeout, partial) — Tasks 1 + 3 - ✅
FetchActorConnectionsdomain event — Task 1 - ✅ Migration +
PgRemoteActorConnectionRepository— Task 2 - ✅ activitypub-base implements both new methods — Task 3
- ✅ event-payload wired — Task 4
- ✅ Worker handles event (fetch collection → resolve profiles → upsert) — Task 5
- ✅ 1-hour TTL cache logic in endpoint — Task 6
- ✅
AppState+ bootstrap wired — Task 6 - ✅
ActorConnectionResponse+ActorConnectionPageResponse— Task 6 - ✅ Two REST endpoints + routes — Task 6
- ✅ Frontend: schema, fetch fns, tabs with lazy load + pagination — Task 7
- ✅ Failure handling: partial resolution, warn log, skip — Task 3
Placeholder scan: None found.
Type consistency:
ActorConnectionSummary.url(domain) →ActorConnectionResponse.url(api-types) →ActorConnection.url(frontend schema) ✅connection_type: &strin port matchesconnection_type: Stringin event (converted via.as_str()when needed) ✅page: u32in port, event, endpoint, frontend ✅RemoteUserCardprop type — noted in Task 7 step 3 ✅