feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1

Merged
GKaszewski merged 334 commits from v2 into master 2026-05-16 09:42:43 +00:00
3 changed files with 102 additions and 4 deletions
Showing only changes of commit a7a331858d - Show all commits

View File

@@ -1,9 +1,12 @@
import { cookies } from "next/headers";
import { getMe, search, User } from "@/lib/api";
import { getMe, search, lookupRemoteActor, User } from "@/lib/api";
import { UserListCard } from "@/components/user-list-card";
import { RemoteUserCard } from "@/components/remote-user-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ThoughtList } from "@/components/thought-list";
const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;
interface SearchPageProps {
searchParams: Promise<{ q?: string }>;
}
@@ -24,8 +27,11 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
);
}
const [results, me] = await Promise.all([
search(query, token).catch(() => null),
const isHandle = HANDLE_RE.test(query);
const [results, remoteActor, me] = await Promise.all([
isHandle ? null : search(query, token).catch(() => null),
isHandle ? lookupRemoteActor(query, token).catch(() => null) : null,
token ? getMe(token).catch(() => null) : null,
]);
@@ -45,7 +51,18 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
</p>
</header>
<main>
{results ? (
{isHandle ? (
remoteActor ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Remote user</h2>
<RemoteUserCard actor={remoteActor} />
</div>
) : (
<p className="text-center text-muted-foreground pt-8">
No user found at {query}
</p>
)
) : results ? (
<Tabs defaultValue="thoughts" className="w-full">
<TabsList>
<TabsTrigger value="thoughts">

View File

@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { followRemoteUser, RemoteActor } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/user-avatar";
import { toast } from "sonner";
import { UserPlus } from "lucide-react";
interface RemoteUserCardProps {
actor: RemoteActor;
}
export function RemoteUserCard({ actor }: RemoteUserCardProps) {
const [followed, setFollowed] = useState(false);
const [loading, setLoading] = useState(false);
const { token } = useAuth();
const handleFollow = async () => {
if (!token) {
toast.error("You must be logged in to follow users.");
return;
}
setLoading(true);
try {
await followRemoteUser(actor.handle, token);
setFollowed(true);
toast.success(`Follow request sent to ${actor.handle}`);
} catch {
toast.error("Failed to send follow request.");
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<UserAvatar src={actor.avatarUrl} alt={actor.displayName ?? actor.handle} />
<div>
<p className="font-medium">{actor.displayName ?? actor.handle}</p>
<p className="text-sm text-muted-foreground">{actor.handle}</p>
</div>
</div>
<Button
onClick={handleFollow}
disabled={loading || followed}
variant={followed ? "secondary" : "default"}
size="sm"
>
<UserPlus className="mr-2 h-4 w-4" />
{followed ? "Requested" : "Follow"}
</Button>
</div>
);
}

View File

@@ -15,6 +15,14 @@ export const UserSchema = z.object({
export const MeSchema = UserSchema;
export const RemoteActorSchema = z.object({
handle: z.string(),
displayName: z.string().nullable(),
avatarUrl: z.string().nullable(),
url: z.string(),
});
export type RemoteActor = z.infer<typeof RemoteActorSchema>;
export const ThoughtSchema = z.object({
id: z.string().uuid(),
content: z.string(),
@@ -208,6 +216,22 @@ export const followUser = (username: string, token: string) =>
export const unfollowUser = (username: string, token: string) =>
apiFetch(`/users/${username}/follow`, { method: "DELETE" }, z.null(), token);
export const lookupRemoteActor = (handle: string, token: string | null) =>
apiFetch(
`/federation/lookup?handle=${encodeURIComponent(handle)}`,
{},
RemoteActorSchema,
token
);
export const followRemoteUser = (handle: string, token: string) =>
apiFetch(
`/federation/follow`,
{ method: "POST", body: JSON.stringify({ handle }) },
z.null(),
token
);
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
apiFetch(
`/users?page=${page}&per_page=${pageSize}`,