diff --git a/thoughts-frontend/app/friends/page.tsx b/thoughts-frontend/app/friends/page.tsx
new file mode 100644
index 0000000..0be5f52
--- /dev/null
+++ b/thoughts-frontend/app/friends/page.tsx
@@ -0,0 +1,43 @@
+// app/friends/page.tsx
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { getMe, getMyFriends, getMyRemoteFriends, getTopFriends } from "@/lib/api";
+import { FriendsList } from "@/components/friends-list";
+import { TopFriendsStrip } from "@/components/top-friends-strip";
+
+export default async function FriendsPage() {
+ const token = (await cookies()).get("auth_token")?.value;
+ if (!token) redirect("/login");
+
+ const me = await getMe(token).catch(() => null);
+ if (!me) redirect("/login");
+
+ const [localFriendsData, remoteFriends, topFriendsData] = await Promise.all([
+ getMyFriends(token).catch(() => ({
+ items: [],
+ total: 0,
+ page: 1,
+ perPage: 50,
+ })),
+ getMyRemoteFriends(token).catch(() => []),
+ getTopFriends(me.username, token).catch(() => ({ topFriends: [] })),
+ ]);
+
+ return (
+
+ );
+}
diff --git a/thoughts-frontend/app/settings/friends/page.tsx b/thoughts-frontend/app/settings/friends/page.tsx
new file mode 100644
index 0000000..4f0a6a8
--- /dev/null
+++ b/thoughts-frontend/app/settings/friends/page.tsx
@@ -0,0 +1,34 @@
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { getMe, getMyFriends, getTopFriends } from "@/lib/api";
+import { TopFriendsEditor } from "@/components/top-friends-editor";
+
+export default async function FriendsSettingsPage() {
+ const token = (await cookies()).get("auth_token")?.value;
+ if (!token) redirect("/login");
+
+ const me = await getMe(token).catch(() => null);
+ if (!me) redirect("/login");
+
+ const [localFriendsData, topFriendsData] = await Promise.all([
+ getMyFriends(token).catch(() => ({ items: [], total: 0, page: 1, perPage: 50 })),
+ getTopFriends(me.username, token).catch(() => ({ topFriends: [] })),
+ ]);
+
+ return (
+
+
+
Top Friends
+
+ Choose up to 8 friends to feature on your profile. Only local friends
+ can be top friends.
+
+
+
+
+ );
+}
diff --git a/thoughts-frontend/components/friends-list.tsx b/thoughts-frontend/components/friends-list.tsx
new file mode 100644
index 0000000..6285c7c
--- /dev/null
+++ b/thoughts-frontend/components/friends-list.tsx
@@ -0,0 +1,47 @@
+import Link from "next/link";
+import { User, RemoteActor } from "@/lib/api";
+import { UserAvatar } from "./user-avatar";
+import { RemoteFriendCard } from "./remote-friend-card";
+import { Card, CardContent } from "./ui/card";
+
+interface FriendsListProps {
+ localFriends: User[];
+ remoteFriends: RemoteActor[];
+}
+
+export function FriendsList({ localFriends, remoteFriends }: FriendsListProps) {
+ if (localFriends.length === 0 && remoteFriends.length === 0) {
+ return (
+
+ No friends yet. Follow someone and wait for them to follow back!
+
+ );
+ }
+
+ return (
+
+
+ {localFriends.map((user) => (
+
+
+
+
+ {user.displayName || user.username}
+
+
+ @{user.username}
+
+
+
+ ))}
+ {remoteFriends.map((actor) => (
+
+ ))}
+
+
+ );
+}
diff --git a/thoughts-frontend/components/remote-friend-card.tsx b/thoughts-frontend/components/remote-friend-card.tsx
new file mode 100644
index 0000000..7ba1a93
--- /dev/null
+++ b/thoughts-frontend/components/remote-friend-card.tsx
@@ -0,0 +1,33 @@
+import { RemoteActor } from "@/lib/api";
+import { UserAvatar } from "./user-avatar";
+
+interface RemoteFriendCardProps {
+ actor: RemoteActor;
+}
+
+export function RemoteFriendCard({ actor }: RemoteFriendCardProps) {
+ return (
+
+
+
+
+ {actor.displayName || actor.handle}
+
+
+ @{actor.handle}
+
+
+
+ federated
+
+
+ );
+}
diff --git a/thoughts-frontend/components/top-friends-editor.tsx b/thoughts-frontend/components/top-friends-editor.tsx
new file mode 100644
index 0000000..b52844c
--- /dev/null
+++ b/thoughts-frontend/components/top-friends-editor.tsx
@@ -0,0 +1,161 @@
+// components/top-friends-editor.tsx
+"use client";
+
+import { useState } from "react";
+import { User, setTopFriends } from "@/lib/api";
+import { UserAvatar } from "./user-avatar";
+import { Button } from "./ui/button";
+import { Input } from "./ui/input";
+import { Card, CardContent } from "./ui/card";
+
+interface TopFriendsEditorProps {
+ token: string;
+ initialTopFriends: User[];
+ localFriends: User[];
+}
+
+export function TopFriendsEditor({
+ token,
+ initialTopFriends,
+ localFriends,
+}: TopFriendsEditorProps) {
+ const [topFriends, setTopFriendsState] = useState(initialTopFriends);
+ const [search, setSearch] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+
+ const topIds = new Set(topFriends.map((f) => f.id));
+ const suggestions = localFriends.filter(
+ (f) =>
+ !topIds.has(f.id) &&
+ (f.username.toLowerCase().includes(search.toLowerCase()) ||
+ (f.displayName ?? "").toLowerCase().includes(search.toLowerCase()))
+ );
+
+ function remove(id: string) {
+ setTopFriendsState((prev) => prev.filter((f) => f.id !== id));
+ setSaved(false);
+ }
+
+ function add(user: User) {
+ if (topFriends.length >= 8) return;
+ setTopFriendsState((prev) => [...prev, user]);
+ setSearch("");
+ setSaved(false);
+ }
+
+ async function save() {
+ setSaving(true);
+ setError(null);
+ setSaved(false);
+ try {
+ await setTopFriends(token, topFriends.map((f) => f.id));
+ setSaved(true);
+ } catch {
+ setError("Failed to save. Please try again.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+ {topFriends.length === 0 && (
+
+ No top friends yet. Add up to 8 from the search below.
+
+ )}
+ {topFriends.map((friend, i) => (
+
+
+ {i + 1}
+
+
+
+
+ {friend.displayName || friend.username}
+
+
+ @{friend.username}
+
+
+
+
+ ))}
+
+
+
+ {topFriends.length < 8 && (
+
+
setSearch(e.target.value)}
+ />
+ {search.length > 0 && (
+
+
+ {suggestions.length === 0 ? (
+
+ No matches.
+
+ ) : (
+ suggestions.slice(0, 5).map((user) => (
+
+ ))
+ )}
+
+
+ )}
+
+ )}
+
+
+
+ {saved && (
+
Saved!
+ )}
+ {error && (
+
{error}
+ )}
+
+
+ );
+}
diff --git a/thoughts-frontend/components/top-friends-strip.tsx b/thoughts-frontend/components/top-friends-strip.tsx
new file mode 100644
index 0000000..227ad12
--- /dev/null
+++ b/thoughts-frontend/components/top-friends-strip.tsx
@@ -0,0 +1,39 @@
+import Link from "next/link";
+import { User } from "@/lib/api";
+import { UserAvatar } from "./user-avatar";
+
+interface TopFriendsStripProps {
+ topFriends: User[];
+}
+
+export function TopFriendsStrip({ topFriends }: TopFriendsStripProps) {
+ return (
+
+
+ {topFriends.length === 0 ? (
+
No top friends yet.
+ ) : (
+ topFriends.map((f) => (
+
+
+
+ ))
+ )}
+
+
+ Edit →
+
+
+ );
+}
diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts
index c94b390..322825b 100644
--- a/thoughts-frontend/lib/api.ts
+++ b/thoughts-frontend/lib/api.ts
@@ -480,6 +480,37 @@ export const getRemoteFollowing = (token: string) =>
token
);
+// ── Friends ───────────────────────────────────────────────────────────────
+
+export const getMyFriends = (token: string, page = 1, perPage = 50) =>
+ apiFetch(
+ `/users/me/friends?page=${page}&per_page=${perPage}`,
+ { cache: "no-store" },
+ z.object({
+ items: z.array(UserSchema),
+ total: z.number(),
+ page: z.number(),
+ perPage: z.number(),
+ }),
+ token
+ );
+
+export const getMyRemoteFriends = (token: string) =>
+ apiFetch(
+ "/federation/me/friends",
+ { cache: "no-store" },
+ z.array(RemoteActorSchema),
+ token
+ );
+
+export const setTopFriends = (token: string, friendIds: string[]) =>
+ apiFetch(
+ "/users/me/top-friends",
+ { method: "PUT", body: JSON.stringify({ friendIds }) },
+ z.null(),
+ token
+ );
+
export const unfollowRemoteActor = (handle: string, token: string) =>
apiFetch(
"/federation/me/following",