feat: refactor thought threads handling to improve structure and efficiency

This commit is contained in:
2025-09-07 15:09:45 +02:00
parent 40695b7ad3
commit 5ce6d9f2da
7 changed files with 89 additions and 90 deletions

View File

@@ -31,7 +31,10 @@ async function FeedPage({ token }: { token: string }) {
getMe(token).catch(() => null) as Promise<Me | null>, getMe(token).catch(() => null) as Promise<Me | null>,
]); ]);
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( const userProfiles = await Promise.all(
authors.map((username) => getUserProfile(username, token).catch(() => null)) 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 }]) .map((user) => [user.username, { avatarUrl: user.avatarUrl }])
); );
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(
feedData.thoughts
);
const friends = (await getFriends(token)).users.map((user) => user.username); 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 ( return (
<div className="container mx-auto max-w-6xl p-4 sm:p-6"> <div className="container mx-auto max-w-6xl p-4 sm:p-6">
@@ -65,16 +64,15 @@ async function FeedPage({ token }: { token: string }) {
</header> </header>
<PostThoughtForm /> <PostThoughtForm />
<div className="space-y-6"> <div className="space-y-6">
{topLevelThoughts.map((thought) => ( {thoughtThreads.map((thought) => (
<ThoughtThread <ThoughtThread
key={thought.id} key={thought.id}
thought={thought} thought={thought}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails} authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
))} ))}
{topLevelThoughts.length === 0 && ( {thoughtThreads.length === 0 && (
<p className="text-center text-muted-foreground pt-8"> <p className="text-center text-muted-foreground pt-8">
Your feed is empty. Follow some users to see their thoughts! Your feed is empty. Follow some users to see their thoughts!
</p> </p>

View File

@@ -24,6 +24,7 @@ export default async function TagPage({ params }: TagPageProps) {
} }
const allThoughts = thoughtsResult.value.thoughts; const allThoughts = thoughtsResult.value.thoughts;
const thoughtThreads = buildThoughtThreads(allThoughts);
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; 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 }]) .map((user) => [user.username, { avatarUrl: user.avatarUrl }])
); );
const { topLevelThoughts, repliesByParentId } =
buildThoughtThreads(allThoughts);
return ( return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6"> <div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6"> <header className="my-6">
@@ -48,16 +46,15 @@ export default async function TagPage({ params }: TagPageProps) {
</h1> </h1>
</header> </header>
<main className="space-y-6"> <main className="space-y-6">
{topLevelThoughts.map((thought) => ( {thoughtThreads.map((thought) => (
<ThoughtThread <ThoughtThread
key={thought.id} key={thought.id}
thought={thought} thought={thought}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails} authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
))} ))}
{topLevelThoughts.length === 0 && ( {thoughtThreads.length === 0 && (
<p className="text-center text-muted-foreground pt-8"> <p className="text-center text-muted-foreground pt-8">
No thoughts found for this tag. No thoughts found for this tag.
</p> </p>

View File

@@ -1,13 +1,12 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { import {
getThoughtById, getThoughtThread,
getUserThoughts,
getUserProfile, getUserProfile,
getMe, getMe,
Me, Me,
Thought, User,
ThoughtThread as ThoughtThreadType,
} from "@/lib/api"; } from "@/lib/api";
import { buildThoughtThreads } from "@/lib/utils";
import { ThoughtThread } from "@/components/thought-thread"; import { ThoughtThread } from "@/components/thought-thread";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -15,57 +14,43 @@ interface ThoughtPageProps {
params: { thoughtId: string }; params: { thoughtId: string };
} }
async function findConversationRoot( function collectAuthors(thread: ThoughtThreadType): string[] {
startThought: Thought, const authors = new Set<string>([thread.authorUsername]);
token: string | null for (const reply of thread.replies) {
): Promise<Thought> { collectAuthors(reply).forEach((author) => authors.add(author));
let currentThought = startThought;
while (currentThought.replyToId) {
const parentThought = await getThoughtById(
currentThought.replyToId,
token
).catch(() => null);
if (!parentThought) break;
currentThought = parentThought;
} }
return currentThought; return Array.from(authors);
} }
export default async function ThoughtPage({ params }: ThoughtPageProps) { export default async function ThoughtPage({ params }: ThoughtPageProps) {
const { thoughtId } = params; const { thoughtId } = params;
const token = (await cookies()).get("auth_token")?.value ?? null; const token = (await cookies()).get("auth_token")?.value ?? null;
const initialThought = await getThoughtById(thoughtId, token).catch( const [threadResult, meResult] = await Promise.allSettled([
() => null getThoughtThread(thoughtId, token),
);
if (!initialThought) {
notFound();
}
const rootThought = await findConversationRoot(initialThought, token);
const [thoughtsResult, meResult] = await Promise.allSettled([
getUserThoughts(rootThought.authorUsername, token),
token ? getMe(token) : Promise.resolve(null), token ? getMe(token) : Promise.resolve(null),
]); ]);
if (thoughtsResult.status === "rejected") { if (threadResult.status === "rejected") {
notFound(); notFound();
} }
const allThoughts = thoughtsResult.value.thoughts; const thread = threadResult.value;
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const author = await getUserProfile(rootThought.authorUsername, token).catch( // Fetch details for all authors in the thread efficiently
() => null const authorUsernames = collectAuthors(thread);
const userProfiles = await Promise.all(
authorUsernames.map((username) =>
getUserProfile(username, token).catch(() => null)
)
); );
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
if (author) {
authorDetails.set(author.username, { avatarUrl: author.avatarUrl });
}
const { repliesByParentId } = buildThoughtThreads(allThoughts); const authorDetails = new Map<string, { avatarUrl?: string | null }>(
userProfiles
.filter((u): u is User => !!u)
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
);
return ( return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6"> <div className="container mx-auto max-w-2xl p-4 sm:p-6">
@@ -74,8 +59,7 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
</header> </header>
<main> <main>
<ThoughtThread <ThoughtThread
thought={rootThought} thought={thread}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails} authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />

View File

@@ -56,7 +56,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const thoughts = const thoughts =
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : []; thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : [];
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(thoughts); const thoughtThreads = buildThoughtThreads(thoughts);
const followersCount = const followersCount =
followersResult.status === "fulfilled" followersResult.status === "fulfilled"
@@ -205,16 +205,15 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
id="profile-card__thoughts" id="profile-card__thoughts"
className="col-span-1 lg:col-span-3 space-y-4" className="col-span-1 lg:col-span-3 space-y-4"
> >
{topLevelThoughts.map((thought) => ( {thoughtThreads.map((thought) => (
<ThoughtThread <ThoughtThread
key={thought.id} key={thought.id}
thought={thought} thought={thought}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails} authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
))} ))}
{topLevelThoughts.length === 0 && ( {thoughtThreads.length === 0 && (
<Card <Card
id="profile-card__no-thoughts" id="profile-card__no-thoughts"
className="flex items-center justify-center h-48" className="flex items-center justify-center h-48"

View File

@@ -1,9 +1,8 @@
import { Me, Thought } from "@/lib/api"; import { Me, ThoughtThread as ThoughtThreadType } from "@/lib/api";
import { ThoughtCard } from "./thought-card"; import { ThoughtCard } from "./thought-card";
interface ThoughtThreadProps { interface ThoughtThreadProps {
thought: Thought; thought: ThoughtThreadType;
repliesByParentId: Map<string, Thought[]>;
authorDetails: Map<string, { avatarUrl?: string | null }>; authorDetails: Map<string, { avatarUrl?: string | null }>;
currentUser: Me | null; currentUser: Me | null;
isReply?: boolean; isReply?: boolean;
@@ -11,7 +10,6 @@ interface ThoughtThreadProps {
export function ThoughtThread({ export function ThoughtThread({
thought, thought,
repliesByParentId,
authorDetails, authorDetails,
currentUser, currentUser,
isReply = false, isReply = false,
@@ -22,8 +20,6 @@ export function ThoughtThread({
...authorDetails.get(thought.authorUsername), ...authorDetails.get(thought.authorUsername),
}; };
const directReplies = repliesByParentId.get(thought.id) || [];
return ( return (
<div id={`thought-thread-${thought.id}`} className="flex flex-col gap-0"> <div id={`thought-thread-${thought.id}`} className="flex flex-col gap-0">
<ThoughtCard <ThoughtCard
@@ -33,16 +29,15 @@ export function ThoughtThread({
isReply={isReply} isReply={isReply}
/> />
{directReplies.length > 0 && ( {thought.replies.length > 0 && (
<div <div
id={`thought-thread-${thought.id}__replies`} id={`thought-thread-${thought.id}__replies`}
className="pl-6 border-l-2 border-primary border-dashed ml-6 flex flex-col gap-4 pt-4" className="pl-6 border-l-2 border-primary border-dashed ml-6 flex flex-col gap-4 pt-4"
> >
{directReplies.map((reply) => ( {thought.replies.map((reply) => (
<ThoughtThread // RECURSIVE CALL <ThoughtThread // RECURSIVE CALL
key={reply.id} key={reply.id}
thought={reply} thought={reply}
repliesByParentId={repliesByParentId} // Pass the full map down
authorDetails={authorDetails} authorDetails={authorDetails}
currentUser={currentUser} currentUser={currentUser}
isReply={true} isReply={true}

View File

@@ -84,6 +84,24 @@ export const CreateApiKeySchema = z.object({
name: z.string().min(1, "Key name cannot be empty."), name: z.string().min(1, "Key name cannot be empty."),
}); });
export const ThoughtThreadSchema: z.ZodType<{
id: string;
authorUsername: string;
content: string;
visibility: "Public" | "FriendsOnly" | "Private";
replyToId: string | null;
createdAt: Date;
replies: ThoughtThread[];
}> = 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<typeof UserSchema>; export type User = z.infer<typeof UserSchema>;
export type Me = z.infer<typeof MeSchema>; export type Me = z.infer<typeof MeSchema>;
export type Thought = z.infer<typeof ThoughtSchema>; export type Thought = z.infer<typeof ThoughtSchema>;
@@ -91,6 +109,7 @@ export type Register = z.infer<typeof RegisterSchema>;
export type Login = z.infer<typeof LoginSchema>; export type Login = z.infer<typeof LoginSchema>;
export type ApiKey = z.infer<typeof ApiKeySchema>; export type ApiKey = z.infer<typeof ApiKeySchema>;
export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>; export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>;
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; 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" }, { method: "DELETE" },
z.null(), z.null(),
token token
); );
export const getThoughtThread = (thoughtId: string, token: string | null) =>
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);

View File

@@ -1,35 +1,39 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { Thought } from "./api"; import { Thought, ThoughtThread as ThoughtThreadType } from "./api";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function buildThoughtThreads(allThoughts: Thought[]) { export function buildThoughtThreads(thoughts: Thought[]): ThoughtThreadType[] {
const repliesByParentId = new Map<string, Thought[]>(); const thoughtMap = new Map<string, Thought>();
const topLevelThoughts: Thought[] = []; thoughts.forEach((t) => thoughtMap.set(t.id, t));
// 1. Group all thoughts into top-level posts or replies const threads: ThoughtThreadType[] = [];
for (const thought of allThoughts) { const repliesMap: Record<string, Thought[]> = {};
thoughts.forEach((thought) => {
if (thought.replyToId) { if (thought.replyToId) {
// It's a reply, group it with its parent if (!repliesMap[thought.replyToId]) {
const replies = repliesByParentId.get(thought.replyToId) || []; repliesMap[thought.replyToId] = [];
replies.push(thought); }
repliesByParentId.set(thought.replyToId, replies); repliesMap[thought.replyToId].push(thought);
} else {
// It's a top-level thought
topLevelThoughts.push(thought);
} }
});
function buildThread(thought: Thought): ThoughtThreadType {
return {
...thought,
replies: (repliesMap[thought.id] || []).map(buildThread),
};
} }
// 2. Sort top-level thoughts by date, newest first thoughts.forEach((thought) => {
topLevelThoughts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); if (!thought.replyToId) {
threads.push(buildThread(thought));
}
});
// 3. Sort replies within each thread by date, oldest first for conversational flow return threads;
for (const replies of repliesByParentId.values()) {
replies.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
return { topLevelThoughts, repliesByParentId };
} }