diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/_lib/page-data.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/_lib/page-data.tsx
index 29b3c805..9efe2273 100644
--- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/_lib/page-data.tsx
+++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/_lib/page-data.tsx
@@ -1,6 +1,9 @@
import "server-only";
import { getDidFromHandleOrDid } from "@/lib/data/atproto/did";
-import { getCommentWithChildren } from "@/lib/data/db/comment";
+import {
+ getCommentWithChildren,
+ shouldHideComment,
+} from "@/lib/data/db/comment";
import { getPost } from "@/lib/data/db/post";
import { notFound } from "next/navigation";
@@ -33,5 +36,9 @@ export async function getCommentPageData(params: CommentPageParams) {
notFound();
}
+ if (shouldHideComment(comment)) {
+ notFound();
+ }
+
return { post, comment, postAuthorDid, commentAuthorDid };
}
diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/og-image/route.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/og-image/route.tsx
index 2748bec2..4c6c3a30 100644
--- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/og-image/route.tsx
+++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/og-image/route.tsx
@@ -10,6 +10,8 @@ import {
} from "@/lib/og";
import { CommentPageParams, getCommentPageData } from "../_lib/page-data";
import { getBlueskyProfile } from "@/lib/data/user";
+import { shouldHideComment } from "@/lib/data/db/comment";
+import { notFound } from "next/navigation";
export const dynamic = "force-static";
export const revalidate = 60 * 60; // 1 hour
@@ -19,6 +21,10 @@ export async function GET(
{ params }: { params: CommentPageParams },
) {
const { comment } = await getCommentPageData(params);
+ if (shouldHideComment(comment) || comment.status !== "live") {
+ notFound();
+ }
+
const { avatar, handle } = await getBlueskyProfile(comment.authorDid);
return frontpageOgImageResponse(
@@ -72,8 +78,8 @@ export async function GET(
- {comment.children.length}{" "}
- {comment.children.length === 1 ? "reply" : "replies"}
+ {comment.children!.length}{" "}
+ {comment.children!.length === 1 ? "reply" : "replies"}
diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/page.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/page.tsx
index 6a2f7c42..20a01471 100644
--- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/page.tsx
+++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/page.tsx
@@ -22,23 +22,26 @@ export async function generateMetadata({
const path = `/post/${params.postAuthor}/${params.postRkey}/${params.commentAuthor}/${params.commentRkey}`;
return {
- title: `${post.title} - @${handle}: "${truncateText(comment.body, 15)}..."`,
+ title: `${post.title} - ${comment.status === "live" ? `@${handle}: "${truncateText(comment.body, 15)}..."` : "Deleted comment"}`,
alternates: {
canonical: `https://frontpage.fyi${path}`,
},
- openGraph: {
- title: `@${handle}'s comment on Frontpage`,
- description: truncateText(comment.body, 47),
- type: "article",
- publishedTime: comment.createdAt.toISOString(),
- authors: [`@${handle}`],
- url: `https://frontpage.fyi${path}`,
- images: [
- {
- url: `${path}/og-image`,
- },
- ],
- },
+ openGraph:
+ comment.status === "live"
+ ? {
+ title: `@${handle}'s comment on Frontpage`,
+ description: truncateText(comment.body, 47),
+ type: "article",
+ publishedTime: comment.createdAt.toISOString(),
+ authors: [`@${handle}`],
+ url: `https://frontpage.fyi${path}`,
+ images: [
+ {
+ url: `${path}/og-image`,
+ },
+ ],
+ }
+ : undefined,
};
}
@@ -53,24 +56,16 @@ export default async function CommentPage({
<>
See all comments
>
);
diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx
index 1e96b4d0..449b76d5 100644
--- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx
+++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx
@@ -21,7 +21,6 @@ import {
deleteCommentAction,
} from "./actions";
import { ChatBubbleIcon, TrashIcon } from "@radix-ui/react-icons";
-import { VariantProps, cva } from "class-variance-authority";
import {
useActionState,
useRef,
@@ -37,27 +36,13 @@ import { Spinner } from "@/lib/components/ui/spinner";
import { DID } from "@/lib/data/atproto/did";
import { InputLengthIndicator } from "@/lib/components/input-length-indicator";
import { MAX_COMMENT_LENGTH } from "@/lib/data/db/constants";
+import type { CommentModel } from "@/lib/data/db/comment";
-const commentVariants = cva(undefined, {
- variants: {
- level: {
- 0: "",
- 1: "pl-8",
- 2: "pl-16",
- 3: "pl-24",
- },
- },
- defaultVariants: {
- level: 0,
- },
-});
-
-export type CommentProps = VariantProps & {
- rkey: string;
- cid: string;
- id: number;
+export type CommentClientProps = Pick<
+ CommentModel,
+ "rkey" | "cid" | "id" | "authorDid"
+> & {
postRkey: string;
- authorDid: DID;
postAuthorDid: DID;
initialVoteState: VoteButtonState;
hasAuthored: boolean;
@@ -71,19 +56,18 @@ export function CommentClientWrapperWithToolbar({
postRkey,
authorDid,
postAuthorDid,
- level,
initialVoteState,
hasAuthored,
children,
-}: CommentProps) {
+}: CommentClientProps) {
const [showNewComment, setShowNewComment] = useState(false);
const commentRef = useRef(null);
const newCommentTextAreaRef = useRef(null);
const { toast } = useToast();
return (
-
+ <>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
-
+
{children}
@@ -173,7 +157,7 @@ export function CommentClientWrapperWithToolbar({
}
/>
) : null}
-
+ >
);
}
diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx
index d77a4822..fe8619ae 100644
--- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx
+++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx
@@ -1,46 +1,79 @@
import { getUser, getVerifiedHandle } from "@/lib/data/user";
-import {
- CommentClientWrapperWithToolbar,
- CommentProps,
-} from "./comment-client";
-import { getCommentsForPost } from "@/lib/data/db/comment";
+import { CommentClientWrapperWithToolbar } from "./comment-client";
+import { CommentModel } from "@/lib/data/db/comment";
import { TimeAgo } from "@/lib/components/time-ago";
-import { UserAvatar } from "@/lib/components/user-avatar";
+import { AvatarFallback, UserAvatar } from "@/lib/components/user-avatar";
import Link from "next/link";
import { getDidFromHandleOrDid } from "@/lib/data/atproto/did";
import { UserHoverCard } from "@/lib/components/user-hover-card";
+import { VariantProps, cva } from "class-variance-authority";
+import { cn } from "@/lib/utils";
-type ServerCommentProps = Omit<
- CommentProps,
- // Client only props
- | "voteAction"
- | "unvoteAction"
- | "initialVoteState"
- | "hasAuthored"
- | "children"
- | "postAuthorDid"
-> & {
- // Server only props
- cid: string;
- isUpvoted: boolean;
- childComments: Awaited>;
- comment: string;
- createdAt: Date;
+const commentVariants = cva(undefined, {
+ variants: {
+ level: {
+ 0: "",
+ 1: "pl-8",
+ 2: "pl-16",
+ 3: "pl-24",
+ },
+ },
+ defaultVariants: {
+ level: 0,
+ },
+});
+
+type CommentProps = VariantProps & {
+ comment: CommentModel;
postAuthorParam: string;
+ postRkey: string;
};
-export async function Comment({
- authorDid,
- isUpvoted,
- childComments,
+export function Comment({ comment, level, ...props }: CommentProps) {
+ if (
+ comment.status !== "live" &&
+ comment.children &&
+ comment.children.length === 0
+ ) {
+ return null;
+ }
+
+ return (
+
+ {comment.status === "live" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function NestComment({
+ children,
+ level,
+ className,
+}: {
+ children: React.ReactNode;
+ level: CommentProps["level"];
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+async function LiveComment({
comment,
- createdAt,
+ level,
postAuthorParam,
- ...props
-}: ServerCommentProps) {
+ postRkey,
+}: CommentProps) {
const [postAuthorDid, handle] = await Promise.all([
getDidFromHandleOrDid(postAuthorParam),
- getVerifiedHandle(authorDid),
+ getVerifiedHandle(comment.authorDid),
]);
if (!postAuthorDid) {
@@ -49,58 +82,93 @@ export async function Comment({
}
const user = await getUser();
- const hasAuthored = user?.did === authorDid;
+ const hasAuthored = user?.did === comment.authorDid;
+
+ const childCommentLevel = getChildCommentLevel(level);
return (
<>
- {childComments?.map((comment) => (
+ {comment.children?.map((comment) => (
))}
>
);
}
+
+function DeletedComment({
+ comment,
+ postAuthorParam,
+ postRkey,
+ level,
+}: CommentProps) {
+ const childCommentLevel = getChildCommentLevel(level);
+
+ return (
+
+
+ {comment.children?.map((comment) => (
+
+ ))}
+
+ );
+}
+
+function getChildCommentLevel(level: number | null | undefined) {
+ // TODO: Show deeper levels behind a parent permalink. For now we just show them all at the max level
+ return Math.min((level ?? 0) + 1, 3) as CommentProps["level"];
+}
diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/page.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/page.tsx
index 72c88a13..e49d0332 100644
--- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/page.tsx
+++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/page.tsx
@@ -56,17 +56,11 @@ export default async function Post({ params }: { params: PostPageParams }) {
{comments.map((comment) => (
))}
diff --git a/packages/frontpage/app/(app)/profile/[user]/page.tsx b/packages/frontpage/app/(app)/profile/[user]/page.tsx
index 54df6448..4ea2804a 100644
--- a/packages/frontpage/app/(app)/profile/[user]/page.tsx
+++ b/packages/frontpage/app/(app)/profile/[user]/page.tsx
@@ -78,18 +78,10 @@ export default async function Profile({ params }: { params: Params }) {
if (entity.type === "comment") {
return (
);
}
@@ -125,18 +117,10 @@ export default async function Profile({ params }: { params: Params }) {
{userComments.map((comment) => {
return (
);
})}
diff --git a/packages/frontpage/lib/components/user-avatar.tsx b/packages/frontpage/lib/components/user-avatar.tsx
index fb05ee3f..9074ce1a 100644
--- a/packages/frontpage/lib/components/user-avatar.tsx
+++ b/packages/frontpage/lib/components/user-avatar.tsx
@@ -24,17 +24,18 @@ export async function UserAvatar(props: UserAvatarProps) {
);
}
-function AvatarFallback({ size }: { size: UserAvatarProps["size"] }) {
+export function AvatarFallback({ size }: { size: UserAvatarProps["size"] }) {
const sizePx = userAvatarSizes[size ?? "small"];
return (
-
+
);
}
diff --git a/packages/frontpage/lib/data/atproto/record.ts b/packages/frontpage/lib/data/atproto/record.ts
index e4ab1075..b324a0d9 100644
--- a/packages/frontpage/lib/data/atproto/record.ts
+++ b/packages/frontpage/lib/data/atproto/record.ts
@@ -2,6 +2,7 @@ import "server-only";
import { z } from "zod";
import { ensureUser } from "../user";
import { DataLayerError } from "../error";
+import { Prettify } from "@/lib/utils";
export const AtUri = z.string().transform((value, ctx) => {
const match = value.match(/^at:\/\/(.+?)(\/.+?)?(\/.+?)?$/);
@@ -30,11 +31,6 @@ export const AtUri = z.string().transform((value, ctx) => {
};
});
-type Prettify = {
- [K in keyof T]: T[K];
- // eslint-disable-next-line @typescript-eslint/ban-types
-} & {};
-
export function createAtUriParser(
collectionSchema: TCollection,
): z.ZodType<
diff --git a/packages/frontpage/lib/data/db/comment.ts b/packages/frontpage/lib/data/db/comment.ts
index 954cf225..c515d108 100644
--- a/packages/frontpage/lib/data/db/comment.ts
+++ b/packages/frontpage/lib/data/db/comment.ts
@@ -1,10 +1,57 @@
import "server-only";
import { cache } from "react";
import { db } from "@/lib/db";
-import { eq, sql, count, desc, and } from "drizzle-orm";
+import {
+ eq,
+ sql,
+ count,
+ desc,
+ and,
+ InferSelectModel,
+ isNotNull,
+} from "drizzle-orm";
import * as schema from "@/lib/schema";
import { getUser } from "../user";
import { DID } from "../atproto/did";
+import { Prettify } from "@/lib/utils";
+
+type CommentRow = InferSelectModel;
+
+type CommentExtras = {
+ children?: CommentModel[];
+ userHasVoted: boolean;
+ // These properties are returned from some methods but not others
+ rank?: number;
+ voteCount?: number;
+ postAuthorDid?: DID;
+ postRkey?: string;
+};
+
+type LiveComment = CommentRow & CommentExtras & { status: "live" };
+
+type HiddenComment = Omit &
+ CommentExtras & {
+ status: Exclude;
+ body: null;
+ };
+
+export type CommentModel = Prettify;
+
+const buildUserHasVotedQuery = cache(async () => {
+ const user = await getUser();
+
+ return db
+ .select({
+ commentId: schema.CommentVote.commentId,
+ // This is not entirely type safe but there isn't a better way to do this in drizzle right now
+ userHasVoted: sql`${isNotNull(schema.CommentVote.commentId)}`.as(
+ "userHasVoted",
+ ),
+ })
+ .from(schema.CommentVote)
+ .where(user ? eq(schema.CommentVote.authorDid, user.did) : sql`false`)
+ .as("hasVoted");
+});
export const getCommentsForPost = cache(async (postId: number) => {
const votes = db
@@ -27,15 +74,11 @@ export const getCommentsForPost = cache(async (postId: number) => {
) / 3600
) + 2
) ^ 1.8
- `.as("rank");
-
- const user = await getUser();
+ `
+ .mapWith(Number)
+ .as("rank");
- const hasVoted = db
- .select({ commentId: schema.CommentVote.commentId })
- .from(schema.CommentVote)
- .where(user ? eq(schema.CommentVote.authorDid, user.did) : sql`false`)
- .as("hasVoted");
+ const hasVoted = await buildUserHasVotedQuery();
const rows = await db
.select({
@@ -46,27 +89,21 @@ export const getCommentsForPost = cache(async (postId: number) => {
body: schema.Comment.body,
createdAt: schema.Comment.createdAt,
authorDid: schema.Comment.authorDid,
+ status: schema.Comment.status,
voteCount: sql`coalesce(${votes.voteCount}, 0) + 1`
.mapWith(Number)
.as("voteCount"),
rank: commentRank,
- userHasVoted: hasVoted.commentId,
+ userHasVoted: hasVoted.userHasVoted,
parentCommentId: schema.Comment.parentCommentId,
})
.from(schema.Comment)
- .where(
- and(eq(schema.Comment.postId, postId), eq(schema.Comment.status, "live")),
- )
+ .where(eq(schema.Comment.postId, postId))
.leftJoin(votes, eq(votes.commentId, schema.Comment.id))
.leftJoin(hasVoted, eq(hasVoted.commentId, schema.Comment.id))
.orderBy(desc(commentRank));
- return nestCommentRows(
- rows.map((row) => ({
- ...row,
- userHasVoted: row.userHasVoted !== null,
- })),
- );
+ return nestCommentRows(rows);
});
export const getCommentWithChildren = cache(
@@ -78,42 +115,61 @@ export const getCommentWithChildren = cache(
},
);
-type CommentRowWithChildren<
- T extends { id: number; parentCommentId: number | null },
-> = T & {
- children: CommentRowWithChildren[];
+const nestCommentRows = (
+ items: (CommentRow & {
+ userHasVoted: boolean;
+ voteCount?: number;
+ rank?: number;
+ })[],
+ id: number | null = null,
+): CommentModel[] => {
+ const comments: CommentModel[] = [];
+
+ for (const item of items) {
+ if (item.parentCommentId !== id) {
+ continue;
+ }
+
+ const children = nestCommentRows(items, item.id);
+ const transformed = {
+ userHasVoted: item.userHasVoted !== null,
+ voteCount: item.voteCount ?? 0,
+ };
+ if (item.status === "live") {
+ comments.push({
+ ...item,
+ ...transformed,
+ // Explicit copy is required to avoid TS error
+ status: item.status,
+ children,
+ });
+ } else {
+ comments.push({
+ ...item,
+ ...transformed,
+ // Explicit copy is required to avoid TS error
+ status: item.status,
+ body: null,
+ children,
+ });
+ }
+ }
+
+ return comments;
};
-const nestCommentRows = <
- T extends { id: number; parentCommentId: number | null },
->(
- items: T[],
- id: number | null = null,
-): CommentRowWithChildren[] =>
- items
- .filter((item) => item.parentCommentId === id)
- .map((item) => ({
- ...item,
- children: nestCommentRows(items, item.id),
- }));
-
-const findCommentSubtree = <
- T extends {
- id: number;
- parentCommentId: number | null;
- rkey: string;
- authorDid: DID;
- },
->(
- items: CommentRowWithChildren[],
+const findCommentSubtree = (
+ items: CommentModel[],
authorDid: DID,
rkey: string,
-): CommentRowWithChildren | null => {
+): CommentModel | null => {
for (const item of items) {
if (item.rkey === rkey && item.authorDid === authorDid) {
return item;
}
+ if (!item.children) return null;
+
const child = findCommentSubtree(item.children, authorDid, rkey);
if (child) {
return child;
@@ -144,7 +200,8 @@ export async function uncached_doesCommentExist(rkey: string) {
}
export const getUserComments = cache(async (userDid: DID) => {
- const posts = await db
+ const hasVoted = await buildUserHasVotedQuery();
+ const comments = await db
.select({
id: schema.Comment.id,
rkey: schema.Comment.rkey,
@@ -156,15 +213,26 @@ export const getUserComments = cache(async (userDid: DID) => {
status: schema.Comment.status,
postRkey: schema.Post.rkey,
postAuthorDid: schema.Post.authorDid,
+ parentCommentId: schema.Comment.parentCommentId,
+ userHasVoted: hasVoted.userHasVoted,
})
.from(schema.Comment)
- .leftJoin(schema.Post, eq(schema.Comment.postId, schema.Post.id))
.where(
and(
eq(schema.Comment.authorDid, userDid),
eq(schema.Comment.status, "live"),
),
- );
+ )
+ .leftJoin(schema.Post, eq(schema.Comment.postId, schema.Post.id))
+ .leftJoin(hasVoted, eq(hasVoted.commentId, schema.Comment.id));
- return posts;
+ return comments as LiveComment[];
});
+
+export function shouldHideComment(comment: CommentModel) {
+ return (
+ comment.status !== "live" &&
+ comment.children &&
+ comment.children.length === 0
+ );
+}
diff --git a/packages/frontpage/lib/utils.ts b/packages/frontpage/lib/utils.ts
index 365058ce..03384689 100644
--- a/packages/frontpage/lib/utils.ts
+++ b/packages/frontpage/lib/utils.ts
@@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+export type Prettify = {
+ [K in keyof T]: T[K];
+ // eslint-disable-next-line @typescript-eslint/ban-types
+} & {};