diff --git a/docs/superpowers/plans/2026-05-15-federation-management.md b/docs/superpowers/plans/2026-05-15-federation-management.md deleted file mode 100644 index 554c83f..0000000 --- a/docs/superpowers/plans/2026-05-15-federation-management.md +++ /dev/null @@ -1,1203 +0,0 @@ -# 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 `` 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, - AuthUser(uid): AuthUser, -) -> Result>, 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, - AuthUser(uid): AuthUser, - Json(body): Json, -) -> Result { - accept_follow_request(&*s.federation, &uid, &body.actor_url).await?; - Ok(StatusCode::NO_CONTENT) -} - -pub async fn delete_follower( - State(s): State, - AuthUser(uid): AuthUser, - Json(body): Json, -) -> Result { - reject_follow_request(&*s.federation, &uid, &body.actor_url).await?; - Ok(StatusCode::NO_CONTENT) -} - -pub async fn get_remote_followers( - State(s): State, - AuthUser(uid): AuthUser, -) -> Result>, 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, - AuthUser(uid): AuthUser, -) -> Result>, 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, - AuthUser(uid): AuthUser, - Json(body): Json, -) -> Result { - 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([]); - 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

Loading…

; - if (requests.length === 0) - return

No pending requests.

; - - return ( -
    - {requests.map((actor) => ( -
  • -
    - -
    -

    - {actor.displayName || actor.handle} -

    -

    - {actor.handle} -

    -
    -
    -
    - - -
    -
  • - ))} -
- ); -} -``` - -- [ ] **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([]); - 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

Loading…

; - if (followers.length === 0) - return

No remote followers yet.

; - - return ( -
    - {followers.map((actor) => ( -
  • -
    - -
    -

    - {actor.displayName || actor.handle} -

    -

    - {actor.handle} -

    -
    -
    - -
  • - ))} -
- ); -} -``` - -- [ ] **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([]); - 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

Loading…

; - if (following.length === 0) - return

Not following anyone remotely yet.

; - - return ( -
    - {following.map((actor) => ( -
  • -
    - -
    -

    - {actor.displayName || actor.handle} -

    -

    - {actor.handle} -

    -
    -
    - -
  • - ))} -
- ); -} -``` - -- [ ] **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 ( - - - - Requests - {pendingCount > 0 && ( - - {pendingCount} - - )} - - Followers - Following - - - - - - - - - - - - ); -} -``` - -- [ ] **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 ( -
-
-

Federation

-

- Manage remote follow requests, followers, and accounts you follow on - other instances. -

-
- -
- ); -} -``` - -- [ ] **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 `` and `` blocks and add alongside them: - -Inside ``: -```tsx -{isOwnProfile && ( - Federation -)} -``` - -After the last ``: -```tsx -{isOwnProfile && ( - - - -)} -``` - -- [ ] **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. `` 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. diff --git a/docs/superpowers/specs/2026-05-15-federation-management-design.md b/docs/superpowers/specs/2026-05-15-federation-management-design.md deleted file mode 100644 index 9ff5882..0000000 --- a/docs/superpowers/specs/2026-05-15-federation-management-design.md +++ /dev/null @@ -1,116 +0,0 @@ -# Federation Management Design - -## Goal - -Allow users to manage their ActivityPub federation: accept/reject incoming remote follow requests, remove accepted remote followers, and unfollow remote actors they're following. Surface this in three places via a shared component set. - -## Architecture - -Hexagonal layers respected throughout: -- **Application layer**: new use cases in `federation_management.rs` — all business routing lives here -- **Presentation layer**: handlers call use cases only, no direct port access -- **Frontend**: four components under `components/federation/`, used in three locations - ---- - -## Backend - -### New use cases — `crates/application/src/use_cases/federation_management.rs` - -Six functions, each taking `&dyn FederationActionPort` and `&UserId`. Return domain types (`Vec` or `()`). - -``` -list_pending_requests(federation, user_id) → Result, DomainError> -accept_follow_request(federation, user_id, actor_url: &str) → Result<(), DomainError> -reject_follow_request(federation, user_id, actor_url: &str) → Result<(), DomainError> -list_remote_followers(federation, user_id) → Result, DomainError> -remove_remote_follower(federation, user_id, actor_url: &str) → Result<(), DomainError> -list_remote_following(federation, user_id) → Result, DomainError> -``` - -Unfollow remote reuses the existing `unfollow_actor` use case in `social.rs` (already routes `@handle` to `federation.unfollow_remote`). - -### New HTTP endpoints — `crates/presentation/src/handlers/federation_management.rs` - -All routes require authentication (`AuthUser` extractor). Actor URLs go in the JSON request body to avoid percent-encoding issues. - -| Method | Path | Body | Action | -|--------|------|------|--------| -| `GET` | `/federation/me/followers/pending` | — | List pending follow requests | -| `POST` | `/federation/me/followers/accept` | `{ actor_url: String }` | Accept a follow request | -| `DELETE` | `/federation/me/followers` | `{ actor_url: String }` | Remove/reject a follower | -| `GET` | `/federation/me/followers` | — | List accepted remote followers | -| `GET` | `/federation/me/following` | — | List remote actors being followed | -| `DELETE` | `/federation/me/following` | `{ handle: String }` | Unfollow a remote actor (delegates to `unfollow_actor`) | - -Handlers are thin: extract auth, call use case, return JSON. No logic. - ---- - -## Frontend - -### API client additions — `thoughts-frontend/lib/api.ts` - -Six new functions mirroring the six endpoints. All take `token: string`. - -```typescript -getPendingFollowRequests(token) -acceptFollowRequest(actorUrl: string, token) -rejectFollowRequest(actorUrl: string, token) -getRemoteFollowers(token) -removeRemoteFollower(actorUrl: string, token) -getRemoteFollowing(token) -// unfollowRemote reuses existing unfollowUser or a new call to DELETE /federation/me/following -``` - -Response schema: `RemoteActorSchema` (already defined — handle, display_name, avatar_url, url). - -### Components — `thoughts-frontend/components/federation/` - -**`pending-requests.tsx`** -- Client component -- Fetches `getPendingFollowRequests` on mount -- Renders list of remote actors with Accept and Reject buttons -- On action: optimistic removal from list, then API call -- Prop: `compact?: boolean` — when true, renders as a flat list without card chrome (for notifications embed) - -**`remote-followers.tsx`** -- Client component -- Fetches `getRemoteFollowers` on mount -- Renders list of accepted remote followers with a Remove button -- On remove: optimistic removal, then API call - -**`remote-following.tsx`** -- Client component -- Fetches `getRemoteFollowing` on mount -- Renders list of remote actors being followed with an Unfollow button -- On unfollow: optimistic removal, then API call - -**`federation-panel.tsx`** -- Composes the three above inside a shadcn `Tabs` component -- Tabs: "Requests", "Followers", "Following" -- Shows a numeric badge on "Requests" tab when pending count > 0 -- No data fetching of its own — delegates entirely to sub-components - -### Usage locations - -**`app/settings/federation/page.tsx`** (new) -- Server component shell, renders `` -- Add "Federation" link to the settings sidebar alongside "Profile" and "API Keys" - -**`app/users/[username]/page.tsx`** (modify) -- Add a "Federation" tab to the profile tabs row -- Render `` as its content -- Tab is only visible when `isOwnProfile === true` - -**Notifications page** (modify) -- Render `` as a card section above the notification feed -- Only shown when the user is authenticated - ---- - -## Out of scope - -- Local follow request management (local follows are auto-accepted) -- Blocking remote actors (separate feature, already partially implemented) -- Notification count badge in the nav for pending requests (can be added later)