diff --git a/docs/superpowers/plans/2026-05-15-federation-management.md b/docs/superpowers/plans/2026-05-15-federation-management.md new file mode 100644 index 0000000..554c83f --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-federation-management.md @@ -0,0 +1,1203 @@ +# 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.