import { Alert, Markdown } from '@app/components' import type { Pull, PullComment, PullCommentThread, PullEvent, PullRef, PullReview, ReviewIntent, } from '@app/domains/pulls/model' import { createComment, deleteComment, dismissReview, getReview, listCommentTree, listEvents, listReviews, publishReview, resolveComment, submitReview, unresolveComment, updateComment, } from '@app/domains/pulls/model' import { type MessageKey, useT } from '@app/i18n' import { fmtRelative, fmtTime, useAsync } from '@app/lib' import { Button } from '@app/ui' import { IconCheck as Check, IconChecks as CheckCheck, IconLock as Lock, IconMessage as MessageSquare, IconPencil as Pencil, IconArrowBackUp as Reply, IconRotate as RotateCcw, IconSend as Send, IconTrash as Trash2, } from '@tabler/icons-react' import { type ReactNode, useMemo, useState } from 'react' import { eventLabel, REVIEW_STATE_LABEL, REVIEW_STATE_TONE, TONE, userName } from '../model/meta' import { Avatar, Copyable, SideSection } from './parts' type Row = | { kind: 'thread'; at: string; thread: PullCommentThread } | { kind: 'review'; at: string; review: PullReview } | { kind: 'event'; at: string; event: PullEvent } export interface ConversationProps { refObj: PullRef pull: Pull onChanged: () => void /** Jump to the files tab anchored at a path (from an inline comment). */ onOpenFile: (path: string) => void /** 时间线末尾、评论框之前的插槽(合并盒等页面级操作)。 */ beforeComposer?: ReactNode } /** 会话页签:评论线程 + 评审卡片 + 事件时间线,按时间归并。 */ export function Conversation({ refObj, pull, onChanged, onOpenFile, beforeComposer }: ConversationProps) { const { t } = useT() const [tick, setTick] = useState(0) const refresh = () => { setTick((t) => t + 1) onChanged() } const key = [refObj.space, refObj.repo, refObj.pull, tick] const threads = useAsync(() => listCommentTree(refObj), key, undefined) const events = useAsync(() => listEvents(refObj, { limit: 100 }), key, undefined) const reviews = useAsync(() => listReviews(refObj), key, undefined) const timeline = useMemo(() => { const rows: Row[] = [] for (const thread of threads.data ?? []) rows.push({ kind: 'thread', at: thread.root.created_at, thread }) for (const review of reviews.data ?? []) rows.push({ kind: 'review', at: review.created_at, review }) for (const event of events.data?.items ?? []) rows.push({ kind: 'event', at: event.created_at, event }) return rows.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)) }, [threads.data, reviews.data, events.data]) return (
{pull.locked ? ( {t('pull.conv.locked')} ) : null} {timeline.map((row) => row.kind === 'thread' ? ( ) : row.kind === 'review' ? ( ) : ( ), )} {beforeComposer}
) } /* -------------------------------------------------------------- threads */ function CommentBubble({ refObj, comment, onChanged, }: { refObj: PullRef comment: PullComment onChanged: () => void }) { const { t } = useT() const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(comment.body) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) async function save() { setBusy(true) setError(null) try { await updateComment(refObj, comment.id, draft) setEditing(false) onChanged() } catch (failure) { setError(failure instanceof Error ? failure.message : t('pull.conv.saveFailed')) } finally { setBusy(false) } } async function remove() { if (!window.confirm(t('pull.conv.deleteConfirm'))) return try { await deleteComment(refObj, comment.id) onChanged() } catch { /* best-effort */ } } return (
{userName(comment.author)} {fmtRelative(comment.created_at)} {comment.updated_at && comment.updated_at !== comment.created_at ? ( {t('pull.conv.edited')} ) : null}
{editing ? (