Skip to content
Source
Sign inSign up

PullRequestReviewersScreen.tsx

305 lines12.2 KB

1import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
2import { AppBar, type AppBarSearch } from "../../components/ui/AppBar";
3import { AsyncResult } from "effect/reactivity";
4import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
5import { usePullRequestConnected } from "./usePullRequestConnected";
6import { useAtomValue } from "@effect/atom-react";
7import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
8import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
10import {
11 hostReviewerQuery,
12 reviewerCandidateView,
13 type ReviewerTruncation,
14} from "@t3tools/client-runtime/pull-request-reviewers";
15import type { PullRequestReviewerCandidate } from "@t3tools/contracts";
16import { useRef, useState } from "react";
17import { Pressable, ScrollView, View } from "react-native";
18import { NativeScrollView } from "../../native/NativeScrollViewMarker";
19import { useContentTopInset, useSearchToolbarInset } from "../../components/ui/useBarInset";
20import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
21import { EmptyState } from "../../components/ui/EmptyState";
22import { Button } from "../../components/ui/Button";
23import { SymbolView } from "../../components/ui/AppSymbol";
24import { composerPullRequests } from "../../state/pull-requests";
25import { useEnvironmentQuery } from "../../state/query";
26import { useDebouncedValue } from "../../state/queries";
27import { useAtomCommand } from "../../state/use-atom-command";
28import { PullRequestAvatar } from "./PullRequestPresentation";
29import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
30​
31const TRUNCATED_LABELS: Record<ReviewerTruncation, (error: string | null) => string> = {
32 unsearchable: () =>
33 "The provider has more reviewers than it returned. Find additional reviewers on the provider.",
34 searchable: () => "The provider has more reviewers than are listed here. Search to find them.",
35 "search-failed": (error) =>
36 `Only listed reviewers are matched: the provider search failed. ${error ?? ""}`,
37 "search-truncated": () =>
38 "More reviewers match than the provider returned. Keep typing to narrow it down.",
39};
40​
41export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequestParams>) {
42 const navigation = useNavigation();
43 const contentTopInset = useContentTopInset();
44 const listRef = useRef<LegendListRef>(null);
45 const target = pullRequestTarget(route.params);
46 const detail = useEnvironmentQuery(composerPullRequests.detail(target));
47 const pr = detail.data;
48 const { singular } = sourceControlClients.get(
49 (pr ?? route.params.initialSummary)?.provider,
50 ).changeRequest;
51 const canWrite = useAtomValue(
52 composerPullRequests.requestReviewers.permissionAtom(target.environmentId),
53 );
54 const connected = usePullRequestConnected(route.params.environmentId);
55 const allowed =
56 connected &&
57 canWrite &&
58 pr?.state === "open" &&
59 pr.capabilities.reviewers.request &&
60 pr.viewerPermissions.requestReviewers;
61 const result = useEnvironmentQuery(
62 pr?.capabilities.reviewers.listCandidates
63 ? composerPullRequests.reviewerCandidates(target)
64 : null,
65 );
66 const request = useAtomCommand(composerPullRequests.requestReviewers, { reportFailure: false });
67 const [query, setQuery] = useState("");
68 const [pending, setPending] = useState<string | null>(null);
69 const [error, setError] = useState<string | null>(null);
70 const inFlight = useRef(false);
71 const debouncedQuery = useDebouncedValue(query.trim(), 250);
72 const searchInput = {
73 loaded: result.data,
74 searchable: pr?.capabilities.reviewers.searchCandidates === true,
75 query,
76 debouncedQuery,
77 };
78 const hostQuery = hostReviewerQuery(searchInput);
79 const searchResult = useEnvironmentQuery(
80 hostQuery !== null
81 ? composerPullRequests.reviewerSearch({
82 ...target,
83 input: { ...target.input, query: hostQuery },
84 })
85 : null,
86 );
87 const view = reviewerCandidateView({
88 ...searchInput,
89 search: searchResult,
90 });
91 const candidates = [...view.candidates].sort(
92 (a, b) => Number(b.isRequested) - Number(a.isRequested) || a.login.localeCompare(b.login),
93 );
94 const queryError = result.error ?? view.searchError;
95 // A new search shows its best matches from the top.
96 const search = (next: string) => {
97 setQuery(next);
98 listRef.current?.scrollToOffset({
99 offset: -contentTopInset,
100 animated: false,
101 });
102 };
103 const update = async (
104 candidate: Pick<PullRequestReviewerCandidate, "id" | "kind" | "isRequested">,
105 ) => {
106 if (!allowed || inFlight.current) return;
107 inFlight.current = true;
108 setPending(`${candidate.kind}:${candidate.id}`);
109 setError(null);
110 const response = await request({
111 ...target,
112 input: {
113 ...target.input,
114 reviewers: [{ id: candidate.id, kind: candidate.kind }],
115 requested: !candidate.isRequested,
116 },
117 });
118 inFlight.current = false;
119 setPending(null);
120 if (AsyncResult.isFailure(response)) {
121 const cause = squashAtomCommandFailure(response);
122 setError(
123 cause instanceof Error ? cause.message : "Couldn't update this review request. Try again.",
124 );
125 return;
126 }
127 if (hostQuery !== null) searchResult.refresh();
128 if (!pr?.capabilities.reviewers.listCandidates) {
129 search("");
130 detail.refresh();
131 }
132 };
133 const listCandidates = pr?.capabilities.reviewers.listCandidates === true;
134 const reviewerSearch: AppBarSearch | undefined = listCandidates
135 ? {
136 mode: "inline",
137 compactToolbar: true,
138 value: query,
139 onChangeText: search,
140 placeholder: "Search reviewers",
141 }
142 : undefined;
143 const searchToolbarInset = useSearchToolbarInset(reviewerSearch);
144 return (
145 <View className="flex-1 bg-sheet">
146 <AppBar
147 title="Request review"
148 subtitle={`#${route.params.number} · ${route.params.repository}`}
149 onClose={() => navigation.goBack()}
150 closeAccessibilityLabel="Close request review"
151 search={reviewerSearch}
152 />
153 {listCandidates ? (
154 <LegendList
155 ref={listRef}
156 data={candidates}
157 keyExtractor={(candidate) => `${candidate.kind}:${candidate.id}`}
158 extraData={{ allowed, pending }}
159 estimatedItemSize={64}
160 recycleItems
161 renderScrollComponent={NativeScrollView}
162 contentInsetAdjustmentBehavior="automatic"
163 automaticallyAdjustKeyboardInsets
164 keyboardDismissMode="on-drag"
165 keyboardShouldPersistTaps="handled"
166 contentContainerStyle={{ paddingBottom: 24 + searchToolbarInset }}
167 ListHeaderComponent={
168 <View className="gap-3 px-4 py-4">
169 <Text className="text-xs text-foreground-muted">
170 Tap someone to request a review. Tap a requested reviewer to remove the request.
171 </Text>
172 {!allowed ? (
173 <Text className="text-xs text-foreground-muted">
174 Requesting reviews requires write access and an open {singular}.
175 </Text>
176 ) : null}
177 {error ? (
178 <Text selectable className="text-sm text-danger-foreground">
179 {error}
180 </Text>
181 ) : null}
182 </View>
183 }
184 renderItem={({ item }) => {
185 const id = `${item.kind}:${item.id}`;
186 return (
187 <Pressable
188 accessibilityRole="button"
189 accessibilityLabel={`${item.isRequested ? "Remove review request from" : "Request review from"} ${item.login}${item.kind === "team" ? " team" : ""}`}
190 accessibilityState={{ disabled: !allowed || pending !== null }}
191 disabled={!allowed || pending !== null}
192 onPress={() => void update(item)}
193 className="min-h-16 flex-row items-center gap-3 border-b border-border-subtle px-4 py-3 active:opacity-70"
194 >
195 <PullRequestAvatar actor={item} size={32} />
196 <View className="min-w-0 flex-1 gap-0.5">
197 <Text className="text-sm font-t3-medium" numberOfLines={1}>
198 {item.name || item.login}
199 </Text>
200 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
201 {item.login}
202 {item.kind === "team" ? " · Team" : ""}
203 </Text>
204 </View>
205 {pending === id ? (
206 <AppActivityIndicator />
207 ) : item.isRequested ? (
208 <View className="flex-row items-center gap-1.5">
209 <Text className="text-xs text-foreground-muted">Requested</Text>
210 <SymbolView name="checkmark" size={14} tintColorClassName="accent-icon" />
211 </View>
212 ) : (
213 <SymbolView name="plus" size={16} tintColorClassName="accent-icon-muted" />
214 )}
215 </Pressable>
216 );
217 }}
218 ListEmptyComponent={
219 (result.isPending && !result.data) || view.searchPending ? (
220 <AppActivityIndicator className="my-8" />
221 ) : (
222 <View className="px-4 py-6">
223 <EmptyState
224 title={
225 queryError
226 ? "Couldn't load reviewers"
227 : query.trim()
228 ? "No matching reviewers"
229 : "No reviewers available"
230 }
231 detail={queryError ?? "Search people and teams with access to this repository."}
232 variant="plain"
233 />
234 {queryError ? (
235 <Button
236 label="Retry"
237 onPress={result.error ? result.refresh : searchResult.refresh}
238 />
239 ) : null}
240 </View>
241 )
242 }
243 ListFooterComponent={
244 view.truncation ? (
245 <Text className="px-4 py-4 text-xs text-foreground-muted">
246 {TRUNCATED_LABELS[view.truncation](view.searchError)}
247 </Text>
248 ) : undefined
249 }
250 />
251 ) : (
252 <ScrollView
253 contentInsetAdjustmentBehavior="automatic"
254 automaticallyAdjustKeyboardInsets
255 keyboardShouldPersistTaps="handled"
256 contentContainerStyle={{ padding: 16, gap: 16 }}
257 >
258 {!pr ? (
259 detail.isPending ? (
260 <AppActivityIndicator />
261 ) : (
262 <Text className="text-danger-foreground">
263 {detail.error ?? `Couldn't load this ${singular}.`}
264 </Text>
265 )
266 ) : (
267 <>
268 <Text className="text-sm text-foreground-muted">
269 This provider accepts a reviewer ID but cannot list candidates.
270 </Text>
271 <TextInput
272 accessibilityLabel="Reviewer ID"
273 placeholder="Reviewer login or ID"
274 placeholderTextColorClassName="accent-foreground-muted"
275 value={query}
276 onChangeText={search}
277 autoCapitalize="none"
278 autoCorrect={false}
279 className="min-h-12 rounded-xl bg-grouped-card px-3 text-base"
280 />
281 <Button
282 label="Request review"
283 fullWidth
284 disabled={!allowed || !query.trim() || pending !== null}
285 onPress={() => void update({ id: query.trim(), kind: "user", isRequested: false })}
286 />
287 <Button
288 label="Remove review request"
289 fullWidth
290 tone="text"
291 disabled={!allowed || !query.trim() || pending !== null}
292 onPress={() => void update({ id: query.trim(), kind: "user", isRequested: true })}
293 />
294 {error ? (
295 <Text selectable className="text-danger-foreground">
296 {error}
297 </Text>
298 ) : null}
299 </>
300 )}
301 </ScrollView>
302 )}
303 </View>
304 );