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 ( <>
- + - -
{handle}
+ +
@{handle}
- +
-
-

{comment}

-
+

{comment.body}

- {childComments?.map((comment) => ( + {comment.children?.map((comment) => ( ))} ); } + +function DeletedComment({ + comment, + postAuthorParam, + postRkey, + level, +}: CommentProps) { + const childCommentLevel = getChildCommentLevel(level); + + return ( + +
+
+ +
+ @deleted +
+
+ +
+
+

Deleted comment

+
+ {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 +} & {};