1import {
2 parsePullRequestAnchor,
3 pullRequestAnchorMatchesUrl,
4} from "@t3tools/shared/pullRequestAnchor";
5import { PullRequestEntryRow } from "./PullRequestEntryRow";
6import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
7import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
8import IconGitCommit from "@tabler/icons-react-native/IconGitCommit";
9import {
10 pullRequestConversationThreads,
11 type PullRequestActivity,
12 type PullRequestComment,
13} from "@t3tools/contracts";
14import { StackActions, type NavigationProp } from "@react-navigation/native";
15import { useMemo, useState, type ComponentProps, type ReactNode } from "react";
16import { Pressable, StyleSheet, View } from "react-native";
17import { parseMarkdownDocument } from "../../lib/markdownDocument";
18import { AppText as Text } from "../../components/ui/AppText";
19import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol";
20import { relativeTime } from "../../lib/time";
21import { PullRequestAvatar } from "./PullRequestPresentation";
22import { PullRequestMarkdown } from "./PullRequestMarkdown";
23import { PullRequestCommentFooter } from "./PullRequestReactions";
24import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";
25import { discussionCommentCountLabel } from "./pullRequestDiffComments";
26import { normalizedReviewState } from "./pullRequestStatus";
27import type { PullRequestParams } from "./usePullRequestDiff";
28
29function TimelineEvent(props: {
30 icon: AppSymbolName | "commit";
31 tone?: "success" | "danger";
32 children: ReactNode;
33}) {
34 const { themeVariables: colors } = useAppearancePreferences();
35 return (
36 <PullRequestEntryRow
37 leading={
38 <>
39 <View className="absolute top-0 bottom-0 w-px bg-border-subtle" />
40 <View className="size-8 items-center justify-center rounded-full border border-border-subtle bg-grouped-card">
41 {props.icon === "commit" ? (
42 <View style={{ transform: [{ rotate: "90deg" }] }}>
43 <IconGitCommit
44 size={17}
45 color={colors["--color-foreground-muted"] ?? "#838383"}
46 strokeWidth={1.8}
47 />
48 </View>
49 ) : (
50 <SymbolView
51 name={props.icon}
52 size={15}
53 tintColorClassName={
54 props.tone === "success"
55 ? "accent-adaptive-emerald-600-400"
56 : props.tone === "danger"
57 ? "accent-danger-foreground"
58 : "accent-icon-muted"
59 }
60 />
61 )}
62 </View>
63 </>
64 }
65 >
66 <View className="gap-2 pt-1 pb-6">{props.children}</View>
67 </PullRequestEntryRow>
68 );
69}
70
71type CommentActions = {
72 readonly permissions: ComponentProps<typeof PullRequestCommentFooter>["permissions"];
73 readonly onChanged: () => void;
74};
75
76function TimelineComment({
77 comment,
78 params,
79 actions,
80}: {
81 comment: PullRequestComment;
82 params: PullRequestParams;
83 actions: CommentActions;
84}) {
85 const target = parsePullRequestAnchor(params.navigationUrl);
86 const linked =
87 target?.kind === "timeline" && pullRequestAnchorMatchesUrl(target.anchor, comment.url);
88 const [expanded, setExpanded] = useState(linked);
89 const { themeVariables: colors } = useAppearancePreferences();
90 const long = comment.body.length > 450 || comment.body.split("\n").length > 8;
91 const content = useMemo(
92 () => pullRequestDescriptionContent(parseMarkdownDocument(comment.body)),
93 [comment.body],
94 );
95 const preview = content.preview;
96 const state = normalizedReviewState(comment.reviewState);
97 const approved = state === "APPROVED";
98 const changes = state === "CHANGES_REQUESTED";
99 return (
100 <TimelineEvent
101 icon={approved ? "checkmark" : changes ? "doc.text" : "text.bubble"}
102 tone={approved ? "success" : changes ? "danger" : undefined}
103 >
104 <View className="flex-row items-center gap-2">
105 <PullRequestAvatar actor={comment.author} size={22} />
106 <View className="min-w-0 flex-1">
107 <Text className="text-sm">
108 <Text className="font-t3-medium">{comment.author?.login ?? "Unknown author"}</Text>
109 <Text className="text-foreground-muted">
110 {approved ? " approved" : changes ? " requested changes" : " commented"}
111 </Text>
112 </Text>
113 </View>
114 <Text className="text-2xs text-foreground-tertiary">{relativeTime(comment.createdAt)}</Text>
115 </View>
116 {comment.path ? (
117 <Text className="font-mono text-2xs text-foreground-muted" numberOfLines={2}>
118 {comment.path}
119 </Text>
120 ) : null}
121 {preview.trim() || content.attachments.length ? (
122 <View
123 className={
124 linked
125 ? "gap-2 overflow-hidden rounded-2xl border border-primary bg-primary/10 p-3"
126 : "gap-2 overflow-hidden rounded-2xl bg-grouped-card p-3"
127 }
128 >
129 <View style={long && !expanded ? { maxHeight: 240, overflow: "hidden" } : undefined}>
130 <PullRequestMarkdown
131 markdown={long && !expanded ? comment.body.slice(0, 1200) : comment.body}
132 imageGroupMarkdown={comment.body}
133 />
134 </View>
135 {long && !expanded ? (
136 <View
137 pointerEvents="none"
138 style={[
139 StyleSheet.absoluteFill,
140 { top: undefined, bottom: 0, left: 0, right: 0, height: 80, zIndex: 1 },
141 ]}
142 >
143 <Svg width="100%" height={80}>
144 <Defs>
145 <LinearGradient
146 id="comment-fade"
147 x1="0"
148 y1="0"
149 x2="0"
150 y2="80"
151 gradientUnits="userSpaceOnUse"
152 >
153 <Stop offset="0" stopColor={colors["--color-grouped-card"]} stopOpacity={0} />
154 <Stop
155 offset="0.5"
156 stopColor={colors["--color-grouped-card"]}
157 stopOpacity={0.94}
158 />
159 <Stop
160 offset="0.72"
161 stopColor={colors["--color-grouped-card"]}
162 stopOpacity={1}
163 />
164 <Stop offset="1" stopColor={colors["--color-grouped-card"]} stopOpacity={1} />
165 </LinearGradient>
166 </Defs>
167 <Rect width="100%" height="100%" fill="url(#comment-fade)" />
168 </Svg>
169 <View
170 style={{
171 position: "absolute",
172 bottom: 0,
173 left: 0,
174 right: 0,
175 height: 22,
176 backgroundColor: colors["--color-grouped-card"],
177 }}
178 />
179 </View>
180 ) : null}
181 {long ? (
182 <Pressable
183 accessibilityRole="button"
184 accessibilityState={{ expanded }}
185 onPress={() => setExpanded(!expanded)}
186 hitSlop={8}
187 style={
188 !expanded
189 ? { position: "absolute", bottom: 2, left: 12, right: 12, height: 44, zIndex: 2 }
190 : undefined
191 }
192 className="flex-row items-center justify-center gap-1 py-2 active:opacity-70"
193 >
194 <Text className="text-xs font-t3-medium text-foreground">
195 {expanded ? "Show less" : "Show more"}
196 </Text>
197 <SymbolView
198 name={expanded ? "chevron.up" : "chevron.down"}
199 size={10}
200 tintColorClassName="accent-foreground"
201 />
202 </Pressable>
203 ) : null}
204 </View>
205 ) : null}
206 <PullRequestCommentFooter
207 params={params}
208 comment={comment}
209 permissions={actions.permissions}
210 onChanged={actions.onChanged}
211 />
212 </TimelineEvent>
213 );
214}
215
216export function pullRequestTimelineRows(
217 activity: PullRequestActivity,
218 params: PullRequestParams,
219 navigation: Pick<NavigationProp<ReactNavigation.RootParamList>, "navigate" | "dispatch">,
220 actions: CommentActions,
221) {
222 const linkTarget = parsePullRequestAnchor(params.navigationUrl);
223 const threads = pullRequestConversationThreads(activity);
224 const discussionIds = new Set(
225 threads.flatMap((thread) => thread.comments.map((comment) => comment.id)),
226 );
227 const events = [
228 ...activity.comments
229 .filter((comment) => !discussionIds.has(comment.id))
230 .map((comment) => ({
231 key: `comment:${comment.id}`,
232 date: comment.createdAt,
233 content: (
234 <TimelineComment
235 key={
236 linkTarget?.kind === "timeline" &&
237 pullRequestAnchorMatchesUrl(linkTarget.anchor, comment.url)
238 ? `${comment.id}:${params.navigationId ?? params.navigationUrl}`
239 : comment.id
240 }
241 comment={comment}
242 params={params}
243 actions={actions}
244 />
245 ),
246 })),
247 ...threads.map((thread) => ({
248 key: `discussion:${thread.id}`,
249 date: thread.comments[0]?.createdAt ?? "",
250 content: (
251 <TimelineEvent icon="text.bubble" tone={thread.isResolved ? "success" : undefined}>
252 <View className="flex-row items-center gap-2">
253 <PullRequestAvatar actor={thread.comments[0]?.author ?? null} size={22} />
254 <Text className="flex-1 text-sm">
255 <Text className="font-t3-medium">
256 {thread.comments[0]?.author?.login ?? "Unknown author"}
257 </Text>
258 <Text className="text-foreground-muted"> started a discussion</Text>
259 </Text>
260 {thread.comments[0] ? (
261 <Text className="text-2xs text-foreground-tertiary">
262 {relativeTime(thread.comments[0].createdAt)}
263 </Text>
264 ) : null}
265 </View>
266 <Pressable
267 accessibilityRole="button"
268 accessibilityLabel={`Open ${thread.isResolved ? "resolved" : "unresolved"} discussion in ${thread.path ?? "the overview"}`}
269 onPress={() =>
270 navigation.navigate("PullRequestDiscussion", { ...params, discussionId: thread.id })
271 }
272 className="gap-2 rounded-2xl bg-grouped-card p-3 active:opacity-70"
273 >
274 <View className="flex-row items-center gap-2">
275 {thread.path === null ? (
276 <Text className="flex-1 text-xs">Overview</Text>
277 ) : (
278 <>
279 <SymbolView name="doc.text" size={14} tintColorClassName="accent-icon-muted" />
280 <Text className="flex-1 font-mono text-xs" numberOfLines={2}>
281 {thread.path}
282 {thread.line ? `:${thread.line}` : ""}
283 </Text>
284 </>
285 )}
286 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-muted" />
287 </View>
288 <Text className="text-xs text-foreground-muted">
289 {thread.isResolved ? "Resolved" : "Unresolved"}
290 {thread.isOutdated ? " · Outdated" : ""} ·{" "}
291 {discussionCommentCountLabel(thread.commentCount ?? thread.comments.length)}
292 </Text>
293 </Pressable>
294 </TimelineEvent>
295 ),
296 })),
297 ...activity.commits.map((commit) => ({
298 key: `commit:${commit.oid}`,
299 date: commit.committedDate,
300 content: (
301 <TimelineEvent icon="commit">
302 <Pressable
303 accessibilityRole="button"
304 accessibilityLabel={`View commit ${commit.oid.slice(0, 7)}, ${commit.messageHeadline}`}
305 onPress={() =>
306 navigation.dispatch(
307 StackActions.push("PullRequestFile", { ...params, commit: commit.oid, path: "" }),
308 )
309 }
310 className="flex-row items-center gap-2 active:opacity-70"
311 >
312 <PullRequestAvatar actor={commit.authors?.[0] ?? null} size={22} />
313 <Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
314 {commit.messageHeadline}
315 </Text>
316 <Text className="font-mono text-2xs text-foreground-muted">
317 {commit.oid.slice(0, 7)}
318 </Text>
319 </Pressable>
320 </TimelineEvent>
321 ),
322 })),
323 ].sort((a, b) => a.date.localeCompare(b.date));
324 if (activity.commentsTruncated || activity.reviewThreadsTruncated)
325 events.push({
326 key: "truncated",
327 date: "",
328 content: (
329 <Text className="py-3 text-xs text-foreground-muted">
330 More activity is available on the provider.
331 </Text>
332 ),
333 });
334 return events;
335}