From 57b1bfc447b52d90e096d45c0e2311eef93cdb15 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:06:06 +0200 Subject: [PATCH 1/7] feat(api-types): TopFriendsResponse with Vec --- crates/api-types/src/responses.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index 2350a22..fa6e9b8 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -75,6 +75,12 @@ pub struct NotificationResponse { pub created_at: DateTime, } +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TopFriendsResponse { + pub top_friends: Vec, +} + #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ErrorResponse { From 29e4af26d8dff12daafbf335db714a222f9f2757 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:08:22 +0200 Subject: [PATCH 2/7] =?UTF-8?q?fix(api):=20top-friends=20endpoint=20return?= =?UTF-8?q?s=20full=20UserResponse=20=E2=80=94=20eliminates=20frontend=20N?= =?UTF-8?q?+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/presentation/src/handlers/social.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index b3bc334..7fbb1bd 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -4,6 +4,8 @@ use crate::{ state::AppState, }; use api_types::requests::SetTopFriendsRequest; +use api_types::responses::TopFriendsResponse; +use crate::handlers::auth::to_user_response; use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends}; use application::use_cases::social::*; use axum::{ @@ -155,15 +157,17 @@ pub async fn put_top_friends( set_top_friends(&*d.top_friends, &uid, ids).await?; Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(get, path = "/users/{username}/top-friends", params(("username" = String, Path, description = "Username")), responses((status = 200, description = "Top friends list")))] +#[utoipa::path(get, path = "/users/{username}/top-friends", + params(("username" = String, Path, description = "Username")), + responses((status = 200, description = "Top friends list", body = TopFriendsResponse)))] pub async fn get_top_friends_handler( Deps(d): Deps, Path(username): Path, -) -> Result, ApiError> { +) -> Result, ApiError> { let user = get_user_by_username(&*d.users, &username).await?; let friends = get_top_friends(&*d.top_friends, &user.id).await?; - let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect(); - Ok(Json(serde_json::json!({ "topFriends": usernames }))) + let top_friends = friends.iter().map(|(_, u)| to_user_response(u)).collect(); + Ok(Json(TopFriendsResponse { top_friends })) } #[cfg(test)] From 2c3e7934b81fa343bb9d8f7a196ed046bca74245 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:10:37 +0200 Subject: [PATCH 3/7] fix(frontend): getTopFriends schema returns UserSchema[] not string[] --- thoughts-frontend/lib/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index d88ff8f..1b8cc59 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -232,7 +232,7 @@ export const getTopFriends = (username: string, token: string | null) => apiFetch( `/users/${username}/top-friends`, { next: { tags: [`profile:${username}`] } }, - z.object({ topFriends: z.array(z.string()) }), + z.object({ topFriends: z.array(UserSchema) }), token ); From 98d3fdb832c9507f66c5b75eb5d54c79534b26f0 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:10:55 +0200 Subject: [PATCH 4/7] feat(frontend): TagsSkeleton and CountSkeleton for sidebar Suspense fallbacks --- .../components/loading-skeleton.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/thoughts-frontend/components/loading-skeleton.tsx b/thoughts-frontend/components/loading-skeleton.tsx index 8b7cbbe..8e6a729 100644 --- a/thoughts-frontend/components/loading-skeleton.tsx +++ b/thoughts-frontend/components/loading-skeleton.tsx @@ -32,3 +32,26 @@ export function ProfileSkeleton() { ) } + +export function TagsSkeleton() { + return ( + + + + {[...Array(5)].map((_, i) => ( + + ))} + + + ) +} + +export function CountSkeleton() { + return ( + + + + + + ) +} From e86f07ef342d6b475a5c6979f0367a40805c3597 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:11:05 +0200 Subject: [PATCH 5/7] =?UTF-8?q?refactor(frontend):=20TopFriends=20self-con?= =?UTF-8?q?tained=20=E2=80=94=20fetches=20own=20data,=20no=20N+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- thoughts-frontend/components/top-friends.tsx | 42 ++++---------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/thoughts-frontend/components/top-friends.tsx b/thoughts-frontend/components/top-friends.tsx index b7aed43..9d4f23a 100644 --- a/thoughts-frontend/components/top-friends.tsx +++ b/thoughts-frontend/components/top-friends.tsx @@ -1,51 +1,25 @@ import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { UserAvatar } from "./user-avatar"; -import { getUserProfile, User } from "@/lib/api"; +import { getTopFriends } from "@/lib/api"; import { cookies } from "next/headers"; interface TopFriendsProps { - mode: "friends" | "top-friends"; - usernames: string[]; + username: string; } -export async function TopFriends({ - mode = "top-friends", - usernames, -}: TopFriendsProps) { +export async function TopFriends({ username }: TopFriendsProps) { const token = (await cookies()).get("auth_token")?.value ?? null; + const data = await getTopFriends(username, token).catch(() => ({ topFriends: [] })); + const friends = data.topFriends; - if (usernames.length === 0) { - return ( - - - Top Friends - - -

- No top friends to display. -

-
-
- ); - } - - const friendsResults = await Promise.allSettled( - usernames.map((username) => getUserProfile(username, token)) - ); - - const friends = friendsResults - .filter( - (result): result is PromiseFulfilledResult => - result.status === "fulfilled" - ) - .map((result) => result.value); + if (friends.length === 0) return null; return ( - {mode === "top-friends" ? "Top Friends" : "Friends"} + Top Friends @@ -59,7 +33,7 @@ export async function TopFriends({ {friend.displayName || friend.username} From 42baf3fe3cdf70c4bce18829706de82dc6e53fd9 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:14:36 +0200 Subject: [PATCH 6/7] =?UTF-8?q?perf(frontend):=20stream=20sidebar=20via=20?= =?UTF-8?q?Suspense=20=E2=80=94=20feed=20renders=20immediately;=20sidebar?= =?UTF-8?q?=20loads=20async?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- thoughts-frontend/app/page.tsx | 49 +++++++------------ .../app/users/[username]/page.tsx | 19 ++----- 2 files changed, 24 insertions(+), 44 deletions(-) diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index ebac652..a4dc23a 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -1,12 +1,6 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { - getFeed, - getFriends, - getMe, - getTopFriends, - Me, -} from "@/lib/api"; +import { getFeed, getMe, Me } from "@/lib/api"; import { ThoughtForm } from "@/components/thought-form"; import { EmptyState } from "@/components/empty-state"; import { Button } from "@/components/ui/button"; @@ -16,9 +10,10 @@ import { ThoughtThread } from "@/components/thought-thread"; import { buildThoughtThreads } from "@/lib/utils"; import { TopFriends } from "@/components/top-friends"; import { UsersCount } from "@/components/users-count"; - import { PaginationNav } from "@/components/pagination-nav"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton"; export const metadata: Metadata = { title: "Home", @@ -61,18 +56,26 @@ async function FeedPage({ const { items: allThoughts, totalPages } = feedData!; const thoughtThreads = buildThoughtThreads(allThoughts); - const friends = (await getFriends(token)).users.map((user) => user.username); - const topFriendsData = me - ? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] })) - : { topFriends: [] }; - const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0; + const sidebar = ( + <> + }> + + + }> + + + }> + + + + ); return (
@@ -84,14 +87,7 @@ async function FeedPage({
- - {shouldDisplayTopFriends && ( - - )} - {!shouldDisplayTopFriends && token && friends.length > 0 && ( - - )} - + {sidebar}
@@ -115,14 +111,7 @@ async function FeedPage({
diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index bbb4ef1..8e8c30c 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -5,7 +5,6 @@ import { getMe, getRemoteFollowers, getRemoteFollowing, - getTopFriends, getUserProfile, getUserThoughts, Me, @@ -52,6 +51,8 @@ import { notFound } from "next/navigation"; import { cookies } from "next/headers"; import { FollowButton } from "@/components/follow-button"; import { TopFriends } from "@/components/top-friends"; +import { Suspense } from "react"; +import { ProfileSkeleton } from "@/components/loading-skeleton"; import { buildThoughtThreads } from "@/lib/utils"; import { ThoughtThread } from "@/components/thought-thread"; import { Button } from "@/components/ui/button"; @@ -127,15 +128,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const fediverseHandle = user.local && apiDomain ? `@${user.username}@${apiDomain}` : null; - // Show who the profile owner follows (uses the already-fetched followingResult). - const friends = - followingResult.status === "fulfilled" - ? followingResult.value.items.map((u) => u.username) - : []; - - const topFriendsData = await getTopFriends(username, token).catch(() => ({ topFriends: [] })); - const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0; - return (
{user.customCss && ( @@ -252,10 +244,9 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
- {shouldDisplayTopFriends && ( - - )} - {token && } + }> + +
From f135e4d5834051e248d114c11a52c244846087a6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:16:45 +0200 Subject: [PATCH 7/7] feat(frontend): loading.tsx skeletons for feed, tags, search, and thread pages --- thoughts-frontend/app/loading.tsx | 20 +++++++++++++++++++ thoughts-frontend/app/search/loading.tsx | 12 +++++++++++ .../app/tags/[tagName]/loading.tsx | 12 +++++++++++ .../app/thoughts/[thoughtId]/loading.tsx | 13 ++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 thoughts-frontend/app/loading.tsx create mode 100644 thoughts-frontend/app/search/loading.tsx create mode 100644 thoughts-frontend/app/tags/[tagName]/loading.tsx create mode 100644 thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx diff --git a/thoughts-frontend/app/loading.tsx b/thoughts-frontend/app/loading.tsx new file mode 100644 index 0000000..55549f4 --- /dev/null +++ b/thoughts-frontend/app/loading.tsx @@ -0,0 +1,20 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function FeedLoading() { + return ( +
+
+
+
+ ); +} diff --git a/thoughts-frontend/app/search/loading.tsx b/thoughts-frontend/app/search/loading.tsx new file mode 100644 index 0000000..7b9887b --- /dev/null +++ b/thoughts-frontend/app/search/loading.tsx @@ -0,0 +1,12 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function SearchLoading() { + return ( +
+
+ + + +
+ ); +} diff --git a/thoughts-frontend/app/tags/[tagName]/loading.tsx b/thoughts-frontend/app/tags/[tagName]/loading.tsx new file mode 100644 index 0000000..30a7a62 --- /dev/null +++ b/thoughts-frontend/app/tags/[tagName]/loading.tsx @@ -0,0 +1,12 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function TagLoading() { + return ( +
+
+ + + +
+ ); +} diff --git a/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx b/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx new file mode 100644 index 0000000..4fbc58f --- /dev/null +++ b/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx @@ -0,0 +1,13 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function ThoughtLoading() { + return ( +
+ +
+ + +
+
+ ); +}