34 KiB
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
FederationActionPortincrates/domain/src/ports.rs
Find the FederationActionPort trait (around line 223). Add these six methods after unfollow_remote:
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
TestStoreincrates/domain/src/testing.rs
Find impl FederationActionPort for TestStore (around line 538). Add after unfollow_remote:
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
cd /mnt/drive/dev/thoughts && cargo build -p domain 2>&1 | grep "^error"
Expected: no errors.
- Step 4: Commit
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):
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 ActivityPubServiceblock
Add after the existing unfollow_remote impl:
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
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error"
Expected: no errors.
- Step 4: Commit
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):
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!())
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)
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:
pub mod federation_management;
- Step 5: Run tests — all 6 should pass
cd /mnt/drive/dev/thoughts && cargo test -p application federation_management 2>&1 | tail -5
Expected: 6 passed.
- Step 6: Commit
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
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
pub mod federation_management;
- Step 3: Register routes in
crates/presentation/src/routes.rs
Add after the existing /federation/actors/... routes:
.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
cd /mnt/drive/dev/thoughts && cargo build -p presentation 2>&1 | grep "^error" | head -10
Expected: no errors.
- Step 5: Run all unit tests
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
Expected: all pass.
- Step 6: Commit
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:
// 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
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
Expected: no errors.
- Step 3: Commit
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
"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
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
Expected: no errors.
- Step 3: Commit
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
"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
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
- Step 3: Commit
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
"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
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
- Step 3: Commit
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
"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
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
- Step 3: Commit
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
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:
const sidebarNavItems = [
{
title: "Profile",
href: "/settings/profile",
},
{
title: "API Keys",
href: "/settings/api-keys",
},
{
title: "Federation",
href: "/settings/federation",
},
];
- Step 3: Type check
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
- Step 4: Commit
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:
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>:
{isOwnProfile && (
<TabsTrigger value="federation">Federation</TabsTrigger>
)}
After the last <TabsContent>:
{isOwnProfile && (
<TabsContent value="federation">
<FederationPanel />
</TabsContent>
)}
- Step 3: Type check
cd /mnt/drive/dev/thoughts/thoughts-frontend && npx tsc --noEmit 2>&1 | grep "error TS" | head -10
- Step 4: Final build check
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error"
- Step 5: Commit
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_followervsreject_follow_request: both pending and accepted followers are removed viaDELETE /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.