authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 16:50:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 16:50:19-07:00
log8c3b5bef17a637bf53f297189350359790ef7afb
treee331bfdec7c792784270861b839672439ffca643
parentd99bc5a4d707df2506e60cbb411b8d30022bbf89
signature Signed by PGP key B5690EEEBB952194

fix(mobile): prefer pull-to-refresh on list screens (#12515)


9 files changed, 166 insertions(+), 77 deletions(-)

apps/mobile/src/components/ScreenHeader.android.tsx+1-1
......@@ -138,7 +138,7 @@ export function ScreenHeader(props: ScreenHeaderProps) {
138138 icon: "magnifyingglass",
139139 onPress: () => setSearchOpen(true),
140140 },
141 ...(search.onRefresh
141 ...(search.refreshInToolbar && search.onRefresh
142142 ? [
143143 {
144144 accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh",
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+10-3
......@@ -14,6 +14,7 @@ import { SymbolView } from "../../components/AppSymbol";
1414import { useCallback, useMemo, useRef, type ComponentProps } from "react";
1515import {
1616 ActivityIndicator,
17 Platform,
1718 Pressable,
1819 RefreshControl,
1920 useWindowDimensions,
......@@ -63,9 +64,6 @@ function ArchivedThreadsHeader(props: {
6364 compactPlaceholder: "Search",
6465 mode: "inline",
6566 compactToolbar: width < 700,
66 onRefresh: props.onRefresh,
67 refreshInToolbar: true,
68 refreshAccessibilityLabel: "Refresh archived threads",
6967 }}
7068 menus={[
7169 {
......@@ -110,6 +108,15 @@ function ArchivedThreadsHeader(props: {
110108 },
111109 ],
112110 },
111 ...(Platform.OS === "android"
112 ? [
113 {
114 id: "refresh",
115 title: "Refresh archived threads",
116 onPress: props.onRefresh,
117 },
118 ]
119 : []),
113120 ],
114121 },
115122 ]}
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+3-26
......@@ -41,16 +41,14 @@ interface CloudEnvironmentRowsProps {
4141 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;
4242 readonly showcaseSignedIn?: boolean;
4343 /**
44 * Hide the "T3 Connect" section title + refresh button for hosts that
45 * provide their own chrome (the onboarding sheet's native header and
46 * pull-to-refresh).
44 * Hide the "T3 Connect" section title when the host provides its own header.
4745 */
4846 readonly showHeader?: boolean;
4947}
5048
5149/**
5250 * "T3 Connect" section: every environment published to the signed-in account,
53 * with connect switches, availability status, refresh, and loading/error
51 * with connect switches, availability status, and loading/error
5452 * states. Shared between the Settings environments screen and the T3 Connect
5553 * onboarding sheet.
5654 *
......@@ -120,29 +118,8 @@ function CloudEnvironmentRowsContent(
120118 return (
121119 <View collapsable={false} className={cn("gap-3", showHeader && "mt-5")}>
122120 {showHeader ? (
123 <View className="flex-row items-center justify-between px-1">
121 <View className="px-1">
124122 <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Connect</Text>
125 {discoveryAvailable ? (
126 <Pressable
127 accessibilityRole="button"
128 disabled={controller.relayDiscovery.isRefreshing}
129 onPress={() => {
130 void controller.refreshRelayEnvironments();
131 }}
132 className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
133 >
134 {controller.relayDiscovery.isRefreshing ? (
135 <ActivityIndicator colorClassName={"accent-icon"} size="small" />
136 ) : (
137 <SymbolView
138 name="arrow.clockwise"
139 size={14}
140 tintColorClassName={"accent-icon"}
141 type="monochrome"
142 />
143 )}
144 </Pressable>
145 ) : null}
146123 </View>
147124 ) : null}
148125
apps/mobile/src/features/files/FileTreeBrowser.tsx+21-11
......@@ -234,15 +234,6 @@ export function FileTreeBrowser(props: {
234234 ],
235235 );
236236
237 if (props.error && props.entries.length === 0) {
238 return (
239 <View className="flex-1 bg-sheet px-4 py-5">
240 <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
241 <Text className="mt-1 text-xs leading-normal text-foreground-muted">{props.error}</Text>
242 </View>
243 );
244 }
245
246237 // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and
247238 // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"`
248239 // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge
......@@ -250,6 +241,7 @@ export function FileTreeBrowser(props: {
250241 // flex-1 Views is ignored, which is why the tree rendered under the header with no blur.
251242 return (
252243 <FlatList
244 alwaysBounceVertical
253245 className="flex-1"
254246 data={visibleNodes}
255247 keyExtractor={(item) => item.node.path}
......@@ -270,7 +262,7 @@ export function FileTreeBrowser(props: {
270262 renderItem={renderItem}
271263 ListHeaderComponent={
272264 <>
273 {props.error ? (
265 {props.error && props.entries.length > 0 ? (
274266 <Text accessibilityRole="alert" className="mx-4 my-2 text-xs text-foreground-muted">
275267 {props.error}
276268 </Text>
......@@ -284,7 +276,25 @@ export function FileTreeBrowser(props: {
284276 }
285277 ListEmptyComponent={
286278 <View className="px-4 py-5">
287 {props.isPending ? (
279 {props.error && props.entries.length === 0 ? (
280 <>
281 <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
282 <Text
283 accessibilityRole="alert"
284 className="mt-1 text-xs leading-normal text-foreground-muted"
285 >
286 {props.error}
287 </Text>
288 <Pressable
289 accessibilityRole="button"
290 onPress={props.onRefresh}
291 disabled={props.isPending}
292 className="mt-3 min-h-11 self-start justify-center rounded-full bg-subtle px-4 active:opacity-70 disabled:opacity-50"
293 >
294 <Text className="text-sm font-t3-medium text-foreground">Try again</Text>
295 </Pressable>
296 </>
297 ) : props.isPending ? (
288298 <ActivityIndicator size="small" />
289299 ) : (
290300 <>
apps/mobile/src/features/files/MaterialFilesHeader.tsx+18-5
......@@ -3,6 +3,7 @@ import { BackHandler, Keyboard, type TextInput, View } from "react-native";
33import { useSafeAreaInsets } from "react-native-safe-area-context";
44
55import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
6import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
67import { MaterialSearchField } from "../../components/MaterialSearchField";
78
89/** Keep Files search in the same header row on compact and expanded layouts. */
......@@ -55,12 +56,24 @@ export function MaterialFilesHeader(props: {
5556 icon: "magnifyingglass",
5657 onPress: () => setSearchOpen(true),
5758 },
58 {
59 accessibilityLabel: "Refresh files",
60 icon: "arrow.clockwise",
61 onPress: props.onRefresh,
62 },
6359 ]}
60 trailing={
61 <AndroidAnchoredMenu
62 title="File options"
63 actions={[{ id: "refresh", title: "Refresh files" }]}
64 onPressAction={({ nativeEvent }) => {
65 if (nativeEvent.event === "refresh") props.onRefresh();
66 }}
67 >
68 {(open) => (
69 <AndroidHeaderIconButton
70 accessibilityLabel="File options"
71 icon="ellipsis"
72 onPress={open}
73 />
74 )}
75 </AndroidAnchoredMenu>
76 }
6477 />
6578 </View>
6679 {searching ? (
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+11-2
......@@ -77,11 +77,20 @@ function FilesBrowserHeader(props: {
7777 value: props.searchQuery,
7878 onChangeText: props.onSearchQueryChange,
7979 placeholder: "Search files",
80 onRefresh: props.onRefresh,
81 refreshAccessibilityLabel: "Refresh files",
8280 closeAccessibilityLabel: "Close file search",
8381 clearAccessibilityLabel: "Clear file search",
8482 }}
83 menus={
84 Platform.OS === "android"
85 ? [
86 {
87 title: "File options",
88 icon: "ellipsis",
89 items: [{ id: "refresh", title: "Refresh files", onPress: props.onRefresh }],
90 },
91 ]
92 : undefined
93 }
8594 />
8695 );
8796}
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+1-11
......@@ -55,16 +55,6 @@ export function ThreadFileNavigatorPane(props: {
5555 const nativeHeaderRightBarButtonItems = useMemo(
5656 () =>
5757 [
58 {
59 accessibilityLabel: "Refresh files",
60 icon: { name: "arrow.clockwise", type: "sfSymbol" as const },
61 identifier: "thread-file-navigator-refresh",
62 onPress: entriesQuery.refresh,
63 sharesBackground: false,
64 tintColor: foregroundColor,
65 type: "button" as const,
66 width: 44,
67 },
6858 {
6959 accessibilityLabel: "Close files",
7060 icon: { name: "xmark", type: "sfSymbol" as const },
......@@ -76,7 +66,7 @@ export function ThreadFileNavigatorPane(props: {
7666 width: 44,
7767 },
7868 ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"],
79 [entriesQuery.refresh, foregroundColor, toggleAuxiliaryPane],
69 [foregroundColor, toggleAuxiliaryPane],
8070 );
8171
8272 const fileTree = (
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+60-1
......@@ -1,16 +1,23 @@
11import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
22import { useNavigation } from "@react-navigation/native";
3import { useAtomValue } from "@effect/atom-react";
4import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
35import type { EnvironmentId } from "@t3tools/contracts";
4import { useCallback, useState } from "react";
6import { useCallback, useRef, useState } from "react";
7import { Platform, RefreshControl } from "react-native";
58import { useSafeAreaInsets } from "react-native-safe-area-context";
69
710import { SettingsScreen } from "./components/SettingsScreen";
11import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
12import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader";
813import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
914import { LocalEnvironmentList } from "../connection/LocalEnvironmentList";
1015import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";
1116import { splitEnvironmentSections } from "../connection/environmentSections";
1217import { useUniwindTheme } from "../../lib/useUniwindTheme";
1318import { useRemoteConnections } from "../../state/use-remote-environment-registry";
19import { relayEnvironmentDiscovery } from "../../state/relay";
20import { useAtomCommand } from "../../state/use-atom-command";
1421import {
1522 applyShowcaseLocalEnvironmentDisplayUrls,
1623 resolveShowcaseEnvironmentUpdateDisplayUrl,
......@@ -42,6 +49,24 @@ export function SettingsEnvironmentsRouteScreen() {
4249 : environmentSections.connectedCloudEnvironments;
4350 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
4451 const headerIconColor = useUniwindTheme()["--color-icon"];
52 const relaySession = useAtomValue(managedRelaySessionAtom);
53 const refreshRelayEnvironments = useAtomCommand(
54 relayEnvironmentDiscovery.refresh,
55 "relay environment refresh",
56 );
57 const [isRefreshingCloud, setIsRefreshingCloud] = useState(false);
58 const cloudRefreshPendingRef = useRef(false);
59 async function refreshCloudEnvironments() {
60 if (!relaySession || cloudRefreshPendingRef.current) return;
61 cloudRefreshPendingRef.current = true;
62 setIsRefreshingCloud(true);
63 try {
64 await refreshRelayEnvironments();
65 } finally {
66 cloudRefreshPendingRef.current = false;
67 setIsRefreshingCloud(false);
68 }
69 }
4570
4671 const handleToggle = useCallback((environmentId: EnvironmentId) => {
4772 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
......@@ -76,6 +101,31 @@ export function SettingsEnvironmentsRouteScreen() {
76101 return (
77102 <SettingsScreen
78103 title="Environments"
104 trailing={
105 Platform.OS === "android" && relaySession ? (
106 <AndroidAnchoredMenu
107 title="Environment options"
108 actions={[
109 {
110 id: "refresh",
111 title: "Refresh cloud environments",
112 attributes: { disabled: isRefreshingCloud },
113 },
114 ]}
115 onPressAction={({ nativeEvent }) => {
116 if (nativeEvent.event === "refresh") void refreshCloudEnvironments();
117 }}
118 >
119 {(open) => (
120 <AndroidHeaderIconButton
121 accessibilityLabel="Environment options"
122 icon="ellipsis"
123 onPress={open}
124 />
125 )}
126 </AndroidAnchoredMenu>
127 ) : undefined
128 }
79129 actions={[
80130 {
81131 accessibilityLabel: "Add environment",
......@@ -90,6 +140,7 @@ export function SettingsEnvironmentsRouteScreen() {
90140 ]}
91141 >
92142 <ScrollView
143 alwaysBounceVertical
93144 contentInsetAdjustmentBehavior="automatic"
94145 showsVerticalScrollIndicator={false}
95146 className="flex-1"
......@@ -97,6 +148,14 @@ export function SettingsEnvironmentsRouteScreen() {
97148 contentContainerStyle={{
98149 paddingBottom: Math.max(insets.bottom, 18) + 18,
99150 }}
151 refreshControl={
152 relaySession ? (
153 <RefreshControl
154 refreshing={isRefreshingCloud}
155 onRefresh={() => void refreshCloudEnvironments()}
156 />
157 ) : undefined
158 }
100159 >
101160 <LocalEnvironmentList
102161 environments={localEnvironments}
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+41-17
......@@ -26,6 +26,7 @@ import {
2626 AndroidHeaderIconButton,
2727 AndroidSheetHeader,
2828} from "../../../components/AndroidScreenHeader";
29import { AndroidAnchoredMenu } from "../../../components/AndroidAnchoredMenu";
2930import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
3031import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout";
3132import { AppText as Text } from "../../../components/AppText";
......@@ -232,6 +233,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
232233
233234 const content = (
234235 <ScrollView
236 alwaysBounceVertical
235237 className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-screen"}
236238 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
237239 showsVerticalScrollIndicator={false}
......@@ -400,7 +402,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
400402 // stack header, so — like the Settings sheet — the header must come from a
401403 // nested native stack INSIDE the sheet. This reuses the exact structure of the
402404 // inspector branch below: branch as the title, status summary as the native
403 // subtitle, refresh as a header button.
405 // subtitle, and content that owns pull-to-refresh.
404406 return (
405407 <View collapsable={false} className="flex-1 bg-sheet">
406408 <ScreenStack style={{ flex: 1 }}>
......@@ -431,6 +433,30 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
431433 );
432434 }
433435
436 const refreshMenu = (
437 <AndroidAnchoredMenu
438 title="Repository options"
439 actions={[
440 {
441 id: "refresh",
442 title: "Refresh repository status",
443 attributes: { disabled: busy || isPullRefreshing },
444 },
445 ]}
446 onPressAction={({ nativeEvent }) => {
447 if (nativeEvent.event === "refresh") void handlePullRefresh();
448 }}
449 >
450 {(open) => (
451 <AndroidHeaderIconButton
452 accessibilityLabel="Repository options"
453 icon="ellipsis"
454 onPress={open}
455 />
456 )}
457 </AndroidAnchoredMenu>
458 );
459
434460 return (
435461 <View
436462 collapsable={false}
......@@ -465,14 +491,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
465491 }
466492 >
467493 {Platform.OS === "android" ? (
468 <View className="absolute right-3 top-4 z-[1]">
469 <AndroidHeaderIconButton
470 accessibilityLabel="Refresh repository status"
471 disabled={busy}
472 icon="arrow.clockwise"
473 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
474 />
475 </View>
494 <View className="absolute right-3 top-4 z-[1]">{refreshMenu}</View>
476495 ) : (
477496 <Pressable
478497 className={
......@@ -506,14 +525,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
506525 hideBottomBorder={Platform.OS === "android"}
507526 subtitle={currentStatusSummary}
508527 onBack={() => navigation.goBack()}
509 actions={[
510 {
511 accessibilityLabel: "Refresh repository status",
512 disabled: busy,
513 icon: "arrow.clockwise",
514 onPress: () => void gitActions.refreshSelectedThreadGitStatus(),
515 },
516 ]}
528 trailing={Platform.OS === "android" ? refreshMenu : undefined}
529 actions={
530 Platform.OS === "android"
531 ? undefined
532 : [
533 {
534 accessibilityLabel: "Refresh repository status",
535 disabled: busy,
536 icon: "arrow.clockwise",
537 onPress: () => void gitActions.refreshSelectedThreadGitStatus(),
538 },
539 ]
540 }
517541 />
518542 )}
519543