1import { NativeScrollView } from "../../native/NativeScrollViewMarker";
2import { LegendList } from "@legendapp/list/react-native";
3import { useAtomValue } from "@effect/atom-react";
4import type { StaticScreenProps } from "@react-navigation/native";
5import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
6import type { PullRequestLabelCandidate } from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { useRef, useState } from "react";
9import { Platform, View } from "react-native";
10import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
11import { AppText as Text } from "../../components/ui/AppText";
12import { Button } from "../../components/ui/Button";
13import { EmptyState } from "../../components/ui/EmptyState";
14import { SelectionRow } from "../../components/ui/SelectionRow";
15import { composerPullRequests } from "../../state/pull-requests";
16import { useEnvironmentQuery } from "../../state/query";
17import { useAtomCommand } from "../../state/use-atom-command";
18import { PullRequestLabelDot } from "./PullRequestLabelDot";
19import { pullRequestLabelColor } from "./pullRequestList";
20import { PullRequestSearchSheet } from "./PullRequestSearchSheet";
21import { usePullRequestConnected } from "./usePullRequestConnected";
22import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
23
24export function PullRequestLabelsScreen({ route }: StaticScreenProps<PullRequestParams>) {
25 const target = pullRequestTarget(route.params);
26 const pr = useEnvironmentQuery(composerPullRequests.detail(target)).data;
27 const canWrite = useAtomValue(
28 composerPullRequests.setLabels.permissionAtom(target.environmentId),
29 );
30 const connected = usePullRequestConnected(route.params.environmentId);
31 const allowed =
32 connected &&
33 canWrite &&
34 pr?.capabilities.labels === true &&
35 pr.viewerPermissions.labels !== false;
36 // Name the first thing in the way, so the notice says what would actually fix it.
37 const readOnlyReason =
38 !pr || allowed
39 ? null
40 : !connected
41 ? "Reconnect to change labels."
42 : !canWrite
43 ? "This device isn't allowed to change source control on this environment."
44 : pr.capabilities.labels !== true
45 ? "This provider doesn't support changing labels."
46 : "Changing labels needs triage access on this repository.";
47 const result = useEnvironmentQuery(composerPullRequests.labelCandidates(target));
48 const setLabels = useAtomCommand(composerPullRequests.setLabels, { reportFailure: false });
49 const [query, setQuery] = useState("");
50 const [pending, setPending] = useState<{ readonly name: string; readonly applied: boolean }>();
51 const [error, setError] = useState<string | null>(null);
52 const inFlight = useRef(false);
53 const needle = query.trim().toLowerCase();
54 // Host order, not applied-first: re-sorting on toggle would move the row out from under a tap.
55 const candidates = (result.data?.candidates ?? []).filter((candidate) =>
56 `${candidate.name} ${candidate.description ?? ""}`.toLowerCase().includes(needle),
57 );
58 const toggle = async (candidate: PullRequestLabelCandidate) => {
59 if (!allowed || inFlight.current) return;
60 inFlight.current = true;
61 setPending({ name: candidate.name, applied: !candidate.isApplied });
62 setError(null);
63 const response = await setLabels({
64 ...target,
65 input: { ...target.input, labels: [candidate.name], applied: !candidate.isApplied },
66 });
67 inFlight.current = false;
68 setPending(undefined);
69 if (AsyncResult.isFailure(response)) {
70 const cause = squashAtomCommandFailure(response);
71 setError(cause instanceof Error ? cause.message : "Couldn't update this label. Try again.");
72 }
73 };
74 return (
75 <PullRequestSearchSheet
76 title="Labels"
77 placeholder="Search labels"
78 closeAccessibilityLabel="Close labels"
79 clearAccessibilityLabel="Clear label search"
80 query={query}
81 onQueryChange={setQuery}
82 >
83 {({ paddingBottom }) => (
84 <LegendList
85 renderScrollComponent={NativeScrollView}
86 data={candidates}
87 keyExtractor={(candidate) => candidate.name}
88 extraData={{ pending, allowed, count: candidates.length }}
89 estimatedItemSize={72}
90 recycleItems
91 className="flex-1"
92 contentInsetAdjustmentBehavior="automatic"
93 automaticallyAdjustKeyboardInsets={Platform.OS === "ios"}
94 keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
95 keyboardShouldPersistTaps="handled"
96 showsVerticalScrollIndicator={false}
97 contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom }}
98 ListHeaderComponent={
99 readOnlyReason || error ? (
100 <View className="gap-2 pb-3">
101 {readOnlyReason ? (
102 <Text className="text-xs text-foreground-muted">{readOnlyReason}</Text>
103 ) : null}
104 {error ? (
105 <Text selectable className="text-sm text-danger-foreground">
106 {error}
107 </Text>
108 ) : null}
109 </View>
110 ) : undefined
111 }
112 renderItem={({ item, index }) => (
113 <SelectionRow
114 title={item.name}
115 {...(item.description ? { subtitle: item.description } : {})}
116 role="checkbox"
117 // The change shows as soon as it is tapped; a failure puts the host's answer back.
118 selected={pending?.name === item.name ? pending.applied : item.isApplied}
119 disabled={!allowed}
120 isFirst={index === 0}
121 isLast={index === candidates.length - 1}
122 leading={<PullRequestLabelDot color={pullRequestLabelColor(item.color)} />}
123 onPress={() => void toggle(item)}
124 />
125 )}
126 ListEmptyComponent={
127 result.isPending && !result.data ? (
128 <AppActivityIndicator className="my-8" />
129 ) : (
130 <View>
131 <EmptyState
132 title={
133 result.error
134 ? "Couldn't load labels"
135 : needle
136 ? "No matching labels"
137 : "This repository has no labels"
138 }
139 detail={
140 result.error ??
141 (needle
142 ? "Try another name."
143 : "Create labels on the provider to use them here.")
144 }
145 variant="plain"
146 />
147 {result.error ? <Button label="Retry" onPress={result.refresh} /> : null}
148 </View>
149 )
150 }
151 ListFooterComponent={
152 result.data?.truncated ? (
153 <Text className="py-4 text-xs text-foreground-muted">
154 This repository has more labels than are listed here. Apply the rest on the
155 provider.
156 </Text>
157 ) : undefined
158 }
159 />
160 )}
161 </PullRequestSearchSheet>
162 );
163}