1import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
2import { useNavigation } from "@react-navigation/native";
3import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { ProjectId } from "@t3tools/contracts";
5import { useAtomValue } from "@effect/atom-react";
6import {
7 ActivityIndicator,
8 Keyboard,
9 Platform,
10 Pressable,
11 RefreshControl,
12 ScrollView,
13 View,
14 type ScrollViewProps,
15} from "react-native";
16import { ScrollViewMarker } from "react-native-screens";
17import { useSafeAreaInsets } from "react-native-safe-area-context";
18
19import { AppText as Text } from "../../components/AppText";
20import { EmptyState } from "../../components/EmptyState";
21import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
22import { useWorkspaceState } from "../../state/workspace";
23import { useProjects } from "../../state/entities";
24import { environmentServerConfigsAtom } from "../../state/server";
25import { PullRequestListRow } from "./PullRequestListRow";
26import { PullRequestListFilters } from "./PullRequestListFilters";
27import { usePullRequestList } from "./usePullRequestList";
28import { usePullRequestListControls } from "./usePullRequestListControls";
29import { usePullRequestListActions } from "./usePullRequestListActions";
30import {
31 filterPullRequestList,
32 pullRequestListFacets,
33 pullRequestListRowsAreEqual,
34 scopedPullRequestListKey as pullRequestKey,
35 type EnvironmentPullRequestListEntry,
36 PR_LIST_PAGE_SIZE,
37 withoutScratchProjectScope,
38} from "./pullRequestList";
39import { useDebouncedValue } from "../../state/queries";
40import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
41import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
42
43function PullRequestScrollView(props: ScrollViewProps) {
44 const insets = useSafeAreaInsets();
45 return (
46 <ScrollViewMarker>
47 <ScrollView
48 {...props}
49 automaticallyAdjustContentInsets={false}
50 contentInsetAdjustmentBehavior="never"
51 contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }}
52 />
53 </ScrollViewMarker>
54 );
55}
56
57export function PullRequestsScreen(
58 props: {
59 readonly sidebar?: boolean;
60 readonly selectedPullRequestKey?: string | null;
61 } = {},
62) {
63 const navigation = useNavigation();
64 const primaryColumn = use(NativePrimaryColumnContext);
65 const workspace = useWorkspaceTabs()!;
66 const { environments } = useWorkspaceState();
67 const configs = useAtomValue(environmentServerConfigsAtom);
68 const insets = useSafeAreaInsets();
69 const [initialOffset] = useState(() => workspace.getScrollOffset("pullRequests"));
70 const [initialAnchor] = useState(() => workspace.getScrollAnchor("pullRequests"));
71 const listRef = useRef<LegendListRef>(null);
72 const tab = workspace.state.tabs.pullRequests;
73 const maintainVisiblePosition = useMemo(
74 () => ({
75 data: tab.searchMode === "resting",
76 // At the top, reveal new PRs without pushing the filter strip out of view.
77 shouldRestorePosition: () => workspace.getScrollOffset("pullRequests") > 0,
78 }),
79 [tab.searchMode, workspace],
80 );
81 const debouncedQuery = useDebouncedValue(tab.query.trim(), 180);
82 // Dismissal must restore the cached unfiltered list before restoring its offset.
83 const query = tab.searchMode === "resting" ? "" : debouncedQuery;
84 const listControls = usePullRequestListControls();
85 const selectedEnvironmentId = listControls.environmentId;
86 const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
87 ? selectedEnvironmentId
88 : null;
89 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
90 const selectPullRequest = useCallback(
91 (item: EnvironmentPullRequestListEntry) => {
92 Keyboard.dismiss();
93 const params = {
94 environmentId: String(item.environmentId),
95 projectId: String(item.projectId),
96 repository: item.repository,
97 number: item.number,
98 host: item.host,
99 initialSummary: item,
100 };
101 if (Platform.OS === "android") selectAndroidWorkspace({ name: "PullRequest", params });
102 else navigation.navigate("PullRequest", params);
103 },
104 [navigation, selectAndroidWorkspace],
105 );
106 const allProjects = useProjects();
107 const savedControls = listControls.controlsFor(environmentId);
108 const controls = useMemo(
109 () => withoutScratchProjectScope(savedControls, allProjects),
110 [allProjects, savedControls],
111 );
112 const targets = useMemo(
113 () =>
114 environments
115 .filter(
116 (environment) =>
117 configs.get(environment.environmentId)?.environment.capabilities.pullRequests ===
118 true &&
119 (environmentId === null || environment.environmentId === environmentId) &&
120 (!controls.projectEnvironmentId ||
121 environment.environmentId === controls.projectEnvironmentId),
122 )
123 .sort((left, right) => left.environmentId.localeCompare(right.environmentId))
124 .map((environment) => ({
125 environmentId: environment.environmentId,
126 input: {
127 state: controls.state,
128 involvement: controls.involvement,
129 filters: controls.filters,
130 limit: PR_LIST_PAGE_SIZE,
131 ...(controls.projectId ? { projectId: ProjectId.make(controls.projectId) } : {}),
132 ...(query ? { query } : {}),
133 },
134 })),
135 [environments, environmentId, configs, controls, query],
136 );
137 const result = usePullRequestList(targets);
138 const connectedTargets = targets.filter((target) =>
139 environments.some(
140 (environment) =>
141 environment.environmentId === target.environmentId &&
142 environment.connectionState === "connected",
143 ),
144 );
145 const { actions, select, busyKey } = usePullRequestListActions(
146 connectedTargets.map((target) => target.environmentId),
147 result.updateEntry,
148 );
149 const sidebar = props.sidebar === true || primaryColumn !== null;
150 const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey;
151 const connectedKey = connectedTargets.map((target) => target.environmentId).join("\n");
152 const entries = useMemo(
153 () =>
154 filterPullRequestList(
155 result.entries,
156 controls,
157 result.data?.viewers ?? {},
158 query,
159 result.data.localSearchHosts,
160 new Set(connectedKey.split("\n")),
161 ),
162 [result.entries, controls, query, result.data, connectedKey],
163 );
164 const facets = useMemo(() => pullRequestListFacets(result.entries), [result.entries]);
165 const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets });
166 if (knownFacets.environmentId !== environmentId || knownFacets.source !== facets) {
167 setKnownFacets((previous) => ({
168 source: facets,
169 environmentId,
170 authors: [
171 ...new Set([
172 ...(previous.environmentId === environmentId ? previous.authors : []),
173 ...facets.authors,
174 ]),
175 ].sort(),
176 labels: [
177 ...new Set([
178 ...(previous.environmentId === environmentId ? previous.labels : []),
179 ...facets.labels,
180 ]),
181 ].sort(),
182 }));
183 }
184 const projects = allProjects.filter(
185 (project) =>
186 !project.isScratch && (environmentId === null || project.environmentId === environmentId),
187 );
188 const renderPullRequest = useCallback(
189 ({ item }: { readonly item: EnvironmentPullRequestListEntry }) => (
190 <PullRequestListRow
191 item={item}
192 onSelect={selectPullRequest}
193 getActions={actions}
194 onAction={select}
195 busy={busyKey === pullRequestKey(item)}
196 selected={selectedKey === pullRequestKey(item)}
197 sidebar={sidebar}
198 />
199 ),
200 [selectPullRequest, selectedKey, actions, select, busyKey, sidebar],
201 );
202 const previousSearchMode = useRef(tab.searchMode);
203 useEffect(() => {
204 const restoringSearch =
205 previousSearchMode.current !== "resting" && tab.searchMode === "resting";
206 previousSearchMode.current = tab.searchMode;
207 if (restoringSearch)
208 void listRef.current?.scrollToOffset({
209 offset: tab.scrollOffset - (Platform.OS === "ios" ? insets.top : 0),
210 animated: false,
211 });
212 }, [tab.searchMode, tab.scrollOffset, insets.top]);
213 const initialIndex = initialAnchor
214 ? entries.findIndex((entry) => pullRequestKey(entry) === initialAnchor.key)
215 : -1;
216
217 return (
218 <View className={sidebar ? "flex-1 bg-drawer" : "flex-1 bg-screen"}>
219 <LegendList
220 ref={listRef}
221 data={entries}
222 extraData={`${selectedKey}:${busyKey}`}
223 keyExtractor={pullRequestKey}
224 itemsAreEqual={pullRequestListRowsAreEqual}
225 // Default-size rows measure about 78pt with one title line and 98pt with two.
226 estimatedItemSize={88}
227 // Build roughly a screen ahead so a fling reaches already-mounted rows.
228 drawDistance={1_000}
229 maintainVisibleContentPosition={result.continued ? maintainVisiblePosition : false}
230 onEndReached={() => void result.loadMore()}
231 onEndReachedThreshold={0.5}
232 onViewableItemsChanged={result.onViewableItemsChanged}
233 refreshControl={
234 <RefreshControl
235 refreshing={result.refreshing}
236 onRefresh={() => void result.onRefresh()}
237 // Place the 60pt native refresh control in the revealed pull-down space.
238 // The list already accounts for the navigation header in its content inset.
239 progressViewOffset={Platform.OS === "ios" ? 60 : undefined}
240 />
241 }
242 alwaysBounceVertical
243 recycleItems
244 renderScrollComponent={Platform.OS === "ios" ? PullRequestScrollView : undefined}
245 contentInsetAdjustmentBehavior="never"
246 contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined}
247 initialScrollOffset={
248 !initialAnchor && initialOffset > 0
249 ? initialOffset - (Platform.OS === "ios" ? insets.top : 0)
250 : undefined
251 }
252 initialScrollIndex={
253 initialIndex >= 0 && initialAnchor
254 ? { index: initialIndex, viewOffset: initialAnchor.viewOffset, viewPosition: 0 }
255 : undefined
256 }
257 contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0}
258 contentContainerStyle={{
259 paddingBottom: Math.max(insets.bottom, workspace.bottomClearance),
260 }}
261 onScroll={(event) => {
262 workspace.recordScrollOffset(
263 "pullRequests",
264 Math.max(
265 0,
266 event.nativeEvent.contentOffset.y + (Platform.OS === "ios" ? insets.top : 0),
267 ),
268 );
269 const list = listRef.current?.getState();
270 const item = list ? entries[list.start] : undefined;
271 if (list && item)
272 workspace.recordScrollAnchor("pullRequests", {
273 key: pullRequestKey(item),
274 viewOffset: list.positionAtIndex(list.start) - event.nativeEvent.contentOffset.y,
275 });
276 }}
277 keyboardDismissMode="on-drag"
278 keyboardShouldPersistTaps="handled"
279 renderItem={renderPullRequest}
280 ListHeaderComponent={
281 <>
282 <PullRequestListFilters
283 environments={environments.map((item) => ({
284 id: String(item.environmentId),
285 label: item.environmentLabel,
286 }))}
287 environmentId={environmentId}
288 onEnvironmentChange={listControls.setEnvironmentId}
289 value={controls}
290 onChange={(next) => {
291 listControls.setControls(environmentId, next);
292 void listRef.current?.scrollToOffset({
293 offset: Platform.OS === "ios" ? -insets.top : 0,
294 animated: false,
295 });
296 }}
297 authors={
298 knownFacets.environmentId === environmentId ? knownFacets.authors : facets.authors
299 }
300 labels={
301 knownFacets.environmentId === environmentId ? knownFacets.labels : facets.labels
302 }
303 projects={projects}
304 />
305 {result.data?.errors.length ? (
306 <Text className="px-4 py-3 text-sm text-foreground-muted">
307 {result.data.errors
308 .map(
309 (error) =>
310 `${error.projectTitle || environments.find((environment) => environment.environmentId === error.environmentId)?.environmentLabel || "Environment"}: ${error.message}`,
311 )
312 .join("\n")}
313 </Text>
314 ) : null}
315 </>
316 }
317 ListFooterComponent={
318 result.loadingMore ? (
319 <View className="items-center py-5">
320 <ActivityIndicator
321 colorClassName="accent-icon"
322 accessibilityLabel="Loading more pull requests"
323 />
324 </View>
325 ) : result.loadMoreError ? (
326 <Pressable
327 accessibilityRole="button"
328 accessibilityLabel="Retry loading more pull requests"
329 onPress={() => void result.loadMore(true)}
330 className="items-center gap-2 px-8 py-4"
331 >
332 <Text className="text-xs text-foreground-muted">{result.loadMoreError}</Text>
333 <Text className="text-sm font-t3-medium text-link">Try again</Text>
334 </Pressable>
335 ) : undefined
336 }
337 ListEmptyComponent={
338 result.isPending && connectedTargets.length > 0 ? (
339 <View className="items-center py-12">
340 <ActivityIndicator colorClassName="accent-icon" />
341 </View>
342 ) : (
343 <View className="px-8 py-12">
344 <EmptyState
345 title={
346 result.error
347 ? "Couldn't load pull requests"
348 : targets.length && connectedTargets.length === 0
349 ? "Environment offline"
350 : targets.length
351 ? query
352 ? "No results"
353 : "No matching pull requests"
354 : "Connect an environment"
355 }
356 detail={
357 result.error ??
358 (targets.length && connectedTargets.length === 0
359 ? "Reconnect to load pull requests that aren't saved on this device."
360 : targets.length
361 ? "Pull requests from the selected environments' projects appear here."
362 : "Add an environment in Settings to browse its pull requests.")
363 }
364 variant="plain"
365 />
366 </View>
367 )
368 }
369 />
370 </View>
371 );
372}