cb9c5dda59
The comment panel lagged for seconds on open and stuttered on every resolve/apply
with many comments (real case: 30 open + 326 resolved ≈ 356 threads), because each
comment body mounted a full TipTap/ProseMirror editor, both tabs mounted at once,
and any mutation re-rendered the whole list.
- CommentContentView: static recursive renderer of comment ProseMirror JSON (no
editor instance) for the read-only body — supports exactly CommentEditor's node
set (doc/paragraph/text/hardBreak/mention) + marks (bold/italic/strike/code/
link), reproducing the 3-level DOM nesting for pixel-identical CSS. Unknown
node/mark or unparseable content degrades that one comment to the read-only
CommentEditor; legacy non-JSON strings render as plain text.
SECURITY: link hrefs are protocol-allowlisted (safeHref, mirroring
@tiptap/extension-link) so a stored comment with a `javascript:`/`data:` href
cannot XSS — the old TipTap read-only path sanitized this; the static renderer
must too. Control-char smuggling (java\tscript:) is stripped before the check.
- MentionContent extracted from MentionView, shared by the TipTap NodeView and the
static renderer (identical user/page-mention behavior).
- keepMounted={false} on the tabs: the inactive tab no longer mounts its editors.
- Lazy reply editor: a stub until click/focus, then the real editor (kept mounted
so the draft survives thread re-renders).
- React.memo(CommentListItem) + a childrenByParent map (replaces the per-thread
O(n^2) filter) + localized reply-send pending state: resolve/apply/reply now
re-render only the touched thread.
- Progressive first paint: useCommentsQuery no longer blocks on hasNextPage.
Gate: client comment+mention suites 22/22 passed, tsc --noEmit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
|
import React, { useMemo, useRef, useState } from "react";
|
|
import classes from "./comment.module.css";
|
|
import { useAtom, useAtomValue } from "jotai";
|
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
import CommentContentView from "@/features/comment/components/comment-content-view";
|
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
|
import CommentActions from "@/features/comment/components/comment-actions";
|
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
|
import ResolveComment from "@/features/comment/components/resolve-comment";
|
|
import { useHover } from "@mantine/hooks";
|
|
import {
|
|
useApplySuggestionMutation,
|
|
useDeleteCommentMutation,
|
|
useDismissSuggestionMutation,
|
|
useResolveCommentMutation,
|
|
useUpdateCommentMutation,
|
|
} from "@/features/comment/queries/comment-query";
|
|
import { IComment } from "@/features/comment/types/comment.types";
|
|
import {
|
|
canShowApply,
|
|
canShowDismiss,
|
|
computeSuggestionDiff,
|
|
} from "@/features/comment/utils/suggestion";
|
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
interface CommentListItemProps {
|
|
comment: IComment;
|
|
pageId: string;
|
|
canComment: boolean;
|
|
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
|
|
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
|
|
// allowed to comment cannot apply edits).
|
|
canEdit?: boolean;
|
|
userSpaceRole?: string;
|
|
}
|
|
|
|
function CommentListItem({
|
|
comment,
|
|
pageId,
|
|
canComment,
|
|
canEdit,
|
|
userSpaceRole,
|
|
}: CommentListItemProps) {
|
|
const { t } = useTranslation();
|
|
const { hovered, ref } = useHover();
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const editor = useAtomValue(pageEditorAtom);
|
|
const editContentRef = useRef<any>(null);
|
|
const updateCommentMutation = useUpdateCommentMutation();
|
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
|
const resolveCommentMutation = useResolveCommentMutation();
|
|
const applySuggestionMutation = useApplySuggestionMutation();
|
|
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
|
const [currentUser] = useAtom(currentUserAtom);
|
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
|
|
|
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
|
// fragments that actually changed get emphasised inside the red/green block,
|
|
// instead of striking through / greening the whole line. Memoised on the
|
|
// (selection, suggestedText) pair so it recomputes only when they change.
|
|
const suggestionDiff = useMemo(
|
|
() =>
|
|
comment.suggestedText != null
|
|
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
|
: null,
|
|
[comment.selection, comment.suggestedText],
|
|
);
|
|
|
|
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
|
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
|
// render an action the server will 403.
|
|
const isOwnerOrAdmin =
|
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
|
|
|
|
|
async function handleUpdateComment() {
|
|
try {
|
|
setIsLoading(true);
|
|
const commentToUpdate = {
|
|
commentId: comment.id,
|
|
content: JSON.stringify(editContentRef.current ?? comment.content),
|
|
};
|
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
|
editContentRef.current = null;
|
|
setIsEditing(false);
|
|
} catch (error) {
|
|
console.error("Failed to update comment:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteComment() {
|
|
try {
|
|
await deleteCommentMutation.mutateAsync(comment.id);
|
|
editor?.commands.unsetComment(comment.id);
|
|
} catch (error) {
|
|
console.error("Failed to delete comment:", error);
|
|
}
|
|
}
|
|
|
|
async function handleResolveComment() {
|
|
try {
|
|
const isResolved = comment.resolvedAt != null;
|
|
await resolveCommentMutation.mutateAsync({
|
|
commentId: comment.id,
|
|
pageId: comment.pageId,
|
|
resolved: !isResolved,
|
|
});
|
|
if (editor) {
|
|
editor.commands.setCommentResolved(comment.id, !isResolved);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to toggle resolved state:", error);
|
|
}
|
|
}
|
|
|
|
async function handleApplySuggestion() {
|
|
try {
|
|
await applySuggestionMutation.mutateAsync({
|
|
commentId: comment.id,
|
|
pageId: comment.pageId,
|
|
});
|
|
} catch (error) {
|
|
// Errors surface via the mutation's onError notification (incl. 409).
|
|
console.error("Failed to apply suggestion:", error);
|
|
}
|
|
}
|
|
|
|
async function handleDismissSuggestion() {
|
|
try {
|
|
await dismissSuggestionMutation.mutateAsync({
|
|
commentId: comment.id,
|
|
pageId: comment.pageId,
|
|
});
|
|
} catch (error) {
|
|
// Idempotent races are reconciled to success in the mutation's onError;
|
|
// anything else surfaces there as a notification.
|
|
console.error("Failed to dismiss suggestion:", error);
|
|
}
|
|
}
|
|
|
|
function handleCommentClick(comment: IComment) {
|
|
const el = document.querySelector(
|
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
|
);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
el.classList.add("comment-highlight");
|
|
setTimeout(() => {
|
|
el.classList.remove("comment-highlight");
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
function handleEditToggle() {
|
|
setIsEditing(true);
|
|
}
|
|
function cancelEdit() {
|
|
editContentRef.current = null;
|
|
setIsEditing(false);
|
|
}
|
|
|
|
return (
|
|
<Box ref={ref} pb={6}>
|
|
<Group gap="xs">
|
|
{comment.createdSource === "agent" && comment.agent ? (
|
|
<AgentAvatarStack
|
|
agent={comment.agent}
|
|
launcher={comment.launcher}
|
|
aiChatId={comment.aiChatId}
|
|
showName={false}
|
|
/>
|
|
) : (
|
|
<CustomAvatar
|
|
size="sm"
|
|
avatarUrl={comment.creator.avatarUrl}
|
|
name={comment.creator.name}
|
|
/>
|
|
)}
|
|
|
|
<div style={{ flex: 1 }}>
|
|
<Group justify="space-between" wrap="nowrap">
|
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
{comment.createdSource === "agent" && comment.agent ? (
|
|
<>
|
|
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
|
{comment.agent.name}
|
|
</Text>
|
|
{comment.launcher && (
|
|
<>
|
|
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
|
·
|
|
</Text>
|
|
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
|
{comment.launcher.name}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
|
{comment.creator.name}
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
|
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
|
{!comment.parentCommentId && canComment && (
|
|
<ResolveComment
|
|
editor={editor}
|
|
commentId={comment.id}
|
|
pageId={comment.pageId}
|
|
resolvedAt={comment.resolvedAt}
|
|
/>
|
|
)}
|
|
|
|
{isOwnerOrAdmin && (
|
|
<CommentMenu
|
|
onEditComment={handleEditToggle}
|
|
onDeleteComment={handleDeleteComment}
|
|
onResolveComment={handleResolveComment}
|
|
canEdit={currentUser?.user?.id === comment.creatorId}
|
|
canComment={canComment}
|
|
isResolved={comment.resolvedAt != null}
|
|
isParentComment={!comment.parentCommentId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Group>
|
|
|
|
<Group gap="xs">
|
|
<Text size="xs" fw={500} c="dimmed" lh={1.1}>
|
|
{createdAtAgo}
|
|
</Text>
|
|
</Group>
|
|
</div>
|
|
</Group>
|
|
|
|
<div>
|
|
{!comment.parentCommentId && comment?.selection && (
|
|
<Box
|
|
className={classes.textSelection}
|
|
onClick={() => handleCommentClick(comment)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
handleCommentClick(comment);
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t("Jump to comment selection")}
|
|
>
|
|
<Text size="xs">{comment?.selection}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
|
|
carrying a suggestion. Old text struck-through/red, new text green. */}
|
|
{!comment.parentCommentId && comment.suggestedText && (
|
|
<Box className={classes.suggestionBlock}>
|
|
{comment.selection && (
|
|
// Old line: read as removed as a whole (line-through/red); only the
|
|
// changed fragments carry the extra intraline emphasis.
|
|
<Text size="xs" className={classes.suggestionOld}>
|
|
{suggestionDiff?.old.map((segment, index) => (
|
|
<span
|
|
key={index}
|
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
|
>
|
|
{segment.text}
|
|
</span>
|
|
))}
|
|
</Text>
|
|
)}
|
|
<Text size="xs" className={classes.suggestionNew}>
|
|
{suggestionDiff?.new.map((segment, index) => (
|
|
<span
|
|
key={index}
|
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
|
>
|
|
{segment.text}
|
|
</span>
|
|
))}
|
|
</Text>
|
|
|
|
{comment.suggestionAppliedAt ? (
|
|
<Badge
|
|
size="sm"
|
|
color="green"
|
|
variant="light"
|
|
mt={6}
|
|
aria-label={t("Applied")}
|
|
>
|
|
{t("Applied")}
|
|
</Badge>
|
|
) : (
|
|
(canShowApply(comment, canEdit) ||
|
|
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
|
<Group gap="xs" mt={6}>
|
|
{canShowApply(comment, canEdit) && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="green"
|
|
onClick={handleApplySuggestion}
|
|
loading={applySuggestionMutation.isPending}
|
|
disabled={
|
|
applySuggestionMutation.isPending ||
|
|
dismissSuggestionMutation.isPending
|
|
}
|
|
>
|
|
{t("Apply")}
|
|
</Button>
|
|
)}
|
|
{/* Dismiss ("Не применять", #329): removes the suggestion
|
|
without changing the page text. Gated on canComment. */}
|
|
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="subtle"
|
|
color="gray"
|
|
onClick={handleDismissSuggestion}
|
|
loading={dismissSuggestionMutation.isPending}
|
|
disabled={
|
|
applySuggestionMutation.isPending ||
|
|
dismissSuggestionMutation.isPending
|
|
}
|
|
>
|
|
{t("Dismiss")}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
)
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{!isEditing ? (
|
|
<CommentContentView content={comment.content} />
|
|
) : (
|
|
<>
|
|
<CommentEditor
|
|
defaultContent={comment.content}
|
|
editable={true}
|
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
|
onSave={handleUpdateComment}
|
|
autofocus={true}
|
|
/>
|
|
|
|
<CommentActions
|
|
onSave={handleUpdateComment}
|
|
isLoading={isLoading}
|
|
onCancel={cancelEdit}
|
|
isCommentEditor={true}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
|
// comment's object identity) re-renders that one thread, not all ~356 items.
|
|
export default React.memo(CommentListItem);
|