1import { memo } from "react";
2import { Pressable, View } from "react-native";
3import { AppText as Text } from "../../components/AppText";
4import { SymbolView } from "../../components/AppSymbol";
5import { ControlPillMenu } from "../../components/ControlPillMenu";
6import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types";
7import { cn } from "../../lib/cn";
8import { relativeTime } from "../../lib/time";
9import {
10 selectedThreadRowColors,
11 THREAD_LIST_V2_SIDEBAR_ROW_STYLE,
12 THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE,
13} from "../threads/thread-list-v2-row-appearance";
14import { PullRequestAvatar, PullRequestDiffStats } from "./PullRequestPresentation";
15import {
16 pullRequestLabelColor,
17 pullRequestListReviewStatus,
18 pullRequestListRowsAreEqual,
19 type EnvironmentPullRequestListEntry,
20} from "./pullRequestList";
21import { pullRequestStatus } from "./pullRequestStatus";
22
23export const PullRequestListRow = memo(
24 function PullRequestListRow(props: {
25 readonly item: EnvironmentPullRequestListEntry;
26 readonly onSelect: (item: EnvironmentPullRequestListEntry) => void;
27 readonly selected: boolean;
28 readonly sidebar: boolean;
29 readonly busy: boolean;
30 readonly getActions: (item: EnvironmentPullRequestListEntry) => ControlPillMenuProps["actions"];
31 readonly onAction: (item: EnvironmentPullRequestListEntry, action: string) => void;
32 }) {
33 const { item } = props;
34 const foreground = props.selected
35 ? selectedThreadRowColors.foregroundClassName
36 : props.sidebar
37 ? "text-drawer-foreground"
38 : "text-foreground";
39 const mutedForeground = props.selected
40 ? selectedThreadRowColors.mutedForegroundClassName
41 : props.sidebar
42 ? "text-drawer-foreground-muted"
43 : "text-foreground-muted";
44 const tertiaryForeground = props.selected
45 ? selectedThreadRowColors.mutedForegroundClassName
46 : props.sidebar
47 ? "text-drawer-foreground-muted"
48 : "text-foreground-tertiary";
49 const status = pullRequestStatus(item);
50 const review = pullRequestListReviewStatus(item);
51 const checkTint =
52 item.checksState === "passing"
53 ? "accent-adaptive-emerald-600-400"
54 : item.checksState === "failing"
55 ? "accent-danger-foreground"
56 : "accent-warning-foreground";
57 return (
58 <View style={props.sidebar ? THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE : undefined}>
59 <ControlPillMenu
60 shouldOpenOnLongPress
61 title={`#${item.number} · ${item.repository}`}
62 actions={props.getActions(item)}
63 onPressAction={({ nativeEvent }) => props.onAction(item, nativeEvent.event)}
64 >
65 <Pressable
66 accessibilityRole="button"
67 accessibilityState={{ selected: props.selected, busy: props.busy }}
68 accessibilityLabel={`Pull request ${item.number}, ${item.title}, ${status.label}, by ${item.author?.login ?? "unknown author"}${review ? `, ${review.accessibleLabel}` : ""}${item.checksState ? `, checks ${item.checksState}` : ""}${item.stack ? `, stack ${item.stack.position} of ${item.stack.size}` : ""}${item.labels.length ? `, ${item.labels.map((label) => label.name).join(", ")}` : ""}, ${item.additions} additions, ${item.deletions} deletions`}
69 accessibilityHint="Double tap to open. Touch and hold for quick actions."
70 onPress={() => props.onSelect(item)}
71 disabled={props.busy}
72 style={props.sidebar ? THREAD_LIST_V2_SIDEBAR_ROW_STYLE : undefined}
73 className={cn(
74 "flex-row gap-3 overflow-hidden",
75 !props.sidebar && "px-4 active:bg-row-hover",
76 props.sidebar && "bg-drawer",
77 props.sidebar && !props.selected && "active:bg-thread-hover",
78 props.selected && "bg-thread-selected",
79 props.busy && "opacity-50",
80 )}
81 >
82 <View className={props.sidebar ? "pt-0.5" : "pt-2.5"}>
83 <SymbolView name={status.icon} size={20} tintColorClassName={status.tint} />
84 </View>
85 <View
86 className={cn(
87 "min-w-0 flex-1 gap-1",
88 !props.sidebar && "border-b border-border-subtle py-2.5",
89 )}
90 >
91 <View className="flex-row items-center gap-1.5">
92 <PullRequestAvatar actor={item.author} size={16} />
93 <Text className={cn("min-w-0 flex-1 text-xs", mutedForeground)} numberOfLines={1}>
94 {item.repository} <Text className={tertiaryForeground}>#{item.number}</Text>
95 </Text>
96 {item.stack ? (
97 <View className="flex-row items-center gap-1">
98 <SymbolView
99 name="square.3.layers.3d"
100 size={12}
101 tintColorClassName={
102 props.selected
103 ? selectedThreadRowColors.mutedIconTintClassName
104 : props.sidebar
105 ? "accent-drawer-foreground-muted"
106 : "accent-foreground-muted"
107 }
108 />
109 <Text className={cn("text-2xs tabular-nums", mutedForeground)}>
110 {item.stack.position}/{item.stack.size}
111 </Text>
112 </View>
113 ) : null}
114 <Text className={cn("text-2xs tabular-nums", tertiaryForeground)}>
115 {relativeTime(item.updatedAt)}
116 </Text>
117 </View>
118 <Text
119 className={cn("text-base leading-5 font-t3-medium", foreground)}
120 numberOfLines={2}
121 ellipsizeMode="tail"
122 >
123 {item.title}
124 </Text>
125 <View className="flex-row items-center gap-1.5">
126 <View className="min-w-0 flex-1 flex-row items-center gap-1">
127 {item.labels.slice(0, 2).map((label) => {
128 const color = pullRequestLabelColor(label.color);
129 return (
130 <View
131 key={label.name}
132 className="min-w-0 shrink flex-row items-center gap-1 rounded-md bg-subtle px-1.5 py-0.5"
133 style={{
134 maxWidth: 160,
135 ...(color ? { backgroundColor: `${color}20` } : {}),
136 }}
137 >
138 {color ? (
139 <View
140 style={{ width: 5, height: 5, borderRadius: 3, backgroundColor: color }}
141 />
142 ) : null}
143 <Text
144 className={cn("shrink text-2xs font-t3-medium", mutedForeground)}
145 numberOfLines={1}
146 >
147 {label.name}
148 </Text>
149 </View>
150 );
151 })}
152 {item.labels.length > 2 ? (
153 <Text className={cn("shrink-0 text-2xs", tertiaryForeground)}>
154 +{item.labels.length - 2}
155 </Text>
156 ) : null}
157 </View>
158 {item.checksState ? (
159 <View className="shrink-0" accessibilityLabel={`Checks ${item.checksState}`}>
160 <SymbolView
161 name={
162 item.checksState === "passing"
163 ? "checkmark.circle"
164 : item.checksState === "failing"
165 ? "xmark.circle.fill"
166 : "clock"
167 }
168 size={13}
169 tintColorClassName={checkTint}
170 />
171 </View>
172 ) : null}
173 {review ? (
174 <View className="shrink-0" accessibilityLabel={review.accessibleLabel}>
175 <SymbolView name={review.icon} size={13} tintColorClassName={review.tint} />
176 </View>
177 ) : null}
178 {item.additions || item.deletions ? (
179 <PullRequestDiffStats
180 additions={item.additions}
181 deletions={item.deletions}
182 compact
183 />
184 ) : null}
185 </View>
186 </View>
187 </Pressable>
188 </ControlPillMenu>
189 </View>
190 );
191 },
192 (previous, next) =>
193 pullRequestListRowsAreEqual(previous.item, next.item) &&
194 previous.selected === next.selected &&
195 previous.sidebar === next.sidebar &&
196 previous.busy === next.busy &&
197 previous.onSelect === next.onSelect &&
198 previous.onAction === next.onAction &&
199 previous.getActions === next.getActions,
200);