Compare commits
19 Commits
f7ac6f6476
...
2e3b81de17
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e3b81de17 | |||
| 4342a06319 | |||
| d88eb5d127 | |||
| d98c338e52 | |||
| 895b892cf2 | |||
| 40d8234225 | |||
| 9df1a55c48 | |||
| cb413ee6e9 | |||
| b86c486530 | |||
| 86a21a5bb7 | |||
| c4dd0797a1 | |||
| 497edf3437 | |||
| e4d1a1f4d1 | |||
| a8fbfcf49e | |||
| 8c6e259133 | |||
| 75c1870891 | |||
| 1b0bb911a0 | |||
| 3903421d54 | |||
| 4533e35092 |
@@ -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)]
|
||||
|
||||
106
crates/application/src/use_cases/federation_management.rs
Normal file
106
crates/application/src/use_cases/federation_management.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod api_keys;
|
||||
pub mod auth;
|
||||
pub mod federation_management;
|
||||
pub mod feed;
|
||||
pub mod notifications;
|
||||
pub mod profile;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
93
crates/presentation/src/handlers/federation_management.rs
Normal file
93
crates/presentation/src/handlers/federation_management.rs
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
23
thoughts-frontend/app/settings/federation/page.tsx
Normal file
23
thoughts-frontend/app/settings/federation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,10 @@ const sidebarNavItems = [
|
||||
title: "API Keys",
|
||||
href: "/settings/api-keys",
|
||||
},
|
||||
{
|
||||
title: "Federation",
|
||||
href: "/settings/federation",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsLayout({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'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't posted any public thoughts yet.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
{isOwnProfile && (
|
||||
<TabsContent value="federation">
|
||||
<PendingRequests />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
47
thoughts-frontend/components/federation/federation-panel.tsx
Normal file
47
thoughts-frontend/components/federation/federation-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
thoughts-frontend/components/federation/pending-requests.tsx
Normal file
95
thoughts-frontend/components/federation/pending-requests.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
thoughts-frontend/components/federation/remote-followers.tsx
Normal file
66
thoughts-frontend/components/federation/remote-followers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
thoughts-frontend/components/federation/remote-following.tsx
Normal file
70
thoughts-frontend/components/federation/remote-following.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
thoughts-frontend/components/pagination-nav.tsx
Normal file
76
thoughts-frontend/components/pagination-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user