All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
500 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|