Files
gerbeur/src/components/CommentThread.tsx
khannurien 73e0114bf7
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
2026-06-26 22:20:07 +00:00

500 lines
15 KiB
TypeScript

import React, { useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { Controller } from "react-hook-form";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import type {
Comment,
CreateCommentRequest,
RawComment,
UpdateCommentRequest,
User,
} from "../model.ts";
import { deserializeComment } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
import { LikeButton } from "./LikeButton.tsx";
import { relativeTime } from "../utils/relativeTime.ts";
import { Tooltip } from "./Tooltip.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
import { useWS } from "../hooks/useWS.ts";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
useApiForm,
} from "./form/index.ts";
function authHeaders(token: string | null, json = true): HeadersInit {
const headers: Record<string, string> = {};
if (json) headers["Content-Type"] = "application/json";
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
interface CommentFormProps {
/** Async submit; throw to surface a root error, resolve to clear/close. */
onSubmit: (body: string) => Promise<void>;
submitLabel: React.ReactNode;
pendingLabel: React.ReactNode;
errorTitle: string;
placeholder?: string;
defaultValue?: string;
onCancel?: () => void;
/** Top-level form: only show Cancel once there's text to discard. */
cancelWhenEmpty?: boolean;
editorRef?: React.Ref<TextEditorHandle>;
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
layout?: "inline" | "top";
leading?: React.ReactNode;
}
/**
* Single-field comment editor (post / reply / edit). Wraps the shared
* react-hook-form lifecycle so the three call sites stay identical.
*/
function CommentForm({
onSubmit,
submitLabel,
pendingLabel,
errorTitle,
placeholder,
defaultValue = "",
onCancel,
cancelWhenEmpty,
editorRef,
layout = "inline",
leading,
}: CommentFormProps) {
const form = useApiForm<{ body: string }>({
defaultValues: { body: defaultValue },
});
const body = form.watch("body");
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
const submit = form.submit(async ({ body }) => {
if (!body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX) return;
await onSubmit(body);
form.reset({ body: "" });
});
const showCancel = onCancel && (!cancelWhenEmpty || body.trim().length > 0);
const inner = (
<>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<TextEditor
ref={editorRef}
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void submit()}
placeholder={placeholder}
autoResize
rows={1}
maxLength={VALIDATION.COMMENT_BODY_MAX}
/>
)}
/>
<FormError title={errorTitle} />
<div className="comment-form-actions">
<SubmitButton
className="comment-submit-btn"
pendingLabel={pendingLabel}
disabled={disabled}
>
{submitLabel}
</SubmitButton>
{showCancel && (
<button
type="button"
className="comment-cancel-btn"
onClick={() => {
form.reset({ body: "" });
onCancel?.();
}}
>
<Trans>Cancel</Trans>
</button>
)}
</div>
</>
);
return (
<FormProvider {...form}>
{layout === "top"
? (
<form className="comment-top-form" onSubmit={submit}>
<div className="comment-top-form-inner">
{leading}
<div className="comment-top-form-body">{inner}</div>
</div>
</form>
)
: (
<form className="comment-form" onSubmit={submit}>
{inner}
</form>
)}
</FormProvider>
);
}
interface CommentThreadProps {
dumpId: string;
comments: Comment[];
currentUser: User | null;
token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
onCommentUpdated: (comment: Comment) => void;
}
function buildTree(comments: Comment[]): Map<string, Comment[]> {
const map = new Map<string, Comment[]>();
for (const c of comments) {
const key = c.parentId ?? "root";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(c);
}
return map;
}
interface CommentNodeProps {
comment: Comment;
tree: Map<string, Comment[]>;
depth: number;
dumpId: string;
currentUser: User | null;
token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
onCommentUpdated: (comment: Comment) => void;
}
function CommentNode({
comment,
tree,
depth,
dumpId,
currentUser,
token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: CommentNodeProps) {
const [replyOpen, setReplyOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
const children = tree.get(comment.id) ?? [];
async function handleReply(body: string) {
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify(
{ body, parentId: comment.id } satisfies CreateCommentRequest,
),
});
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
setReplyOpen(false);
}
async function handleDelete() {
if (!token) return;
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => null);
if (res?.ok) {
onCommentDeleted(comment.id);
}
}
async function handleEditSave(body: string) {
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH",
headers: authHeaders(token),
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
});
onCommentUpdated(deserializeComment(await expectOk<RawComment>(res)));
setEditOpen(false);
}
const canDelete = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
const canEdit = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
if (comment.deleted) {
return (
<li className="comment-node">
<div className="comment-node-inner comment-node-inner--deleted">
<div className="comment-avatar comment-avatar--deleted">
<div
className="comment-avatar-placeholder"
style={{ width: 28, height: 28 }}
/>
</div>
<div className="comment-content">
<p className="comment-deleted-placeholder">
<Trans>[deleted]</Trans>
</p>
</div>
</div>
{children.length > 0 && (
<ul
className="comment-replies"
style={{ "--depth": depth } as React.CSSProperties}
>
{children.map((child) => (
<CommentNode
key={child.id}
comment={child}
tree={tree}
depth={depth + 1}
dumpId={dumpId}
currentUser={currentUser}
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
)}
</li>
);
}
return (
<li className="comment-node">
<div className="comment-node-inner" id={`comment-${comment.id}`}>
<div className="comment-avatar">
<Avatar
userId={comment.userId}
username={comment.authorUsername}
hasAvatar={!!comment.authorAvatarMime}
size={28}
/>
</div>
<div className="comment-content">
<div className="comment-meta">
<Link
to={`/users/${comment.authorUsername}`}
className="comment-author"
>
{comment.authorUsername}
</Link>
<Link
to={`/dumps/${dumpId}#comment-${comment.id}`}
className="comment-time"
>
<Tooltip text={comment.createdAt.toLocaleString()}>
<time dateTime={comment.createdAt.toISOString()}>
{relativeTime(comment.createdAt)}
</time>
</Tooltip>
</Link>
{comment.updatedAt && (
<Tooltip text={t`Edited ${comment.updatedAt.toLocaleString()}`}>
<span className="comment-edited">
<Trans>edited {relativeTime(comment.updatedAt)}</Trans>
</span>
</Tooltip>
)}
</div>
{editOpen
? (
<CommentForm
editorRef={editEditorRef}
defaultValue={comment.body}
onSubmit={handleEditSave}
submitLabel={<Trans>Save</Trans>}
pendingLabel={<Trans>Saving</Trans>}
errorTitle={t`Failed to save edit`}
onCancel={() => setEditOpen(false)}
/>
)
: <Markdown className="comment-body">{comment.body}</Markdown>}
<div className="comment-actions">
{!editOpen && (
<LikeButton
commentId={comment.id}
count={likeCounts[comment.id] ?? comment.likeCount}
liked={myLikes.has(comment.id)}
disabled={!currentUser}
onLike={castCommentLike}
onUnlike={removeCommentLike}
/>
)}
{currentUser && !editOpen && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setReplyOpen((v) => !v);
setTimeout(() => replyEditorRef.current?.focus(), 0);
}}
>
<Trans>Reply</Trans>
</button>
)}
{canEdit && !editOpen && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setEditOpen(true);
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
>
<Trans>Edit</Trans>
</button>
)}
{canDelete && !editOpen && (
<button
type="button"
className="comment-action-btn comment-delete-btn"
onClick={() => setConfirmDelete(true)}
>
<Trans>Delete</Trans>
</button>
)}
{confirmDelete && (
<ConfirmModal
message={t`Delete this comment?`}
confirmLabel={t`Delete`}
onConfirm={() => {
setConfirmDelete(false);
handleDelete();
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
{replyOpen && (
<CommentForm
editorRef={replyEditorRef}
onSubmit={handleReply}
submitLabel={<Trans>Post reply</Trans>}
pendingLabel={<Trans>Posting</Trans>}
errorTitle={t`Failed to post reply`}
placeholder={t`Write a reply…`}
onCancel={() => setReplyOpen(false)}
/>
)}
</div>
</div>
{children.length > 0 && (
<ul
className="comment-replies"
style={{ "--depth": depth } as React.CSSProperties}
>
{children.map((child) => (
<CommentNode
key={child.id}
comment={child}
tree={tree}
depth={depth + 1}
dumpId={dumpId}
currentUser={currentUser}
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
)}
</li>
);
}
export function CommentThread({
dumpId,
comments,
currentUser,
token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: CommentThreadProps) {
const tree = useMemo(() => buildTree(comments), [comments]);
const roots = tree.get("root") ?? [];
async function handleTopLevelSubmit(body: string) {
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify({ body } satisfies CreateCommentRequest),
});
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
}
const visibleCount = comments.filter((c) => !c.deleted).length;
return (
<section className="comment-section">
<h2 className="comment-section-title">
<Plural value={visibleCount} one="# comment" other="# comments" />
</h2>
{currentUser && (
<CommentForm
layout="top"
leading={
<div className="comment-avatar">
<Avatar
userId={currentUser.id}
username={currentUser.username}
hasAvatar={!!currentUser.avatarMime}
size={28}
/>
</div>
}
onSubmit={handleTopLevelSubmit}
submitLabel={<Trans>Post comment</Trans>}
pendingLabel={<Trans>Posting</Trans>}
errorTitle={t`Failed to post comment`}
placeholder={t`Add a comment…`}
onCancel={() => {}}
cancelWhenEmpty
/>
)}
{roots.length > 0 && (
<ul className="comment-list">
{roots.map((comment) => (
<CommentNode
key={comment.id}
comment={comment}
tree={tree}
depth={0}
dumpId={dumpId}
currentUser={currentUser}
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
)}
</section>
);
}