Files
gerbeur/src/components/comment/CommentNode.tsx
khannurien 2b6e68391b v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:44:14 +00:00

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>
);
}