diff --git a/thoughts-frontend/app/search/page.tsx b/thoughts-frontend/app/search/page.tsx
index c794bbd..1414c0e 100644
--- a/thoughts-frontend/app/search/page.tsx
+++ b/thoughts-frontend/app/search/page.tsx
@@ -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) {
- {results ? (
+ {isHandle ? (
+ remoteActor ? (
+
+
Remote user
+
+
+ ) : (
+
+ No user found at {query}
+
+ )
+ ) : results ? (
diff --git a/thoughts-frontend/components/remote-user-card.tsx b/thoughts-frontend/components/remote-user-card.tsx
new file mode 100644
index 0000000..332b608
--- /dev/null
+++ b/thoughts-frontend/components/remote-user-card.tsx
@@ -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 (
+
+
+
+
+
{actor.displayName ?? actor.handle}
+
{actor.handle}
+
+
+
+
+ );
+}
diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts
index da02dd2..d2c5359 100644
--- a/thoughts-frontend/lib/api.ts
+++ b/thoughts-frontend/lib/api.ts
@@ -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;
+
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}`,