301 lines
9.3 KiB
Markdown
301 lines
9.3 KiB
Markdown
# 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(¬e.ap_id) {
|
|
Ok(u) => u,
|
|
Err(_) => continue,
|
|
};
|
|
// accept_note is idempotent — ignore duplicate errors
|
|
let _ = self.ap_repo.accept_note(
|
|
&ap_id, &author_id, ¬e.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
|