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 = {}; 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; 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; /** `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 = ( <> ( void submit()} placeholder={placeholder} autoResize rows={1} maxLength={VALIDATION.COMMENT_BODY_MAX} /> )} />
{submitLabel} {showCancel && ( )}
); return ( {layout === "top" ? (
{leading}
{inner}
) : (
{inner}
)}
); } 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 { const map = new Map(); 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; 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(null); const editEditorRef = useRef(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(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(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 (
  • [deleted]

    {children.length > 0 && (
      {children.map((child) => ( ))}
    )}
  • ); } return (
  • {comment.authorUsername} {comment.updatedAt && ( edited {relativeTime(comment.updatedAt)} )}
    {editOpen ? ( Save} pendingLabel={Saving…} errorTitle={t`Failed to save edit`} onCancel={() => setEditOpen(false)} /> ) : {comment.body}}
    {!editOpen && ( )} {currentUser && !editOpen && ( )} {canEdit && !editOpen && ( )} {canDelete && !editOpen && ( )} {confirmDelete && ( { setConfirmDelete(false); handleDelete(); }} onCancel={() => setConfirmDelete(false)} /> )}
    {replyOpen && ( Post reply} pendingLabel={Posting…} errorTitle={t`Failed to post reply`} placeholder={t`Write a reply…`} onCancel={() => setReplyOpen(false)} /> )}
    {children.length > 0 && (
      {children.map((child) => ( ))}
    )}
  • ); } 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(res))); } const visibleCount = comments.filter((c) => !c.deleted).length; return (

    {currentUser && ( } onSubmit={handleTopLevelSubmit} submitLabel={Post comment} pendingLabel={Posting…} errorTitle={t`Failed to post comment`} placeholder={t`Add a comment…`} onCancel={() => {}} cancelWhenEmpty /> )} {roots.length > 0 && (
      {roots.map((comment) => ( ))}
    )}
    ); }