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}`,