Files
thoughts/docs/superpowers/plans/2026-05-14-remote-actor-profile.md

1289 lines
40 KiB
Markdown

# Remote Actor Profile 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:** Display full remote actor profiles at `/users/@user@instance` — avatar, banner, bio, profile fields, and their public posts fetched in the background by the NATS worker.
**Architecture:** New `DomainEvent::FetchRemoteActorPosts` triggers the worker to fetch a remote outbox page and store notes via `ActivityPubRepository::accept_note`. A new REST endpoint returns cached posts + fires the event. The frontend detects the `@user@domain` URL format and renders a dedicated `RemoteUserProfile` component.
**Tech Stack:** Rust (axum, domain ports, activitypub_federation, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod, shadcn/ui.
---
## File Map
| Action | Path | Change |
|--------|------|--------|
| Modify | `crates/domain/src/models/remote_actor.rs` | Add 5 new fields |
| Create | `crates/domain/src/models/remote_note.rs` | New model |
| Modify | `crates/domain/src/models/mod.rs` | `pub mod remote_note` |
| Modify | `crates/domain/src/events.rs` | Add `FetchRemoteActorPosts` variant |
| Modify | `crates/domain/src/ports.rs` | Add `fetch_outbox_page` to `FederationActionPort` |
| Modify | `crates/domain/src/testing.rs` | Stub `fetch_outbox_page` on `TestStore` |
| Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `fetch_outbox_page`; populate new `RemoteActor` fields |
| Modify | `crates/adapters/event-payload/src/lib.rs` | Add `FetchRemoteActorPosts` to all 4 impls + test |
| Modify | `crates/presentation/src/state.rs` | Add `ap_repo` field |
| Modify | `crates/bootstrap/src/factory.rs` | Wire `ap_repo` into `AppState` |
| Modify | `crates/api-types/src/responses.rs` | Add `ProfileField`, extend `RemoteActorResponse` |
| Modify | `crates/presentation/src/handlers/feed.rs` | Make `to_thought_response` pub |
| Modify | `crates/presentation/src/handlers/users.rs` | Populate new `RemoteActorResponse` fields in `lookup_handler` |
| Create | `crates/presentation/src/handlers/federation_actors.rs` | `remote_actor_posts_handler` |
| Modify | `crates/presentation/src/handlers/mod.rs` | `pub mod federation_actors` |
| Modify | `crates/presentation/src/routes.rs` | Mount `GET /federation/actors/{handle}/posts` |
| Modify | `crates/application/src/services/federation_event.rs` | Handle `FetchRemoteActorPosts`; add new deps |
| Modify | `crates/worker/src/factory.rs` | Wire `federation_action` + `ap_repo` into `FederationEventService` |
| Modify | `thoughts-frontend/lib/api.ts` | Extend `RemoteActorSchema`; add `getRemoteActorPosts` |
| Create | `thoughts-frontend/components/remote-user-profile.tsx` | Full remote profile component |
| Modify | `thoughts-frontend/app/users/[username]/page.tsx` | Handle detection + remote profile branch |
---
## Task 1: Domain — extend `RemoteActor`, add `RemoteNote`, new event, new port method
**Files:**
- Modify: `crates/domain/src/models/remote_actor.rs`
- Create: `crates/domain/src/models/remote_note.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: Extend `RemoteActor` with new fields**
Replace the full content of `crates/domain/src/models/remote_actor.rs`:
```rust
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub public_key: String,
pub avatar_url: Option<String>,
pub last_fetched_at: DateTime<Utc>,
pub bio: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub outbox_url: Option<String>,
pub attachment: Vec<(String, String)>,
}
```
- [ ] **Step 2: Create `RemoteNote`**
Create `crates/domain/src/models/remote_note.rs`:
```rust
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct RemoteNote {
pub ap_id: String,
pub content: String,
pub published: DateTime<Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
}
```
- [ ] **Step 3: Register in `mod.rs`**
In `crates/domain/src/models/mod.rs`, add:
```rust
pub mod remote_note;
```
- [ ] **Step 4: Add `FetchRemoteActorPosts` to `DomainEvent`**
Read `crates/domain/src/events.rs`. Add the new variant at the end of the enum (before the closing brace):
```rust
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
},
```
- [ ] **Step 5: Write failing test**
In `crates/domain/src/testing.rs`, find the `federation_port_tests` module. Add:
```rust
#[tokio::test]
async fn test_store_fetch_outbox_returns_empty() {
let store = TestStore::default();
let notes = store.fetch_outbox_page("https://example.com/outbox", 1).await.unwrap();
assert!(notes.is_empty());
}
```
- [ ] **Step 6: Run to see compile failure**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests::test_store_fetch_outbox 2>&1 | tail -10
```
Expected: compile error — `fetch_outbox_page` not in trait.
- [ ] **Step 7: Add `fetch_outbox_page` to `FederationActionPort`**
Read `crates/domain/src/ports.rs`. In the `FederationActionPort` trait, add after `following_collection_json`:
```rust
async fn fetch_outbox_page(
&self,
outbox_url: &str,
page: u32,
) -> Result<Vec<domain::models::remote_note::RemoteNote>, DomainError>;
```
Note: you need to import or reference `RemoteNote`. Since it's in the same crate, use the full path `crate::models::remote_note::RemoteNote` or add it to the use block at the top of the trait impl. Check what's currently imported and add `use crate::models::remote_note::RemoteNote;` to the imports if not present.
- [ ] **Step 8: Add stub to `TestStore`**
In `crates/domain/src/testing.rs`, inside `impl FederationActionPort for TestStore`, add:
```rust
async fn fetch_outbox_page(
&self,
_outbox_url: &str,
_page: u32,
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError> {
Ok(vec![])
}
```
- [ ] **Step 9: Fix `RemoteActor` construction sites**
Adding new fields to `RemoteActor` will break all existing construction sites. Find them:
```bash
cd /mnt/drive/dev/thoughts && grep -rn "RemoteActor {" --include="*.rs" | grep -v "target/"
```
For each construction site (likely in `activitypub-base/src/actors.rs`, `activitypub-base/src/service.rs`, `adapters/postgres/src/remote_actor.rs`), add the new fields with default `None`/`vec![]` values:
```rust
bio: None,
banner_url: None,
also_known_as: None,
outbox_url: None,
attachment: vec![],
```
- [ ] **Step 10: Run tests to confirm pass**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10
```
Expected: all tests pass.
- [ ] **Step 11: Compile check**
```bash
cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5
```
- [ ] **Step 12: Commit**
```bash
cd /mnt/drive/dev/thoughts
git add crates/domain/src/models/remote_actor.rs \
crates/domain/src/models/remote_note.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): RemoteActor fields, RemoteNote model, FetchRemoteActorPosts event, fetch_outbox_page port"
```
---
## Task 2: activitypub-base — implement `fetch_outbox_page` + populate new `RemoteActor` fields
**Files:**
- Modify: `crates/adapters/activitypub-base/src/service.rs`
- [ ] **Step 1: Confirm compile failure**
```bash
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -10
```
Expected: error — `fetch_outbox_page` not implemented on `ActivityPubService`.
- [ ] **Step 2: Update `lookup_actor` to populate new `RemoteActor` fields**
Read `crates/adapters/activitypub-base/src/service.rs`. Find the `lookup_actor` impl. The current `Ok(domain::models::remote_actor::RemoteActor { ... })` block sets `handle: full_handle` and `avatar_url`. Extend it with the new fields:
```rust
let domain_str = actor.ap_id.host_str().unwrap_or("");
let full_handle = format!("{}@{}", actor.username, domain_str);
Ok(domain::models::remote_actor::RemoteActor {
url: actor.ap_id.to_string(),
handle: full_handle,
display_name: Some(actor.username.clone()),
inbox_url: actor.inbox_url.to_string(),
shared_inbox_url: None,
public_key: actor.public_key_pem.clone(),
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
last_fetched_at: actor.last_refreshed_at,
bio: actor.bio.clone(),
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
also_known_as: actor.also_known_as.clone(),
outbox_url: Some(actor.outbox_url.to_string()),
attachment: actor
.attachment
.iter()
.map(|f| (f.name.clone(), f.value.clone()))
.collect(),
})
```
- [ ] **Step 3: Implement `fetch_outbox_page`**
In the `impl domain::ports::FederationActionPort for ActivityPubService` block, after `following_collection_json`, add:
```rust
async fn fetch_outbox_page(
&self,
outbox_url: &str,
page: u32,
) -> Result<Vec<domain::models::remote_note::RemoteNote>, domain::errors::DomainError> {
use chrono::DateTime;
let url = format!("{}?page={}", outbox_url, page);
let resp: serde_json::Value = reqwest::Client::new()
.get(&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);
let notes = items
.iter()
.filter_map(|item| {
// Items are Create activities wrapping a Note, or Notes directly
let note = if item["type"].as_str() == Some("Create") {
&item["object"]
} else if item["type"].as_str() == Some("Note") {
item
} else {
return None;
};
// Only public notes
let to = note["to"].as_array()?;
let is_public = to.iter().any(|t| {
t.as_str()
== Some("https://www.w3.org/ns/activitystreams#Public")
});
if !is_public {
return None;
}
let published = DateTime::parse_from_rfc3339(
note["published"].as_str()?,
)
.ok()?
.with_timezone(&chrono::Utc);
Some(domain::models::remote_note::RemoteNote {
ap_id: note["id"].as_str()?.to_string(),
content: note["content"].as_str().unwrap_or("").to_string(),
published,
sensitive: note["sensitive"].as_bool().unwrap_or(false),
content_warning: note["summary"]
.as_str()
.map(|s| s.to_string()),
})
})
.collect();
Ok(notes)
}
```
- [ ] **Step 4: Compile check**
```bash
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
```
- [ ] **Step 5: Run all tests**
```bash
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
```
Expected: all pass.
- [ ] **Step 6: Commit**
```bash
cd /mnt/drive/dev/thoughts
git add crates/adapters/activitypub-base/src/service.rs
git commit -m "feat(activitypub-base): impl fetch_outbox_page; populate all RemoteActor fields in lookup_actor"
```
---
## Task 3: event-payload — add `FetchRemoteActorPosts`
**Files:**
- Modify: `crates/adapters/event-payload/src/lib.rs`
- [ ] **Step 1: Add variant to `EventPayload` enum**
Read the file. In the `EventPayload` enum, add at the end (before the closing brace):
```rust
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
},
```
- [ ] **Step 2: Add subject**
In `impl EventPayload { pub fn subject(&self) -> &'static str { match self { ... } } }`, add:
```rust
Self::FetchRemoteActorPosts { .. } => "federation.fetch_actor_posts",
```
- [ ] **Step 3: Add `From<&DomainEvent>` arm**
In `impl From<&DomainEvent> for EventPayload { fn from(e: &DomainEvent) -> Self { match e { ... } } }`, add:
```rust
DomainEvent::FetchRemoteActorPosts {
actor_ap_url,
outbox_url,
} => Self::FetchRemoteActorPosts {
actor_ap_url: actor_ap_url.clone(),
outbox_url: outbox_url.clone(),
},
```
- [ ] **Step 4: Add `TryFrom<EventPayload>` arm**
In `impl TryFrom<EventPayload> for DomainEvent { fn try_from(p: EventPayload) -> Result<Self, DomainError> { Ok(match p { ... }) } }`, add:
```rust
EventPayload::FetchRemoteActorPosts {
actor_ap_url,
outbox_url,
} => DomainEvent::FetchRemoteActorPosts {
actor_ap_url,
outbox_url,
},
```
- [ ] **Step 5: Add to the uniqueness test sample array**
Find the test that asserts each event has a unique subject (look for a `let samples: Vec<EventPayload> = vec![...]` in the `#[cfg(test)]` block). Add to the array:
```rust
EventPayload::FetchRemoteActorPosts {
actor_ap_url: "https://mastodon.social/users/alice".into(),
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
},
```
- [ ] **Step 6: Compile and test**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -10
```
Expected: all tests pass (uniqueness test passes with the new variant).
- [ ] **Step 7: Commit**
```bash
cd /mnt/drive/dev/thoughts
git add crates/adapters/event-payload/src/lib.rs
git commit -m "feat(event-payload): add FetchRemoteActorPosts event"
```
---
## Task 4: AppState + bootstrap — add `ap_repo`
**Files:**
- Modify: `crates/presentation/src/state.rs`
- Modify: `crates/bootstrap/src/factory.rs`
- [ ] **Step 1: Add `ap_repo` to `AppState`**
Read `crates/presentation/src/state.rs`. Add the new field:
```rust
pub ap_repo: Arc<dyn ActivityPubRepository>,
```
`ActivityPubRepository` is in `domain::ports::*` which is already imported via `use domain::ports::*`.
- [ ] **Step 2: Wire in `factory.rs`**
Read `crates/bootstrap/src/factory.rs`. Add the import at the top if not present:
```rust
use postgres::activitypub::PgActivityPubRepository;
```
In the `AppState { ... }` construction block, add:
```rust
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
```
- [ ] **Step 3: Compile check**
```bash
cd /mnt/drive/dev/thoughts && cargo check -p bootstrap 2>&1 | tail -10
```
Expected: no errors. (Presentation tests may fail with missing `ap_repo` in `make_state()` — they will be fixed in Task 5.)
- [ ] **Step 4: Commit**
```bash
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/state.rs crates/bootstrap/src/factory.rs
git commit -m "feat(bootstrap): add ap_repo to AppState"
```
---
## Task 5: REST endpoint — extend `RemoteActorResponse`, new handler, update `lookup_handler`
**Files:**
- Modify: `crates/api-types/src/responses.rs`
- Modify: `crates/presentation/src/handlers/feed.rs`
- Modify: `crates/presentation/src/handlers/users.rs`
- Create: `crates/presentation/src/handlers/federation_actors.rs`
- Modify: `crates/presentation/src/handlers/mod.rs`
- Modify: `crates/presentation/src/routes.rs`
- [ ] **Step 1: Add `ProfileField` + extend `RemoteActorResponse` in api-types**
Read `crates/api-types/src/responses.rs`. Add a new struct and extend `RemoteActorResponse`:
```rust
#[derive(Serialize, Clone, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProfileField {
pub name: String,
pub value: String,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RemoteActorResponse {
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub url: String,
pub bio: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub outbox_url: Option<String>,
pub attachment: Vec<ProfileField>,
}
```
- [ ] **Step 2: Make `to_thought_response` pub in `feed.rs`**
Read `crates/presentation/src/handlers/feed.rs`. Find `fn to_thought_response` (currently private) and change it to:
```rust
pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
```
- [ ] **Step 3: Update `lookup_handler` in `users.rs` to populate new fields**
Read `crates/presentation/src/handlers/users.rs`. Find `lookup_handler`. Update the `Ok(Json(RemoteActorResponse { ... }))` return to include all new fields:
```rust
pub async fn lookup_handler(
State(s): State<AppState>,
Query(q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, ApiError> {
let actor = s.federation.lookup_actor(&q.handle).await?;
Ok(Json(RemoteActorResponse {
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
url: actor.url,
bio: actor.bio,
banner_url: actor.banner_url,
also_known_as: actor.also_known_as,
outbox_url: actor.outbox_url,
attachment: actor
.attachment
.into_iter()
.map(|(name, value)| api_types::responses::ProfileField { name, value })
.collect(),
}))
}
```
- [ ] **Step 4: Write failing tests for the new handler**
Create `crates/presentation/src/handlers/federation_actors.rs` with tests first:
```rust
use crate::{
errors::ApiError,
extractors::OptionalAuthUser,
handlers::feed::to_thought_response,
state::AppState,
};
use api_types::requests::PaginationQuery;
use application::use_cases::feed::get_user_feed;
use axum::{
extract::{Path, Query, State},
Json,
};
use domain::{events::DomainEvent, models::feed::PageParams};
pub async fn remote_actor_posts_handler(
State(_s): State<AppState>,
Path(_handle): Path<String>,
Query(_q): Query<PaginationQuery>,
OptionalAuthUser(_viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::Request,
routing::get,
Router,
};
use domain::testing::TestStore;
use std::sync::Arc;
use tower::ServiceExt;
// Copy NoOpAuth and NoOpHasher structs from another handler test module
// (e.g. crates/presentation/src/handlers/notifications.rs tests section).
// They implement AuthService and PasswordHasher minimally for tests.
fn make_state() -> crate::state::AppState {
let store = Arc::new(TestStore::default());
crate::state::AppState {
users: store.clone(),
thoughts: store.clone(),
likes: store.clone(),
boosts: store.clone(),
follows: store.clone(),
blocks: store.clone(),
tags: store.clone(),
api_keys: store.clone(),
top_friends: store.clone(),
notifications: store.clone(),
remote_actors: store.clone(),
feed: store.clone(),
search: store.clone(),
auth: Arc::new(NoOpAuth),
hasher: Arc::new(NoOpHasher),
events: store.clone(),
federation: store.clone(),
ap_repo: store.clone(),
}
}
fn app() -> Router {
Router::new()
.route(
"/federation/actors/{handle}/posts",
get(remote_actor_posts_handler),
)
.with_state(make_state())
}
#[tokio::test]
async fn unknown_actor_returns_404() {
// TestStore.lookup_actor returns NotFound, so unknown handle → 404
let resp = app()
.oneshot(
Request::builder()
.uri("/federation/actors/%40alice%40example.com/posts")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
}
```
Note: `TestStore` must implement `ActivityPubRepository` for `make_state()` to compile. Check `crates/domain/src/testing.rs``TestStore` already implements it (look for `impl ActivityPubRepository for TestStore`). If the `ap_repo` field expects `Arc<dyn ActivityPubRepository>`, pass `store.clone()`.
- [ ] **Step 5: Add `pub mod federation_actors` to `mod.rs`**
In `crates/presentation/src/handlers/mod.rs`, add:
```rust
pub mod federation_actors;
```
- [ ] **Step 6: Run tests to see compile/fail state**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::federation_actors::tests 2>&1 | tail -20
```
Expected: compile error or panic from `todo!()`.
- [ ] **Step 7: Implement `remote_actor_posts_handler`**
Replace the `todo!()` body with:
```rust
pub async fn remote_actor_posts_handler(
State(s): State<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let actor = s.federation.lookup_actor(&handle).await?;
let ap_url = url::Url::parse(&actor.url)
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
// Get or create interned local UserId for this remote actor
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
Some(id) => id,
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
};
// Return cached posts from DB
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = get_user_feed(&*s.feed, &author_id, &page, viewer.as_ref()).await?;
// Trigger background outbox fetch (fire and forget — ignore publish errors)
if let Some(outbox_url) = &actor.outbox_url {
let _ = s
.events
.publish(&DomainEvent::FetchRemoteActorPosts {
actor_ap_url: actor.url.clone(),
outbox_url: outbox_url.clone(),
})
.await;
}
Ok(Json(serde_json::json!({
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
}
```
Add the missing import at the top:
```rust
use application::use_cases::feed::get_user_feed;
use domain::{events::DomainEvent, models::feed::PageParams};
use url;
```
- [ ] **Step 8: Mount the route**
Read `crates/presentation/src/routes.rs`. After the `/search` route, add:
```rust
.route(
"/federation/actors/{handle}/posts",
get(federation_actors::remote_actor_posts_handler),
)
```
- [ ] **Step 9: Run tests to confirm pass**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::federation_actors::tests 2>&1 | tail -10
```
Expected: `unknown_actor_returns_404` passes.
- [ ] **Step 10: Fix any broken tests caused by `ap_repo` in `make_state()`**
Other test modules (notifications, social, users) also build `AppState` via `make_state()`. They will fail to compile because `AppState` now has `ap_repo`. Find them with:
```bash
cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "error" | head -20
```
For each test module that constructs `AppState`, add `ap_repo: store.clone()` to the struct literal.
- [ ] **Step 11: Full compile + test**
```bash
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
```
Expected: all pass.
- [ ] **Step 12: Commit**
```bash
cd /mnt/drive/dev/thoughts
git add crates/api-types/src/responses.rs \
crates/presentation/src/handlers/feed.rs \
crates/presentation/src/handlers/users.rs \
crates/presentation/src/handlers/federation_actors.rs \
crates/presentation/src/handlers/mod.rs \
crates/presentation/src/routes.rs
git commit -m "feat(presentation): remote actor posts endpoint + extended RemoteActorResponse"
```
---
## Task 6: Worker — handle `FetchRemoteActorPosts` + wire deps
**Files:**
- Modify: `crates/application/src/services/federation_event.rs`
- Modify: `crates/worker/src/factory.rs`
- [ ] **Step 1: Add new deps to `FederationEventService`**
Read `crates/application/src/services/federation_event.rs`. Add two new fields to the struct:
```rust
pub struct FederationEventService {
pub thoughts: Arc<dyn ThoughtRepository>,
pub users: Arc<dyn UserRepository>,
pub ap: Arc<dyn OutboundFederationPort>,
pub base_url: String,
pub federation_action: Arc<dyn domain::ports::FederationActionPort>,
pub ap_repo: Arc<dyn domain::ports::ActivityPubRepository>,
}
```
- [ ] **Step 2: Handle `FetchRemoteActorPosts` in `process()`**
In the `match event { ... }` block in `process()`, add a new arm after `DomainEvent::BoostRemoved`:
```rust
DomainEvent::FetchRemoteActorPosts {
actor_ap_url,
outbox_url,
} => {
let notes = match self
.federation_action
.fetch_outbox_page(outbox_url, 1)
.await
{
Ok(n) => n,
Err(e) => {
tracing::warn!(outbox_url, error = %e, "failed to fetch remote outbox");
return Ok(());
}
};
let actor_url = url::Url::parse(actor_ap_url)
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
for note in notes {
let ap_id = match url::Url::parse(&note.ap_id) {
Ok(u) => u,
Err(_) => continue,
};
// accept_note is idempotent — duplicate ap_ids are ignored
let _ = self
.ap_repo
.accept_note(
&ap_id,
&author_id,
&note.content,
note.published,
note.sensitive,
note.content_warning,
"public",
)
.await;
}
Ok(())
}
```
Add `url` to the imports at the top of the file if not already imported:
```rust
use url;
```
- [ ] **Step 3: Fix the `FederationEventService` construction in `worker/factory.rs`**
Read `crates/worker/src/factory.rs`. Currently it creates `ap_service` as `Arc<dyn OutboundFederationPort>`. Change to create it as a concrete `Arc<ActivityPubService>` first, then cast:
```rust
use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort};
```
Replace the current `let ap_service: Arc<dyn domain::ports::OutboundFederationPort> = Arc::new(ActivityPubService::new(...).await.expect("..."))` with:
```rust
let ap_service = Arc::new(
ActivityPubService::new(
Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new(
pool.clone(),
base_url.to_string(),
)),
Arc::new(ThoughtsObjectHandler::new(
Arc::new(PgActivityPubRepository::new(pool.clone())),
base_url,
)),
base_url.to_string(),
false,
"thoughts".to_string(),
false,
None,
)
.await
.expect("ActivityPubService build failed"),
);
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
let ap_federation = ap_service.clone() as Arc<dyn FederationActionPort>;
let ap_repo_worker = Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
```
Update the `FederationEventService` construction:
```rust
let federation_svc = Arc::new(FederationEventService {
thoughts,
users,
ap: ap_outbound,
base_url: base_url.to_string(),
federation_action: ap_federation,
ap_repo: ap_repo_worker,
});
```
- [ ] **Step 4: Fix existing tests in `federation_event.rs`**
The `svc()` helper in tests constructs `FederationEventService` and will now fail because of missing new fields. Find the helper and add:
```rust
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
FederationEventService {
thoughts: Arc::new(store.clone()),
users: Arc::new(store.clone()),
ap: spy,
base_url: "https://example.com".to_string(),
federation_action: Arc::new(store.clone()), // TestStore implements FederationActionPort
ap_repo: Arc::new(store.clone()), // TestStore implements ActivityPubRepository
}
}
```
- [ ] **Step 5: Write a test for `FetchRemoteActorPosts`**
In the `#[cfg(test)]` block of `federation_event.rs`, add after the existing tests:
```rust
#[tokio::test]
async fn fetch_remote_actor_posts_is_noop_when_outbox_empty() {
// TestStore.fetch_outbox_page returns Ok(vec![]) — no notes to store
let store = TestStore::default();
let spy = Arc::new(SpyPort::default());
svc(&store, spy.clone())
.process(&DomainEvent::FetchRemoteActorPosts {
actor_ap_url: "https://mastodon.social/users/alice".into(),
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
})
.await
.unwrap();
// No assertions needed — just confirm it doesn't panic or error
}
```
- [ ] **Step 6: Run tests**
```bash
cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -15
```
Expected: all existing federation_event tests pass + new test passes.
- [ ] **Step 7: Full compile + test suite**
```bash
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
```
Expected: all pass.
- [ ] **Step 8: Commit**
```bash
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 FetchRemoteActorPosts — fetch and store remote outbox notes"
```
---
## Task 7: Frontend — API + `RemoteUserProfile` component + page routing
**Files:**
- Modify: `thoughts-frontend/lib/api.ts`
- Create: `thoughts-frontend/components/remote-user-profile.tsx`
- Modify: `thoughts-frontend/app/users/[username]/page.tsx`
- [ ] **Step 1: Extend `RemoteActorSchema` and add `getRemoteActorPosts` in `api.ts`**
Read `thoughts-frontend/lib/api.ts`. Replace `RemoteActorSchema` with the enriched version:
```typescript
export const ProfileFieldSchema = z.object({
name: z.string(),
value: z.string(),
});
export type ProfileField = z.infer<typeof ProfileFieldSchema>;
export const RemoteActorSchema = z.object({
handle: z.string(),
displayName: z.string().nullable(),
avatarUrl: z.string().nullable(),
url: z.string(),
bio: z.string().nullable(),
bannerUrl: z.string().nullable(),
alsoKnownAs: z.string().nullable(),
outboxUrl: z.string().nullable(),
attachment: z.array(ProfileFieldSchema),
});
export type RemoteActor = z.infer<typeof RemoteActorSchema>;
```
After `lookupRemoteActor`, add:
```typescript
export const getRemoteActorPosts = (
handle: string,
page: number,
token: string | null
) =>
apiFetch(
`/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
{},
z.object({
total: z.number(),
page: z.number(),
per_page: z.number(),
items: z.array(ThoughtSchema),
}),
token
);
```
- [ ] **Step 2: Create `RemoteUserProfile` component**
Create `thoughts-frontend/components/remote-user-profile.tsx`:
```typescript
"use client";
import { useState } from "react";
import Link from "next/link";
import { UserAvatar } from "@/components/user-avatar";
import { ThoughtList } from "@/components/thought-list";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ExternalLink, UserPlus, UserMinus } from "lucide-react";
import { followUser, unfollowUser, RemoteActor, Thought, Me } from "@/lib/api";
import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
interface RemoteUserProfileProps {
actor: RemoteActor;
initialPosts: Thought[];
me: Me | null;
}
export function RemoteUserProfile({
actor,
initialPosts,
me,
}: RemoteUserProfileProps) {
const [followed, setFollowed] = useState(false);
const [loading, setLoading] = useState(false);
const { token } = useAuth();
const handleFollow = async () => {
if (!token) {
toast.error("You must be logged in to follow users.");
return;
}
setLoading(true);
try {
if (followed) {
await unfollowUser(actor.handle, token);
setFollowed(false);
} else {
await followUser(actor.handle, token);
setFollowed(true);
toast.success(`Follow request sent to ${actor.handle}`);
}
} catch {
toast.error(followed ? "Failed to unfollow." : "Failed to send follow request.");
} finally {
setLoading(false);
}
};
const isOwnProfile = me?.username === actor.handle;
// Build authorDetails for ThoughtList
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
initialPosts.forEach((t) => {
authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl });
});
return (
<div>
{/* Banner */}
<div
className="h-48 bg-muted bg-cover bg-center"
style={{
backgroundImage: actor.bannerUrl ? `url(${actor.bannerUrl})` : "none",
}}
/>
<main className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Left sidebar */}
<aside className="col-span-1 space-y-6">
<div className="sticky top-20 space-y-6">
<Card className="p-6 bg-card/80 backdrop-blur-lg">
<div className="flex justify-between items-start">
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
<UserAvatar
src={actor.avatarUrl}
alt={actor.displayName}
className="w-full h-full"
/>
</div>
{!isOwnProfile && token && (
<Button
onClick={handleFollow}
disabled={loading}
variant={followed ? "secondary" : "default"}
size="sm"
>
{followed ? (
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
) : (
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)}
</Button>
)}
</div>
<div className="mt-4">
<h1 className="text-2xl font-bold">
{actor.displayName ?? actor.handle}
</h1>
<p className="text-sm text-muted-foreground">{actor.handle}</p>
</div>
{actor.bio && (
<p className="mt-4 text-sm whitespace-pre-wrap">{actor.bio}</p>
)}
<Button asChild variant="outline" size="sm" className="mt-4 w-full">
<Link href={actor.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View on {new URL(actor.url).hostname}
</Link>
</Button>
{actor.alsoKnownAs && (
<p className="mt-2 text-xs text-muted-foreground">
Also known as:{" "}
<Link
href={actor.alsoKnownAs}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{actor.alsoKnownAs}
</Link>
</p>
)}
{actor.attachment.length > 0 && (
<table className="mt-4 w-full text-sm border-collapse">
<tbody>
{actor.attachment.map((field) => (
<tr key={field.name} className="border-t">
<td className="py-1 pr-2 font-medium text-muted-foreground">
{field.name}
</td>
<td
className="py-1"
dangerouslySetInnerHTML={{ __html: field.value }}
/>
</tr>
))}
</tbody>
</table>
)}
</Card>
</div>
</aside>
{/* Posts */}
<div className="col-span-1 lg:col-span-3 space-y-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>
)}
</div>
</main>
</div>
);
}
```
Note: `dangerouslySetInnerHTML` on `field.value` is needed because Mastodon returns HTML in profile field values (e.g. links). This is safe because the data comes from a trusted AP fetch, not user input.
- [ ] **Step 3: Update `app/users/[username]/page.tsx` to handle remote actors**
Read the full file. Add a handle-detection branch at the top of `ProfilePage`, before the existing promise setup:
```typescript
import {
getFollowersList,
getFollowingList,
getMe,
getTopFriends,
getUserProfile,
getUserThoughts,
lookupRemoteActor,
getRemoteActorPosts,
Me,
} from "@/lib/api";
import { RemoteUserProfile } from "@/components/remote-user-profile";
// ... existing imports unchanged
```
After `const { username } = await params;` and `const token = ...`, add the branch:
```typescript
const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;
if (HANDLE_RE.test(username)) {
const [actorResult, postsResult, meResult] = await Promise.allSettled([
lookupRemoteActor(username, token),
getRemoteActorPosts(username, 1, token),
token ? getMe(token) : Promise.resolve(null),
]);
if (actorResult.status === "rejected") {
notFound();
}
const actor = actorResult.value;
const posts =
postsResult.status === "fulfilled" ? postsResult.value.items : [];
const me =
meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;
return <RemoteUserProfile actor={actor} initialPosts={posts} me={me} />;
}
```
Place this block immediately before the existing `const userProfilePromise = ...` line. The rest of the file continues unchanged.
- [ ] **Step 4: Type-check**
```bash
cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20
```
Expected: no errors. If `ThoughtList` props don't match, check its interface and adjust.
- [ ] **Step 5: Commit**
```bash
cd /mnt/drive/dev/thoughts
git add thoughts-frontend/lib/api.ts \
thoughts-frontend/components/remote-user-profile.tsx \
thoughts-frontend/app/users/[username]/page.tsx
git commit -m "feat(frontend): remote actor profile page with bio, fields, and posts"
```
---
## Self-Review
**Spec coverage:**
-`RemoteActor` extended with bio, banner_url, also_known_as, outbox_url, attachment — Task 1 + 2
-`RemoteNote` domain model — Task 1
-`FetchRemoteActorPosts` domain event — Task 1
-`fetch_outbox_page` port method — Task 1 + 2
-`fetch_outbox_page` impl (HTTPS, Create/Note both handled, public-only filter) — Task 2
-`lookup_actor` populates new fields — Task 2
-`EventPayload::FetchRemoteActorPosts` (enum, subject, From, TryFrom, test) — Task 3
-`AppState.ap_repo` wired — Task 4
-`ProfileField` + extended `RemoteActorResponse` — Task 5
-`to_thought_response` made pub — Task 5
-`lookup_handler` updated to return new fields — Task 5
-`GET /federation/actors/{handle}/posts` endpoint — Task 5
- ✅ Worker handles `FetchRemoteActorPosts` — Task 6
- ✅ Worker factory wires new deps — Task 6
-`RemoteActorSchema` extended + `getRemoteActorPosts` — Task 7
-`RemoteUserProfile` component (banner, avatar, bio, fields, alsoKnownAs, external link, follow, posts) — Task 7
- ✅ Handle detection in profile page — Task 7
**Placeholder scan:** None found.
**Type consistency:**
- `RemoteNote { ap_id, content, published, sensitive, content_warning }` defined Task 1, used in Task 2 impl and Task 6 worker ✅
- `actor.outbox_url: Option<String>` returned by `lookup_actor` (Task 2), used in handler (Task 5) and event payload (Task 3) ✅
- `RemoteActorResponse.attachment: Vec<ProfileField>` defined Task 5, mapped from `actor.attachment: Vec<(String, String)>` in Task 2 ✅
- `FederationEventService { federation_action, ap_repo }` — new fields added Task 6 step 1, wired in factory Task 6 step 3, test helper updated Task 6 step 4 ✅
- `ap_repo: Arc<dyn ActivityPubRepository>` in `AppState` added Task 4, used in Task 5 handler, used in test `make_state()` Task 5 step 4 ✅
- `getRemoteActorPosts` returns `{ items: ThoughtSchema[] }``ThoughtSchema` already imported in `api.ts`