| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | import type { PullRequestReviewerCandidate } from "@t3tools/contracts"; |
| 6 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 7 | import { |
| 8 | ActivityIndicator, |
| 9 | FlatList, |
| 10 | Platform, |
| 11 | Pressable, |
| 12 | ScrollView, |
| 13 | View, |
| 14 | type ScrollViewProps, |
| 15 | } from "react-native"; |
| 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | import { ScrollViewMarker } from "react-native-screens"; |
| 18 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 19 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 20 | import { EmptyState } from "../../components/EmptyState"; |
| 21 | import { MaterialButton } from "../../components/MaterialButton"; |
| 22 | import { SymbolView } from "../../components/AppSymbol"; |
| 23 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 24 | import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; |
| 25 | import { composerPullRequests } from "../../state/pull-requests"; |
| 26 | import { useEnvironmentQuery } from "../../state/query"; |
| 27 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 28 | import { |
| 29 | createNativeMailSearchToolbarItem, |
| 30 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 31 | } from "../layout/native-mail-search-toolbar"; |
| 32 | import { PullRequestAvatar } from "./PullRequestPresentation"; |
| 33 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 34 | |
| 35 | function ReviewerScrollView(props: ScrollViewProps) { |
| 36 | const insets = useSafeAreaInsets(); |
| 37 | return ( |
| 38 | <ScrollViewMarker style={{ flex: 1 }}> |
| 39 | <ScrollView |
| 40 | {...props} |
| 41 | automaticallyAdjustContentInsets={false} |
| 42 | contentInsetAdjustmentBehavior="never" |
| 43 | contentInset={{ top: insets.top }} |
| 44 | contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }} |
| 45 | /> |
| 46 | </ScrollViewMarker> |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequestParams>) { |
| 51 | const navigation = useNavigation(); |
| 52 | const insets = useSafeAreaInsets(); |
| 53 | const listRef = useRef<FlatList<PullRequestReviewerCandidate>>(null); |
| 54 | const nativeMetrics = useNativeLayoutMetrics(); |
| 55 | const integratedSearch = |
| 56 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && |
| 57 | (!nativeMetrics || nativeMetrics.verticalBarEdge === "none"); |
| 58 | const target = pullRequestTarget(route.params); |
| 59 | const detail = useEnvironmentQuery(composerPullRequests.detail(target)); |
| 60 | const pr = detail.data; |
| 61 | const canWrite = useAtomValue( |
| 62 | composerPullRequests.requestReviewers.permissionAtom(target.environmentId), |
| 63 | ); |
| 64 | const connected = usePullRequestConnected(route.params.environmentId); |
| 65 | const allowed = |
| 66 | connected && |
| 67 | canWrite && |
| 68 | pr?.state === "open" && |
| 69 | pr.capabilities.reviewers.request && |
| 70 | pr.viewerPermissions.requestReviewers; |
| 71 | const result = useEnvironmentQuery( |
| 72 | pr?.capabilities.reviewers.listCandidates |
| 73 | ? composerPullRequests.reviewerCandidates(target) |
| 74 | : null, |
| 75 | ); |
| 76 | const request = useAtomCommand(composerPullRequests.requestReviewers, { reportFailure: false }); |
| 77 | const [query, setQuery] = useState(""); |
| 78 | const [pending, setPending] = useState<string | null>(null); |
| 79 | const [error, setError] = useState<string | null>(null); |
| 80 | const inFlight = useRef(false); |
| 81 | const candidates = useMemo(() => { |
| 82 | const needle = query.trim().toLowerCase(); |
| 83 | return [...(result.data?.candidates ?? [])] |
| 84 | .filter((candidate) => |
| 85 | `${candidate.login} ${candidate.name ?? ""}`.toLowerCase().includes(needle), |
| 86 | ) |
| 87 | .sort( |
| 88 | (a, b) => Number(b.isRequested) - Number(a.isRequested) || a.login.localeCompare(b.login), |
| 89 | ); |
| 90 | }, [result.data, query]); |
| 91 | useEffect(() => { |
| 92 | if (candidates.length === 0) return; |
| 93 | // Filtering must reveal the first match below the translucent native header. |
| 94 | listRef.current?.scrollToOffset({ |
| 95 | offset: Platform.OS === "ios" ? -insets.top : 0, |
| 96 | animated: false, |
| 97 | }); |
| 98 | }, [candidates, insets.top]); |
| 99 | const update = async ( |
| 100 | candidate: Pick<PullRequestReviewerCandidate, "id" | "kind" | "isRequested">, |
| 101 | ) => { |
| 102 | if (!allowed || inFlight.current) return; |
| 103 | inFlight.current = true; |
| 104 | setPending(`${candidate.kind}:${candidate.id}`); |
| 105 | setError(null); |
| 106 | try { |
| 107 | const response = await request({ |
| 108 | ...target, |
| 109 | input: { |
| 110 | ...target.input, |
| 111 | reviewers: [{ id: candidate.id, kind: candidate.kind }], |
| 112 | requested: !candidate.isRequested, |
| 113 | }, |
| 114 | }); |
| 115 | if (response._tag === "Failure") throw squashAtomCommandFailure(response); |
| 116 | if (!pr?.capabilities.reviewers.listCandidates) { |
| 117 | setQuery(""); |
| 118 | detail.refresh(); |
| 119 | } |
| 120 | } catch (cause) { |
| 121 | setError( |
| 122 | cause instanceof Error ? cause.message : "Couldn't update this review request. Try again.", |
| 123 | ); |
| 124 | } finally { |
| 125 | inFlight.current = false; |
| 126 | setPending(null); |
| 127 | } |
| 128 | }; |
| 129 | const listCandidates = pr?.capabilities.reviewers.listCandidates === true; |
| 130 | const searchOptions = { |
| 131 | placeholder: "Search reviewers", |
| 132 | autoCapitalize: "none" as const, |
| 133 | onChangeText: (event: { nativeEvent: { text: string } }) => setQuery(event.nativeEvent.text), |
| 134 | onCancelButtonPress: () => setQuery(""), |
| 135 | hideNavigationBar: false, |
| 136 | hideWhenScrolling: false, |
| 137 | obscureBackground: false, |
| 138 | allowToolbarIntegration: integratedSearch, |
| 139 | placement: integratedSearch ? ("automatic" as const) : ("stacked" as const), |
| 140 | }; |
| 141 | return ( |
| 142 | <View className="flex-1 bg-sheet"> |
| 143 | <NativeHeaderToolbar placement="left"> |
| 144 | <NativeHeaderToolbar.Button |
| 145 | icon="xmark" |
| 146 | accessibilityLabel="Close request review" |
| 147 | onPress={() => navigation.goBack()} |
| 148 | /> |
| 149 | </NativeHeaderToolbar> |
| 150 | <NativeStackScreenOptions |
| 151 | options={{ |
| 152 | title: "Request review", |
| 153 | unstable_headerSubtitle: `#${route.params.number} · ${route.params.repository}`, |
| 154 | headerSearchBarOptions: listCandidates ? searchOptions : undefined, |
| 155 | unstable_headerToolbarItems: () => |
| 156 | listCandidates && integratedSearch |
| 157 | ? [ |
| 158 | createNativeMailSearchToolbarItem({ |
| 159 | placeholder: "Search reviewers", |
| 160 | onSearchTextChange: setQuery, |
| 161 | showsSearchDismissButton: true, |
| 162 | searchTextChangeId: "pr-reviewer-search", |
| 163 | }), |
| 164 | ] |
| 165 | : [], |
| 166 | }} |
| 167 | /> |
| 168 | {Platform.OS === "android" ? ( |
| 169 | <AndroidScreenHeader title="Request review" onBack={() => navigation.goBack()} /> |
| 170 | ) : null} |
| 171 | {listCandidates ? ( |
| 172 | <FlatList |
| 173 | ref={listRef} |
| 174 | data={candidates} |
| 175 | keyExtractor={(candidate) => `${candidate.kind}:${candidate.id}`} |
| 176 | renderScrollComponent={ |
| 177 | Platform.OS === "ios" ? (props) => <ReviewerScrollView {...props} /> : undefined |
| 178 | } |
| 179 | contentInsetAdjustmentBehavior="never" |
| 180 | automaticallyAdjustKeyboardInsets |
| 181 | keyboardDismissMode="on-drag" |
| 182 | keyboardShouldPersistTaps="handled" |
| 183 | contentContainerStyle={{ paddingBottom: 24 }} |
| 184 | ListHeaderComponent={ |
| 185 | <View className="gap-3 px-4 py-4"> |
| 186 | <Text className="text-xs text-foreground-muted"> |
| 187 | Tap someone to request a review. Tap a requested reviewer to remove the request. |
| 188 | </Text> |
| 189 | {Platform.OS === "android" ? ( |
| 190 | <TextInput |
| 191 | accessibilityLabel="Search reviewers" |
| 192 | placeholder="Search reviewers" |
| 193 | placeholderTextColorClassName="accent-foreground-muted" |
| 194 | value={query} |
| 195 | onChangeText={setQuery} |
| 196 | autoCapitalize="none" |
| 197 | className="min-h-11 rounded-xl bg-grouped-card px-3 text-sm" |
| 198 | /> |
| 199 | ) : null} |
| 200 | {!allowed ? ( |
| 201 | <Text className="text-xs text-foreground-muted"> |
| 202 | Requesting reviews requires write access and an open pull request. |
| 203 | </Text> |
| 204 | ) : null} |
| 205 | {error ? ( |
| 206 | <Text selectable className="text-sm text-danger-foreground"> |
| 207 | {error} |
| 208 | </Text> |
| 209 | ) : null} |
| 210 | </View> |
| 211 | } |
| 212 | renderItem={({ item }) => { |
| 213 | const id = `${item.kind}:${item.id}`; |
| 214 | return ( |
| 215 | <Pressable |
| 216 | accessibilityRole="button" |
| 217 | accessibilityLabel={`${item.isRequested ? "Remove review request from" : "Request review from"} ${item.login}${item.kind === "team" ? " team" : ""}`} |
| 218 | accessibilityState={{ disabled: !allowed || pending !== null }} |
| 219 | disabled={!allowed || pending !== null} |
| 220 | onPress={() => void update(item)} |
| 221 | className="min-h-16 flex-row items-center gap-3 border-b border-border-subtle px-4 py-3 active:opacity-70" |
| 222 | > |
| 223 | <PullRequestAvatar actor={item} size={32} /> |
| 224 | <View className="min-w-0 flex-1 gap-0.5"> |
| 225 | <Text className="text-sm font-t3-medium" numberOfLines={1}> |
| 226 | {item.name || item.login} |
| 227 | </Text> |
| 228 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> |
| 229 | {item.login} |
| 230 | {item.kind === "team" ? " · Team" : ""} |
| 231 | </Text> |
| 232 | </View> |
| 233 | {pending === id ? ( |
| 234 | <ActivityIndicator /> |
| 235 | ) : item.isRequested ? ( |
| 236 | <View className="flex-row items-center gap-1.5"> |
| 237 | <Text className="text-xs text-foreground-muted">Requested</Text> |
| 238 | <SymbolView name="checkmark" size={14} tintColorClassName="accent-icon" /> |
| 239 | </View> |
| 240 | ) : ( |
| 241 | <SymbolView name="plus" size={16} tintColorClassName="accent-icon-muted" /> |
| 242 | )} |
| 243 | </Pressable> |
| 244 | ); |
| 245 | }} |
| 246 | ListEmptyComponent={ |
| 247 | result.isPending && !result.data ? ( |
| 248 | <ActivityIndicator className="my-8" /> |
| 249 | ) : ( |
| 250 | <View className="px-4 py-6"> |
| 251 | <EmptyState |
| 252 | title={ |
| 253 | result.error |
| 254 | ? "Couldn't load reviewers" |
| 255 | : query.trim() |
| 256 | ? "No matching reviewers" |
| 257 | : "No reviewers available" |
| 258 | } |
| 259 | detail={result.error ?? "Search people and teams with access to this repository."} |
| 260 | variant="plain" |
| 261 | /> |
| 262 | {result.error ? <MaterialButton label="Retry" onPress={result.refresh} /> : null} |
| 263 | </View> |
| 264 | ) |
| 265 | } |
| 266 | ListFooterComponent={ |
| 267 | result.data?.truncated ? ( |
| 268 | <Text className="px-4 py-4 text-xs text-foreground-muted"> |
| 269 | The provider has more reviewers than it returned. Find additional reviewers on the |
| 270 | provider. |
| 271 | </Text> |
| 272 | ) : undefined |
| 273 | } |
| 274 | /> |
| 275 | ) : ( |
| 276 | <ScrollView |
| 277 | contentInsetAdjustmentBehavior="automatic" |
| 278 | automaticallyAdjustKeyboardInsets |
| 279 | keyboardShouldPersistTaps="handled" |
| 280 | contentContainerStyle={{ padding: 16, gap: 16 }} |
| 281 | > |
| 282 | {!pr ? ( |
| 283 | detail.isPending ? ( |
| 284 | <ActivityIndicator /> |
| 285 | ) : ( |
| 286 | <Text className="text-danger-foreground"> |
| 287 | {detail.error ?? "Couldn't load this pull request."} |
| 288 | </Text> |
| 289 | ) |
| 290 | ) : ( |
| 291 | <> |
| 292 | <Text className="text-sm text-foreground-muted"> |
| 293 | This provider accepts a reviewer ID but cannot list candidates. |
| 294 | </Text> |
| 295 | <TextInput |
| 296 | accessibilityLabel="Reviewer ID" |
| 297 | placeholder="Reviewer login or ID" |
| 298 | placeholderTextColorClassName="accent-foreground-muted" |
| 299 | value={query} |
| 300 | onChangeText={setQuery} |
| 301 | autoCapitalize="none" |
| 302 | autoCorrect={false} |
| 303 | className="min-h-12 rounded-xl bg-grouped-card px-3 text-base" |
| 304 | /> |
| 305 | <MaterialButton |
| 306 | label="Request review" |
| 307 | fullWidth |
| 308 | disabled={!allowed || !query.trim() || pending !== null} |
| 309 | onPress={() => void update({ id: query.trim(), kind: "user", isRequested: false })} |
| 310 | /> |
| 311 | <MaterialButton |
| 312 | label="Remove review request" |
| 313 | fullWidth |
| 314 | tone="text" |
| 315 | disabled={!allowed || !query.trim() || pending !== null} |
| 316 | onPress={() => void update({ id: query.trim(), kind: "user", isRequested: true })} |
| 317 | /> |
| 318 | {error ? ( |
| 319 | <Text selectable className="text-danger-foreground"> |
| 320 | {error} |
| 321 | </Text> |
| 322 | ) : null} |
| 323 | </> |
| 324 | )} |
| 325 | </ScrollView> |
| 326 | )} |
| 327 | </View> |
| 328 | ); |
| 329 | } |