/** * 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} /> )} { setRepliesFolded(false); setCollapsedState(false); }} > {n === 0 ? t("Reply") : n === 1 ? tx`${n} reply` : tx`${n} replies`} {last && {prettyDateTime(last.createdAt)}} )} {resolve.canChange && ( void resolve.toggle()}> {t(resolveText(thread.isResolved))} )} )} {!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 && ( setExpanded(true)}> … {tx`${hidden} more replies`} )} ))} {resolve.error && {resolve.error}} {replying ? ( setReplying(false)} /> ) : ( (thread.viewerCanReply || resolve.canChange) && ( {thread.viewerCanReply && ( setReplying(true)}> {t("Reply")} )} {resolve.canChange && ( void resolve.toggle()} > {t(resolveText(thread.isResolved))} )} ) )} ); }