Files
thoughts/docs/superpowers/plans/2026-05-15-federation-management.md

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.