/** * A review thread, in two variants: * * - `full` (`GHPRTimelineThreadComponentFactory`, the timeline): the first comment's author * and date with the OUTDATED / RESOLVED / PENDING tags and its actions; the file header * (opening the diff at the line) over the hunk with the anchor highlighted, then the * comment. Resolved threads start collapsed: a 2-line grey preview above the file header. * Replies start folded: their authors' avatars, "Reply" / "{n} replies", the last reply's * date and Resolve / Unresolve; unfolded, the replies and the reply box. * - `compact` (`GHPRReviewThreadComponentFactory`, diff inlays): the comments (with more * than 3, the first, "… {n} more replies" and the last), then "Reply" and Resolve / * Unresolve; "Reply" opens the reply box. * * Mutations go through `data/operations.ts`; the thread comes from the pull request's * `threads` data, which they refresh. */ import { useState } from "react"; import { tx, useTx } from "@/platform/i18n"; import { IconButton } from "@/ui"; import { errorMessage } from "../../api/client"; import type { GHPullRequestReviewComment, GHPullRequestReviewThread } from "../../api/types"; import { openPullRequestFileDiff } from "../../data/local"; import { deleteReviewComment, replyToComment, resolveThread, toggleReaction, unresolveThread, updateReviewComment, } from "../../data/operations"; import { AvatarStack } from "../../ui/Avatar"; import { Markdown } from "../../ui/Markdown"; import { prettyDateTime } from "../../ui/prettyDate"; import { presentableName, threadDiffHunk, threadDiffWithAnchor } from "../model"; import { ChatItem, Tag, type ChatItemVariant } from "./ChatItem"; import { CommentEditor, submitShortcut } from "./CommentEditor"; import { confirmDeleteComment, CommentItem } from "./CommentItem"; import { DiffBox, HunkView } from "./DiffHunk"; import { AddReactionButton, Reactions, useReactionsState } from "./Reactions"; import { ReviewCommentBody } from "./SuggestedChange"; import styles from "./comments.module.css"; export interface ThreadViewProps { /** The pull request number. */ number: number; thread: GHPullRequestReviewThread; variant?: "full" | "compact"; } export function ThreadView({ variant = "full", ...props }: ThreadViewProps) { return variant === "full" ? : ; } /** "Resolve" / "Unresolve" (`getResolveToggleActionText`). */ const resolveText = (resolved: boolean) => (resolved ? "Unresolve" : "Resolve"); /** The thread's resolve toggle and busy state; `onChanged` gets the new resolved state. */ function useResolve(number: number, thread: GHPullRequestReviewThread, onChanged?: (resolved: boolean) => void) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const canChange = thread.isResolved ? thread.viewerCanUnresolve : thread.viewerCanResolve; const toggle = async () => { setBusy(true); setError(null); try { const t = thread.isResolved ? await unresolveThread(number, thread.id) : await resolveThread(number, thread.id); onChanged?.(t.isResolved); } catch (e) { setError(errorMessage(e)); } finally { setBusy(false); } }; return { busy, error, canChange, toggle }; } /** The reply box: "Reply" plus Resolve / Unresolve. */ function ReplyBox({ number, thread, variant, resolve, autoFocus, onCancel, }: { number: number; thread: GHPullRequestReviewThread; variant: ChatItemVariant; resolve: ReturnType; autoFocus?: boolean; onCancel?: () => void; }) { const first = thread.comments.nodes[0]; if (!thread.viewerCanReply || !first) return null; return (
replyToComment(number, first.id, text)} />
); } const tagsOf = (thread: GHPullRequestReviewThread, comment: GHPullRequestReviewComment | undefined, first: boolean) => ( <> {first && thread.isOutdated && OUTDATED} {first && thread.isResolved && RESOLVED} {comment?.state === "PENDING" && PENDING} ); /** A review comment of `thread` as a chat item, editable, deletable and reactable as allowed. */ function ReviewComment({ number, thread, comment, variant, first, }: { number: number; thread: GHPullRequestReviewThread; comment: GHPullRequestReviewComment; variant: ChatItemVariant; first?: boolean; }) { return ( PENDING } renderBody={(body) => } onSave={(body) => updateReviewComment(number, comment.id, body)} onDelete={() => deleteReviewComment(number, comment.id)} onToggleReaction={(content, add) => toggleReaction(number, comment.id, content, add, "threads")} /> ); } function openThreadDiff(number: number, thread: GHPullRequestReviewThread) { const line = thread.line != null && thread.diffSide === "RIGHT" ? thread.line - 1 : undefined; void openPullRequestFileDiff(number, thread.path, { line }); } function FullThread({ number, thread }: Omit) { const t = useTx(); // Only resolved threads collapse (`collapsible = isResolved`): unresolved elsewhere, it opens. const [collapsedState, setCollapsedState] = useState(thread.isResolved); const collapsed = thread.isResolved && collapsedState; const [repliesFolded, setRepliesFolded] = useState(true); const [editing, setEditing] = useState(false); const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); const setCollapsed = (c: boolean) => { setCollapsedState(c); if (c) setRepliesFolded(true); }; const resolve = useResolve(number, thread, setCollapsed); const [main, ...replies] = thread.comments.nodes; const reactions = useReactionsState(main?.reactions.nodes ?? [], (content, add) => main ? toggleReaction(number, main.id, content, add, "threads") : Promise.resolve(), ); const busy = resolve.busy || deleting; const diff = threadDiffWithAnchor({ ...thread, diffHunk: threadDiffHunk(thread) }); const box = ( openThreadDiff(number, thread)} collapsible={thread.isResolved} collapsed={collapsed} onToggleCollapsed={() => setCollapsed(!collapsed)} > {diff ? ( ) : thread.subjectType === "FILE" ? null : (
{t("Could not load diff hunk for thread")}
)}
); const remove = async () => { if (!main || !(await confirmDeleteComment())) return; setDeleting(true); setError(null); try { await deleteReviewComment(number, main.id); } catch (e) { setError(errorMessage(e)); } finally { setDeleting(false); } }; const actions = main && (main.viewerCanUpdate || main.viewerCanDelete || main.viewerCanReact) && ( <> {main.viewerCanUpdate && ( setEditing(true)} /> )} {main.viewerCanDelete && ( void remove()} /> )} {main.viewerCanReact && } ); const replyAuthors = [...new Map(replies.filter((r) => r.author).map((r) => [r.author!.login, r.author!])).values()]; const last = replies[replies.length - 1]; const n = replies.length; return (
{!main ? ( box ) : collapsed ? ( <>
{box} ) : ( <> {box} {editing ? ( setEditing(false)} onSubmit={async (text) => { await updateReviewComment(number, main.id, text); setEditing(false); }} /> ) : ( )} )}
{(error || resolve.error) &&
{error ?? resolve.error}
} {repliesFolded && (n > 0 || thread.viewerCanReply || resolve.canChange) && (
{(n > 0 || thread.viewerCanReply) && ( {n > 0 && ( ({ key: a.login, url: a.avatarUrl, name: presentableName(a) }))} max={replyAuthors.length} size={20} /> )} {last && {prettyDateTime(last.createdAt)}} )} {resolve.canChange && ( )}
)}
{!repliesFolded && ( <> {replies.map((c) => ( ))} )}
); } function CompactThread({ number, thread }: Omit) { const t = useTx(); const [expanded, setExpanded] = useState(false); const [replying, setReplying] = useState(false); const resolve = useResolve(number, thread); const comments = thread.comments.nodes; const folded = !expanded && comments.length > 3; const shown = folded ? [comments[0]!, comments[comments.length - 1]!] : comments; const hidden = comments.length - 2; return (
{shown.map((c, i) => (
{folded && i === 0 && (
)}
))} {resolve.error &&
{resolve.error}
} {replying ? ( setReplying(false)} /> ) : ( (thread.viewerCanReply || resolve.canChange) && (
{thread.viewerCanReply && ( )} {resolve.canChange && ( )}
) )}
); }