| 1 | import { memo } from "react"; |
| 2 | import { Pressable, View } from "react-native"; |
| 3 | import { AppText as Text } from "../../components/AppText"; |
| 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 5 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 6 | import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types"; |
| 7 | import { cn } from "../../lib/cn"; |
| 8 | import { relativeTime } from "../../lib/time"; |
| 9 | import { |
| 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"; |
| 14 | import { PullRequestAvatar, PullRequestDiffStats } from "./PullRequestPresentation"; |
| 15 | import { |
| 16 | pullRequestLabelColor, |
| 17 | pullRequestListReviewStatus, |
| 18 | pullRequestListRowsAreEqual, |
| 19 | type EnvironmentPullRequestListEntry, |
| 20 | } from "./pullRequestList"; |
| 21 | import { pullRequestStatus } from "./pullRequestStatus"; |
| 22 | |
| 23 | export 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 | ); |