docs: remote actor profile design spec

This commit is contained in:
2026-05-14 21:58:09 +02:00
parent ed6996e350
commit 2e64e196b5

View File

@@ -0,0 +1,300 @@
# Remote Actor Profile Design
Display full profiles for remote ActivityPub actors: metadata (avatar, bio, banner, profile fields) plus their public posts, fetched in the background via the NATS worker.
## Data Flow
1. User navigates to `/users/@gabrielkaszewski@mastodon.social`
2. Frontend detects `@user@domain` format, calls in parallel:
- `GET /users/lookup?handle=@user@instance` → enriched profile metadata
- `GET /federation/actors/{handle}/posts?page=1` → cached posts (empty on first visit)
3. Posts endpoint: looks up interned local `UserId`, queries `feed.user_feed`, **then** publishes `DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url }` fire-and-forget
4. Worker receives event → fetches remote outbox page via HTTP → stores public notes via `ap_repo.accept_note`
5. On next visit/refresh posts are populated
## Domain Changes
### Extend `domain/src/models/remote_actor.rs`
Add fields:
```rust
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>,
// new:
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)>, // (name, value)
}
```
### New `domain/src/models/remote_note.rs`
```rust
pub struct RemoteNote {
pub ap_id: String,
pub content: String,
pub published: chrono::DateTime<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
}
```
### New `DomainEvent` variant (`domain/src/events.rs`)
```rust
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
}
```
### New `FederationActionPort` method (`domain/src/ports.rs`)
```rust
async fn fetch_outbox_page(
&self,
outbox_url: &str,
page: u32,
) -> Result<Vec<RemoteNote>, DomainError>;
```
`TestStore` stub returns `Ok(vec![])`.
## activitypub-base Implementation
### `lookup_actor` — populate new `RemoteActor` fields
Map from `DbActor`:
```rust
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(),
```
### `fetch_outbox_page` impl on `ActivityPubService`
```rust
async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<RemoteNote>, DomainError> {
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| DomainError::ExternalService(e.to_string()))?
.json().await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let items = resp["orderedItems"].as_array().cloned().unwrap_or_default();
Ok(items.iter().filter_map(|item| {
// Items are Create activities 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; }
Some(RemoteNote {
ap_id: note["id"].as_str()?.to_string(),
content: note["content"].as_str().unwrap_or("").to_string(),
published: chrono::DateTime::parse_from_rfc3339(
note["published"].as_str()?
).ok()?.with_timezone(&chrono::Utc),
sensitive: note["sensitive"].as_bool().unwrap_or(false),
content_warning: note["summary"].as_str().map(|s| s.to_string()),
})
}).collect())
}
```
## AppState + Bootstrap
Add `ap_repo: Arc<dyn ActivityPubRepository>` to `presentation/src/state.rs`.
Wire in `bootstrap/src/factory.rs`:
```rust
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
```
## event-payload
Add to `EventPayload` enum:
```rust
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
}
```
Add subject (`"fetch_remote_actor_posts"`), mapping from/to `DomainEvent`, and a sample in the uniqueness test.
## REST Endpoint
**`GET /federation/actors/{handle}/posts?page=1`** (new handler in `presentation/src/handlers/federation_actors.rs`):
```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
let page = PageParams { page: q.page(), per_page: q.per_page() };
let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?;
// Trigger background fetch (fire and forget)
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<_>>(),
})))
}
```
Mount at `GET /federation/actors/{handle}/posts` in `routes.rs`.
Add `pub mod federation_actors;` to `handlers/mod.rs`.
Make `to_thought_response` in `feed.rs` `pub` so `federation_actors.rs` can import it.
## api-types
Extend `RemoteActorResponse`:
```rust
pub struct RemoteActorResponse {
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub url: String,
// new:
pub bio: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub outbox_url: Option<String>,
pub attachment: Vec<ProfileField>,
}
pub struct ProfileField {
pub name: String,
pub value: String,
}
```
Update `lookup_handler` in `users.rs` to populate all new fields.
## Worker
### `FederationEventService` new deps
Add `federation: Arc<dyn FederationActionPort>` and `ap_repo: Arc<dyn ActivityPubRepository>` to `FederationEventService`. Handle the new event:
```rust
DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } => {
let notes = match self.federation.fetch_outbox_page(outbox_url, 1).await {
Ok(n) => n,
Err(e) => { tracing::warn!("failed to fetch outbox: {e}"); 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 — ignore duplicate errors
let _ = self.ap_repo.accept_note(
&ap_id, &author_id, &note.content, note.published,
note.sensitive, note.content_warning, "public",
).await;
}
Ok(())
}
```
Wire new deps in `worker/src/factory.rs`.
## Frontend
### `lib/api.ts`
```typescript
// Enriched RemoteActorSchema (same endpoint, more fields)
export const ProfileFieldSchema = z.object({
name: z.string(),
value: z.string(),
});
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 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
);
```
### `app/users/[username]/page.tsx`
Detect `@user@domain` regex. If handle: call `lookupRemoteActor` + `getRemoteActorPosts` in parallel; render `<RemoteUserProfile>`. Otherwise: existing local profile.
### New `components/remote-user-profile.tsx`
Client component showing:
- Banner (`bannerUrl`) — full-width image or placeholder
- Avatar + display name + handle (`@user@instance`)
- Bio (rendered as text)
- Profile fields (`attachment`) — key-value table
- "Also known as" link (if present)
- External profile link button → `url` in new tab
- Follow button (reuse `followUser(handle, token)`)
- Posts list using `ThoughtList` or similar, with empty state "Posts are loading, check back soon"
- Pagination controls