From 5ce6d9f2da2cb274b6aa3e7e3cf883bc19f03491 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 7 Sep 2025 15:09:45 +0200 Subject: [PATCH] feat: refactor thought threads handling to improve structure and efficiency --- thoughts-frontend/app/page.tsx | 16 ++--- thoughts-frontend/app/tags/[tagName]/page.tsx | 9 +-- .../app/thoughts/[thoughtId]/page.tsx | 64 +++++++------------ .../app/users/[username]/page.tsx | 7 +- .../components/thought-thread.tsx | 13 ++-- thoughts-frontend/lib/api.ts | 24 ++++++- thoughts-frontend/lib/utils.ts | 46 +++++++------ 7 files changed, 89 insertions(+), 90 deletions(-) diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index 7a0b476..1e978f7 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -31,7 +31,10 @@ async function FeedPage({ token }: { token: string }) { getMe(token).catch(() => null) as Promise, ]); - const authors = [...new Set(feedData.thoughts.map((t) => t.authorUsername))]; + const allThoughts = feedData.thoughts; + const thoughtThreads = buildThoughtThreads(feedData.thoughts); + + const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; const userProfiles = await Promise.all( authors.map((username) => getUserProfile(username, token).catch(() => null)) ); @@ -42,12 +45,8 @@ async function FeedPage({ token }: { token: string }) { .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) ); - const { topLevelThoughts, repliesByParentId } = buildThoughtThreads( - feedData.thoughts - ); - const friends = (await getFriends(token)).users.map((user) => user.username); - const shouldDisplayTopFriends = me?.topFriends && me.topFriends.length > 8; + const shouldDisplayTopFriends = me?.topFriends && me.topFriends.length > 0; return (
@@ -65,16 +64,15 @@ async function FeedPage({ token }: { token: string }) {
- {topLevelThoughts.map((thought) => ( + {thoughtThreads.map((thought) => ( ))} - {topLevelThoughts.length === 0 && ( + {thoughtThreads.length === 0 && (

Your feed is empty. Follow some users to see their thoughts!

diff --git a/thoughts-frontend/app/tags/[tagName]/page.tsx b/thoughts-frontend/app/tags/[tagName]/page.tsx index 1892d26..696bb32 100644 --- a/thoughts-frontend/app/tags/[tagName]/page.tsx +++ b/thoughts-frontend/app/tags/[tagName]/page.tsx @@ -24,6 +24,7 @@ export default async function TagPage({ params }: TagPageProps) { } const allThoughts = thoughtsResult.value.thoughts; + const thoughtThreads = buildThoughtThreads(allThoughts); const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; @@ -36,9 +37,6 @@ export default async function TagPage({ params }: TagPageProps) { .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) ); - const { topLevelThoughts, repliesByParentId } = - buildThoughtThreads(allThoughts); - return (
@@ -48,16 +46,15 @@ export default async function TagPage({ params }: TagPageProps) {
- {topLevelThoughts.map((thought) => ( + {thoughtThreads.map((thought) => ( ))} - {topLevelThoughts.length === 0 && ( + {thoughtThreads.length === 0 && (

No thoughts found for this tag.

diff --git a/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx b/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx index 664972f..722f474 100644 --- a/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx +++ b/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx @@ -1,13 +1,12 @@ import { cookies } from "next/headers"; import { - getThoughtById, - getUserThoughts, + getThoughtThread, getUserProfile, getMe, Me, - Thought, + User, + ThoughtThread as ThoughtThreadType, } from "@/lib/api"; -import { buildThoughtThreads } from "@/lib/utils"; import { ThoughtThread } from "@/components/thought-thread"; import { notFound } from "next/navigation"; @@ -15,57 +14,43 @@ interface ThoughtPageProps { params: { thoughtId: string }; } -async function findConversationRoot( - startThought: Thought, - token: string | null -): Promise { - let currentThought = startThought; - while (currentThought.replyToId) { - const parentThought = await getThoughtById( - currentThought.replyToId, - token - ).catch(() => null); - if (!parentThought) break; - currentThought = parentThought; +function collectAuthors(thread: ThoughtThreadType): string[] { + const authors = new Set([thread.authorUsername]); + for (const reply of thread.replies) { + collectAuthors(reply).forEach((author) => authors.add(author)); } - return currentThought; + return Array.from(authors); } export default async function ThoughtPage({ params }: ThoughtPageProps) { const { thoughtId } = params; const token = (await cookies()).get("auth_token")?.value ?? null; - const initialThought = await getThoughtById(thoughtId, token).catch( - () => null - ); - - if (!initialThought) { - notFound(); - } - - const rootThought = await findConversationRoot(initialThought, token); - - const [thoughtsResult, meResult] = await Promise.allSettled([ - getUserThoughts(rootThought.authorUsername, token), + const [threadResult, meResult] = await Promise.allSettled([ + getThoughtThread(thoughtId, token), token ? getMe(token) : Promise.resolve(null), ]); - if (thoughtsResult.status === "rejected") { + if (threadResult.status === "rejected") { notFound(); } - const allThoughts = thoughtsResult.value.thoughts; + const thread = threadResult.value; const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; - const author = await getUserProfile(rootThought.authorUsername, token).catch( - () => null + // Fetch details for all authors in the thread efficiently + const authorUsernames = collectAuthors(thread); + const userProfiles = await Promise.all( + authorUsernames.map((username) => + getUserProfile(username, token).catch(() => null) + ) ); - const authorDetails = new Map(); - if (author) { - authorDetails.set(author.username, { avatarUrl: author.avatarUrl }); - } - const { repliesByParentId } = buildThoughtThreads(allThoughts); + const authorDetails = new Map( + userProfiles + .filter((u): u is User => !!u) + .map((user) => [user.username, { avatarUrl: user.avatarUrl }]) + ); return (
@@ -74,8 +59,7 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index 2254567..1962797 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -56,7 +56,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const thoughts = thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : []; - const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(thoughts); + const thoughtThreads = buildThoughtThreads(thoughts); const followersCount = followersResult.status === "fulfilled" @@ -205,16 +205,15 @@ export default async function ProfilePage({ params }: ProfilePageProps) { id="profile-card__thoughts" className="col-span-1 lg:col-span-3 space-y-4" > - {topLevelThoughts.map((thought) => ( + {thoughtThreads.map((thought) => ( ))} - {topLevelThoughts.length === 0 && ( + {thoughtThreads.length === 0 && ( ; + thought: ThoughtThreadType; authorDetails: Map; currentUser: Me | null; isReply?: boolean; @@ -11,7 +10,6 @@ interface ThoughtThreadProps { export function ThoughtThread({ thought, - repliesByParentId, authorDetails, currentUser, isReply = false, @@ -22,8 +20,6 @@ export function ThoughtThread({ ...authorDetails.get(thought.authorUsername), }; - const directReplies = repliesByParentId.get(thought.id) || []; - return (
- {directReplies.length > 0 && ( + {thought.replies.length > 0 && (
- {directReplies.map((reply) => ( + {thought.replies.map((reply) => ( = z.object({ + id: z.uuid(), + authorUsername: z.string(), + content: z.string(), + visibility: z.enum(["Public", "FriendsOnly", "Private"]), + replyToId: z.uuid().nullable(), + createdAt: z.coerce.date(), + replies: z.lazy(() => z.array(ThoughtThreadSchema)), +}); + export type User = z.infer; export type Me = z.infer; export type Thought = z.infer; @@ -91,6 +109,7 @@ export type Register = z.infer; export type Login = z.infer; export type ApiKey = z.infer; export type ApiKeyResponse = z.infer; +export type ThoughtThread = z.infer; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; @@ -294,4 +313,7 @@ export const deleteApiKey = (keyId: string, token: string) => { method: "DELETE" }, z.null(), token - ); \ No newline at end of file + ); + +export const getThoughtThread = (thoughtId: string, token: string | null) => + apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token); \ No newline at end of file diff --git a/thoughts-frontend/lib/utils.ts b/thoughts-frontend/lib/utils.ts index 64f24df..f4a2c83 100644 --- a/thoughts-frontend/lib/utils.ts +++ b/thoughts-frontend/lib/utils.ts @@ -1,35 +1,39 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" -import { Thought } from "./api"; +import { Thought, ThoughtThread as ThoughtThreadType } from "./api"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export function buildThoughtThreads(allThoughts: Thought[]) { - const repliesByParentId = new Map(); - const topLevelThoughts: Thought[] = []; +export function buildThoughtThreads(thoughts: Thought[]): ThoughtThreadType[] { + const thoughtMap = new Map(); + thoughts.forEach((t) => thoughtMap.set(t.id, t)); - // 1. Group all thoughts into top-level posts or replies - for (const thought of allThoughts) { + const threads: ThoughtThreadType[] = []; + const repliesMap: Record = {}; + + thoughts.forEach((thought) => { if (thought.replyToId) { - // It's a reply, group it with its parent - const replies = repliesByParentId.get(thought.replyToId) || []; - replies.push(thought); - repliesByParentId.set(thought.replyToId, replies); - } else { - // It's a top-level thought - topLevelThoughts.push(thought); + if (!repliesMap[thought.replyToId]) { + repliesMap[thought.replyToId] = []; + } + repliesMap[thought.replyToId].push(thought); } + }); + + function buildThread(thought: Thought): ThoughtThreadType { + return { + ...thought, + replies: (repliesMap[thought.id] || []).map(buildThread), + }; } - // 2. Sort top-level thoughts by date, newest first - topLevelThoughts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + thoughts.forEach((thought) => { + if (!thought.replyToId) { + threads.push(buildThread(thought)); + } + }); - // 3. Sort replies within each thread by date, oldest first for conversational flow - for (const replies of repliesByParentId.values()) { - replies.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - } - - return { topLevelThoughts, repliesByParentId }; + return threads; } \ No newline at end of file