@reviewflow/ui 0.0.0-stage → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +101 -3
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. package/src/slots.tsx +34 -0
@@ -0,0 +1,143 @@
1
+ import type { PullRequestCommit } from "@reviewflow/sdk";
2
+
3
+ /**
4
+ * How a file of one commit's diff relates to the pull request's diff, which
5
+ * threads, pending comments and viewed marks are read on.
6
+ *
7
+ * - `pullRequest`: no other commit of the pull request changes it, so both
8
+ * sides of this commit's diff are the pull request's.
9
+ * - `head`: an earlier commit changed it too: the new side is the head's, the
10
+ * old side is not the base's.
11
+ * - `commit`: a later commit changes it again: neither side is the pull
12
+ * request's, so a comment is anchored on this commit and goes out alone.
13
+ */
14
+ export type CommitFileAnchoring = "commit" | "head" | "pullRequest";
15
+
16
+ export type CommitsToRead =
17
+ | { kind: "available"; commits: PullRequestCommit[] }
18
+ | { kind: "none" }
19
+ | { kind: "unavailable"; reason: string };
20
+
21
+ interface CommitsToReadParams {
22
+ /** oldest first, as GitHub lists them */
23
+ listed: PullRequestCommit[];
24
+ /** every commit of the pull request, `listed` included */
25
+ total: number;
26
+ /** the head the diff shows */
27
+ head: string;
28
+ }
29
+
30
+ const isChain = (commits: PullRequestCommit[]): boolean =>
31
+ commits.every(
32
+ (commit, index) =>
33
+ index === 0 || commit.parentOid === commits[index - 1]?.oid,
34
+ );
35
+
36
+ /**
37
+ * The commits up to the head shown, which can be read one by one only when
38
+ * each one's diff against its parent is a part of the pull request's: a chain
39
+ * with no merge, which a merge would break by carrying what it merged in.
40
+ */
41
+ export const commitsToRead = ({
42
+ listed,
43
+ total,
44
+ head,
45
+ }: CommitsToReadParams): CommitsToRead => {
46
+ const end = listed.findIndex(({ oid }) => oid === head);
47
+ if (end === -1) {
48
+ return {
49
+ kind: "unavailable",
50
+ reason:
51
+ total > listed.length
52
+ ? "The pull request has more commits than GitHub lists at once"
53
+ : "The commits listed do not lead to the head shown",
54
+ };
55
+ }
56
+ const commits = listed.slice(0, end + 1);
57
+ if (commits.length < 2) return { kind: "none" };
58
+ if (commits.some(({ merge }) => merge)) {
59
+ return {
60
+ kind: "unavailable",
61
+ reason: "A merge commit's own diff carries what it merged in",
62
+ };
63
+ }
64
+ if (commits[0]?.parentOid === null || !isChain(commits)) {
65
+ return {
66
+ kind: "unavailable",
67
+ reason: "The commits do not follow one another",
68
+ };
69
+ }
70
+ return { kind: "available", commits };
71
+ };
72
+
73
+ /** The index of the commit after `oid`; 0, every commit, when `oid` is null or not one of them. */
74
+ export const firstCommitAfter = (
75
+ commits: PullRequestCommit[],
76
+ oid: string | null,
77
+ ): number =>
78
+ oid === null ? 0 : commits.findIndex((commit) => commit.oid === oid) + 1;
79
+
80
+ /** No path is changed by more than one commit. */
81
+ export const touchDifferentFiles = (
82
+ pathsByCommit: readonly ReadonlySet<string>[],
83
+ ): boolean => {
84
+ const seen = new Set<string>();
85
+ for (const paths of pathsByCommit) {
86
+ for (const path of paths) {
87
+ if (seen.has(path)) return false;
88
+ seen.add(path);
89
+ }
90
+ }
91
+ return true;
92
+ };
93
+
94
+ const unionOf = (pathsByCommit: readonly ReadonlySet<string>[]): Set<string> =>
95
+ new Set(pathsByCommit.flatMap((paths) => [...paths]));
96
+
97
+ /** Every path of the commit at `index`, with how it is anchored. */
98
+ export const commitAnchorings = (
99
+ pathsByCommit: readonly ReadonlySet<string>[],
100
+ index: number,
101
+ ): Map<string, CommitFileAnchoring> => {
102
+ const earlier = unionOf(pathsByCommit.slice(0, index));
103
+ const later = unionOf(pathsByCommit.slice(index + 1));
104
+ const anchoringOf = (path: string): CommitFileAnchoring => {
105
+ if (later.has(path)) return "commit";
106
+ if (earlier.has(path)) return "head";
107
+ return "pullRequest";
108
+ };
109
+ return new Map(
110
+ [...(pathsByCommit[index] ?? [])].map((path) => [path, anchoringOf(path)]),
111
+ );
112
+ };
113
+
114
+ /** What one commit changes, to pick it from a list. */
115
+ export interface CommitSummary {
116
+ /** the commit's workspaces, the root named "Root" */
117
+ workspaces: string[];
118
+ /** the files it changes, as `changedPathsOf` counts them */
119
+ files: number;
120
+ }
121
+
122
+ interface SummarizeCommitsParams {
123
+ /** the diff of each readable commit, as loaded */
124
+ diffs: readonly { groups: readonly { name: string }[] }[];
125
+ /** the paths each diff changes, aligned with `diffs` */
126
+ pathsByCommit: readonly ReadonlySet<string>[];
127
+ /** where the stepped commits start in the readable ones */
128
+ sinceIndex: number;
129
+ }
130
+
131
+ /**
132
+ * The summaries of the stepped commits: the diffs are those of every readable
133
+ * commit, so those before `sinceIndex` are left out, as the commits are.
134
+ */
135
+ export const summarizeCommits = ({
136
+ diffs,
137
+ pathsByCommit,
138
+ sinceIndex,
139
+ }: SummarizeCommitsParams): CommitSummary[] =>
140
+ diffs.slice(sinceIndex).map(({ groups }, index) => ({
141
+ workspaces: groups.map(({ name }) => (name === "" ? "Root" : name)),
142
+ files: pathsByCommit[sinceIndex + index]?.size ?? 0,
143
+ }));
@@ -0,0 +1,221 @@
1
+ import type {
2
+ PullRequestCommit,
3
+ PullRequestDetailResponse,
4
+ PullRequestEdit,
5
+ } from "@reviewflow/sdk";
6
+ import {
7
+ Button,
8
+ Code,
9
+ InfoMessage,
10
+ PressableListItem,
11
+ Text,
12
+ View,
13
+ } from "alouette";
14
+ import { ArrowLeftRegularIcon } from "alouette-icons/phosphor-icons/ArrowLeft";
15
+ import type { ReactNode } from "react";
16
+ import { useState } from "react";
17
+ import { usePullRequestDataSource } from "../dataSource.ts";
18
+ import { formatDataAge } from "../services/formatDataAge.ts";
19
+ import { PersonAvatar } from "./pull-request-conversation-person.tsx";
20
+ import { PullRequestDiffView } from "./pull-request-diff-view.tsx";
21
+ import {
22
+ PullRequestPartError,
23
+ PullRequestPartPending,
24
+ PullRequestPartReading,
25
+ } from "./pull-request-part-notice.tsx";
26
+
27
+ export interface PullRequestCommitsProps {
28
+ detail: PullRequestDetailResponse;
29
+ /** reads the pull request again once GitHub took a comment */
30
+ onEdited: (edit: PullRequestEdit) => Promise<void>;
31
+ /**
32
+ * The commit whose diff is open, by oid; absent: the component holds it
33
+ * itself. Pass it, with `onOpenCommitChange`, to keep it in a url.
34
+ */
35
+ openCommit?: string | null;
36
+ onOpenCommitChange?: (oid: string | null) => void;
37
+ }
38
+
39
+ const shortSha = (oid: string): string => oid.slice(0, 7);
40
+
41
+ const dayOf = (commit: PullRequestCommit): string =>
42
+ new Date(commit.committedAt).toLocaleDateString([], {
43
+ year: "numeric",
44
+ month: "long",
45
+ day: "numeric",
46
+ });
47
+
48
+ interface CommitRowProps {
49
+ commit: PullRequestCommit;
50
+ onOpen: () => void;
51
+ }
52
+
53
+ /** A link, as a board row is: it opens the commit's own page, which a url keeps. */
54
+ function CommitRow({ commit, onOpen }: CommitRowProps): ReactNode {
55
+ const login = commit.author?.login ?? "unknown";
56
+
57
+ return (
58
+ <PressableListItem
59
+ role="link"
60
+ aria-label={`Show the changes of ${commit.headline}`}
61
+ onPress={onOpen}
62
+ >
63
+ <View className="flex-row items-center gap-sm">
64
+ <PersonAvatar person={commit.author} />
65
+ <View className="min-w-0 flex-1">
66
+ <Text className="text-sm font-body-bold" numberOfLines={1}>
67
+ {commit.headline}
68
+ </Text>
69
+ <Text className="text-xs text-muted" numberOfLines={1}>
70
+ {`${login} committed ${formatDataAge(commit.committedAt)}`}
71
+ </Text>
72
+ </View>
73
+ <Code className="text-xs">{shortSha(commit.oid)}</Code>
74
+ </View>
75
+ </PressableListItem>
76
+ );
77
+ }
78
+
79
+ interface CommitDiffProps {
80
+ detail: PullRequestDetailResponse;
81
+ commit: PullRequestCommit;
82
+ onBack: () => void;
83
+ onEdited: (edit: PullRequestEdit) => Promise<void>;
84
+ }
85
+
86
+ /**
87
+ * A commit's own diff. A comment is anchored on the commit, as GitHub's
88
+ * commit view does, on the new side only: GitHub reads the old side's line
89
+ * numbers against the pull request's base, not this commit's parent.
90
+ */
91
+ function CommitDiff({
92
+ detail,
93
+ commit,
94
+ onBack,
95
+ onEdited,
96
+ }: CommitDiffProps): ReactNode {
97
+ const { pullRequest } = detail;
98
+ const dataSource = usePullRequestDataSource();
99
+ return (
100
+ <View className="gap-m">
101
+ <View className="flex-row flex-wrap items-center gap-sm">
102
+ <Button
103
+ text="Back to commits"
104
+ icon={<ArrowLeftRegularIcon />}
105
+ accent="neutral"
106
+ variant="tonal"
107
+ size="sm"
108
+ onPress={onBack}
109
+ />
110
+ <Text className="min-w-0 flex-1 text-sm font-body-bold">
111
+ {commit.headline}
112
+ </Text>
113
+ <Code className="text-xs">{shortSha(commit.oid)}</Code>
114
+ </View>
115
+ {commit.body.trim() === "" ? null : (
116
+ <Text className="select-auto font-mono text-xs text-muted">
117
+ {commit.body}
118
+ </Text>
119
+ )}
120
+ {commit.parentOid === null ? (
121
+ <InfoMessage>
122
+ This is a root commit with no parent: its changes cannot be shown.
123
+ </InfoMessage>
124
+ ) : (
125
+ <PullRequestDiffView
126
+ range={{
127
+ ownerRepo: pullRequest.ownerRepo,
128
+ number: pullRequest.number,
129
+ head: commit.oid,
130
+ base: commit.parentOid,
131
+ }}
132
+ review={{
133
+ commentable: true,
134
+ rightOnly: true,
135
+ commitOid: commit.oid,
136
+ headSha: pullRequest.headSha,
137
+ threads: [],
138
+ files: [],
139
+ onEdit: async (edit) => {
140
+ await dataSource.edit(pullRequest, edit);
141
+ await onEdited(edit);
142
+ },
143
+ onViewed: () => Promise.resolve(),
144
+ }}
145
+ />
146
+ )}
147
+ </View>
148
+ );
149
+ }
150
+
151
+ /** Oldest first, grouped by day as on GitHub; pressing a commit shows its own diff, read-only. */
152
+ export function PullRequestCommits({
153
+ detail,
154
+ onEdited,
155
+ openCommit,
156
+ onOpenCommitChange,
157
+ }: PullRequestCommitsProps): ReactNode {
158
+ const { pullRequest } = detail;
159
+ const { usePart } = usePullRequestDataSource();
160
+ const [localOid, setLocalOid] = useState<string | null>(null);
161
+ const openOid = openCommit === undefined ? localOid : openCommit;
162
+ const { value, error, reading } = usePart(pullRequest, "commits");
163
+ if (value === null) {
164
+ return <PullRequestPartPending error={error} what="the commits" />;
165
+ }
166
+ const { commits: listed } = value;
167
+ const opened = listed.find(({ oid }) => oid === openOid);
168
+ const setOpenOid = (oid: string | null): void => {
169
+ setLocalOid(oid);
170
+ onOpenCommitChange?.(oid);
171
+ };
172
+
173
+ if (opened !== undefined) {
174
+ return (
175
+ <CommitDiff
176
+ key={opened.oid}
177
+ detail={detail}
178
+ commit={opened}
179
+ onEdited={onEdited}
180
+ onBack={() => {
181
+ setOpenOid(null);
182
+ }}
183
+ />
184
+ );
185
+ }
186
+
187
+ const days = new Map<string, PullRequestCommit[]>();
188
+ for (const commit of listed) {
189
+ const day = dayOf(commit);
190
+ const ofDay = days.get(day);
191
+ if (ofDay === undefined) days.set(day, [commit]);
192
+ else ofDay.push(commit);
193
+ }
194
+ const unlisted = pullRequest.commitsTotal - listed.length;
195
+
196
+ return (
197
+ <View className="gap-m">
198
+ <PullRequestPartReading reading={reading} />
199
+ <PullRequestPartError error={error} />
200
+ {[...days].map(([day, commits]) => (
201
+ <View key={day} className="gap-xs">
202
+ <Text className="text-xs font-body-bold text-muted">{day}</Text>
203
+ {commits.map((commit) => (
204
+ <CommitRow
205
+ key={commit.oid}
206
+ commit={commit}
207
+ onOpen={() => {
208
+ setOpenOid(commit.oid);
209
+ }}
210
+ />
211
+ ))}
212
+ </View>
213
+ ))}
214
+ {unlisted > 0 ? (
215
+ <Text className="text-xs text-muted">
216
+ {`${String(unlisted)} more commit${unlisted === 1 ? "" : "s"} not listed`}
217
+ </Text>
218
+ ) : null}
219
+ </View>
220
+ );
221
+ }
@@ -0,0 +1,220 @@
1
+ import type {
2
+ PullRequestEdit,
3
+ PullRequestPerson,
4
+ PullRequestReactable,
5
+ PullRequestReviewState,
6
+ } from "@reviewflow/sdk";
7
+ import type { Accent } from "alouette";
8
+ import {
9
+ Badge,
10
+ Box,
11
+ ExternalLinkText,
12
+ IconButton,
13
+ Text,
14
+ Tooltip,
15
+ View,
16
+ } from "alouette";
17
+ import { PencilSimpleRegularIcon } from "alouette-icons/phosphor-icons/PencilSimple";
18
+ import type { ReactNode } from "react";
19
+ import { useState } from "react";
20
+ import { useHasDraftText } from "../services/PullRequestDraftsProvider.tsx";
21
+ import { formatDataAge } from "../services/formatDataAge.ts";
22
+ import { linkInRow } from "./link-in-row.ts";
23
+ import { MarkdownText } from "./markdown-text.tsx";
24
+ import type { ConversationEditing } from "./pull-request-conversation-edit.tsx";
25
+ import { BodyEditor, useSendEdit } from "./pull-request-conversation-edit.tsx";
26
+ import { PersonAvatar } from "./pull-request-conversation-person.tsx";
27
+ import { PullRequestReactions } from "./pull-request-reactions.tsx";
28
+ import { EditedLink } from "./pull-request-revisions.tsx";
29
+
30
+ const verdictLabel = (state: PullRequestReviewState): string => {
31
+ if (state === "approved") return "approved these changes";
32
+ if (state === "changesRequested") return "requested changes";
33
+ if (state === "dismissed") return "review dismissed";
34
+ if (state === "pending") return "pending review";
35
+ return "reviewed";
36
+ };
37
+
38
+ const verdictAccent = (state: PullRequestReviewState): Accent => {
39
+ if (state === "approved") return "success";
40
+ if (state === "changesRequested") return "danger";
41
+ if (state === "pending") return "warning";
42
+ return "info";
43
+ };
44
+
45
+ interface CardHeaderProps {
46
+ author: PullRequestPerson | null;
47
+ /** a review's verdict, shown as a badge instead of `action` */
48
+ verdict?: PullRequestReviewState;
49
+ action: string;
50
+ createdAt: string;
51
+ url: string;
52
+ edited: ReactNode;
53
+ onEdit: (() => void) | null;
54
+ }
55
+
56
+ function CardHeader({
57
+ author,
58
+ verdict,
59
+ action,
60
+ createdAt,
61
+ url,
62
+ edited,
63
+ onEdit,
64
+ }: CardHeaderProps): ReactNode {
65
+ return (
66
+ <View className="flex-row items-center gap-xs">
67
+ <View className="min-w-0 flex-1 flex-row flex-wrap items-center gap-xs">
68
+ <PersonAvatar person={author} />
69
+ <Text className="text-sm font-body-bold">
70
+ {author?.login ?? "ghost"}
71
+ </Text>
72
+ {verdict === undefined ? (
73
+ <Text className="text-sm text-muted">{action}</Text>
74
+ ) : (
75
+ <Badge accent={verdictAccent(verdict)} size="sm">
76
+ {verdictLabel(verdict)}
77
+ </Badge>
78
+ )}
79
+ <ExternalLinkText
80
+ size="sm"
81
+ href={url}
82
+ text={formatDataAge(createdAt)}
83
+ className={linkInRow}
84
+ />
85
+ {edited}
86
+ </View>
87
+ {onEdit === null ? null : (
88
+ <Tooltip
89
+ content="Edit the text, on GitHub too"
90
+ render={(triggerProps) => (
91
+ <IconButton
92
+ {...triggerProps}
93
+ icon={<PencilSimpleRegularIcon />}
94
+ aria-label="Edit"
95
+ accent="neutral"
96
+ variant="soft"
97
+ size="sm"
98
+ onPress={onEdit}
99
+ />
100
+ )}
101
+ />
102
+ )}
103
+ </View>
104
+ );
105
+ }
106
+
107
+ interface CommentCardProps {
108
+ editing: ConversationEditing;
109
+ author: PullRequestPerson | null;
110
+ action: string;
111
+ verdict?: PullRequestReviewState;
112
+ createdAt: string;
113
+ lastEditedAt: string | null;
114
+ url: string;
115
+ body: string;
116
+ /** what an empty body says; without it an empty body renders nothing */
117
+ emptyText?: string;
118
+ canEdit: boolean;
119
+ toEdit: (body: string) => PullRequestEdit;
120
+ /** what the reactions under the body are those of */
121
+ reactable: PullRequestReactable;
122
+ /** inside a thread's frame, which is already the card */
123
+ bare?: boolean;
124
+ /** under the body, hidden with it while editing */
125
+ footer?: ReactNode;
126
+ }
127
+
128
+ /**
129
+ * One piece of the conversation with its author: the body is rendered
130
+ * markdown, edited in place through `toEdit`, and its task checkboxes save the
131
+ * edited source the same way.
132
+ */
133
+ export function CommentCard({
134
+ editing,
135
+ author,
136
+ action,
137
+ verdict,
138
+ createdAt,
139
+ lastEditedAt,
140
+ url,
141
+ body,
142
+ emptyText,
143
+ canEdit,
144
+ toEdit,
145
+ reactable,
146
+ bare = false,
147
+ footer,
148
+ }: CommentCardProps): ReactNode {
149
+ const sendEdit = useSendEdit(editing);
150
+ const draftKey = `edit:${reactable.id}`;
151
+ // an edit left unsent on an earlier visit opens again on its draft
152
+ const hasDraft = useHasDraftText(draftKey);
153
+ const [editorOpen, setEditorOpen] = useState(hasDraft);
154
+ const empty = body.trim() === "";
155
+
156
+ const content = (
157
+ <>
158
+ <CardHeader
159
+ author={author}
160
+ verdict={verdict}
161
+ action={action}
162
+ createdAt={createdAt}
163
+ url={url}
164
+ edited={
165
+ <EditedLink
166
+ pullRequest={editing.pullRequest}
167
+ subjectId={reactable.id}
168
+ lastEditedAt={lastEditedAt}
169
+ url={url}
170
+ />
171
+ }
172
+ onEdit={
173
+ canEdit && !editorOpen
174
+ ? () => {
175
+ setEditorOpen(true);
176
+ }
177
+ : null
178
+ }
179
+ />
180
+ {editorOpen ? (
181
+ <BodyEditor
182
+ editing={editing}
183
+ draftKey={draftKey}
184
+ initial={body}
185
+ ariaLabel="Edit"
186
+ toEdit={toEdit}
187
+ onDone={() => {
188
+ setEditorOpen(false);
189
+ }}
190
+ />
191
+ ) : null}
192
+ {!editorOpen && !empty ? (
193
+ <MarkdownText
194
+ github
195
+ text={body}
196
+ onTaskToggle={
197
+ canEdit
198
+ ? async (nextText) => {
199
+ await sendEdit(toEdit(nextText));
200
+ }
201
+ : undefined
202
+ }
203
+ />
204
+ ) : null}
205
+ {!editorOpen && empty && emptyText !== undefined ? (
206
+ <Text className="text-sm text-muted">{emptyText}</Text>
207
+ ) : null}
208
+ {editorOpen ? null : footer}
209
+ {editorOpen ? null : (
210
+ <PullRequestReactions
211
+ subject={reactable}
212
+ onEdit={(edit) => sendEdit(edit)}
213
+ />
214
+ )}
215
+ </>
216
+ );
217
+
218
+ if (bare) return <View className="min-w-0 gap-xs">{content}</View>;
219
+ return <Box className="surface min-w-0 gap-sm p-m">{content}</Box>;
220
+ }