perf(frontend): eliminate author profile waterfall — use thought.author directly
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
8567
thoughts-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user