1204 lines
34 KiB
Markdown
1204 lines
34 KiB
Markdown
# Federation Management 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:** Let users see and manage their incoming remote follow requests, accepted remote followers, and remote following — surfaced via a shared `<FederationPanel>` component used in settings and the profile page.
|
|
|
|
**Architecture:** Six new methods added to `FederationActionPort` (domain port), delegated to existing `ActivityPubService` logic. Six application-layer use cases wrap the port — no logic in handlers. Four frontend components (`PendingRequests`, `RemoteFollowers`, `RemoteFollowing`, `FederationPanel`) share one data-fetching pattern.
|
|
|
|
**Tech Stack:** Rust / axum / async-trait / domain ports (backend), Next.js 15 / TypeScript / Zod / shadcn Tabs (frontend).
|
|
|
|
---
|
|
|
|
## Files
|
|
|
|
| Action | Path | Purpose |
|
|
|--------|------|---------|
|
|
| Modify | `crates/domain/src/ports.rs` | Add 6 methods to `FederationActionPort` |
|
|
| Modify | `crates/domain/src/testing.rs` | Add no-op impls on `TestStore` |
|
|
| Modify | `crates/adapters/activitypub-base/src/service.rs` | Implement the 6 new port methods |
|
|
| Create | `crates/application/src/use_cases/federation_management.rs` | 6 use case functions |
|
|
| Modify | `crates/application/src/use_cases/mod.rs` | Expose new module |
|
|
| Create | `crates/presentation/src/handlers/federation_management.rs` | 6 HTTP handlers |
|
|
| Modify | `crates/presentation/src/handlers/mod.rs` | Expose new handler module |
|
|
| Modify | `crates/presentation/src/routes.rs` | Register 6 new routes |
|
|
| Modify | `thoughts-frontend/lib/api.ts` | 6 new API functions + schema |
|
|
| Create | `thoughts-frontend/components/federation/pending-requests.tsx` | Accept/reject pending follows |
|
|
| Create | `thoughts-frontend/components/federation/remote-followers.tsx` | View/remove accepted followers |
|
|
| Create | `thoughts-frontend/components/federation/remote-following.tsx` | View/unfollow remote following |
|
|
| Create | `thoughts-frontend/components/federation/federation-panel.tsx` | Tabbed wrapper |
|
|
| Create | `thoughts-frontend/app/settings/federation/page.tsx` | Settings page |
|
|
| Modify | `thoughts-frontend/app/settings/layout.tsx` | Add "Federation" nav item |
|
|
| Modify | `thoughts-frontend/app/users/[username]/page.tsx` | Add "Federation" tab on own profile |
|
|
|
|
---
|
|
|
|
## Task 1: Extend FederationActionPort with management methods
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/ports.rs`
|
|
- Modify: `crates/domain/src/testing.rs`
|
|
|
|
- [ ] **Step 1: Add 6 methods to `FederationActionPort` in `crates/domain/src/ports.rs`**
|
|
|
|
Find the `FederationActionPort` trait (around line 223). Add these six methods after `unfollow_remote`:
|
|
|
|
```rust
|
|
async fn get_pending_followers(
|
|
&self,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError>;
|
|
|
|
async fn accept_follow_request(
|
|
&self,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError>;
|
|
|
|
async fn reject_follow_request(
|
|
&self,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError>;
|
|
|
|
async fn get_remote_followers(
|
|
&self,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError>;
|
|
|
|
async fn remove_remote_follower(
|
|
&self,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError>;
|
|
|
|
async fn get_remote_following(
|
|
&self,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError>;
|
|
```
|
|
|
|
`RemoteActor` here is `crate::models::remote_actor::RemoteActor` — already in scope via the existing import.
|
|
|
|
- [ ] **Step 2: Add no-op impls to `TestStore` in `crates/domain/src/testing.rs`**
|
|
|
|
Find `impl FederationActionPort for TestStore` (around line 538). Add after `unfollow_remote`:
|
|
|
|
```rust
|
|
async fn get_pending_followers(
|
|
&self,
|
|
_user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
Ok(vec![])
|
|
}
|
|
|
|
async fn accept_follow_request(
|
|
&self,
|
|
_user_id: &UserId,
|
|
_actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn reject_follow_request(
|
|
&self,
|
|
_user_id: &UserId,
|
|
_actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_remote_followers(
|
|
&self,
|
|
_user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
Ok(vec![])
|
|
}
|
|
|
|
async fn remove_remote_follower(
|
|
&self,
|
|
_user_id: &UserId,
|
|
_actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_remote_following(
|
|
&self,
|
|
_user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
Ok(vec![])
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compilation**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo build -p domain 2>&1 | grep "^error"
|
|
```
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add crates/domain/src/ports.rs crates/domain/src/testing.rs
|
|
git commit -m "feat(domain): add federation management methods to FederationActionPort"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Implement new port methods in ActivityPubService
|
|
|
|
**Files:**
|
|
- Modify: `crates/adapters/activitypub-base/src/service.rs`
|
|
|
|
The existing private `ActivityPubService` methods (`get_pending_followers`, `accept_follower`, etc.) take `uuid::Uuid` and return adapter-level `RemoteActor` (from `crate::repository::RemoteActor`). The port returns domain `RemoteActor`. Add a private mapping helper and implement the six port methods.
|
|
|
|
- [ ] **Step 1: Add a private mapping helper to `service.rs`**
|
|
|
|
Add this private function anywhere in the `impl ActivityPubService` block (not in the `impl FederationActionPort` block):
|
|
|
|
```rust
|
|
fn adapter_actor_to_domain(
|
|
a: crate::repository::RemoteActor,
|
|
) -> domain::models::remote_actor::RemoteActor {
|
|
domain::models::remote_actor::RemoteActor {
|
|
url: a.url,
|
|
handle: a.handle,
|
|
display_name: a.display_name,
|
|
inbox_url: a.inbox_url,
|
|
shared_inbox_url: a.shared_inbox_url,
|
|
avatar_url: a.avatar_url,
|
|
outbox_url: a.outbox_url,
|
|
public_key: String::new(),
|
|
last_fetched_at: chrono::Utc::now(),
|
|
bio: None,
|
|
banner_url: None,
|
|
also_known_as: None,
|
|
followers_url: None,
|
|
following_url: None,
|
|
attachment: vec![],
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement the 6 new methods in the `impl domain::ports::FederationActionPort for ActivityPubService` block**
|
|
|
|
Add after the existing `unfollow_remote` impl:
|
|
|
|
```rust
|
|
async fn get_pending_followers(
|
|
&self,
|
|
user_id: &domain::value_objects::UserId,
|
|
) -> Result<Vec<domain::models::remote_actor::RemoteActor>, domain::errors::DomainError> {
|
|
self.get_pending_followers(user_id.as_uuid())
|
|
.await
|
|
.map(|v| v.into_iter().map(Self::adapter_actor_to_domain).collect())
|
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
}
|
|
|
|
async fn accept_follow_request(
|
|
&self,
|
|
user_id: &domain::value_objects::UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), domain::errors::DomainError> {
|
|
self.accept_follower(user_id.as_uuid(), actor_url)
|
|
.await
|
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
}
|
|
|
|
async fn reject_follow_request(
|
|
&self,
|
|
user_id: &domain::value_objects::UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), domain::errors::DomainError> {
|
|
self.reject_follower(user_id.as_uuid(), actor_url)
|
|
.await
|
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
}
|
|
|
|
async fn get_remote_followers(
|
|
&self,
|
|
user_id: &domain::value_objects::UserId,
|
|
) -> Result<Vec<domain::models::remote_actor::RemoteActor>, domain::errors::DomainError> {
|
|
self.get_accepted_followers(user_id.as_uuid())
|
|
.await
|
|
.map(|v| v.into_iter().map(Self::adapter_actor_to_domain).collect())
|
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
}
|
|
|
|
async fn remove_remote_follower(
|
|
&self,
|
|
user_id: &domain::value_objects::UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), domain::errors::DomainError> {
|
|
self.remove_follower(user_id.as_uuid(), actor_url)
|
|
.await
|
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
}
|
|
|
|
async fn get_remote_following(
|
|
&self,
|
|
user_id: &domain::value_objects::UserId,
|
|
) -> Result<Vec<domain::models::remote_actor::RemoteActor>, domain::errors::DomainError> {
|
|
self.get_following(user_id.as_uuid())
|
|
.await
|
|
.map(|v| v.into_iter().map(Self::adapter_actor_to_domain).collect())
|
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compilation**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error"
|
|
```
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add crates/adapters/activitypub-base/src/service.rs
|
|
git commit -m "feat(activitypub-base): implement federation management port methods"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Application use cases
|
|
|
|
**Files:**
|
|
- Create: `crates/application/src/use_cases/federation_management.rs`
|
|
- Modify: `crates/application/src/use_cases/mod.rs`
|
|
|
|
- [ ] **Step 1: Write failing tests first**
|
|
|
|
Create `crates/application/src/use_cases/federation_management.rs` with just the tests (no implementations yet):
|
|
|
|
```rust
|
|
use domain::{
|
|
errors::DomainError,
|
|
models::remote_actor::RemoteActor,
|
|
ports::FederationActionPort,
|
|
value_objects::UserId,
|
|
};
|
|
|
|
pub async fn list_pending_requests(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
todo!()
|
|
}
|
|
|
|
pub async fn accept_follow_request(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
todo!()
|
|
}
|
|
|
|
pub async fn reject_follow_request(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
todo!()
|
|
}
|
|
|
|
pub async fn list_remote_followers(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
todo!()
|
|
}
|
|
|
|
pub async fn remove_remote_follower(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
todo!()
|
|
}
|
|
|
|
pub async fn list_remote_following(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
todo!()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use domain::testing::TestStore;
|
|
|
|
#[tokio::test]
|
|
async fn list_pending_returns_empty_by_default() {
|
|
let store = TestStore::default();
|
|
let uid = UserId::new();
|
|
let result = list_pending_requests(&store, &uid).await.unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accept_follow_request_returns_ok() {
|
|
let store = TestStore::default();
|
|
let uid = UserId::new();
|
|
accept_follow_request(&store, &uid, "https://mastodon.social/users/alice")
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reject_follow_request_returns_ok() {
|
|
let store = TestStore::default();
|
|
let uid = UserId::new();
|
|
reject_follow_request(&store, &uid, "https://mastodon.social/users/alice")
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_remote_followers_returns_empty_by_default() {
|
|
let store = TestStore::default();
|
|
let uid = UserId::new();
|
|
let result = list_remote_followers(&store, &uid).await.unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_remote_follower_returns_ok() {
|
|
let store = TestStore::default();
|
|
let uid = UserId::new();
|
|
remove_remote_follower(&store, &uid, "https://mastodon.social/users/alice")
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_remote_following_returns_empty_by_default() {
|
|
let store = TestStore::default();
|
|
let uid = UserId::new();
|
|
let result = list_remote_following(&store, &uid).await.unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to confirm they fail (panic on todo!())**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p application federation_management 2>&1 | tail -10
|
|
```
|
|
Expected: tests fail with `not yet implemented`.
|
|
|
|
- [ ] **Step 3: Implement the use case functions (replace `todo!()` bodies)**
|
|
|
|
```rust
|
|
pub async fn list_pending_requests(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
federation.get_pending_followers(user_id).await
|
|
}
|
|
|
|
pub async fn accept_follow_request(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
federation.accept_follow_request(user_id, actor_url).await
|
|
}
|
|
|
|
pub async fn reject_follow_request(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
federation.reject_follow_request(user_id, actor_url).await
|
|
}
|
|
|
|
pub async fn list_remote_followers(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
federation.get_remote_followers(user_id).await
|
|
}
|
|
|
|
pub async fn remove_remote_follower(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
actor_url: &str,
|
|
) -> Result<(), DomainError> {
|
|
federation.remove_remote_follower(user_id, actor_url).await
|
|
}
|
|
|
|
pub async fn list_remote_following(
|
|
federation: &dyn FederationActionPort,
|
|
user_id: &UserId,
|
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
federation.get_remote_following(user_id).await
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Expose the module in `crates/application/src/use_cases/mod.rs`**
|
|
|
|
Add:
|
|
```rust
|
|
pub mod federation_management;
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests — all 6 should pass**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p application federation_management 2>&1 | tail -5
|
|
```
|
|
Expected: `6 passed`.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/application/src/use_cases/federation_management.rs \
|
|
crates/application/src/use_cases/mod.rs
|
|
git commit -m "feat(application): federation management use cases"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: HTTP handlers and routes
|
|
|
|
**Files:**
|
|
- Create: `crates/presentation/src/handlers/federation_management.rs`
|
|
- Modify: `crates/presentation/src/handlers/mod.rs`
|
|
- Modify: `crates/presentation/src/routes.rs`
|
|
|
|
Response shape: a slim subset of `RemoteActorResponse` is enough. Reuse it — it already has `handle`, `display_name`, `avatar_url`, `url`.
|
|
|
|
- [ ] **Step 1: Create `crates/presentation/src/handlers/federation_management.rs`**
|
|
|
|
```rust
|
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
|
use api_types::responses::RemoteActorResponse;
|
|
use application::use_cases::federation_management::{
|
|
accept_follow_request, list_pending_requests, list_remote_followers, list_remote_following,
|
|
reject_follow_request, remove_remote_follower,
|
|
};
|
|
use axum::{extract::State, http::StatusCode, Json};
|
|
use serde::Deserialize;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ActorUrlBody {
|
|
pub actor_url: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct HandleBody {
|
|
pub handle: String,
|
|
}
|
|
|
|
fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorResponse {
|
|
RemoteActorResponse {
|
|
handle: a.handle,
|
|
display_name: a.display_name,
|
|
avatar_url: a.avatar_url,
|
|
url: a.url,
|
|
bio: a.bio,
|
|
banner_url: a.banner_url,
|
|
also_known_as: a.also_known_as,
|
|
outbox_url: a.outbox_url,
|
|
followers_url: a.followers_url,
|
|
following_url: a.following_url,
|
|
attachment: vec![],
|
|
}
|
|
}
|
|
|
|
pub async fn get_pending_requests(
|
|
State(s): State<AppState>,
|
|
AuthUser(uid): AuthUser,
|
|
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
|
let actors = list_pending_requests(&*s.federation, &uid).await?;
|
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
|
}
|
|
|
|
pub async fn post_accept_request(
|
|
State(s): State<AppState>,
|
|
AuthUser(uid): AuthUser,
|
|
Json(body): Json<ActorUrlBody>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
accept_follow_request(&*s.federation, &uid, &body.actor_url).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn delete_follower(
|
|
State(s): State<AppState>,
|
|
AuthUser(uid): AuthUser,
|
|
Json(body): Json<ActorUrlBody>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
reject_follow_request(&*s.federation, &uid, &body.actor_url).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn get_remote_followers(
|
|
State(s): State<AppState>,
|
|
AuthUser(uid): AuthUser,
|
|
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
|
let actors = list_remote_followers(&*s.federation, &uid).await?;
|
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
|
}
|
|
|
|
pub async fn get_remote_following(
|
|
State(s): State<AppState>,
|
|
AuthUser(uid): AuthUser,
|
|
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
|
let actors = list_remote_following(&*s.federation, &uid).await?;
|
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
|
}
|
|
|
|
pub async fn delete_following(
|
|
State(s): State<AppState>,
|
|
AuthUser(uid): AuthUser,
|
|
Json(body): Json<HandleBody>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
application::use_cases::social::unfollow_actor(
|
|
&*s.follows,
|
|
&*s.users,
|
|
&*s.federation,
|
|
&*s.events,
|
|
&uid,
|
|
&body.handle,
|
|
)
|
|
.await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add module to `crates/presentation/src/handlers/mod.rs`**
|
|
|
|
```rust
|
|
pub mod federation_management;
|
|
```
|
|
|
|
- [ ] **Step 3: Register routes in `crates/presentation/src/routes.rs`**
|
|
|
|
Add after the existing `/federation/actors/...` routes:
|
|
|
|
```rust
|
|
.route(
|
|
"/federation/me/followers/pending",
|
|
get(federation_management::get_pending_requests),
|
|
)
|
|
.route(
|
|
"/federation/me/followers/accept",
|
|
post(federation_management::post_accept_request),
|
|
)
|
|
.route(
|
|
"/federation/me/followers",
|
|
get(federation_management::get_remote_followers)
|
|
.delete(federation_management::delete_follower),
|
|
)
|
|
.route(
|
|
"/federation/me/following",
|
|
get(federation_management::get_remote_following)
|
|
.delete(federation_management::delete_following),
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Verify compilation**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo build -p presentation 2>&1 | grep "^error" | head -10
|
|
```
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 5: Run all unit tests**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
|
|
```
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/presentation/src/handlers/federation_management.rs \
|
|
crates/presentation/src/handlers/mod.rs \
|
|
crates/presentation/src/routes.rs
|
|
git commit -m "feat(presentation): federation management endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Frontend API client
|
|
|
|
**Files:**
|
|
- Modify: `thoughts-frontend/lib/api.ts`
|
|
|
|
- [ ] **Step 1: Add schema and API functions to `thoughts-frontend/lib/api.ts`**
|
|
|
|
The `RemoteActorSchema` and `ActorConnectionSchema` already exist. Add a leaner `FederationActorSchema` for the management responses (same shape as `RemoteActorSchema` — reuse it):
|
|
|
|
After the existing `lookupRemoteActor` function, add:
|
|
|
|
```typescript
|
|
// Federation management
|
|
export const getPendingFollowRequests = (token: string) =>
|
|
apiFetch(
|
|
"/federation/me/followers/pending",
|
|
{},
|
|
z.array(RemoteActorSchema),
|
|
token
|
|
);
|
|
|
|
export const acceptFollowRequest = (actorUrl: string, token: string) =>
|
|
apiFetch(
|
|
"/federation/me/followers/accept",
|
|
{ method: "POST", body: JSON.stringify({ actor_url: actorUrl }) },
|
|
z.null(),
|
|
token
|
|
);
|
|
|
|
export const rejectFollowRequest = (actorUrl: string, token: string) =>
|
|
apiFetch(
|
|
"/federation/me/followers",
|
|
{ method: "DELETE", body: JSON.stringify({ actor_url: actorUrl }) },
|
|
z.null(),
|
|
token
|
|
);
|
|
|
|
export const getRemoteFollowers = (token: string) =>
|
|
apiFetch(
|
|
"/federation/me/followers",
|
|
{},
|
|
z.array(RemoteActorSchema),
|
|
token
|
|
);
|
|
|
|
export const getRemoteFollowing = (token: string) =>
|
|
apiFetch(
|
|
"/federation/me/following",
|
|
{},
|
|
z.array(RemoteActorSchema),
|
|
token
|
|
);
|
|
|
|
export const unfollowRemoteActor = (handle: string, token: string) =>
|
|
apiFetch(
|
|
"/federation/me/following",
|
|
{ method: "DELETE", body: JSON.stringify({ handle }) },
|
|
z.null(),
|
|
token
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 2: Type check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
|
|
```
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add thoughts-frontend/lib/api.ts
|
|
git commit -m "feat(frontend): federation management API client functions"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: PendingRequests component
|
|
|
|
**Files:**
|
|
- Create: `thoughts-frontend/components/federation/pending-requests.tsx`
|
|
|
|
- [ ] **Step 1: Create the component**
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
getPendingFollowRequests,
|
|
acceptFollowRequest,
|
|
rejectFollowRequest,
|
|
type RemoteActor,
|
|
} from "@/lib/api";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { UserAvatar } from "@/components/user-avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { toast } from "sonner";
|
|
|
|
interface Props {
|
|
compact?: boolean;
|
|
}
|
|
|
|
export function PendingRequests({ compact = false }: Props) {
|
|
const { token } = useAuth();
|
|
const [requests, setRequests] = useState<RemoteActor[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
getPendingFollowRequests(token)
|
|
.then(setRequests)
|
|
.catch(() => toast.error("Failed to load follow requests"))
|
|
.finally(() => setLoading(false));
|
|
}, [token]);
|
|
|
|
const accept = async (actorUrl: string) => {
|
|
if (!token) return;
|
|
setRequests((prev) => prev.filter((r) => r.url !== actorUrl));
|
|
await acceptFollowRequest(actorUrl, token).catch(() => {
|
|
toast.error("Failed to accept follow request");
|
|
});
|
|
};
|
|
|
|
const reject = async (actorUrl: string) => {
|
|
if (!token) return;
|
|
setRequests((prev) => prev.filter((r) => r.url !== actorUrl));
|
|
await rejectFollowRequest(actorUrl, token).catch(() => {
|
|
toast.error("Failed to reject follow request");
|
|
});
|
|
};
|
|
|
|
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
if (requests.length === 0)
|
|
return <p className="text-sm text-muted-foreground">No pending requests.</p>;
|
|
|
|
return (
|
|
<ul className={compact ? "space-y-2" : "space-y-3"}>
|
|
{requests.map((actor) => (
|
|
<li
|
|
key={actor.url}
|
|
className="flex items-center justify-between gap-3"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<UserAvatar
|
|
src={actor.avatarUrl}
|
|
alt={actor.displayName}
|
|
className="h-8 w-8 shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{actor.displayName || actor.handle}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{actor.handle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 shrink-0">
|
|
<Button size="sm" onClick={() => accept(actor.url)}>
|
|
Accept
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => reject(actor.url)}
|
|
>
|
|
Reject
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Type check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
|
|
```
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add thoughts-frontend/components/federation/pending-requests.tsx
|
|
git commit -m "feat(frontend): PendingRequests component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: RemoteFollowers component
|
|
|
|
**Files:**
|
|
- Create: `thoughts-frontend/components/federation/remote-followers.tsx`
|
|
|
|
- [ ] **Step 1: Create the component**
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { getRemoteFollowers, rejectFollowRequest, type RemoteActor } from "@/lib/api";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { UserAvatar } from "@/components/user-avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { toast } from "sonner";
|
|
|
|
export function RemoteFollowers() {
|
|
const { token } = useAuth();
|
|
const [followers, setFollowers] = useState<RemoteActor[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
getRemoteFollowers(token)
|
|
.then(setFollowers)
|
|
.catch(() => toast.error("Failed to load followers"))
|
|
.finally(() => setLoading(false));
|
|
}, [token]);
|
|
|
|
const remove = async (actorUrl: string) => {
|
|
if (!token) return;
|
|
setFollowers((prev) => prev.filter((f) => f.url !== actorUrl));
|
|
await rejectFollowRequest(actorUrl, token).catch(() => {
|
|
toast.error("Failed to remove follower");
|
|
});
|
|
};
|
|
|
|
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
if (followers.length === 0)
|
|
return <p className="text-sm text-muted-foreground">No remote followers yet.</p>;
|
|
|
|
return (
|
|
<ul className="space-y-3">
|
|
{followers.map((actor) => (
|
|
<li key={actor.url} className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<UserAvatar
|
|
src={actor.avatarUrl}
|
|
alt={actor.displayName}
|
|
className="h-8 w-8 shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{actor.displayName || actor.handle}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{actor.handle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button size="sm" variant="outline" onClick={() => remove(actor.url)}>
|
|
Remove
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Type check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add thoughts-frontend/components/federation/remote-followers.tsx
|
|
git commit -m "feat(frontend): RemoteFollowers component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: RemoteFollowing component
|
|
|
|
**Files:**
|
|
- Create: `thoughts-frontend/components/federation/remote-following.tsx`
|
|
|
|
- [ ] **Step 1: Create the component**
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { getRemoteFollowing, unfollowRemoteActor, type RemoteActor } from "@/lib/api";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { UserAvatar } from "@/components/user-avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { toast } from "sonner";
|
|
|
|
export function RemoteFollowing() {
|
|
const { token } = useAuth();
|
|
const [following, setFollowing] = useState<RemoteActor[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
getRemoteFollowing(token)
|
|
.then(setFollowing)
|
|
.catch(() => toast.error("Failed to load following"))
|
|
.finally(() => setLoading(false));
|
|
}, [token]);
|
|
|
|
const unfollow = async (handle: string) => {
|
|
if (!token) return;
|
|
setFollowing((prev) => prev.filter((f) => f.handle !== handle));
|
|
await unfollowRemoteActor(handle, token).catch(() => {
|
|
toast.error("Failed to unfollow");
|
|
});
|
|
};
|
|
|
|
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
if (following.length === 0)
|
|
return <p className="text-sm text-muted-foreground">Not following anyone remotely yet.</p>;
|
|
|
|
return (
|
|
<ul className="space-y-3">
|
|
{following.map((actor) => (
|
|
<li key={actor.url} className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<UserAvatar
|
|
src={actor.avatarUrl}
|
|
alt={actor.displayName}
|
|
className="h-8 w-8 shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{actor.displayName || actor.handle}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{actor.handle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => unfollow(actor.handle)}
|
|
>
|
|
Unfollow
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Type check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add thoughts-frontend/components/federation/remote-following.tsx
|
|
git commit -m "feat(frontend): RemoteFollowing component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: FederationPanel wrapper
|
|
|
|
**Files:**
|
|
- Create: `thoughts-frontend/components/federation/federation-panel.tsx`
|
|
|
|
- [ ] **Step 1: Create the component**
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { PendingRequests } from "./pending-requests";
|
|
import { RemoteFollowers } from "./remote-followers";
|
|
import { RemoteFollowing } from "./remote-following";
|
|
import { getPendingFollowRequests } from "@/lib/api";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
|
|
export function FederationPanel() {
|
|
const { token } = useAuth();
|
|
const [pendingCount, setPendingCount] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
getPendingFollowRequests(token)
|
|
.then((r) => setPendingCount(r.length))
|
|
.catch(() => {});
|
|
}, [token]);
|
|
|
|
return (
|
|
<Tabs defaultValue="requests">
|
|
<TabsList className="mb-4">
|
|
<TabsTrigger value="requests">
|
|
Requests
|
|
{pendingCount > 0 && (
|
|
<span className="ml-1.5 rounded-full bg-primary text-primary-foreground text-xs px-1.5 py-0.5">
|
|
{pendingCount}
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="followers">Followers</TabsTrigger>
|
|
<TabsTrigger value="following">Following</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="requests">
|
|
<PendingRequests />
|
|
</TabsContent>
|
|
<TabsContent value="followers">
|
|
<RemoteFollowers />
|
|
</TabsContent>
|
|
<TabsContent value="following">
|
|
<RemoteFollowing />
|
|
</TabsContent>
|
|
</Tabs>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Type check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add thoughts-frontend/components/federation/federation-panel.tsx
|
|
git commit -m "feat(frontend): FederationPanel tabbed wrapper"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Settings page
|
|
|
|
**Files:**
|
|
- Create: `thoughts-frontend/app/settings/federation/page.tsx`
|
|
- Modify: `thoughts-frontend/app/settings/layout.tsx`
|
|
|
|
- [ ] **Step 1: Create `thoughts-frontend/app/settings/federation/page.tsx`**
|
|
|
|
```tsx
|
|
import { cookies } from "next/headers";
|
|
import { redirect } from "next/navigation";
|
|
import { FederationPanel } from "@/components/federation/federation-panel";
|
|
|
|
export default async function FederationSettingsPage() {
|
|
const token = (await cookies()).get("auth_token")?.value;
|
|
if (!token) {
|
|
redirect("/login");
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
|
|
<h3 className="text-lg font-medium">Federation</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Manage remote follow requests, followers, and accounts you follow on
|
|
other instances.
|
|
</p>
|
|
</div>
|
|
<FederationPanel />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add "Federation" to the settings nav in `thoughts-frontend/app/settings/layout.tsx`**
|
|
|
|
Find `sidebarNavItems` and add:
|
|
|
|
```tsx
|
|
const sidebarNavItems = [
|
|
{
|
|
title: "Profile",
|
|
href: "/settings/profile",
|
|
},
|
|
{
|
|
title: "API Keys",
|
|
href: "/settings/api-keys",
|
|
},
|
|
{
|
|
title: "Federation",
|
|
href: "/settings/federation",
|
|
},
|
|
];
|
|
```
|
|
|
|
- [ ] **Step 3: Type check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add thoughts-frontend/app/settings/federation/page.tsx \
|
|
thoughts-frontend/app/settings/layout.tsx
|
|
git commit -m "feat(frontend): federation settings page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Profile page — Federation tab
|
|
|
|
**Files:**
|
|
- Modify: `thoughts-frontend/app/users/[username]/page.tsx`
|
|
|
|
The profile page uses a tab pattern for Thoughts / Followers / Following. Add a "Federation" tab visible only when `isOwnProfile`.
|
|
|
|
- [ ] **Step 1: Import FederationPanel**
|
|
|
|
At the top of `thoughts-frontend/app/users/[username]/page.tsx`, add:
|
|
|
|
```tsx
|
|
import { FederationPanel } from "@/components/federation/federation-panel";
|
|
```
|
|
|
|
- [ ] **Step 2: Add the Federation tab**
|
|
|
|
Find the section that renders the profile tabs (the `Tabs` component with Thoughts/Followers/Following). Add a "Federation" tab that only renders when `isOwnProfile`. The exact location depends on how the tabs are structured — look for `<TabsList>` and `<TabsContent>` blocks and add alongside them:
|
|
|
|
Inside `<TabsList>`:
|
|
```tsx
|
|
{isOwnProfile && (
|
|
<TabsTrigger value="federation">Federation</TabsTrigger>
|
|
)}
|
|
```
|
|
|
|
After the last `<TabsContent>`:
|
|
```tsx
|
|
{isOwnProfile && (
|
|
<TabsContent value="federation">
|
|
<FederationPanel />
|
|
</TabsContent>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3: Type check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
|
|
```
|
|
|
|
- [ ] **Step 4: Final build check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error"
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add thoughts-frontend/app/users/\[username\]/page.tsx
|
|
git commit -m "feat(frontend): federation tab on own profile"
|
|
```
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- **Notifications page**: no notifications page exists yet. `<PendingRequests compact>` can be added there once that page is built.
|
|
- **`delete_follower` vs `reject_follow_request`**: both pending and accepted followers are removed via `DELETE /federation/me/followers`. The service (`reject_follower` / `remove_follower`) handles both cases — accepted actors are removed, pending ones are rejected and a Reject activity is sent.
|