Compare commits

...

19 Commits

Author SHA1 Message Date
2e3b81de17 fix: full fediverse handle display + follower count includes remote
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Has been cancelled
test / unit (pull_request) Has been cancelled
test / integration (pull_request) Has been cancelled
2026-05-15 04:35:04 +02:00
4342a06319 clean up 2026-05-15 04:29:18 +02:00
d88eb5d127 feat(frontend): show @handle and link to profile in federation actor lists 2026-05-15 04:28:36 +02:00
d98c338e52 fix(frontend): unify local+remote followers/following — profile tab becomes Requests only 2026-05-15 04:25:33 +02:00
895b892cf2 feat(frontend): proper pagination with page numbers and ellipsis 2026-05-15 04:21:11 +02:00
40d8234225 fix(frontend): hide pagination when only one page 2026-05-15 04:19:45 +02:00
9df1a55c48 feat(frontend): federation tab on own profile 2026-05-15 04:14:56 +02:00
cb413ee6e9 feat(frontend): federation settings page 2026-05-15 04:14:29 +02:00
b86c486530 feat(frontend): FederationPanel tabbed wrapper 2026-05-15 04:14:07 +02:00
86a21a5bb7 feat(frontend): RemoteFollowing component 2026-05-15 04:13:51 +02:00
c4dd0797a1 feat(frontend): RemoteFollowers component 2026-05-15 04:13:34 +02:00
497edf3437 feat(frontend): PendingRequests component 2026-05-15 04:13:17 +02:00
e4d1a1f4d1 feat(frontend): federation management API client functions 2026-05-15 04:12:52 +02:00
a8fbfcf49e feat(presentation): federation management endpoints 2026-05-15 04:10:36 +02:00
8c6e259133 feat(application): federation management use cases 2026-05-15 04:08:40 +02:00
75c1870891 feat(activitypub-base): implement federation management port methods 2026-05-15 04:05:31 +02:00
1b0bb911a0 feat(domain): add federation management methods to FederationActionPort 2026-05-15 03:35:58 +02:00
3903421d54 docs: federation management implementation plan 2026-05-15 03:33:39 +02:00
4533e35092 docs: federation management design spec 2026-05-15 03:28:01 +02:00
22 changed files with 911 additions and 81 deletions

View File

@@ -422,9 +422,11 @@ impl ActivityPubService {
);
}
let domain = remote_actor.ap_id.host_str().unwrap_or("");
let full_handle = format!("{}@{}", remote_actor.username, domain);
let remote = RemoteActor {
url: remote_actor.ap_id.to_string(),
handle: remote_actor.username.clone(),
handle: full_handle,
inbox_url: remote_actor.inbox_url.to_string(),
shared_inbox_url: None,
display_name: Some(remote_actor.username.clone()),
@@ -1140,6 +1142,28 @@ impl ActivityPubService {
Ok(())
}
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![],
}
}
fn spawn_backfill(&self, owner_user_id: uuid::Uuid, follower_inbox_url: String) {
let config = self.federation_config.clone();
let base_url = self.base_url.clone();
@@ -1769,6 +1793,66 @@ impl domain::ports::FederationActionPort for ActivityPubService {
})
.collect()
}
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()))
}
}
#[cfg(test)]

View File

@@ -0,0 +1,106 @@
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> {
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
}
#[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());
}
}

View File

@@ -1,5 +1,6 @@
pub mod api_keys;
pub mod auth;
pub mod federation_management;
pub mod feed;
pub mod notifications;
pub mod profile;

View File

@@ -228,6 +228,29 @@ pub trait FederationActionPort: Send + Sync {
local_user_id: &UserId,
handle: &str,
) -> Result<(), DomainError>;
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>;
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
async fn followers_collection_json(
&self,

View File

@@ -556,6 +556,51 @@ impl FederationActionPort for TestStore {
Ok(())
}
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![])
}
async fn actor_json(&self, _user_id: &UserId) -> Result<String, DomainError> {
Err(DomainError::NotFound)
}

View File

@@ -0,0 +1,93 @@
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,
};
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)
}

View File

@@ -1,6 +1,7 @@
pub mod api_keys;
pub mod auth;
pub mod federation_actors;
pub mod federation_management;
pub mod feed;
pub mod health;
pub mod notifications;

View File

@@ -77,6 +77,24 @@ pub fn router() -> Router<AppState> {
"/federation/actors/{handle}/following-list",
get(federation_actors::actor_following_handler),
)
.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),
)
.route("/tags/popular", get(feed::get_popular_tags))
.route("/tags/{name}", get(feed::tag_thoughts_handler))
// notifications

View File

@@ -18,13 +18,7 @@ import { buildThoughtThreads } from "@/lib/utils";
import { TopFriends } from "@/components/top-friends";
import { UsersCount } from "@/components/users-count";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { PaginationNav } from "@/components/pagination-nav";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
@@ -127,22 +121,11 @@ async function FeedPage({
</p>
)}
</div>
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={page > 1 ? `/?page=${page - 1}` : "#"}
aria-disabled={page <= 1}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
href={page < totalPages ? `/?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<PaginationNav
page={page}
totalPages={totalPages}
buildHref={(p) => `/?page=${p}`}
/>
</main>
<aside className="hidden lg:block lg:col-span-1">

View File

@@ -0,0 +1,23 @@
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>
);
}

View File

@@ -11,6 +11,10 @@ const sidebarNavItems = [
title: "API Keys",
href: "/settings/api-keys",
},
{
title: "Federation",
href: "/settings/federation",
},
];
export default function SettingsLayout({

View File

@@ -1,7 +1,8 @@
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { getFollowersList } from "@/lib/api";
import { getFollowersList, getMe } from "@/lib/api";
import { UserListCard } from "@/components/user-list-card";
import { RemoteFollowers } from "@/components/federation/remote-followers";
interface FollowersPageProps {
params: Promise<{ username: string }>;
@@ -11,22 +12,33 @@ export default async function FollowersPage({ params }: FollowersPageProps) {
const { username } = await params;
const token = (await cookies()).get("auth_token")?.value ?? null;
const followersData = await getFollowersList(username, token).catch(
() => null
);
const [followersData, me] = await Promise.all([
getFollowersList(username, token).catch(() => null),
token ? getMe(token).catch(() => null) : null,
]);
if (!followersData) {
notFound();
}
const isOwnProfile = me?.username === username;
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
<h1 className="text-3xl font-bold">Followers</h1>
<p className="text-muted-foreground">Users following @{username}.</p>
</header>
<main>
<main className="space-y-8">
<UserListCard users={followersData.items} />
{isOwnProfile && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Remote followers
</h2>
<RemoteFollowers />
</section>
)}
</main>
</div>
);

View File

@@ -1,7 +1,8 @@
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { getFollowingList } from "@/lib/api";
import { getFollowingList, getMe } from "@/lib/api";
import { UserListCard } from "@/components/user-list-card";
import { RemoteFollowing } from "@/components/federation/remote-following";
interface FollowingPageProps {
params: Promise<{ username: string }>;
@@ -11,22 +12,33 @@ export default async function FollowingPage({ params }: FollowingPageProps) {
const { username } = await params;
const token = (await cookies()).get("auth_token")?.value ?? null;
const followingData = await getFollowingList(username, token).catch(
() => null
);
const [followingData, me] = await Promise.all([
getFollowingList(username, token).catch(() => null),
token ? getMe(token).catch(() => null) : null,
]);
if (!followingData) {
notFound();
}
const isOwnProfile = me?.username === username;
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
<h1 className="text-3xl font-bold">Following</h1>
<p className="text-muted-foreground">Users that @{username} follows.</p>
</header>
<main>
<main className="space-y-8">
<UserListCard users={followingData.items} />
{isOwnProfile && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Remote following
</h2>
<RemoteFollowing />
</section>
)}
</main>
</div>
);

View File

@@ -3,6 +3,8 @@ import {
getFollowersList,
getFollowingList,
getMe,
getRemoteFollowers,
getRemoteFollowing,
getTopFriends,
getUserProfile,
getUserThoughts,
@@ -53,6 +55,8 @@ import { buildThoughtThreads } from "@/lib/utils";
import { ThoughtThread } from "@/components/thought-thread";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PendingRequests } from "@/components/federation/pending-requests";
interface ProfilePageProps {
params: Promise<{ username: string }>;
@@ -93,16 +97,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.items : [];
const thoughtThreads = buildThoughtThreads(thoughts);
const followersCount =
const localFollowersCount =
followersResult.status === "fulfilled"
? followersResult.value.total
: 0;
const followingCount =
const localFollowingCount =
followingResult.status === "fulfilled"
? followingResult.value.total
: 0;
const isOwnProfile = me?.username === user.username;
const [remoteFollowersCount, remoteFollowingCount] =
isOwnProfile && token
? await Promise.all([
getRemoteFollowers(token).then((r) => r.length).catch(() => 0),
getRemoteFollowing(token).then((r) => r.length).catch(() => 0),
])
: [0, 0];
const followersCount = localFollowersCount + remoteFollowersCount;
const followingCount = localFollowingCount + remoteFollowingCount;
const isFollowing = user.isFollowedByViewer;
const apiDomain = process.env.NEXT_PUBLIC_API_URL
@@ -250,24 +265,39 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
id="profile-card__thoughts"
className="col-span-1 lg:col-span-3 space-y-4"
>
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
authorDetails={authorDetails}
currentUser={me}
/>
))}
{thoughtThreads.length === 0 && (
<Card
id="profile-card__no-thoughts"
className="flex items-center justify-center h-48"
>
<p className="text-center text-muted-foreground">
This user hasn&apos;t posted any public thoughts yet.
</p>
</Card>
)}
<Tabs defaultValue="thoughts">
<TabsList className="mb-4">
<TabsTrigger value="thoughts">Thoughts</TabsTrigger>
{isOwnProfile && (
<TabsTrigger value="federation">Requests</TabsTrigger>
)}
</TabsList>
<TabsContent value="thoughts" className="space-y-4">
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
authorDetails={authorDetails}
currentUser={me}
/>
))}
{thoughtThreads.length === 0 && (
<Card
id="profile-card__no-thoughts"
className="flex items-center justify-center h-48"
>
<p className="text-center text-muted-foreground">
This user hasn&apos;t posted any public thoughts yet.
</p>
</Card>
)}
</TabsContent>
{isOwnProfile && (
<TabsContent value="federation">
<PendingRequests />
</TabsContent>
)}
</Tabs>
</div>
</main>
</div>

View File

@@ -1,12 +1,6 @@
import { getAllUsers } from "@/lib/api";
import { UserListCard } from "@/components/user-list-card";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { PaginationNav } from "@/components/pagination-nav";
export default async function AllUsersPage({
searchParams,
@@ -29,8 +23,7 @@ export default async function AllUsersPage({
}
const { items, total, per_page } = usersData;
const perPage = per_page;
const totalPages = Math.ceil(total / perPage);
const totalPages = Math.ceil(total / per_page);
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
@@ -42,24 +35,11 @@ export default async function AllUsersPage({
</header>
<main>
<UserListCard users={items} />
{totalPages > 1 && (
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={page > 1 ? `/users/all?page=${page - 1}` : "#"}
aria-disabled={page <= 1}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
href={page < totalPages ? `/users/all?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
<PaginationNav
page={page}
totalPages={totalPages}
buildHref={(p) => `/users/all?page=${p}`}
/>
</main>
</div>
);

View File

@@ -0,0 +1,47 @@
"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>
);
}

View File

@@ -0,0 +1,95 @@
"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";
import Link from "next/link";
import { fullFediverseHandle } from "@/lib/utils";
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"
>
<Link
href={`/users/@${actor.handle}`}
className="flex items-center gap-2 min-w-0 hover:opacity-80"
>
<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 font-mono">
@{fullFediverseHandle(actor.handle, actor.url)}
</p>
</div>
</Link>
<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>
);
}

View File

@@ -0,0 +1,66 @@
"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";
import Link from "next/link";
import { fullFediverseHandle } from "@/lib/utils";
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">
<Link
href={`/users/@${actor.handle}`}
className="flex items-center gap-2 min-w-0 hover:opacity-80"
>
<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 font-mono">
@{fullFediverseHandle(actor.handle, actor.url)}
</p>
</div>
</Link>
<Button size="sm" variant="outline" onClick={() => remove(actor.url)}>
Remove
</Button>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,70 @@
"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";
import Link from "next/link";
import { fullFediverseHandle } from "@/lib/utils";
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">
<Link
href={`/users/@${actor.handle}`}
className="flex items-center gap-2 min-w-0 hover:opacity-80"
>
<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 font-mono">
@{fullFediverseHandle(actor.handle, actor.url)}
</p>
</div>
</Link>
<Button
size="sm"
variant="outline"
onClick={() => unfollow(actor.handle)}
>
Unfollow
</Button>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,76 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
interface Props {
page: number;
totalPages: number;
buildHref: (page: number) => string;
}
function pageNumbers(
page: number,
totalPages: number
): (number | "ellipsis")[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | "ellipsis")[] = [1];
if (page > 3) pages.push("ellipsis");
const start = Math.max(2, page - 1);
const end = Math.min(totalPages - 1, page + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (page < totalPages - 2) pages.push("ellipsis");
pages.push(totalPages);
return pages;
}
export function PaginationNav({ page, totalPages, buildHref }: Props) {
if (totalPages <= 1) return null;
return (
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={page > 1 ? buildHref(page - 1) : "#"}
aria-disabled={page <= 1}
/>
</PaginationItem>
{pageNumbers(page, totalPages).map((p, i) =>
p === "ellipsis" ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink href={buildHref(p)} isActive={p === page}>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href={page < totalPages ? buildHref(page + 1) : "#"}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}

View File

@@ -402,3 +402,53 @@ export const deleteApiKey = (keyId: string, token: string) =>
export const getFriends = (token: string) =>
getMeFollowingList(token).then((r) => ({ users: r.items }));
// ── 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
);

View File

@@ -6,6 +6,17 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Construct a full fediverse handle like `user@instance.social`.
* Falls back gracefully for existing DB rows that only stored the username. */
export function fullFediverseHandle(handle: string, actorUrl: string): string {
if (handle.includes("@")) return handle;
try {
return `${handle}@${new URL(actorUrl).hostname}`;
} catch {
return handle;
}
}
export function buildThoughtThreads(thoughts: Thought[]): ThoughtThreadType[] {
const thoughtMap = new Map<string, Thought>();
thoughts.forEach((t) => thoughtMap.set(t.id, t));