| 1 | import { PullRequestEntryRow } from "./PullRequestEntryRow"; |
| 2 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 3 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 4 | import IconGitCommit from "@tabler/icons-react-native/IconGitCommit"; |
| 5 | import type { PullRequestActivity, PullRequestComment } from "@t3tools/contracts"; |
| 6 | import { StackActions, type NavigationProp } from "@react-navigation/native"; |
| 7 | import { useMemo, useState, type ReactNode } from "react"; |
| 8 | import { Pressable, StyleSheet, View } from "react-native"; |
| 9 | import { parseMarkdownDocument } from "../../lib/markdownDocument"; |
| 10 | import { AppText as Text } from "../../components/AppText"; |
| 11 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 12 | import { relativeTime } from "../../lib/time"; |
| 13 | import { PullRequestAvatar } from "./PullRequestPresentation"; |
| 14 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 15 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; |
| 16 | import type { PullRequestParams } from "./usePullRequestDiff"; |
| 17 | |
| 18 | function 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 | |
| 60 | function 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 | |
| 176 | export 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 | } |