feat: refactor thought threads handling to improve structure and efficiency
This commit is contained in:
@@ -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>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
@@ -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"
|
||||||
|
@@ -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}
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
@@ -295,3 +314,6 @@ export const deleteApiKey = (keyId: string, token: string) =>
|
|||||||
z.null(),
|
z.null(),
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getThoughtThread = (thoughtId: string, token: string | null) =>
|
||||||
|
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
|
@@ -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);
|
|
||||||
} else {
|
|
||||||
// It's a top-level thought
|
|
||||||
topLevelThoughts.push(thought);
|
|
||||||
}
|
}
|
||||||
|
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
|
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
|
|
||||||
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;
|
||||||
}
|
}
|
Reference in New Issue
Block a user