perf(frontend): eliminate author profile waterfall — use thought.author directly

This commit is contained in:
2026-05-15 19:47:06 +02:00
parent 9ecbde019d
commit dadfe04934
10 changed files with 8581 additions and 114 deletions

View File

@@ -5,9 +5,7 @@ import {
getFriends, getFriends,
getMe, getMe,
getTopFriends, getTopFriends,
getUserProfile,
Me, Me,
User,
} from "@/lib/api"; } from "@/lib/api";
import { PostThoughtForm } from "@/components/post-thought-form"; import { PostThoughtForm } from "@/components/post-thought-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -62,17 +60,6 @@ async function FeedPage({
const { items: allThoughts, totalPages } = feedData!; const { items: allThoughts, totalPages } = feedData!;
const thoughtThreads = buildThoughtThreads(allThoughts); const thoughtThreads = buildThoughtThreads(allThoughts);
const authors = [...new Set(allThoughts.map((t) => t.author.username))];
const userProfiles = await Promise.all(
authors.map((username) => getUserProfile(username, token).catch(() => null))
);
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
userProfiles
.filter((u): u is User => !!u)
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
);
const friends = (await getFriends(token)).users.map((user) => user.username); const friends = (await getFriends(token)).users.map((user) => user.username);
const topFriendsData = me const topFriendsData = me
? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] })) ? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] }))
@@ -111,7 +98,6 @@ async function FeedPage({
<ThoughtThread <ThoughtThread
key={thought.id} key={thought.id}
thought={thought} thought={thought}
authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
))} ))}

View File

@@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getMe, search, lookupRemoteActor, User } from "@/lib/api"; import { getMe, search, lookupRemoteActor } from "@/lib/api";
export async function generateMetadata({ export async function generateMetadata({
searchParams, searchParams,
@@ -51,13 +51,6 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
token ? getMe(token).catch(() => null) : null, token ? getMe(token).catch(() => null) : null,
]); ]);
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
if (results) {
results.users.forEach((user: User) => {
authorDetails.set(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">
<header className="my-6"> <header className="my-6">
@@ -91,7 +84,6 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
<TabsContent value="thoughts"> <TabsContent value="thoughts">
<ThoughtList <ThoughtList
thoughts={results.thoughts} thoughts={results.thoughts}
authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
</TabsContent> </TabsContent>

View File

@@ -1,7 +1,7 @@
// app/tags/[tagName]/page.tsx // app/tags/[tagName]/page.tsx
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api"; import { getThoughtsByTag, getMe, Me } from "@/lib/api";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -49,16 +49,6 @@ export default async function TagPage({ params }: TagPageProps) {
const thoughtThreads = buildThoughtThreads(allThoughts); 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.author.username))];
const userProfiles = await Promise.all(
authors.map((username) => getUserProfile(username, token).catch(() => null))
);
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">
<header className="my-6"> <header className="my-6">
@@ -72,7 +62,6 @@ export default async function TagPage({ params }: TagPageProps) {
<ThoughtThread <ThoughtThread
key={thought.id} key={thought.id}
thought={thought} thought={thought}
authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
))} ))}

View File

@@ -3,10 +3,8 @@ import { cookies } from "next/headers";
import { import {
getThoughtById, getThoughtById,
getThoughtThread, getThoughtThread,
getUserProfile,
getMe, getMe,
Me, Me,
User,
ThoughtThread as ThoughtThreadType, ThoughtThread as ThoughtThreadType,
} from "@/lib/api"; } from "@/lib/api";
import { ThoughtThread } from "@/components/thought-thread"; import { ThoughtThread } from "@/components/thought-thread";
@@ -52,14 +50,6 @@ export async function generateMetadata({
}; };
} }
function collectAuthors(thread: ThoughtThreadType): string[] {
const authors = new Set<string>([thread.author.username]);
for (const reply of thread.replies) {
collectAuthors(reply).forEach((author) => authors.add(author));
}
return Array.from(authors);
}
export default async function ThoughtPage({ params }: ThoughtPageProps) { export default async function ThoughtPage({ params }: ThoughtPageProps) {
const { thoughtId } = await params; const { thoughtId } = await params;
const token = (await cookies()).get("auth_token")?.value ?? null; const token = (await cookies()).get("auth_token")?.value ?? null;
@@ -76,20 +66,6 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
const thread = threadResult.value; const thread = threadResult.value;
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; const me = meResult.status === "fulfilled" ? (meResult.value as Me) : 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<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">
<header className="my-6"> <header className="my-6">
@@ -98,7 +74,6 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
<main> <main>
<ThoughtThread <ThoughtThread
thought={thread} thought={thread}
authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
</main> </main>

View File

@@ -126,9 +126,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const fediverseHandle = const fediverseHandle =
user.local && apiDomain ? `@${user.username}@${apiDomain}` : null; user.local && apiDomain ? `@${user.username}@${apiDomain}` : null;
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
// Show who the profile owner follows (uses the already-fetched followingResult). // Show who the profile owner follows (uses the already-fetched followingResult).
const friends = const friends =
followingResult.status === "fulfilled" followingResult.status === "fulfilled"
@@ -277,7 +274,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
<ThoughtThread <ThoughtThread
key={thought.id} key={thought.id}
thought={thought} thought={thought}
authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
))} ))}

View File

@@ -91,11 +91,6 @@ export function RemoteUserProfile({
const isOwnProfile = me?.username === actor.handle; const isOwnProfile = me?.username === actor.handle;
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
initialPosts.forEach((t) => {
authorDetails.set(t.author.username, { avatarUrl: actor.avatarUrl });
});
return ( return (
<div> <div>
<div <div
@@ -220,7 +215,6 @@ export function RemoteUserProfile({
{initialPosts.length > 0 ? ( {initialPosts.length > 0 ? (
<ThoughtList <ThoughtList
thoughts={initialPosts} thoughts={initialPosts}
authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
) : ( ) : (

View File

@@ -7,11 +7,11 @@ import {
CardHeader, CardHeader,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { UserAvatar } from "./user-avatar"; import { UserAvatar } from "./user-avatar";
import { deleteThought, Me, Thought } from "@/lib/api"; import { Me, Thought } from "@/lib/api";
import { deleteThought } from "@/app/actions/thoughts";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
DropdownMenu, DropdownMenu,
@@ -42,25 +42,19 @@ import { cn } from "@/lib/utils";
interface ThoughtCardProps { interface ThoughtCardProps {
thought: Thought; thought: Thought;
author: {
username: string;
displayName?: string | null;
avatarUrl?: string | null;
};
currentUser: Me | null; currentUser: Me | null;
isReply?: boolean; isReply?: boolean;
} }
export function ThoughtCard({ export function ThoughtCard({
thought, thought,
author,
currentUser, currentUser,
isReply = false, isReply = false,
}: ThoughtCardProps) { }: ThoughtCardProps) {
const { author } = thought;
const [isAlertOpen, setIsAlertOpen] = useState(false); const [isAlertOpen, setIsAlertOpen] = useState(false);
const [isReplyOpen, setIsReplyOpen] = useState(false); const [isReplyOpen, setIsReplyOpen] = useState(false);
const { token } = useAuth(); const { token } = useAuth();
const router = useRouter();
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), { const timeAgo = formatDistanceToNow(new Date(thought.createdAt), {
addSuffix: true, addSuffix: true,
}); });
@@ -68,11 +62,9 @@ export function ThoughtCard({
const isAuthor = currentUser?.username === thought.author.username; const isAuthor = currentUser?.username === thought.author.username;
const handleDelete = async () => { const handleDelete = async () => {
if (!token) return;
try { try {
await deleteThought(thought.id, token); await deleteThought(thought.id);
toast.success("Thought deleted successfully."); toast.success("Thought deleted successfully.");
router.refresh();
} catch (error) { } catch (error) {
console.error("Failed to delete thought:", error); console.error("Failed to delete thought:", error);
toast.error("Failed to delete thought."); toast.error("Failed to delete thought.");

View File

@@ -4,15 +4,10 @@ import { Card, CardContent } from "./ui/card";
interface ThoughtListProps { interface ThoughtListProps {
thoughts: Thought[]; thoughts: Thought[];
authorDetails: Map<string, { avatarUrl?: string | null }>;
currentUser: Me | null; currentUser: Me | null;
} }
export function ThoughtList({ export function ThoughtList({ thoughts, currentUser }: ThoughtListProps) {
thoughts,
authorDetails,
currentUser,
}: ThoughtListProps) {
if (thoughts.length === 0) { if (thoughts.length === 0) {
return ( return (
<p className="text-center text-muted-foreground pt-8"> <p className="text-center text-muted-foreground pt-8">
@@ -25,21 +20,13 @@ export function ThoughtList({
<Card> <Card>
<CardContent className="divide-y p-0"> <CardContent className="divide-y p-0">
<div className="space-y-6 p-4"> <div className="space-y-6 p-4">
{thoughts.map((thought) => { {thoughts.map((thought) => (
const author = {
username: thought.author.username,
displayName: thought.author.displayName,
...authorDetails.get(thought.author.username),
};
return (
<ThoughtCard <ThoughtCard
key={thought.id} key={thought.id}
thought={thought} thought={thought}
author={author}
currentUser={currentUser} currentUser={currentUser}
/> />
); ))}
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -3,29 +3,19 @@ import { ThoughtCard } from "./thought-card";
interface ThoughtThreadProps { interface ThoughtThreadProps {
thought: ThoughtThreadType; thought: ThoughtThreadType;
authorDetails: Map<string, { avatarUrl?: string | null }>;
currentUser: Me | null; currentUser: Me | null;
isReply?: boolean; isReply?: boolean;
} }
export function ThoughtThread({ export function ThoughtThread({
thought, thought,
authorDetails,
currentUser, currentUser,
isReply = false, isReply = false,
}: ThoughtThreadProps) { }: ThoughtThreadProps) {
const author = {
username: thought.author.username,
displayName: thought.author.displayName,
avatarUrl: thought.author.avatarUrl, // API-provided avatar (from DB COALESCE)
...authorDetails.get(thought.author.username), // override for local users
};
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
thought={thought} thought={thought}
author={author}
currentUser={currentUser} currentUser={currentUser}
isReply={isReply} isReply={isReply}
/> />
@@ -39,7 +29,6 @@ export function ThoughtThread({
<ThoughtThread <ThoughtThread
key={reply.id} key={reply.id}
thought={reply} thought={reply}
authorDetails={authorDetails}
currentUser={currentUser} currentUser={currentUser}
isReply={true} isReply={true}
/> />

8567
thoughts-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff