| 1 | import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; |
| 2 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { ProjectId } from "@t3tools/contracts"; |
| 5 | import { useAtomValue } from "@effect/atom-react"; |
| 6 | import { |
| 7 | ActivityIndicator, |
| 8 | Keyboard, |
| 9 | Platform, |
| 10 | Pressable, |
| 11 | RefreshControl, |
| 12 | ScrollView, |
| 13 | View, |
| 14 | type ScrollViewProps, |
| 15 | } from "react-native"; |
| 16 | import { ScrollViewMarker } from "react-native-screens"; |
| 17 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 18 | |
| 19 | import { AppText as Text } from "../../components/AppText"; |
| 20 | import { EmptyState } from "../../components/EmptyState"; |
| 21 | import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; |
| 22 | import { useWorkspaceState } from "../../state/workspace"; |
| 23 | import { useProjects } from "../../state/entities"; |
| 24 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 25 | import { PullRequestListRow } from "./PullRequestListRow"; |
| 26 | import { PullRequestListFilters } from "./PullRequestListFilters"; |
| 27 | import { usePullRequestList } from "./usePullRequestList"; |
| 28 | import { usePullRequestListControls } from "./usePullRequestListControls"; |
| 29 | import { usePullRequestListActions } from "./usePullRequestListActions"; |
| 30 | import { |
| 31 | filterPullRequestList, |
| 32 | pullRequestListFacets, |
| 33 | pullRequestListRowsAreEqual, |
| 34 | scopedPullRequestListKey as pullRequestKey, |
| 35 | type EnvironmentPullRequestListEntry, |
| 36 | PR_LIST_PAGE_SIZE, |
| 37 | withoutScratchProjectScope, |
| 38 | } from "./pullRequestList"; |
| 39 | import { useDebouncedValue } from "../../state/queries"; |
| 40 | import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; |
| 41 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; |
| 42 | |
| 43 | function 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 | |
| 57 | export 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 | } |