From 8ef7c939702a008b9555f1a07179fe8a0da5bd89 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 22:25:53 +0200 Subject: [PATCH] feat(frontend): remote actor profile page with bio, fields, and posts --- .../app/users/[username]/page.tsx | 25 +++ .../components/remote-user-profile.tsx | 179 ++++++++++++++++++ thoughts-frontend/lib/api.ts | 28 +++ 3 files changed, 232 insertions(+) create mode 100644 thoughts-frontend/components/remote-user-profile.tsx diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index dec722c..f49e000 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -5,8 +5,11 @@ import { getTopFriends, getUserProfile, getUserThoughts, + lookupRemoteActor, + getRemoteActorPosts, Me, } from "@/lib/api"; +import { RemoteUserProfile } from "@/components/remote-user-profile"; import { UserAvatar } from "@/components/user-avatar"; import { Calendar, Settings } from "lucide-react"; import { Card } from "@/components/ui/card"; @@ -27,6 +30,28 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const { username } = await params; const token = (await cookies()).get("auth_token")?.value ?? null; + const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/; + + if (HANDLE_RE.test(username)) { + const [actorResult, postsResult, meResult] = await Promise.allSettled([ + lookupRemoteActor(username, token), + getRemoteActorPosts(username, 1, token), + token ? getMe(token) : Promise.resolve(null), + ]); + + if (actorResult.status === "rejected") { + notFound(); + } + + const actor = actorResult.value as Awaited>; + const posts = + postsResult.status === "fulfilled" ? postsResult.value.items : []; + const me = + meResult.status === "fulfilled" ? (meResult.value as Me | null) : null; + + return ; + } + const userProfilePromise = getUserProfile(username, token); const thoughtsPromise = getUserThoughts(username, token); const mePromise = token ? getMe(token) : Promise.resolve(null); diff --git a/thoughts-frontend/components/remote-user-profile.tsx b/thoughts-frontend/components/remote-user-profile.tsx new file mode 100644 index 0000000..82f1389 --- /dev/null +++ b/thoughts-frontend/components/remote-user-profile.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { UserAvatar } from "@/components/user-avatar"; +import { ThoughtList } from "@/components/thought-list"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ExternalLink, UserPlus, UserMinus } from "lucide-react"; +import { followUser, unfollowUser, RemoteActor, Thought, Me } from "@/lib/api"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; + +interface RemoteUserProfileProps { + actor: RemoteActor; + initialPosts: Thought[]; + me: Me | null; +} + +export function RemoteUserProfile({ + actor, + initialPosts, + me, +}: RemoteUserProfileProps) { + 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 { + if (followed) { + await unfollowUser(actor.handle, token); + setFollowed(false); + } else { + await followUser(actor.handle, token); + setFollowed(true); + toast.success(`Follow request sent to ${actor.handle}`); + } + } catch { + toast.error( + followed ? "Failed to unfollow." : "Failed to send follow request." + ); + } finally { + setLoading(false); + } + }; + + const isOwnProfile = me?.username === actor.handle; + + const authorDetails = new Map(); + initialPosts.forEach((t) => { + authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl }); + }); + + return ( +
+
+ +
+ + +
+ {initialPosts.length > 0 ? ( + + ) : ( + +

+ Posts are being fetched — check back soon. +

+
+ )} +
+
+
+ ); +} diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index 5ae3ae2..81631e6 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -15,11 +15,22 @@ export const UserSchema = z.object({ export const MeSchema = UserSchema; +export const ProfileFieldSchema = z.object({ + name: z.string(), + value: z.string(), +}); +export type ProfileField = z.infer; + export const RemoteActorSchema = z.object({ handle: z.string(), displayName: z.string().nullable(), avatarUrl: z.string().nullable(), url: z.string(), + bio: z.string().nullable(), + bannerUrl: z.string().nullable(), + alsoKnownAs: z.string().nullable(), + outboxUrl: z.string().nullable(), + attachment: z.array(ProfileFieldSchema), }); export type RemoteActor = z.infer; @@ -240,6 +251,23 @@ export const lookupRemoteActor = (handle: string, token: string | null) => token ); +export const getRemoteActorPosts = ( + handle: string, + page: number, + token: string | null +) => + apiFetch( + `/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`, + {}, + z.object({ + total: z.number(), + page: z.number(), + per_page: z.number(), + items: z.array(ThoughtSchema), + }), + token + ); + export const getAllUsers = (page: number = 1, pageSize: number = 20) => apiFetch( `/users?page=${page}&per_page=${pageSize}`,