clean up
This commit is contained in:
@@ -1,118 +0,0 @@
|
||||
# REST API Cleanup Design
|
||||
|
||||
Clean up the REST API to be professional, consistent, and RESTful. No new features — only renames, unifications, and content negotiation.
|
||||
|
||||
## Route Changes
|
||||
|
||||
| Before | After | Reason |
|
||||
|--------|-------|--------|
|
||||
| `GET /users/{username}/profile` | `GET /users/{username}` | content negotiation replaces the /profile workaround |
|
||||
| `GET /federation/lookup?handle=` | `GET /users/lookup?handle=` | federation lookup belongs under /users |
|
||||
| `POST /users/{id}/follow` | `POST /users/{username}/follow` | param was mislabelled; now also handles remote follows |
|
||||
| `DELETE /users/{id}/follow` | `DELETE /users/{username}/follow` | param rename |
|
||||
| `POST /users/{id}/block` | `POST /users/{username}/block` | param rename |
|
||||
| `DELETE /users/{id}/block` | `DELETE /users/{username}/block` | param rename |
|
||||
| `GET /users/{username}/follower-list` | `GET /users/{username}/followers` | verbose name |
|
||||
| `GET /users/{username}/following-list` | `GET /users/{username}/following` | verbose name |
|
||||
| `GET /users/me/following-list` | `GET /users/me/following` | verbose name |
|
||||
| `POST /notifications/{id}/read` | `PATCH /notifications/{id}` | POST for state change → PATCH |
|
||||
| `POST /notifications/read-all` | `PATCH /notifications` | POST bulk action → PATCH |
|
||||
| `PUT /users/me` | removed | `PATCH /users/me` is sufficient |
|
||||
| `POST /federation/follow` | removed | unified into `POST /users/{username}/follow` |
|
||||
|
||||
## Content Negotiation at `GET /users/{username}`
|
||||
|
||||
The AP router currently owns `/users/{username}` (returns `application/activity+json`). The REST profile was at `/users/{username}/profile` as a workaround.
|
||||
|
||||
**Solution:** Remove `/users/{username}` from the AP router. Add a single handler at `GET /users/{username}` in the REST router that checks the `Accept` header:
|
||||
|
||||
- `Accept: application/activity+json` → return AP actor JSON with `Content-Type: application/activity+json`
|
||||
- Anything else → return `UserResponse` with `Content-Type: application/json`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
Add `actor_json(&self, user_id: &UserId) -> Result<String, DomainError>` to `FederationActionPort` in domain. Implement in `ActivityPubService` by delegating to the existing `self.actor_json(&user_id.as_uuid().to_string())` inherent method.
|
||||
|
||||
The unified handler in `presentation/src/handlers/users.rs`:
|
||||
1. Looks up user by username via `UserRepository` → 404 if not found
|
||||
2. Checks `Accept` header
|
||||
3. AP path: calls `s.federation.actor_json(&user.id)` → returns with `Content-Type: application/activity+json`
|
||||
4. REST path: returns `UserResponse` as before
|
||||
|
||||
The AP router in `bootstrap/src/main.rs` no longer registers `/users/{username}`.
|
||||
|
||||
## Unified Follow at `POST /users/{username}/follow`
|
||||
|
||||
The handler detects whether `{username}` is a local user or a remote actor:
|
||||
|
||||
```rust
|
||||
if username.contains('@') {
|
||||
// Remote: e.g. "gabrielkaszewski@mastodon.social"
|
||||
s.federation.follow_remote(&uid, &username).await?;
|
||||
} else {
|
||||
// Local: look up by username, call follow_user use case
|
||||
let target = get_user_by_username(&*s.users, &username).await?;
|
||||
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
||||
}
|
||||
```
|
||||
|
||||
`POST /federation/follow` and `federation::follow_remote_handler` are deleted.
|
||||
|
||||
## Remote Actor Handle Format Fix
|
||||
|
||||
`lookup_actor` currently returns `handle: actor.username` (just `preferred_username`, e.g. `gabrielkaszewski`). Fix: return the full `user@domain` handle by extracting the domain from `actor.ap_id`:
|
||||
|
||||
```rust
|
||||
let domain = actor.ap_id.host_str().unwrap_or("");
|
||||
let full_handle = format!("{}@{}", actor.username, domain);
|
||||
// RemoteActor { handle: full_handle, ... }
|
||||
```
|
||||
|
||||
This means `RemoteActorResponse.handle` = `"gabrielkaszewski@mastodon.social"`, which the frontend passes directly to `POST /users/gabrielkaszewski@mastodon.social/follow`.
|
||||
|
||||
## Remote Unfollow Scope
|
||||
|
||||
`DELETE /users/{username}/follow` for a remote handle (contains `@`) is **out of scope**. The handler returns `501 Not Implemented` when `username` contains `@`. Remote unfollow requires an `Undo Follow` ActivityPub activity and is a separate feature.
|
||||
|
||||
## Notification Endpoints
|
||||
|
||||
Add `NotificationUpdateRequest { read: bool }` to `api-types/src/requests.rs`.
|
||||
|
||||
- `PATCH /notifications/{id}` — mark single notification read (body: `{"read": true}`)
|
||||
- `PATCH /notifications` — mark all notifications read (body: `{"read": true}`)
|
||||
|
||||
Both replace their existing `POST` counterparts.
|
||||
|
||||
## Frontend (`thoughts-frontend/lib/api.ts`)
|
||||
|
||||
| Function | Change |
|
||||
|----------|--------|
|
||||
| `getUserProfile(username)` | URL: `/users/${username}/profile` → `/users/${username}` |
|
||||
| `getFollowersList(username)` | URL: `/follower-list` → `/followers` |
|
||||
| `getFollowingList(username)` | URL: `/following-list` → `/following` |
|
||||
| `getMeFollowingList()` | URL: `/me/following-list` → `/me/following` |
|
||||
| `lookupRemoteActor(handle)` | URL: `/federation/lookup?handle=` → `/users/lookup?handle=` |
|
||||
| `followRemoteUser(handle)` | **Deleted** — use unified `followUser(handle)` instead |
|
||||
| `markNotificationRead(id)` | **New** — `PATCH /notifications/{id}` with body `{"read":true}` (no prior frontend impl) |
|
||||
| `markAllNotificationsRead()` | **New** — `PATCH /notifications` with body `{"read":true}` (no prior frontend impl) |
|
||||
|
||||
Also update `remote-user-card.tsx` to call `followUser(actor.handle, token)` instead of `followRemoteUser`.
|
||||
|
||||
## Files Touched
|
||||
|
||||
**Backend:**
|
||||
- `crates/domain/src/ports.rs` — add `actor_json` to `FederationActionPort`
|
||||
- `crates/domain/src/testing.rs` — add `actor_json` to `TestStore` impl
|
||||
- `crates/adapters/activitypub-base/src/service.rs` — add `actor_json` to `FederationActionPort` impl; fix `lookup_actor` handle format
|
||||
- `crates/presentation/src/handlers/users.rs` — unified `GET /users/{username}` handler; remove old `get_user` (was /profile)
|
||||
- `crates/presentation/src/handlers/social.rs` — unify `post_follow`; rename `{id}` → `{username}` in follow/block; rename follower/following list handlers
|
||||
- `crates/presentation/src/handlers/federation.rs` — delete `follow_remote_handler`; move `lookup_handler` to `users.rs`; delete file if empty
|
||||
- `crates/presentation/src/handlers/notifications.rs` — replace read handlers with PATCH
|
||||
- `crates/presentation/src/routes.rs` — all route changes
|
||||
- `crates/api-types/src/requests.rs` — add `NotificationUpdateRequest`
|
||||
- `crates/bootstrap/src/main.rs` — remove `/users/{username}` from ap_router
|
||||
|
||||
**Frontend:**
|
||||
- `thoughts-frontend/lib/api.ts` — all URL/method changes listed above
|
||||
- `thoughts-frontend/components/remote-user-card.tsx` — use `followUser` instead of `followRemoteUser`
|
||||
- Any page that calls `getFollowersList`, `getFollowingList`, `getMeFollowingList`, `markNotificationRead`, `markAllNotificationsRead` (check all pages under `app/`)
|
||||
@@ -1,300 +0,0 @@
|
||||
# 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
|
||||
@@ -1,81 +0,0 @@
|
||||
# Remote Actor Search & Follow
|
||||
|
||||
Allows local users to search for and follow users on other ActivityPub instances (e.g. `@user@mastodon.social`) directly from the existing search page.
|
||||
|
||||
## Architecture
|
||||
|
||||
Approach A: new `FederationActionPort` domain trait + dedicated `/federation/*` REST endpoints. Keeps hexagonal arch intact — presentation has no dep on `activitypub-base`.
|
||||
|
||||
## Domain changes
|
||||
|
||||
**`domain/src/models/remote_actor.rs`** — add `avatar_url: Option<String>`
|
||||
|
||||
**`domain/src/errors.rs`** — add `ExternalService(String)` variant
|
||||
|
||||
**`domain/src/ports.rs`** — new trait:
|
||||
|
||||
```rust
|
||||
pub trait FederationActionPort: Send + Sync {
|
||||
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
||||
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
```
|
||||
|
||||
## activitypub-base impl
|
||||
|
||||
`impl domain::ports::FederationActionPort for ActivityPubService` in `service.rs`:
|
||||
|
||||
- `lookup_actor`: calls `webfinger_resolve_actor(handle, &data)` → maps `DbActor` to `domain::RemoteActor`
|
||||
- `follow_remote`: delegates to existing `self.follow(local_user_id.inner(), handle)` (already handles WebFinger + Follow activity + federation DB record)
|
||||
|
||||
## Bootstrap refactor
|
||||
|
||||
`factory.rs` currently builds `FederationData` + `ApFederationConfig` directly. Switch to `ActivityPubService::new(...)` which creates both internally. `Infrastructure` holds `Arc<ActivityPubService>` instead of `ApFederationConfig`. `main.rs` uses `infra.ap_service.federation_config().middleware()`.
|
||||
|
||||
`AppState` gets one new field:
|
||||
|
||||
```rust
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
```
|
||||
|
||||
Wired to `Arc::clone(&ap_service)` in factory.
|
||||
|
||||
## REST endpoints
|
||||
|
||||
**`api-types/src/responses.rs`** — new:
|
||||
```rust
|
||||
pub struct RemoteActorResponse {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub url: String,
|
||||
}
|
||||
```
|
||||
|
||||
**`presentation/src/handlers/federation.rs`** (new file):
|
||||
|
||||
| Method | Path | Auth | Body | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/federation/lookup?handle=@user@instance.tld` | none | — | `RemoteActorResponse` |
|
||||
| POST | `/federation/follow` | bearer | `{"handle":"@user@instance.tld"}` | 204 |
|
||||
|
||||
Mounted in `routes.rs` under `/federation`.
|
||||
|
||||
Error mapping: `DomainError::ExternalService` → 502, `DomainError::NotFound` → 404.
|
||||
|
||||
## Frontend
|
||||
|
||||
**`lib/api.ts`**:
|
||||
- `RemoteActorSchema` + `RemoteActor` type
|
||||
- `lookupRemoteActor(handle, token)` → `GET /federation/lookup?handle=...`
|
||||
- `followRemoteUser(handle, token)` → `POST /federation/follow`
|
||||
|
||||
**`app/search/page.tsx`**:
|
||||
- Detect `@user@instance.tld` via regex `/^@[\w.-]+@[\w.-]+\.\w+$/`
|
||||
- If matches: call `lookupRemoteActor` in parallel with local search
|
||||
- Pass remote actor result to component; show in Users tab above local results
|
||||
|
||||
**`components/remote-user-card.tsx`** (new client component):
|
||||
- Displays avatar, handle, display name
|
||||
- Follow button calls `followRemoteUser(handle, token)`
|
||||
- No unfollow needed for MVP (remote following status not tracked locally)
|
||||
@@ -1,285 +0,0 @@
|
||||
# Thoughts v2 — Architecture Rewrite Design
|
||||
|
||||
## Context
|
||||
|
||||
Thoughts is a federated social web service currently running on a monolithic axum + Sea-ORM backend with no domain layer, no traits, and tightly coupled persistence. v2 is a full rewrite targeting:
|
||||
|
||||
- Hexagonal architecture (ports & adapters, zero leakage between layers)
|
||||
- Full bidirectional ActivityPub federation (Mastodon-compatible Fediverse citizen)
|
||||
- sqlx with raw SQL — no ORM
|
||||
- Postgres only (for now), but no coupling to any concrete adapter
|
||||
- Crate structure mirroring movies-diary (the reference implementation)
|
||||
- Production data must survive cutover via additive migrations
|
||||
|
||||
---
|
||||
|
||||
## Crate Structure
|
||||
|
||||
```
|
||||
crates/
|
||||
domain/ # entities, value objects, ports (traits), domain events
|
||||
application/ # use cases (commands + queries), no framework deps
|
||||
api-types/ # request/response DTOs, shared serializable types
|
||||
presentation/ # axum handlers, routes, extractors, state, openapi — JSON REST only, no HTML rendering (client is Next.js)
|
||||
worker/ # event consumer loop, dispatches to event handlers
|
||||
adapters/
|
||||
postgres/ # sqlx impls of all repos + migrations/
|
||||
postgres-search/ # SearchPort via pg_trgm / tsvector
|
||||
postgres-federation/ # federation-specific queries (known actors, etc.)
|
||||
activitypub-base/ # copied from movies-diary — signing, WebFinger, NodeInfo
|
||||
activitypub/ # thoughts-specific AP objects (Note, Person) + activity handlers
|
||||
auth/ # JWT AuthService impl
|
||||
nats/ # EventPublisher + EventConsumer via NATS
|
||||
event-payload/ # serializable event envelope types (NATS wire format)
|
||||
event-publisher/ # event routing — domain events → NATS subjects
|
||||
```
|
||||
|
||||
**Dependency rule:** `domain` has zero external deps. `application` depends only on `domain`. All adapters depend on `domain` traits only — never on each other. `presentation` and `worker` wire concrete adapters into `Arc<dyn Port>` and inject via state. `presentation` never imports from `postgres` directly.
|
||||
|
||||
---
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Entities & Value Objects
|
||||
|
||||
```
|
||||
User — UserId, Username, Email, PasswordHash, DisplayName, Bio,
|
||||
AvatarUrl, HeaderUrl, local: bool, ap_id: Url,
|
||||
public_key: String, private_key: Option<String> (None for remote)
|
||||
|
||||
Thought — ThoughtId, UserId, Content (≤128 chars local / unlimited remote),
|
||||
in_reply_to: Option<ThoughtId | RemoteUrl>, ap_id: Url,
|
||||
visibility: Public|Followers|Unlisted|Direct,
|
||||
content_warning: Option<String>, sensitive: bool, local: bool
|
||||
|
||||
Like — LikeId, UserId, ThoughtId, ap_id: Url
|
||||
Boost — BoostId, UserId, ThoughtId, ap_id: Url
|
||||
Follow — FollowerId, FollowingId, state: Pending|Accepted|Rejected, ap_id: Url
|
||||
Block — BlockerId, BlockedId
|
||||
Tag — TagId, name
|
||||
ApiKey — ApiKeyId, UserId, key_hash, name
|
||||
TopFriend — UserId, FriendId, position (1–8)
|
||||
RemoteActor — url, handle, display_name, inbox_url, shared_inbox_url, public_key
|
||||
```
|
||||
|
||||
### Ports (traits in domain, implemented by adapters)
|
||||
|
||||
`UserRepository`, `ThoughtRepository`, `LikeRepository`, `BoostRepository`,
|
||||
`FollowRepository`, `BlockRepository`, `TagRepository`, `ApiKeyRepository`,
|
||||
`TopFriendRepository`, `RemoteActorRepository`, `AuthService`, `PasswordHasher`,
|
||||
`EventPublisher`, `EventConsumer`, `SearchPort`, `SearchCommand`
|
||||
|
||||
### Domain Events
|
||||
|
||||
Published after mutations, consumed by worker for federation and side-effects:
|
||||
|
||||
`ThoughtCreated`, `ThoughtDeleted`, `ThoughtUpdated`,
|
||||
`LikeAdded`, `LikeRemoved`,
|
||||
`BoostAdded`, `BoostRemoved`,
|
||||
`FollowRequested`, `FollowAccepted`, `FollowRejected`, `Unfollowed`,
|
||||
`UserBlocked`
|
||||
|
||||
---
|
||||
|
||||
## Application Layer (Use Cases)
|
||||
|
||||
Each use case lives in `application/src/use_cases/` and receives only `&dyn Port` references — no framework types, no sqlx, no axum. Fully testable with mock impls.
|
||||
|
||||
**Commands** (mutate state, publish domain event):
|
||||
```
|
||||
register, login
|
||||
create_thought, delete_thought, edit_thought
|
||||
create_reply, delete_reply
|
||||
like_thought, unlike_thought
|
||||
boost_thought, unboost_thought
|
||||
follow_user, unfollow_user, accept_follow, reject_follow
|
||||
block_user, unblock_user
|
||||
update_profile, update_top_friends
|
||||
create_api_key, delete_api_key
|
||||
handle_inbox ← processes incoming AP activities from remote instances
|
||||
```
|
||||
|
||||
**Queries** (read-only, no events):
|
||||
```
|
||||
get_thought, get_thread ← thought + its reply tree
|
||||
get_home_feed ← thoughts from followed users (local + remote)
|
||||
get_public_feed ← all local public thoughts
|
||||
get_user_feed ← one user's public thoughts
|
||||
get_profile, get_top_friends
|
||||
get_followers, get_following
|
||||
list_api_keys
|
||||
search
|
||||
get_by_tag
|
||||
get_notifications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Federation & ActivityPub
|
||||
|
||||
`activitypub-base/` (copied verbatim from movies-diary) handles: HTTP signatures, WebFinger, NodeInfo, generic actor/inbox/outbox/followers HTTP handlers, remote actor fetching.
|
||||
|
||||
`activitypub/` wires `activitypub-base` to the thoughts domain.
|
||||
|
||||
### Outbound (worker: domain event → AP activity → remote inboxes)
|
||||
|
||||
| Domain Event | AP Activity | Destination |
|
||||
|------------------|---------------------|--------------------------|
|
||||
| ThoughtCreated | Create(Note) | followers' inboxes |
|
||||
| ThoughtDeleted | Delete(Note) | followers' inboxes |
|
||||
| ThoughtUpdated | Update(Note) | followers' inboxes |
|
||||
| LikeAdded | Like | thought author's inbox |
|
||||
| LikeRemoved | Undo(Like) | thought author's inbox |
|
||||
| BoostAdded | Announce | followers' inboxes |
|
||||
| BoostRemoved | Undo(Announce) | followers' inboxes |
|
||||
| FollowRequested | Follow | target's inbox |
|
||||
| FollowAccepted | Accept(Follow) | requester's inbox |
|
||||
| FollowRejected | Reject(Follow) | requester's inbox |
|
||||
| Unfollowed | Undo(Follow) | target's inbox |
|
||||
| UserBlocked | Block | blocked user's inbox |
|
||||
|
||||
### Inbound (`handle_inbox` use case)
|
||||
|
||||
| Incoming Activity | Use Case invoked |
|
||||
|-------------------|----------------------------|
|
||||
| Create(Note) | create_thought (remote) |
|
||||
| Delete | delete_thought (remote) |
|
||||
| Update(Note) | edit_thought (remote) |
|
||||
| Like | like_thought (remote) |
|
||||
| Undo(Like) | unlike_thought (remote) |
|
||||
| Announce | boost_thought (remote) |
|
||||
| Undo(Announce) | unboost_thought (remote) |
|
||||
| Follow | follow_user → auto-accept (public accounts) / pending (locked accounts) |
|
||||
| Accept(Follow) | accept_follow |
|
||||
| Reject(Follow) | reject_follow |
|
||||
| Undo(Follow) | unfollow_user |
|
||||
| Block | block_user (remote) |
|
||||
|
||||
### AP Endpoints (in presentation/)
|
||||
|
||||
```
|
||||
GET /.well-known/webfinger
|
||||
GET /.well-known/nodeinfo
|
||||
GET /nodeinfo/2.0
|
||||
GET /users/:username ← Actor object
|
||||
GET /users/:username/inbox
|
||||
POST /users/:username/inbox ← receives remote activities
|
||||
GET /users/:username/outbox
|
||||
GET /users/:username/followers
|
||||
GET /users/:username/following
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema & Migration Strategy
|
||||
|
||||
### Remote thought caching
|
||||
|
||||
`likes` and `boosts` reference `thought_id UUID REFERENCES thoughts(id)`. When a local user likes or boosts a remote thought, the remote Note is first fetched and cached as a row in `thoughts` with `local = false`. This keeps referential integrity and allows rendering liked/boosted remote content without additional AP lookups.
|
||||
|
||||
### Migration approach
|
||||
|
||||
sqlx `migrations/` in `adapters/postgres/`. First migration recreates existing schema in sqlx format (matching production exactly, preserving all UUIDs). Subsequent migrations are additive only — no destructive changes.
|
||||
|
||||
### Additive changes to existing tables
|
||||
|
||||
```sql
|
||||
-- users: federation
|
||||
ALTER TABLE users ADD COLUMN ap_id TEXT UNIQUE;
|
||||
ALTER TABLE users ADD COLUMN inbox_url TEXT;
|
||||
ALTER TABLE users ADD COLUMN public_key TEXT;
|
||||
ALTER TABLE users ADD COLUMN private_key TEXT; -- NULL for remote users
|
||||
ALTER TABLE users ADD COLUMN local BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- thoughts: replies + AP + visibility
|
||||
ALTER TABLE thoughts ADD COLUMN in_reply_to_id UUID REFERENCES thoughts(id);
|
||||
ALTER TABLE thoughts ADD COLUMN in_reply_to_url TEXT; -- remote parent
|
||||
ALTER TABLE thoughts ADD COLUMN ap_id TEXT UNIQUE;
|
||||
ALTER TABLE thoughts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public';
|
||||
ALTER TABLE thoughts ADD COLUMN content_warning TEXT;
|
||||
ALTER TABLE thoughts ADD COLUMN sensitive BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE thoughts ADD COLUMN local BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE thoughts ADD COLUMN updated_at TIMESTAMPTZ;
|
||||
|
||||
-- follows: pending state + AP id
|
||||
ALTER TABLE follows ADD COLUMN state TEXT NOT NULL DEFAULT 'accepted';
|
||||
ALTER TABLE follows ADD COLUMN ap_id TEXT;
|
||||
ALTER TABLE follows ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
```
|
||||
|
||||
### New tables
|
||||
|
||||
```sql
|
||||
CREATE TABLE likes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
thought_id UUID NOT NULL REFERENCES thoughts(id),
|
||||
ap_id TEXT UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, thought_id)
|
||||
);
|
||||
|
||||
CREATE TABLE boosts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
thought_id UUID NOT NULL REFERENCES thoughts(id),
|
||||
ap_id TEXT UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, thought_id)
|
||||
);
|
||||
|
||||
CREATE TABLE blocks (
|
||||
blocker_id UUID NOT NULL REFERENCES users(id),
|
||||
blocked_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (blocker_id, blocked_id)
|
||||
);
|
||||
|
||||
CREATE TABLE remote_actors (
|
||||
url TEXT PRIMARY KEY,
|
||||
handle TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
inbox_url TEXT NOT NULL,
|
||||
shared_inbox_url TEXT,
|
||||
public_key TEXT NOT NULL,
|
||||
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
type TEXT NOT NULL, -- 'like','boost','follow','mention','reply'
|
||||
from_user_id UUID REFERENCES users(id),
|
||||
thought_id UUID REFERENCES thoughts(id),
|
||||
read BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event System & Worker
|
||||
|
||||
### event-payload/
|
||||
Serializable wire types for NATS. Mirror of domain events with all fields as primitives (UUIDs as strings). `serde::Serialize/Deserialize`. No domain dependency.
|
||||
|
||||
### event-publisher/
|
||||
Receives `DomainEvent`, serializes to event-payload, routes to NATS subject (e.g. `thoughts.created`, `likes.added`). Implements domain's `EventPublisher` trait.
|
||||
|
||||
### nats/
|
||||
Wraps `async-nats`. Implements `EventPublisher` (publish to subject) and `EventConsumer` (subscribe, yields `EventEnvelope` stream with ack/nack handles).
|
||||
|
||||
### worker/ (binary)
|
||||
```
|
||||
EventConsumer::consume()
|
||||
→ deserialize EventEnvelope
|
||||
→ match event type → dispatch to EventHandler impl
|
||||
→ ack on success, nack on failure (NATS redelivers)
|
||||
|
||||
Handlers:
|
||||
FederationHandler ← domain events → AP activities → remote inboxes
|
||||
NotificationHandler ← writes notifications on like/boost/follow/mention/reply
|
||||
SearchIndexHandler ← indexes/removes documents on create/delete
|
||||
```
|
||||
|
||||
Handlers are plain structs taking `Arc<dyn Port>` — no NATS coupling inside them. Worker `main.rs` wires everything together.
|
||||
@@ -1,213 +0,0 @@
|
||||
# Remote Actor Connections (Followers/Following) Design
|
||||
|
||||
Display a remote actor's followers and following lists in the thoughts UI, with worker-backed caching and concurrent AP profile resolution.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User opens the Followers or Following tab on a remote actor profile
|
||||
2. Frontend calls `GET /federation/actors/{handle}/followers-list?page=1`
|
||||
3. Backend returns cached data immediately (may be empty on first visit)
|
||||
4. If cache is empty OR older than 1 hour: publish `FetchActorConnections` event fire-and-forget
|
||||
5. Worker receives event → fetches remote collection page → concurrently resolves each actor URL to a profile → stores results
|
||||
6. Next visit / tab re-open shows populated data
|
||||
|
||||
## Domain Changes
|
||||
|
||||
### New models (`domain/src/models/`)
|
||||
|
||||
**`connection_type.rs`**:
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConnectionType {
|
||||
Followers,
|
||||
Following,
|
||||
}
|
||||
|
||||
impl ConnectionType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self { Self::Followers => "followers", Self::Following => "following" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`actor_connection_summary.rs`**:
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActorConnectionSummary {
|
||||
pub url: String, // AP URL of the connected actor
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### New `DomainEvent` variant (`domain/src/events.rs`)
|
||||
|
||||
```rust
|
||||
FetchActorConnections {
|
||||
actor_ap_url: String,
|
||||
collection_url: String,
|
||||
connection_type: String, // "followers" | "following"
|
||||
page: u32,
|
||||
},
|
||||
```
|
||||
|
||||
### New port (`domain/src/ports.rs`)
|
||||
|
||||
```rust
|
||||
pub trait RemoteActorConnectionRepository: Send + Sync {
|
||||
async fn upsert_connections(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
actors: &[ActorConnectionSummary],
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn list_connections(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<ActorConnectionSummary>, DomainError>;
|
||||
|
||||
async fn connection_page_age(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
|
||||
}
|
||||
```
|
||||
|
||||
### New `FederationActionPort` method
|
||||
|
||||
```rust
|
||||
async fn resolve_actor_profiles(
|
||||
&self,
|
||||
urls: Vec<String>,
|
||||
) -> Vec<ActorConnectionSummary>;
|
||||
```
|
||||
|
||||
Returns only successful resolutions. Per-actor timeout: 5 seconds. Concurrent. No error propagation — failures are silently skipped (warn logged).
|
||||
|
||||
## Storage
|
||||
|
||||
### Migration: `006_remote_actor_connections.sql`
|
||||
|
||||
```sql
|
||||
CREATE TABLE remote_actor_connections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_url TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL,
|
||||
page INT NOT NULL,
|
||||
connected_actor_url TEXT NOT NULL,
|
||||
connected_handle TEXT NOT NULL,
|
||||
connected_display_name TEXT,
|
||||
connected_avatar_url TEXT,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(actor_url, connection_type, page, connected_actor_url)
|
||||
);
|
||||
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);
|
||||
```
|
||||
|
||||
### `PgRemoteActorConnectionRepository`
|
||||
|
||||
- `upsert_connections`: `INSERT ... ON CONFLICT DO UPDATE SET connected_handle=EXCLUDED.connected_handle, connected_display_name=EXCLUDED.connected_display_name, connected_avatar_url=EXCLUDED.connected_avatar_url, fetched_at=NOW()`
|
||||
- `list_connections`: `SELECT * WHERE actor_url=$1 AND connection_type=$2 AND page=$3 ORDER BY connected_handle`
|
||||
- `connection_page_age`: `SELECT MAX(fetched_at) WHERE actor_url=$1 AND connection_type=$2 AND page=$3`
|
||||
|
||||
## activitypub-base: `resolve_actor_profiles`
|
||||
|
||||
`ActivityPubService` implements `FederationActionPort::resolve_actor_profiles`:
|
||||
|
||||
1. For each URL: spawn `tokio::time::timeout(5s, fetch_actor_profile(url))`
|
||||
2. `fetch_actor_profile`: `GET {url}` with `Accept: application/activity+json` → parse `preferred_username`, `name`, `icon.url`, `id`
|
||||
3. Collect `Ok` results → return as `Vec<ActorConnectionSummary>`
|
||||
4. Failed/timed-out actors: `tracing::warn!` and skip
|
||||
|
||||
## event-payload
|
||||
|
||||
Add `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }` to `EventPayload` — subject: `"federation.fetch_actor_connections"`. Add to `From<&DomainEvent>`, `TryFrom<EventPayload>`, and uniqueness test.
|
||||
|
||||
## Worker
|
||||
|
||||
`FederationEventService` gains `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>`.
|
||||
|
||||
Handler for `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }`:
|
||||
|
||||
1. Fetch `collection_url` (as AP JSON) → extract `orderedItems` array as Vec of URL strings
|
||||
2. If empty: return Ok(()) — nothing to store
|
||||
3. `federation_action.resolve_actor_profiles(urls).await` — concurrent, partial success OK
|
||||
4. `remote_actor_connections.upsert_connections(actor_ap_url, connection_type, page, &results).await`
|
||||
5. Log: `tracing::info!(count = results.len(), "actor connections cached")`
|
||||
|
||||
Wire `remote_actor_connections` in `worker/src/factory.rs`.
|
||||
|
||||
## AppState + Bootstrap
|
||||
|
||||
Add `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>` to `AppState`. Wire `PgRemoteActorConnectionRepository` in `bootstrap/src/factory.rs`.
|
||||
|
||||
## REST Endpoints
|
||||
|
||||
**`GET /federation/actors/{handle}/followers-list?page=1`**
|
||||
|
||||
```
|
||||
1. lookup_actor(handle) → get actor_ap_url + followers_url
|
||||
2. list_connections(actor_ap_url, "followers", page) → cached items
|
||||
3. connection_page_age(...) → if None or > 1 hour: publish FetchActorConnections (fire-and-forget)
|
||||
4. Return { items: [...], page, has_more: items.len() == PAGE_SIZE }
|
||||
```
|
||||
|
||||
`PAGE_SIZE = 20`. `has_more` tells the frontend whether to show a "next" button.
|
||||
|
||||
**`GET /federation/actors/{handle}/following-list?page=1`** — identical, uses `following_url` and `"following"`.
|
||||
|
||||
Response item shape (reuses `RemoteActorResponse` minus `bio`/`banner`/`attachment`/`outbox_url`):
|
||||
```json
|
||||
{ "handle": "...", "displayName": "...", "avatarUrl": "...", "url": "..." }
|
||||
```
|
||||
|
||||
Define as a new `ActorConnectionResponse` in api-types.
|
||||
|
||||
Mount both routes in `routes.rs`. Add new handler file `federation_actors.rs` (already exists — add to it).
|
||||
|
||||
## Frontend
|
||||
|
||||
### `lib/api.ts`
|
||||
|
||||
```typescript
|
||||
export const ActorConnectionSchema = z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
url: z.string(),
|
||||
});
|
||||
export type ActorConnection = z.infer<typeof ActorConnectionSchema>;
|
||||
|
||||
const ConnectionPageSchema = z.object({
|
||||
items: z.array(ActorConnectionSchema),
|
||||
page: z.number(),
|
||||
hasMore: z.boolean(),
|
||||
});
|
||||
|
||||
export const getActorFollowers = (handle, page, token) =>
|
||||
apiFetch(`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, {}, ConnectionPageSchema, token);
|
||||
|
||||
export const getActorFollowing = (handle, page, token) =>
|
||||
apiFetch(`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, {}, ConnectionPageSchema, token);
|
||||
```
|
||||
|
||||
### `RemoteUserProfile` changes
|
||||
|
||||
Replace the plain "Followers / Following" link section with two client-side tabs. Each tab:
|
||||
- Shows a list of `RemoteUserCard` components (reuse existing)
|
||||
- "Load more" button if `hasMore`
|
||||
- Empty state: "Loading — check back soon."
|
||||
- Tab is lazy: only fetches when first opened (not on profile load)
|
||||
|
||||
Use the existing `RemoteUserCard` component — it already handles follow button and linking.
|
||||
|
||||
### `remote-user-profile.tsx` note
|
||||
|
||||
The component is already a client component (`"use client"`), so React state for tab selection and paginated data works fine. Each tab fetches via `getActorFollowers`/`getActorFollowing` when first activated.
|
||||
Reference in New Issue
Block a user