Files
spsays-training/components/social/comment/comment-row.tsx
T
2026-08-07 23:47:22 +05:30

230 lines
7.9 KiB
TypeScript

"use client";
/**
* CommentRow — one comment (top-level or reply) in the comments drawer.
*
* Top-level rows own a lazily-loaded, flat reply thread (one level deep) and
* register a splice hook so the drawer can drop a freshly-posted reply in;
* replies render the "Replying to @handle" prefix. Like is optimistic; the
* kebab (Pin / Delete / Report) lives in CommentMenu; deletes/reports bubble
* up to the drawer's modals via onRequest* callbacks.
*/
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { HiHeart, HiOutlineHeart } from "react-icons/hi2";
import { Pin } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar";
import { renderMentions } from "@/components/social/mention-text";
import { timeAgo } from "@/lib/time";
import { CommentMenu } from "./comment-menu";
export function CommentRow({
comment,
viewerId,
isPostAuthor,
onReply,
onLikeChange,
onPinToggle,
onRequestDelete,
onRequestReport,
requireAuth,
registerThread,
isReply = false,
}: {
comment: Comment;
viewerId?: string;
isPostAuthor: boolean;
onReply: (c: Comment) => void;
onLikeChange: (likes: number, liked: boolean) => void;
onPinToggle: () => void;
onRequestDelete: (c: Comment) => void;
onRequestReport: (c: Comment) => void;
requireAuth: () => Promise<boolean>;
/** Top-level rows register a callback so a newly-posted reply can be spliced
* into their open thread. Replies pass nothing. */
registerThread?: (fn: ((c: Comment) => void) | null) => void;
isReply?: boolean;
}) {
const [replies, setReplies] = useState<Comment[]>([]);
const [showReplies, setShowReplies] = useState(false);
const [repliesLoaded, setRepliesLoaded] = useState(false);
const likeBusy = useRef(false);
const canDelete = viewerId === comment.user_id || isPostAuthor;
const canReport = !!viewerId && viewerId !== comment.user_id;
const pinnable = isPostAuthor && !isReply;
// Expose a splice hook so the drawer can append a reply created from the
// composer. Replies created while collapsed still get the count bump; they
// show once expanded (re-fetched) or appended if already open.
useEffect(() => {
if (isReply || !registerThread) return;
registerThread((c: Comment) => {
if (c.body === "__deleted__") {
setReplies((prev) => prev.filter((x) => x.id !== c.id));
return;
}
setRepliesLoaded(true);
setShowReplies(true);
setReplies((prev) =>
prev.some((x) => x.id === c.id) ? prev : [...prev, c],
);
});
return () => registerThread(null);
}, [isReply, registerThread]);
async function toggleReplies() {
if (!showReplies && !repliesLoaded) {
try {
const rows = await commentsApi.replies(comment.id, { limit: 50 });
setReplies(rows ?? []);
setRepliesLoaded(true);
} catch {
/* ignore */
}
}
setShowReplies((v) => !v);
}
async function likeToggle() {
if (likeBusy.current) return;
if (!(await requireAuth())) return;
likeBusy.current = true;
const next = !comment.viewer_has_liked;
// optimistic
onLikeChange(Math.max(0, comment.likes_count + (next ? 1 : -1)), next);
try {
const res = next
? await commentsApi.like(comment.id)
: await commentsApi.unlike(comment.id);
onLikeChange(res.likes_count, next);
} catch (err) {
onLikeChange(comment.likes_count, comment.viewer_has_liked);
toast.error(
err instanceof SocialError ? err.message : "Could not update like",
{ duration: 3000 },
);
} finally {
likeBusy.current = false;
}
}
return (
<li className={cn(isReply && "mt-3")}>
<div className="flex gap-3">
<UserAvatar
src={comment.author_avatar_url}
name={comment.author_name}
className="size-9"
textClassName="text-xs"
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="text-sm font-semibold text-foreground truncate">
{comment.author_name}
</span>
{!isReply && comment.pinned_at ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
<Pin className="size-3" /> Pinned
</span>
) : null}
<span className="text-xs text-muted-foreground">
{timeAgo(comment.created_at)}
</span>
</div>
<CommentMenu
canPin={pinnable}
isPinned={!!comment.pinned_at}
canDelete={canDelete}
canReport={canReport}
onPin={onPinToggle}
onDelete={() => onRequestDelete(comment)}
onReport={() => onRequestReport(comment)}
/>
</div>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm text-foreground">
{comment.reply_to_handle ? (
<span className="font-medium text-primary">
@{comment.reply_to_handle}{" "}
</span>
) : null}
{renderMentions(comment.body)}
</p>
{/* Inline action row */}
<div className="mt-1.5 flex items-center gap-4 text-xs text-muted-foreground">
<button
type="button"
onClick={likeToggle}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
aria-label={comment.viewer_has_liked ? "Unlike" : "Like"}
>
{comment.viewer_has_liked ? (
<HiHeart className="size-4 text-red-500" />
) : (
<HiOutlineHeart className="size-4" />
)}
{comment.likes_count > 0 ? (
<span className="tabular-nums">{comment.likes_count}</span>
) : null}
</button>
<button
type="button"
onClick={() => onReply(comment)}
className="font-medium hover:text-foreground transition-colors"
>
Reply
</button>
</div>
{/* Replies */}
{!isReply && comment.reply_count > 0 ? (
<button
type="button"
onClick={toggleReplies}
className="mt-2 text-xs font-medium text-primary hover:underline"
>
{showReplies
? "Hide replies"
: `View ${comment.reply_count} ${comment.reply_count === 1 ? "reply" : "replies"}`}
</button>
) : null}
{!isReply && showReplies ? (
<ul className="mt-1 border-l border-border pl-3">
{replies.map((r) => (
<CommentRow
key={r.id}
comment={r}
viewerId={viewerId}
isPostAuthor={isPostAuthor}
onReply={onReply}
onLikeChange={(likes, liked) =>
setReplies((prev) =>
prev.map((x) =>
x.id === r.id
? { ...x, likes_count: likes, viewer_has_liked: liked }
: x,
),
)
}
onPinToggle={() => {}}
onRequestDelete={onRequestDelete}
onRequestReport={onRequestReport}
requireAuth={requireAuth}
isReply
/>
))}
</ul>
) : null}
</div>
</div>
</li>
);
}