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

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 FederationActionPort in crates/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 TestStore in crates/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 ActivityPubService block

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_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.