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