From e06a4b1cf8d48c5959d98aaacb8fcd4391bed1c9 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 12 Jun 2026 11:21:11 +0330 Subject: [PATCH] fix(blog): flatten nested comment replies --- src/components/BlogPostInteractions.tsx | 123 ++++++++++++++++-------- 1 file changed, 81 insertions(+), 42 deletions(-) diff --git a/src/components/BlogPostInteractions.tsx b/src/components/BlogPostInteractions.tsx index 6315c58..0fe4be0 100644 --- a/src/components/BlogPostInteractions.tsx +++ b/src/components/BlogPostInteractions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Check, Edit3, @@ -38,6 +38,11 @@ type Props = { initialComments: number; }; +type FlattenedReply = { + comment: Types.CommentSchema; + replyTo: Types.CommentSchema; +}; + function displayName(author: Types.CommentSchema["author"]) { return [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username; } @@ -57,6 +62,20 @@ function allDescendants(comment: Types.CommentSchema): Types.CommentSchema[] { return (comment.replies || []).flatMap((reply) => [reply, ...allDescendants(reply)]); } +function flattenReplies(comment: Types.CommentSchema): FlattenedReply[] { + const replies: FlattenedReply[] = []; + + const walk = (items: Types.CommentSchema[] | undefined, parent: Types.CommentSchema) => { + (items || []).forEach((reply) => { + replies.push({ comment: reply, replyTo: parent }); + walk(reply.replies, reply); + }); + }; + + walk(comment.replies, comment); + return replies; +} + function findComment(comments: Types.CommentSchema[], id: number | null): Types.CommentSchema | undefined { if (!id) return undefined; for (const comment of comments) { @@ -79,12 +98,13 @@ export default function BlogPostInteractions({ const [replyTo, setReplyTo] = useState(null); const [editingId, setEditingId] = useState(null); const [editContent, setEditContent] = useState(""); - const [expandedReplies, setExpandedReplies] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState(null); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [savingEdit, setSavingEdit] = useState(false); const [moderatingId, setModeratingId] = useState(null); + const [highlightedCommentId, setHighlightedCommentId] = useState(null); + const highlightTimeoutRef = useRef | null>(null); const canModerate = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts); @@ -115,6 +135,28 @@ export default function BlogPostInteractions({ }; }, [slug]); + useEffect(() => () => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + }, []); + + const scrollToComment = (commentId: number) => { + const element = document.getElementById(`blog-comment-${commentId}`); + if (!element) return; + + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + + element.scrollIntoView({ behavior: "smooth", block: "center" }); + setHighlightedCommentId(commentId); + highlightTimeoutRef.current = setTimeout(() => { + setHighlightedCommentId(null); + highlightTimeoutRef.current = null; + }, 1800); + }; + const submitComment = async () => { const trimmed = content.trim(); if (!trimmed) return; @@ -220,32 +262,32 @@ export default function BlogPostInteractions({ } }; - const toggleExpandedReplies = (commentId: number) => { - setExpandedReplies((current) => { - const next = new Set(current); - if (next.has(commentId)) { - next.delete(commentId); - } else { - next.add(commentId); - } - return next; - }); - }; - - const renderComment = (comment: Types.CommentSchema, nested = false, compact = false, parentHidden = false) => { + const renderComment = ( + comment: Types.CommentSchema, + options: { + parentHidden?: boolean; + replyToComment?: Types.CommentSchema; + topLevelParent?: Types.CommentSchema; + } = {}, + ) => { const isOwnComment = Boolean(user?.id === comment.author.id); const isEditing = editingId === comment.id; + const { parentHidden = false, replyToComment, topLevelParent } = options; const hidden = parentHidden || Boolean(comment.is_hidden); - const deeperReplies = nested ? allDescendants(comment) : []; - const showDeeperReplies = expandedReplies.has(comment.id); + const isReply = Boolean(replyToComment); + const flattenedReplies = isReply ? [] : flattenReplies(comment); + const showReplyContext = Boolean( + replyToComment && topLevelParent && replyToComment.id !== topLevelParent.id, + ); return (