306 lines
9.8 KiB
TypeScript
306 lines
9.8 KiB
TypeScript
import React, { useRef, useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { API_URL } from "../../config/api.ts";
|
|
import { can } from "../../utils/auth/permissions.ts";
|
|
import type {
|
|
Comment,
|
|
CreateCommentRequest,
|
|
RawComment,
|
|
UpdateCommentRequest,
|
|
User,
|
|
} from "../../model.ts";
|
|
import { deserializeComment } from "../../model.ts";
|
|
import { Avatar } from "../shared/Avatar.tsx";
|
|
import { Markdown } from "../editor/Markdown.tsx";
|
|
import type { TextEditorHandle } from "../editor/TextEditor.tsx";
|
|
import { LikeButton } from "../social/LikeButton.tsx";
|
|
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
|
import { Tooltip } from "../shared/Tooltip.tsx";
|
|
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
|
|
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
|
import { useWS } from "../../hooks/ws/useWS.ts";
|
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
|
import { expectOk } from "../../utils/shared/api.ts";
|
|
import { CommentForm } from "./CommentForm.tsx";
|
|
|
|
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;
|
|
}
|
|
|
|
export 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 [deleteFailed, setDeleteFailed] = useState(false);
|
|
const innerRef = useHeightTransition<HTMLDivElement>(
|
|
`${editOpen}:${replyOpen}`,
|
|
);
|
|
|
|
const replyEditorRef = useRef<TextEditorHandle>(null);
|
|
const editEditorRef = useRef<TextEditorHandle>(null);
|
|
|
|
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
|
|
const { authFetch } = useAuth();
|
|
|
|
const children = tree.get(comment.id) ?? [];
|
|
|
|
async function handleReply(body: string) {
|
|
const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
|
method: "POST",
|
|
body: JSON.stringify(
|
|
{ body, parentId: comment.id } satisfies CreateCommentRequest,
|
|
),
|
|
});
|
|
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
|
|
setReplyOpen(false);
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!token) return;
|
|
setDeleteFailed(false);
|
|
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
|
|
method: "DELETE",
|
|
}).catch(() => null);
|
|
if (res?.ok) {
|
|
onCommentDeleted(comment.id);
|
|
} else {
|
|
setDeleteFailed(true);
|
|
}
|
|
}
|
|
|
|
async function handleEditSave(body: string) {
|
|
// Saving it unchanged would only stamp it "edited".
|
|
if (body === comment.body) {
|
|
setEditOpen(false);
|
|
return;
|
|
}
|
|
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
|
|
});
|
|
onCommentUpdated(deserializeComment(await expectOk<RawComment>(res)));
|
|
setEditOpen(false);
|
|
}
|
|
|
|
const canModerate = can(currentUser, "comment:moderate");
|
|
const canDelete = !comment.deleted && !!currentUser &&
|
|
(currentUser.id === comment.userId || canModerate);
|
|
const canEdit = !comment.deleted && !!currentUser &&
|
|
(currentUser.id === comment.userId || canModerate);
|
|
|
|
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}`}
|
|
ref={innerRef}
|
|
>
|
|
<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`}
|
|
className="animate-in"
|
|
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>
|
|
{deleteFailed && (
|
|
<p className="form-hint form-hint--error" role="alert">
|
|
<Trans>This comment couldn't be deleted. Try again.</Trans>
|
|
</p>
|
|
)}
|
|
{replyOpen && (
|
|
<CommentForm
|
|
editorRef={replyEditorRef}
|
|
onSubmit={handleReply}
|
|
submitLabel={<Trans>Post reply</Trans>}
|
|
pendingLabel={<Trans>Posting…</Trans>}
|
|
errorTitle={t`Failed to post reply`}
|
|
className="animate-in"
|
|
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>
|
|
);
|
|
}
|