1289 lines
40 KiB
Markdown
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(¬e.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,
|
|
¬e.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` ✅
|