1import { usePullRequestConnected } from "./usePullRequestConnected";
2import { useAtomValue } from "@effect/atom-react";
3import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import type { PullRequestReviewerCandidate } from "@t3tools/contracts";
6import { useEffect, useMemo, useRef, useState } from "react";
7import {
8 ActivityIndicator,
9 FlatList,
10 Platform,
11 Pressable,
12 ScrollView,
13 View,
14 type ScrollViewProps,
15} from "react-native";
16import { useSafeAreaInsets } from "react-native-safe-area-context";
17import { ScrollViewMarker } from "react-native-screens";
18import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
19import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
20import { EmptyState } from "../../components/EmptyState";
21import { MaterialButton } from "../../components/MaterialButton";
22import { SymbolView } from "../../components/AppSymbol";
23import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
24import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
25import { composerPullRequests } from "../../state/pull-requests";
26import { useEnvironmentQuery } from "../../state/query";
27import { useAtomCommand } from "../../state/use-atom-command";
28import {
29 createNativeMailSearchToolbarItem,
30 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
31} from "../layout/native-mail-search-toolbar";
32import { PullRequestAvatar } from "./PullRequestPresentation";
33import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
34
35function 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
50export 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}