| author | |
| committer | |
| log | 6676f9c83b6a6dd1e2076eaa68dff48845ad955b |
| tree | 7c5a24a445475243efa2068f382709e84a5c80c3 |
| parent | 65b005f1e4bfccb6a404b3b1e5bfa363d534ac2a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Thuong Tin <thuongtin@gmail.com>
Co-authored-by: Kapish14 <kapishnarang01@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>38 files changed, 3411 insertions(+), 1421 deletions(-)
apps/mobile/package.json+1-1| ... | ... | @@ -49,7 +49,7 @@ |
| 49 | 49 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 50 | 50 | "@expo/metro-runtime": "~56.0.15", |
| 51 | 51 | "@expo/ui": "~56.0.18", |
| 52 | "@legendapp/list": "3.3.3", | |
| 52 | "@legendapp/list": "catalog:", | |
| 53 | 53 | "@noble/curves": "catalog:", |
| 54 | 54 | "@noble/hashes": "catalog:", |
| 55 | 55 | "@pierre/diffs": "catalog:", |
apps/mobile/src/Stack.tsx+20-13| ... | ... | @@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText"; |
| 18 | 18 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; |
| 19 | 19 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; |
| 20 | 20 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; |
| 21 | import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; | |
| 22 | 21 | import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; |
| 23 | 22 | import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; |
| 24 | 23 | import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; |
| ... | ... | @@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { |
| 134 | 133 | presentation: "fullScreenModal", |
| 135 | 134 | }; |
| 136 | 135 | |
| 137 | const SettingsSheetStack = createNativeStackNavigator({ | |
| 136 | const SettingsContentStack = createNativeStackNavigator({ | |
| 138 | 137 | initialRouteName: "Settings", |
| 139 | 138 | screenOptions: { |
| 140 | 139 | ...GLASS_HEADER_OPTIONS, |
| ... | ... | @@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 198 | 197 | title: "Usage", |
| 199 | 198 | }, |
| 200 | 199 | }), |
| 200 | }, | |
| 201 | }); | |
| 202 | ||
| 203 | // The outer stack never owns visible chrome. Settings routes render inside a | |
| 204 | // nested stack whose native header remains mounted, while Clerk owns auth chrome. | |
| 205 | // Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. | |
| 206 | const SettingsSheetStack = createNativeStackNavigator({ | |
| 207 | initialRouteName: "SettingsContent", | |
| 208 | screenOptions: { | |
| 209 | headerShown: false, | |
| 210 | }, | |
| 211 | screens: { | |
| 212 | SettingsContent: createNativeStackScreen({ | |
| 213 | screen: SettingsContentStack, | |
| 214 | linking: "", | |
| 215 | }), | |
| 201 | 216 | SettingsAuth: createNativeStackScreen({ |
| 202 | 217 | screen: SettingsAuthRouteScreen, |
| 203 | 218 | linking: "auth", |
| 204 | options: { | |
| 205 | title: "Sign in", | |
| 206 | }, | |
| 207 | 219 | }), |
| 208 | 220 | SettingsWaitlist: createNativeStackScreen({ |
| 209 | 221 | // Keep the old deep link working after the Connect GA launch. |
| 210 | 222 | screen: SettingsAuthRouteScreen, |
| 211 | 223 | linking: "waitlist", |
| 212 | options: { | |
| 213 | title: "Sign in", | |
| 214 | }, | |
| 215 | 224 | }), |
| 216 | 225 | }, |
| 217 | 226 | }); |
| ... | ... | @@ -347,11 +356,9 @@ function RootStackLayout(props: { |
| 347 | 356 | <HardwareKeyboardCommandProvider pathname={pathname}> |
| 348 | 357 | <ThreadOutboxDrainWorker /> |
| 349 | 358 | <ShowcaseCaptureCoordinator pathname={pathname} /> |
| 350 | <ClerkSettingsSheetDetentProvider initiallyExpanded={false}> | |
| 351 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> | |
| 352 | {props.children} | |
| 353 | </AdaptiveWorkspaceLayout> | |
| 354 | </ClerkSettingsSheetDetentProvider> | |
| 359 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> | |
| 360 | {props.children} | |
| 361 | </AdaptiveWorkspaceLayout> | |
| 355 | 362 | </HardwareKeyboardCommandProvider> |
| 356 | 363 | ); |
| 357 | 364 | } |
apps/mobile/src/components/ControlPill.tsx+26-1| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | type ComponentProps, |
| 7 | 7 | type ReactElement, |
| 8 | 8 | type ReactNode, |
| 9 | useRef, | |
| 9 | 10 | } from "react"; |
| 10 | 11 | import { Platform, Pressable, useColorScheme, View } from "react-native"; |
| 11 | 12 | import { useThemeColor } from "../lib/useThemeColor"; |
| ... | ... | @@ -21,10 +22,31 @@ export function ControlPill(props: { |
| 21 | 22 | readonly label?: string; |
| 22 | 23 | readonly accessibilityLabel?: string; |
| 23 | 24 | readonly onPress?: () => void; |
| 25 | readonly activateOnPressIn?: boolean; | |
| 24 | 26 | readonly variant?: "circle" | "pill" | "primary" | "danger"; |
| 25 | 27 | readonly disabled?: boolean; |
| 28 | readonly className?: string; | |
| 26 | 29 | }) { |
| 27 | 30 | const variant = props.variant ?? "circle"; |
| 31 | const activatedOnPressInRef = useRef(false); | |
| 32 | ||
| 33 | const handlePressIn = () => { | |
| 34 | activatedOnPressInRef.current = true; | |
| 35 | props.onPress?.(); | |
| 36 | }; | |
| 37 | const handlePressOut = () => { | |
| 38 | // Pressability invokes onPressOut immediately before onPress on release. | |
| 39 | // Defer the reset so onPress can identify the same physical gesture. | |
| 40 | setTimeout(() => { | |
| 41 | activatedOnPressInRef.current = false; | |
| 42 | }, 0); | |
| 43 | }; | |
| 44 | const handlePress = () => { | |
| 45 | if (activatedOnPressInRef.current) { | |
| 46 | return; | |
| 47 | } | |
| 48 | props.onPress?.(); | |
| 49 | }; | |
| 28 | 50 | |
| 29 | 51 | const iconColor = useThemeColor("--color-icon"); |
| 30 | 52 | const iconSubtle = useThemeColor("--color-icon-subtle"); |
| ... | ... | @@ -54,6 +76,7 @@ export function ControlPill(props: { |
| 54 | 76 | : variant === "danger" |
| 55 | 77 | ? "bg-danger" |
| 56 | 78 | : "bg-subtle", |
| 79 | props.className, | |
| 57 | 80 | ); |
| 58 | 81 | const labelClassName = cn( |
| 59 | 82 | "text-center text-xs font-t3-bold", |
| ... | ... | @@ -68,7 +91,9 @@ export function ControlPill(props: { |
| 68 | 91 | <Pressable |
| 69 | 92 | accessibilityLabel={props.accessibilityLabel ?? props.label} |
| 70 | 93 | accessibilityRole="button" |
| 71 | onPress={props.onPress} | |
| 94 | onPress={props.activateOnPressIn ? handlePress : props.onPress} | |
| 95 | onPressIn={props.activateOnPressIn ? handlePressIn : undefined} | |
| 96 | onPressOut={props.activateOnPressIn ? handlePressOut : undefined} | |
| 72 | 97 | disabled={props.disabled} |
| 73 | 98 | className={containerClassName} |
| 74 | 99 | > |
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+1-4| ... | ... | @@ -5,7 +5,6 @@ import { useFocusEffect } from "@react-navigation/native"; |
| 5 | 5 | import { useCallback, useMemo, useState } from "react"; |
| 6 | 6 | |
| 7 | 7 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 8 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | |
| 9 | 8 | import { useArchivedThreadListActions } from "../home/useThreadListActions"; |
| 10 | 9 | import { |
| 11 | 10 | ArchivedThreadsScreen, |
| ... | ... | @@ -18,7 +17,6 @@ import { |
| 18 | 17 | } from "./useArchivedThreadSnapshots"; |
| 19 | 18 | |
| 20 | 19 | export function ArchivedThreadsRouteScreen() { |
| 21 | const { expand } = useClerkSettingsSheetDetent(); | |
| 22 | 20 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 23 | 21 | const [searchQuery, setSearchQuery] = useState(""); |
| 24 | 22 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); |
| ... | ... | @@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() { |
| 70 | 68 | |
| 71 | 69 | useFocusEffect( |
| 72 | 70 | useCallback(() => { |
| 73 | expand(); | |
| 74 | 71 | refresh(); |
| 75 | }, [expand, refresh]), | |
| 72 | }, [refresh]), | |
| 76 | 73 | ); |
| 77 | 74 | |
| 78 | 75 | return ( |
apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx deleted-44| ... | ... | @@ -1,44 +0,0 @@ |
| 1 | import { | |
| 2 | createContext, | |
| 3 | type PropsWithChildren, | |
| 4 | useCallback, | |
| 5 | useContext, | |
| 6 | useMemo, | |
| 7 | useState, | |
| 8 | } from "react"; | |
| 9 | ||
| 10 | interface ClerkSettingsSheetDetentValue { | |
| 11 | collapse: () => void; | |
| 12 | expand: () => void; | |
| 13 | isExpanded: boolean; | |
| 14 | } | |
| 15 | ||
| 16 | const ClerkSettingsSheetDetentContext = createContext<ClerkSettingsSheetDetentValue | null>(null); | |
| 17 | ||
| 18 | interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { | |
| 19 | initiallyExpanded: boolean; | |
| 20 | } | |
| 21 | ||
| 22 | export function ClerkSettingsSheetDetentProvider({ | |
| 23 | children, | |
| 24 | initiallyExpanded, | |
| 25 | }: ClerkSettingsSheetDetentProviderProps) { | |
| 26 | const [isExpanded, setIsExpanded] = useState(initiallyExpanded); | |
| 27 | const collapse = useCallback(() => setIsExpanded(false), []); | |
| 28 | const expand = useCallback(() => setIsExpanded(true), []); | |
| 29 | const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); | |
| 30 | ||
| 31 | return ( | |
| 32 | <ClerkSettingsSheetDetentContext value={value}>{children}</ClerkSettingsSheetDetentContext> | |
| 33 | ); | |
| 34 | } | |
| 35 | ||
| 36 | export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { | |
| 37 | const value = useContext(ClerkSettingsSheetDetentContext); | |
| 38 | if (!value) { | |
| 39 | throw new Error( | |
| 40 | "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", | |
| 41 | ); | |
| 42 | } | |
| 43 | return value; | |
| 44 | } |
apps/mobile/src/features/cloud/connectOnboardingNavigation.ts+2-2| ... | ... | @@ -6,8 +6,8 @@ import { appAtomRegistry } from "../../state/atom-registry"; |
| 6 | 6 | import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; |
| 7 | 7 | import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; |
| 8 | 8 | |
| 9 | // Sign-in happens inside the Settings sheet; give its detent/session-state | |
| 10 | // transitions a beat to settle before presenting another formSheet on top. | |
| 9 | // Sign-in happens inside the Settings sheet; give its session-state transition | |
| 10 | // a beat to settle before presenting another formSheet on top. | |
| 11 | 11 | const PRESENT_ONBOARDING_DELAY_MS = 600; |
| 12 | 12 | |
| 13 | 13 | /** |
apps/mobile/src/features/home/HomeRouteScreen.tsx+24-5| ... | ... | @@ -134,7 +134,10 @@ export function HomeRouteScreen() { |
| 134 | 134 | <NativeStackScreenOptions |
| 135 | 135 | options={getConnectionAwareBrandHeaderOptions({ |
| 136 | 136 | onOpenEnvironments: () => |
| 137 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), | |
| 137 | navigation.navigate("SettingsSheet", { | |
| 138 | screen: "SettingsContent", | |
| 139 | params: { screen: "SettingsEnvironments" }, | |
| 140 | }), | |
| 138 | 141 | })} |
| 139 | 142 | /> |
| 140 | 143 | <HomeHeader |
| ... | ... | @@ -148,9 +151,17 @@ export function HomeRouteScreen() { |
| 148 | 151 | onEnvironmentChange={setSelectedEnvironmentId} |
| 149 | 152 | onProjectChange={setSelectedProjectKey} |
| 150 | 153 | onOpenEnvironments={() => |
| 151 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) | |
| 154 | navigation.navigate("SettingsSheet", { | |
| 155 | screen: "SettingsContent", | |
| 156 | params: { screen: "SettingsEnvironments" }, | |
| 157 | }) | |
| 158 | } | |
| 159 | onOpenSettings={() => | |
| 160 | navigation.navigate("SettingsSheet", { | |
| 161 | screen: "SettingsContent", | |
| 162 | params: { screen: "Settings" }, | |
| 163 | }) | |
| 152 | 164 | } |
| 153 | onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} | |
| 154 | 165 | onProjectSortOrderChange={setProjectSortOrder} |
| 155 | 166 | onSearchQueryChange={setSearchQuery} |
| 156 | 167 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| ... | ... | @@ -161,7 +172,10 @@ export function HomeRouteScreen() { |
| 161 | 172 | catalogState={catalogState} |
| 162 | 173 | environments={environments} |
| 163 | 174 | onAddConnection={() => |
| 164 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) | |
| 175 | navigation.navigate("SettingsSheet", { | |
| 176 | screen: "SettingsContent", | |
| 177 | params: { screen: "SettingsEnvironmentNew" }, | |
| 178 | }) | |
| 165 | 179 | } |
| 166 | 180 | onArchiveThread={archiveThread} |
| 167 | 181 | onDeleteThread={confirmDeleteThread} |
| ... | ... | @@ -174,7 +188,12 @@ export function HomeRouteScreen() { |
| 174 | 188 | onMovePinnedThread={movePinnedThread} |
| 175 | 189 | onEnvironmentChange={setSelectedEnvironmentId} |
| 176 | 190 | onProjectChange={setSelectedProjectKey} |
| 177 | onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} | |
| 191 | onOpenSettings={() => | |
| 192 | navigation.navigate("SettingsSheet", { | |
| 193 | screen: "SettingsContent", | |
| 194 | params: { screen: "Settings" }, | |
| 195 | }) | |
| 196 | } | |
| 178 | 197 | onProjectSortOrderChange={setProjectSortOrder} |
| 179 | 198 | onSearchQueryChange={setSearchQuery} |
| 180 | 199 | onSelectThread={(thread) => { |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+8-2| ... | ... | @@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent( |
| 429 | 429 | ); |
| 430 | 430 | |
| 431 | 431 | const handleOpenSettings = useCallback(() => { |
| 432 | navigation.navigate("SettingsSheet", { screen: "Settings" }); | |
| 432 | navigation.navigate("SettingsSheet", { | |
| 433 | screen: "SettingsContent", | |
| 434 | params: { screen: "Settings" }, | |
| 435 | }); | |
| 433 | 436 | }, [navigation]); |
| 434 | 437 | |
| 435 | 438 | // Minted here (root stack navigation) so the sidebar pane stays free of |
| 436 | 439 | // navigation hooks — on iOS it renders inside an independent nav tree. |
| 437 | 440 | const handleOpenEnvironmentSettings = useCallback(() => { |
| 438 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); | |
| 441 | navigation.navigate("SettingsSheet", { | |
| 442 | screen: "SettingsContent", | |
| 443 | params: { screen: "SettingsEnvironments" }, | |
| 444 | }); | |
| 439 | 445 | }, [navigation]); |
| 440 | 446 | |
| 441 | 447 | const handleNewThreadInProject = useCallback( |
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx+16-17| ... | ... | @@ -1,8 +1,7 @@ |
| 1 | 1 | import { useAuth } from "@clerk/expo"; |
| 2 | 2 | import { AuthView, UserProfileView } from "@clerk/expo/native"; |
| 3 | 3 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 4 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 5 | import { useCallback, useEffect } from "react"; | |
| 4 | import { useCallback, useLayoutEffect } from "react"; | |
| 6 | 5 | import { View } from "react-native"; |
| 7 | 6 | |
| 8 | 7 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| ... | ... | @@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 10 | 9 | export function SettingsAuthRouteScreen() { |
| 11 | 10 | const navigation = useNavigation(); |
| 12 | 11 | |
| 13 | useEffect(() => { | |
| 12 | useLayoutEffect(() => { | |
| 14 | 13 | if (!hasCloudPublicConfig()) { |
| 15 | navigation.dispatch(StackActions.replace("Settings")); | |
| 14 | navigation.dispatch(StackActions.replace("SettingsContent")); | |
| 16 | 15 | } |
| 17 | 16 | }, [navigation]); |
| 18 | 17 | |
| ... | ... | @@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() { |
| 22 | 21 | function ConfiguredSettingsAuthRouteScreen() { |
| 23 | 22 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 24 | 23 | const navigation = useNavigation(); |
| 25 | const handleHostBack = useCallback(() => navigation.goBack(), [navigation]); | |
| 24 | const handleHostBack = useCallback( | |
| 25 | () => navigation.dispatch(StackActions.popTo("SettingsContent")), | |
| 26 | [navigation], | |
| 27 | ); | |
| 26 | 28 | |
| 27 | 29 | return ( |
| 28 | <> | |
| 29 | <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 30 | <View collapsable={false} className="flex-1 overflow-hidden bg-sheet"> | |
| 31 | {isLoaded ? ( | |
| 32 | isSignedIn ? ( | |
| 33 | <UserProfileView isDismissible={false} onHostBack={handleHostBack} /> | |
| 34 | ) : ( | |
| 35 | <AuthView isDismissible={false} onHostBack={handleHostBack} /> | |
| 36 | ) | |
| 37 | ) : null} | |
| 38 | </View> | |
| 39 | </> | |
| 30 | <View collapsable={false} className="flex-1 overflow-hidden bg-sheet"> | |
| 31 | {isLoaded ? ( | |
| 32 | isSignedIn ? ( | |
| 33 | <UserProfileView isDismissible={false} onHostBack={handleHostBack} /> | |
| 34 | ) : ( | |
| 35 | <AuthView isDismissible={false} onHostBack={handleHostBack} /> | |
| 36 | ) | |
| 37 | ) : null} | |
| 38 | </View> | |
| 40 | 39 | ); |
| 41 | 40 | } |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+8-2| ... | ... | @@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() { |
| 98 | 98 | accessibilityLabel: "Add environment", |
| 99 | 99 | icon: "plus", |
| 100 | 100 | onPress: () => |
| 101 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }), | |
| 101 | navigation.navigate("SettingsSheet", { | |
| 102 | screen: "SettingsContent", | |
| 103 | params: { screen: "SettingsEnvironmentNew" }, | |
| 104 | }), | |
| 102 | 105 | }, |
| 103 | 106 | ]} |
| 104 | 107 | /> |
| ... | ... | @@ -108,7 +111,10 @@ export function SettingsEnvironmentsRouteScreen() { |
| 108 | 111 | <NativeHeaderToolbar.Button |
| 109 | 112 | icon="plus" |
| 110 | 113 | onPress={() => |
| 111 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) | |
| 114 | navigation.navigate("SettingsSheet", { | |
| 115 | screen: "SettingsContent", | |
| 116 | params: { screen: "SettingsEnvironmentNew" }, | |
| 117 | }) | |
| 112 | 118 | } |
| 113 | 119 | separateBackground |
| 114 | 120 | tintColor={headerIconColor} |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+6-13| ... | ... | @@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo"; |
| 2 | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import Constants from "expo-constants"; |
| 4 | 4 | import * as Notifications from "expo-notifications"; |
| 5 | import * as Updates from "expo-updates"; | |
| 6 | 5 | import { useNavigation } from "@react-navigation/native"; |
| 7 | 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 8 | 7 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | ... | @@ -30,7 +29,6 @@ import { |
| 30 | 29 | subscribeAgentAwarenessRegistrationStatus, |
| 31 | 30 | } from "../agent-awareness/remoteRegistration"; |
| 32 | 31 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; |
| 33 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | |
| 34 | 32 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; |
| 35 | 33 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 36 | 34 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| ... | ... | @@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/ |
| 40 | 38 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 41 | 39 | import { |
| 42 | 40 | type AppUpdateCheckState, |
| 41 | isAppUpdateCheckAvailable, | |
| 43 | 42 | registerHiddenUpdateTap, |
| 44 | 43 | runAppUpdateCheck, |
| 45 | 44 | } from "../updates/app-updates"; |
| ... | ... | @@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() { |
| 147 | 146 | const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); |
| 148 | 147 | const insets = useSafeAreaInsets(); |
| 149 | 148 | const navigation = useNavigation(); |
| 150 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); | |
| 151 | 149 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 152 | 150 | const { user } = useUser(); |
| 153 | 151 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| ... | ... | @@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() { |
| 436 | 434 | |
| 437 | 435 | const openAccount = useCallback(() => { |
| 438 | 436 | if (!isLoaded) return; |
| 439 | if (!isSignedIn) { | |
| 440 | expandClerkSheet(); | |
| 441 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | |
| 442 | return; | |
| 443 | } | |
| 444 | expandClerkSheet(); | |
| 445 | 437 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); |
| 446 | }, [expandClerkSheet, isLoaded, isSignedIn, navigation]); | |
| 438 | }, [isLoaded, navigation]); | |
| 447 | 439 | |
| 448 | 440 | return ( |
| 449 | 441 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | ... | @@ -577,6 +569,7 @@ function AppSettingsSection() { |
| 577 | 569 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; |
| 578 | 570 | const variantLabel = variant === "production" ? "" : capitalize(variant); |
| 579 | 571 | const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; |
| 572 | const updateCheckAvailable = isAppUpdateCheckAvailable(); | |
| 580 | 573 | const busy = |
| 581 | 574 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; |
| 582 | 575 | |
| ... | ... | @@ -604,13 +597,13 @@ function AppSettingsSection() { |
| 604 | 597 | }, []); |
| 605 | 598 | |
| 606 | 599 | const handleVersionPress = useCallback(() => { |
| 607 | if (!Updates.isEnabled || updateInFlight.current) return; | |
| 600 | if (!updateCheckAvailable || updateInFlight.current) return; | |
| 608 | 601 | const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); |
| 609 | 602 | hiddenUpdateTapCount.current = tap.nextCount; |
| 610 | 603 | if (tap.shouldCheck) { |
| 611 | 604 | void checkForUpdate(); |
| 612 | 605 | } |
| 613 | }, [checkForUpdate]); | |
| 606 | }, [checkForUpdate, updateCheckAvailable]); | |
| 614 | 607 | |
| 615 | 608 | const statusLabel = |
| 616 | 609 | updateState === "checking" |
| ... | ... | @@ -646,7 +639,7 @@ function AppSettingsSection() { |
| 646 | 639 | <SettingsSection title="App"> |
| 647 | 640 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> |
| 648 | 641 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 649 | {Updates.isEnabled ? ( | |
| 642 | {updateCheckAvailable ? ( | |
| 650 | 643 | <Pressable |
| 651 | 644 | accessibilityLabel={`Version ${versionLabel}`} |
| 652 | 645 | accessibilityRole="text" |
apps/mobile/src/features/settings/components/SettingsRow.tsx+2-1| ... | ... | @@ -64,7 +64,8 @@ export function SettingsRow(props: { |
| 64 | 64 | disabled={props.disabled} |
| 65 | 65 | onPress={() => |
| 66 | 66 | navigation.navigate("SettingsSheet", { |
| 67 | screen: target, | |
| 67 | screen: "SettingsContent", | |
| 68 | params: { screen: target }, | |
| 68 | 69 | }) |
| 69 | 70 | } |
| 70 | 71 | > |
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx+20-8| ... | ... | @@ -1,6 +1,12 @@ |
| 1 | 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | 2 | import { Keyboard, View } from "react-native"; |
| 3 | import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; | |
| 3 | import { | |
| 4 | CommonActions, | |
| 5 | type NavigationState, | |
| 6 | type PartialState, | |
| 7 | StackActions, | |
| 8 | useNavigation, | |
| 9 | } from "@react-navigation/native"; | |
| 4 | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | 11 | |
| 6 | 12 | import { useConnectionController } from "../connection/useConnectionController"; |
| ... | ... | @@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry"; |
| 25 | 31 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 26 | 32 | const SHOWCASE_THREAD_ID = "remote-command-center"; |
| 27 | 33 | |
| 34 | type ShowcaseResetRoute = PartialState<NavigationState>["routes"][number]; | |
| 35 | ||
| 28 | 36 | function sceneFromPathname(pathname: string): ShowcaseScene | null { |
| 29 | 37 | const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; |
| 30 | 38 | if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { |
| ... | ... | @@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 166 | 174 | navigation.dispatch(StackActions.popToTop()); |
| 167 | 175 | return; |
| 168 | 176 | } |
| 169 | const routes: Array<{ | |
| 170 | name: string; | |
| 171 | params?: Record<string, unknown>; | |
| 172 | state?: { index: number; routes: Array<{ name: string }> }; | |
| 173 | }> = [{ name: "Home" }]; | |
| 177 | const routes: ShowcaseResetRoute[] = [{ name: "Home" }]; | |
| 174 | 178 | if (requestedScene === "environments") { |
| 175 | 179 | routes.push({ |
| 176 | 180 | name: "SettingsSheet", |
| 177 | 181 | state: { |
| 178 | index: 1, | |
| 179 | routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], | |
| 182 | index: 0, | |
| 183 | routes: [ | |
| 184 | { | |
| 185 | name: "SettingsContent", | |
| 186 | state: { | |
| 187 | index: 1, | |
| 188 | routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], | |
| 189 | }, | |
| 190 | }, | |
| 191 | ], | |
| 180 | 192 | }, |
| 181 | 193 | }); |
| 182 | 194 | } else { |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+23-4| ... | ... | @@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | 2 | import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; |
| 3 | 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | 4 | import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; |
| 5 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; | |
| 5 | import { | |
| 6 | KeyboardAvoidingView, | |
| 7 | KeyboardStickyView, | |
| 8 | useKeyboardState, | |
| 9 | } from "react-native-keyboard-controller"; | |
| 6 | 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 11 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | 12 | import { useFontFamily } from "../../lib/useFontFamily"; |
| ... | ... | @@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: { |
| 985 | 989 | // The draft is a thread that doesn't exist yet, so it mirrors the thread |
| 986 | 990 | // page: in-screen header, empty feed canvas above, and the same floating |
| 987 | 991 | // composer chrome as ThreadComposer (collapsed pill → expanded card). |
| 992 | // | |
| 993 | // Composer positioning mirrors ThreadDetailScreen's floating overlay | |
| 994 | // (KeyboardStickyView, absolute bottom overlay) rather than | |
| 995 | // KeyboardAvoidingView's automaticOffset+padding: automaticOffset | |
| 996 | // resolves the composer's on-screen frame via a native | |
| 997 | // viewPositionInWindow measurement, which this app's Android | |
| 998 | // edge-to-edge setup (KeyboardProvider's native content-view margin | |
| 999 | // handling neutralizes windowSoftInputMode="adjustResize" while active) | |
| 1000 | // makes unreliable — the composer stayed under the keyboard instead of | |
| 1001 | // translating above it. KeyboardStickyView sticks directly to the | |
| 1002 | // animated keyboard height instead, sidestepping that measurement. | |
| 988 | 1003 | return ( |
| 989 | 1004 | <View className="flex-1 bg-screen"> |
| 990 | 1005 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 991 | 1006 | <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} /> |
| 992 | 1007 | |
| 993 | <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1"> | |
| 994 | <View className="flex-1" /> | |
| 1008 | <View className="flex-1" /> | |
| 995 | 1009 | |
| 1010 | <KeyboardStickyView | |
| 1011 | enabled={isKeyboardVisible} | |
| 1012 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 1013 | offset={{ closed: 0, opened: 0 }} | |
| 1014 | > | |
| 996 | 1015 | <View |
| 997 | 1016 | className="px-4 pt-2" |
| 998 | 1017 | style={{ |
| ... | ... | @@ -1056,7 +1075,7 @@ export function NewTaskDraftScreen(props: { |
| 1056 | 1075 | </ComposerToolbarRow> |
| 1057 | 1076 | ) : null} |
| 1058 | 1077 | </View> |
| 1059 | </KeyboardAvoidingView> | |
| 1078 | </KeyboardStickyView> | |
| 1060 | 1079 | {settingsSheet} |
| 1061 | 1080 | </View> |
| 1062 | 1081 | ); |
apps/mobile/src/features/threads/PendingUserInputCard.tsx+301-67| ... | ... | @@ -1,18 +1,64 @@ |
| 1 | import type { ApprovalRequestId } from "@t3tools/contracts"; | |
| 2 | import { Pressable, View } from "react-native"; | |
| 1 | import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; | |
| 2 | import { useCallback, useRef } from "react"; | |
| 3 | import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; | |
| 4 | import Animated, { | |
| 5 | Easing, | |
| 6 | FadeInUp, | |
| 7 | FadeOutDown, | |
| 8 | LinearTransition, | |
| 9 | useAnimatedStyle, | |
| 10 | useSharedValue, | |
| 11 | withTiming, | |
| 12 | type SharedValue, | |
| 13 | } from "react-native-reanimated"; | |
| 3 | 14 | |
| 15 | import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; | |
| 16 | ||
| 17 | import { SymbolView } from "../../components/AppSymbol"; | |
| 4 | 18 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 19 | import { ControlPill } from "../../components/ControlPill"; | |
| 5 | 20 | import { cn } from "../../lib/cn"; |
| 6 | import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; | |
| 21 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 22 | import { | |
| 23 | isPendingUserInputOptionSelected, | |
| 24 | type PendingUserInput, | |
| 25 | type PendingUserInputDraftAnswer, | |
| 26 | } from "../../lib/threadActivity"; | |
| 7 | 27 | |
| 8 | 28 | export interface PendingUserInputCardProps { |
| 9 | 29 | readonly pendingUserInput: PendingUserInput; |
| 30 | /** | |
| 31 | * Constant while a request is pending (it reserves keyboard space), so the | |
| 32 | * keyboard transition is pure translation; changes only on rare discrete | |
| 33 | * corrections, which the layout transition smooths. | |
| 34 | */ | |
| 35 | readonly maxHeight: number; | |
| 36 | readonly collapsed: boolean; | |
| 37 | readonly onToggleCollapsed: () => void; | |
| 38 | /** Renders a stop control on the collapsed bar, which replaces the composer. */ | |
| 39 | readonly onStopThread?: () => void; | |
| 40 | /** | |
| 41 | * 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the | |
| 42 | * collapsed bar (inside a clipping window) on the UI thread; the host | |
| 43 | * animates it directly from the tap handler so the card and the feed | |
| 44 | * inset glide start the same frame. | |
| 45 | */ | |
| 46 | readonly cardProgress?: SharedValue<number>; | |
| 47 | /** | |
| 48 | * Receives how far the expanded card extends above the bar footprint | |
| 49 | * (written from onLayout with no re-render); the host adds it to the | |
| 50 | * thread feed's end inset so the end of the chat stays visible above the | |
| 51 | * card. | |
| 52 | */ | |
| 53 | readonly cardCoverage?: SharedValue<number>; | |
| 54 | /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */ | |
| 55 | readonly onInputFocusChange?: (focused: boolean) => void; | |
| 10 | 56 | readonly drafts: Record<string, PendingUserInputDraftAnswer>; |
| 11 | readonly answers: Record<string, string> | null; | |
| 57 | readonly answers: Record<string, string | ReadonlyArray<string>> | null; | |
| 12 | 58 | readonly respondingUserInputId: ApprovalRequestId | null; |
| 13 | 59 | readonly onSelectOption: ( |
| 14 | 60 | requestId: ApprovalRequestId, |
| 15 | questionId: string, | |
| 61 | question: UserInputQuestion, | |
| 16 | 62 | label: string, |
| 17 | 63 | ) => void; |
| 18 | 64 | readonly onChangeCustomAnswer: ( |
| ... | ... | @@ -23,74 +69,242 @@ export interface PendingUserInputCardProps { |
| 23 | 69 | readonly onSubmit: () => Promise<unknown>; |
| 24 | 70 | } |
| 25 | 71 | |
| 72 | /** | |
| 73 | * On iOS the collapsed bar is the PERMANENT in-flow footprint — the expanded | |
| 74 | * card is an absolutely-positioned overlay rising above it. The overlay's | |
| 75 | * measured height (which drives the thread feed's bottom inset) therefore | |
| 76 | * never changes on collapse/expand, so the transcript stays perfectly still | |
| 77 | * while the card animates over it. | |
| 78 | * | |
| 79 | * Android cannot use the overlay: it does not hit-test touches outside a | |
| 80 | * parent's bounds, which made everything above the bar-sized wrapper | |
| 81 | * untouchable. There the expanded card renders in-flow instead (the wrapper | |
| 82 | * grows with it, and the host skips the coverage inset since the measured | |
| 83 | * overlay already includes the card). | |
| 84 | */ | |
| 85 | const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; | |
| 86 | ||
| 87 | const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); | |
| 88 | ||
| 26 | 89 | export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 27 | // The surface is opaque on purpose: the card floats over the thread feed | |
| 28 | // with no blur behind it, so a translucent background renders the questions | |
| 29 | // on top of whatever message happens to sit underneath. | |
| 30 | return ( | |
| 31 | <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900"> | |
| 32 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> | |
| 33 | User input needed | |
| 34 | </Text> | |
| 35 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> | |
| 36 | Fill in the pending answers | |
| 37 | </Text> | |
| 38 | {props.pendingUserInput.questions.map((question) => { | |
| 39 | const draft = props.drafts[question.id]; | |
| 40 | return ( | |
| 41 | <View key={question.id} className="gap-2 pt-1"> | |
| 42 | <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500"> | |
| 43 | {question.header} | |
| 44 | </Text> | |
| 45 | <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50"> | |
| 46 | {question.question} | |
| 47 | </Text> | |
| 48 | <View className="flex-row flex-wrap gap-2.5"> | |
| 49 | {question.options.map((option) => { | |
| 50 | const selected = | |
| 51 | draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; | |
| 52 | return ( | |
| 53 | <Pressable | |
| 54 | key={option.label} | |
| 55 | className={cn( | |
| 56 | "rounded-full border px-3 py-2.5 ", | |
| 57 | selected | |
| 58 | ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" | |
| 59 | : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70", | |
| 60 | )} | |
| 61 | onPress={() => | |
| 62 | props.onSelectOption( | |
| 63 | props.pendingUserInput.requestId, | |
| 64 | question.id, | |
| 65 | option.label, | |
| 66 | ) | |
| 67 | } | |
| 68 | > | |
| 69 | <Text | |
| 90 | const iconSubtle = useThemeColor("--color-icon-subtle"); | |
| 91 | const questionCount = props.pendingUserInput.questions.length; | |
| 92 | ||
| 93 | const cardCoverage = props.cardCoverage; | |
| 94 | const barHeightRef = useRef(0); | |
| 95 | const cardHeightRef = useRef(0); | |
| 96 | // Measured card height, written straight from onLayout: the collapse slide | |
| 97 | // distance. Not animated — it only changes on discrete relayouts. | |
| 98 | const cardHeight = useSharedValue(0); | |
| 99 | const notifyCoverage = useCallback(() => { | |
| 100 | if (!cardCoverage) { | |
| 101 | return; | |
| 102 | } | |
| 103 | const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); | |
| 104 | if (coverage === cardCoverage.value) { | |
| 105 | return; | |
| 106 | } | |
| 107 | if (cardCoverage.value === 0) { | |
| 108 | // First measurement lands while the list is doing its initial | |
| 109 | // end-pin (thread opened onto a pending request); animating it from | |
| 110 | // zero would move the end anchor out from under that scroll. | |
| 111 | cardCoverage.value = coverage; | |
| 112 | return; | |
| 113 | } | |
| 114 | // Animated so a coverage change at rest (discrete max-height | |
| 115 | // corrections) glides the feed instead of stepping it; toggle timing is | |
| 116 | // owned by the host's progress values. | |
| 117 | cardCoverage.value = withTiming(coverage, { | |
| 118 | duration: USER_INPUT_TOGGLE_DURATION_MS, | |
| 119 | easing: Easing.out(Easing.cubic), | |
| 120 | }); | |
| 121 | }, [cardCoverage]); | |
| 122 | const handleBarLayout = useCallback( | |
| 123 | (event: LayoutChangeEvent) => { | |
| 124 | barHeightRef.current = event.nativeEvent.layout.height; | |
| 125 | notifyCoverage(); | |
| 126 | }, | |
| 127 | [notifyCoverage], | |
| 128 | ); | |
| 129 | const handleCardLayout = useCallback( | |
| 130 | (event: LayoutChangeEvent) => { | |
| 131 | cardHeightRef.current = event.nativeEvent.layout.height; | |
| 132 | cardHeight.value = event.nativeEvent.layout.height; | |
| 133 | notifyCoverage(); | |
| 134 | }, | |
| 135 | [cardHeight, notifyCoverage], | |
| 136 | ); | |
| 137 | const cardProgress = props.cardProgress; | |
| 138 | // No opacity: fading an opaque card over the live transcript reads as a | |
| 139 | // crossfade (card text, transcript, and bar all half-visible at once). | |
| 140 | // Instead the card stays opaque and slides its full height down past the | |
| 141 | // clipping window's bottom edge, so the transcript is only revealed where | |
| 142 | // the card has physically left. | |
| 143 | const cardAnimatedStyle = useAnimatedStyle(() => { | |
| 144 | const progress = cardProgress === undefined ? 1 : cardProgress.value; | |
| 145 | return { | |
| 146 | transform: [{ translateY: (1 - progress) * cardHeight.value }], | |
| 147 | }; | |
| 148 | }); | |
| 149 | ||
| 150 | // On iOS the card stays MOUNTED while collapsed (hidden via the animated | |
| 151 | // style): expanding animates existing views on the UI thread the same | |
| 152 | // frame the host starts the progress timing, instead of paying a React | |
| 153 | // mount + layout before anything moves. | |
| 154 | const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed; | |
| 155 | const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; | |
| 156 | // The bar renders UNDER the card (earlier in JSX), always opaque: while | |
| 157 | // expanded the opaque card covers it, and during the collapse slide the | |
| 158 | // card's top edge wipes past and reveals it — no opacity handoff, so no | |
| 159 | // crossfade frames. | |
| 160 | const bar = showBar ? ( | |
| 161 | <View | |
| 162 | onLayout={handleBarLayout} | |
| 163 | pointerEvents={props.collapsed ? "auto" : "none"} | |
| 164 | accessibilityElementsHidden={!props.collapsed} | |
| 165 | importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"} | |
| 166 | className="flex-row items-center gap-2 rounded-full border border-neutral-200 bg-neutral-100 py-1.5 pl-4 pr-1.5 dark:border-white/6 dark:bg-neutral-900" | |
| 167 | > | |
| 168 | <Pressable | |
| 169 | accessibilityRole="button" | |
| 170 | accessibilityLabel={`Expand user input, ${questionCount} question${ | |
| 171 | questionCount === 1 ? "" : "s" | |
| 172 | }`} | |
| 173 | onPress={props.onToggleCollapsed} | |
| 174 | className="min-h-10 flex-1 flex-row items-center gap-2 active:opacity-70" | |
| 175 | > | |
| 176 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> | |
| 177 | User input needed | |
| 178 | </Text> | |
| 179 | <Text className="font-sans text-xs text-neutral-500 dark:text-neutral-400"> | |
| 180 | {questionCount} question{questionCount === 1 ? "" : "s"} | |
| 181 | </Text> | |
| 182 | <View className="flex-1" /> | |
| 183 | <SymbolView name="chevron.up" size={12} tintColor={iconSubtle} type="monochrome" /> | |
| 184 | </Pressable> | |
| 185 | {props.onStopThread ? ( | |
| 186 | <ControlPill | |
| 187 | accessibilityLabel="Stop" | |
| 188 | icon="stop.fill" | |
| 189 | variant="danger" | |
| 190 | className="h-9 w-9" | |
| 191 | onPress={props.onStopThread} | |
| 192 | /> | |
| 193 | ) : null} | |
| 194 | </View> | |
| 195 | ) : null; | |
| 196 | const card = renderCard ? ( | |
| 197 | // The surface is opaque on purpose: the card floats over the thread | |
| 198 | // feed with no blur behind it, so a translucent background renders | |
| 199 | // the questions on top of whatever message happens to sit underneath. | |
| 200 | <Animated.View | |
| 201 | onLayout={handleCardLayout} | |
| 202 | pointerEvents={props.collapsed ? "none" : "auto"} | |
| 203 | accessibilityElementsHidden={props.collapsed} | |
| 204 | importantForAccessibility={props.collapsed ? "no-hide-descendants" : "auto"} | |
| 205 | entering={ | |
| 206 | EXPANDED_CARD_IS_OVERLAY | |
| 207 | ? undefined | |
| 208 | : FadeInUp.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) | |
| 209 | } | |
| 210 | exiting={ | |
| 211 | EXPANDED_CARD_IS_OVERLAY | |
| 212 | ? undefined | |
| 213 | : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) | |
| 214 | } | |
| 215 | layout={CARD_LAYOUT_TRANSITION} | |
| 216 | className="overflow-hidden gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900" | |
| 217 | style={ | |
| 218 | EXPANDED_CARD_IS_OVERLAY | |
| 219 | ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle] | |
| 220 | : { maxHeight: props.maxHeight } | |
| 221 | } | |
| 222 | > | |
| 223 | <Pressable | |
| 224 | accessibilityRole="button" | |
| 225 | accessibilityLabel="Collapse user input" | |
| 226 | onPress={props.onToggleCollapsed} | |
| 227 | className="flex-row items-start gap-2" | |
| 228 | > | |
| 229 | <View className="flex-1 gap-2.5"> | |
| 230 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> | |
| 231 | User input needed | |
| 232 | </Text> | |
| 233 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> | |
| 234 | Fill in the pending answers | |
| 235 | </Text> | |
| 236 | </View> | |
| 237 | <View className="h-8 w-8 items-center justify-center rounded-full bg-neutral-200/70 dark:bg-white/8"> | |
| 238 | <SymbolView name="chevron.down" size={13} tintColor={iconSubtle} type="monochrome" /> | |
| 239 | </View> | |
| 240 | </Pressable> | |
| 241 | <ScrollView | |
| 242 | bounces={false} | |
| 243 | className="min-h-0" | |
| 244 | contentContainerClassName="gap-2.5 pb-1" | |
| 245 | keyboardShouldPersistTaps="handled" | |
| 246 | nestedScrollEnabled | |
| 247 | showsVerticalScrollIndicator | |
| 248 | style={{ flexShrink: 1 }} | |
| 249 | > | |
| 250 | {props.pendingUserInput.questions.map((question) => { | |
| 251 | const draft = props.drafts[question.id]; | |
| 252 | return ( | |
| 253 | <View key={question.id} className="gap-2 pt-1"> | |
| 254 | <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500"> | |
| 255 | {question.header} | |
| 256 | </Text> | |
| 257 | <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50"> | |
| 258 | {question.question} | |
| 259 | </Text> | |
| 260 | <View className="flex-row flex-wrap gap-2.5"> | |
| 261 | {question.options.map((option) => { | |
| 262 | const selected = isPendingUserInputOptionSelected(draft, option.label); | |
| 263 | return ( | |
| 264 | <Pressable | |
| 265 | key={option.label} | |
| 70 | 266 | className={cn( |
| 71 | "font-t3-bold text-sm", | |
| 267 | "rounded-full border px-3 py-2.5 ", | |
| 72 | 268 | selected |
| 73 | ? "text-sky-700 dark:text-sky-300" | |
| 74 | : "text-neutral-600 dark:text-neutral-300", | |
| 269 | ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" | |
| 270 | : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70", | |
| 75 | 271 | )} |
| 272 | onPress={() => | |
| 273 | props.onSelectOption( | |
| 274 | props.pendingUserInput.requestId, | |
| 275 | question, | |
| 276 | option.label, | |
| 277 | ) | |
| 278 | } | |
| 76 | 279 | > |
| 77 | {option.label} | |
| 78 | </Text> | |
| 79 | </Pressable> | |
| 80 | ); | |
| 81 | })} | |
| 280 | <Text | |
| 281 | className={cn( | |
| 282 | "font-t3-bold text-sm", | |
| 283 | selected | |
| 284 | ? "text-sky-700 dark:text-sky-300" | |
| 285 | : "text-neutral-600 dark:text-neutral-300", | |
| 286 | )} | |
| 287 | > | |
| 288 | {option.label} | |
| 289 | </Text> | |
| 290 | </Pressable> | |
| 291 | ); | |
| 292 | })} | |
| 293 | </View> | |
| 294 | <TextInput | |
| 295 | value={draft?.customAnswer ?? ""} | |
| 296 | onChangeText={(value) => | |
| 297 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) | |
| 298 | } | |
| 299 | onFocus={() => props.onInputFocusChange?.(true)} | |
| 300 | onBlur={() => props.onInputFocusChange?.(false)} | |
| 301 | placeholder="Or type a custom answer" | |
| 302 | className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" | |
| 303 | /> | |
| 82 | 304 | </View> |
| 83 | <TextInput | |
| 84 | value={draft?.customAnswer ?? ""} | |
| 85 | onChangeText={(value) => | |
| 86 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) | |
| 87 | } | |
| 88 | placeholder="Or type a custom answer" | |
| 89 | className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" | |
| 90 | /> | |
| 91 | </View> | |
| 92 | ); | |
| 93 | })} | |
| 305 | ); | |
| 306 | })} | |
| 307 | </ScrollView> | |
| 94 | 308 | <Pressable |
| 95 | 309 | className={cn( |
| 96 | 310 | "items-center justify-center rounded-2xl px-4 py-3.5", |
| ... | ... | @@ -103,6 +317,26 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 103 | 317 | > |
| 104 | 318 | <Text className="font-t3-extrabold text-sm text-white">Submit answers</Text> |
| 105 | 319 | </Pressable> |
| 320 | </Animated.View> | |
| 321 | ) : null; | |
| 322 | return ( | |
| 323 | <View className="relative"> | |
| 324 | {bar} | |
| 325 | {EXPANDED_CARD_IS_OVERLAY ? ( | |
| 326 | // Clipping window for the collapse slide: same footprint as the | |
| 327 | // expanded card, bottom edge on the bar's bottom edge. The sliding | |
| 328 | // card exits through the bottom edge instead of drawing over the | |
| 329 | // composer area, wiping the bar (and the transcript) into view. | |
| 330 | <View | |
| 331 | pointerEvents={props.collapsed ? "none" : "box-none"} | |
| 332 | className="absolute inset-x-0 bottom-0 justify-end overflow-hidden" | |
| 333 | style={{ height: props.maxHeight }} | |
| 334 | > | |
| 335 | {card} | |
| 336 | </View> | |
| 337 | ) : ( | |
| 338 | card | |
| 339 | )} | |
| 106 | 340 | </View> |
| 107 | 341 | ); |
| 108 | 342 | } |
apps/mobile/src/features/threads/ThreadComposer.tsx+94-13| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | serializeComposerFileLink, |
| 15 | 15 | type ComposerTrigger, |
| 16 | 16 | } from "@t3tools/shared/composerTrigger"; |
| 17 | import * as Haptics from "expo-haptics"; | |
| 17 | 18 | import type { ReactNode } from "react"; |
| 18 | 19 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 19 | 20 | import { |
| ... | ... | @@ -51,7 +52,7 @@ import { |
| 51 | 52 | ComposerToolbarScroller, |
| 52 | 53 | ComposerToolbarTrigger, |
| 53 | 54 | } from "../../components/ComposerToolbarTrigger"; |
| 54 | import { ControlPill } from "../../components/ControlPill"; | |
| 55 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | |
| 55 | 56 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 56 | 57 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 57 | 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| ... | ... | @@ -62,9 +63,13 @@ import { |
| 62 | 63 | normalizeSearchQuery, |
| 63 | 64 | scoreQueryMatch, |
| 64 | 65 | } from "@t3tools/shared/searchRanking"; |
| 65 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | |
| 66 | import { | |
| 67 | applyProviderOptionSelection, | |
| 68 | resolveProviderOptionDescriptors, | |
| 69 | } from "../../lib/providerOptions"; | |
| 66 | 70 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 67 | 71 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 72 | import { buildThreadSettingsMenu } from "./thread-settings-menu"; | |
| 68 | 73 | import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; |
| 69 | 74 | import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; |
| 70 | 75 | |
| ... | ... | @@ -113,6 +118,8 @@ export interface ThreadComposerProps { |
| 113 | 118 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| 114 | 119 | readonly onReconnectEnvironment: () => void; |
| 115 | 120 | readonly onExpandedChange?: (expanded: boolean) => void; |
| 121 | /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ | |
| 122 | readonly onEditorFocusChange?: (focused: boolean) => void; | |
| 116 | 123 | } |
| 117 | 124 | |
| 118 | 125 | /** |
| ... | ... | @@ -307,13 +314,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 307 | 314 | } |
| 308 | 315 | }, [inputRef]); |
| 309 | 316 | |
| 317 | const onEditorFocusChange = props.onEditorFocusChange; | |
| 310 | 318 | const handleFocus = useCallback(() => { |
| 311 | 319 | setIsFocused(true); |
| 312 | }, []); | |
| 320 | onEditorFocusChange?.(true); | |
| 321 | }, [onEditorFocusChange]); | |
| 313 | 322 | |
| 314 | 323 | const handleBlur = useCallback(() => { |
| 315 | 324 | setIsFocused(false); |
| 316 | }, []); | |
| 325 | onEditorFocusChange?.(false); | |
| 326 | }, [onEditorFocusChange]); | |
| 317 | 327 | const showStopAction = |
| 318 | 328 | props.selectedThread.session?.status === "running" || |
| 319 | 329 | props.selectedThread.session?.status === "starting"; |
| ... | ... | @@ -623,6 +633,61 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 623 | 633 | interactionMode: currentInteractionMode, |
| 624 | 634 | }); |
| 625 | 635 | |
| 636 | // iOS gets a native menu on the trigger pill: the everyday adjustments | |
| 637 | // apply without resigning the keyboard, while "All Settings…" (and the | |
| 638 | // Android trigger) still route through the sheet, which must dismiss it. | |
| 639 | const settingsMenu = useMemo( | |
| 640 | () => | |
| 641 | Platform.OS === "ios" | |
| 642 | ? buildThreadSettingsMenu({ | |
| 643 | providerGroups: threadProviderGroups, | |
| 644 | selectedModel: currentModelSelection, | |
| 645 | optionDescriptors: providerOptionDescriptors, | |
| 646 | runtimeMode: currentRuntimeMode, | |
| 647 | }) | |
| 648 | : null, | |
| 649 | [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode], | |
| 650 | ); | |
| 651 | ||
| 652 | const onUpdateModelSelection = props.onUpdateModelSelection; | |
| 653 | const onUpdateRuntimeMode = props.onUpdateRuntimeMode; | |
| 654 | const handleSettingsMenuAction = useCallback( | |
| 655 | (eventId: string) => { | |
| 656 | const event = settingsMenu?.events.get(eventId); | |
| 657 | if (!event) { | |
| 658 | return; | |
| 659 | } | |
| 660 | switch (event.type) { | |
| 661 | case "select-model": | |
| 662 | void Haptics.selectionAsync(); | |
| 663 | onUpdateModelSelection(event.option.selection); | |
| 664 | return; | |
| 665 | case "set-option": { | |
| 666 | const options = applyProviderOptionSelection(providerOptionDescriptors, { | |
| 667 | id: event.optionId, | |
| 668 | value: event.value, | |
| 669 | }); | |
| 670 | if (options) { | |
| 671 | void Haptics.selectionAsync(); | |
| 672 | onUpdateModelSelection({ ...currentModelSelection, options }); | |
| 673 | } | |
| 674 | return; | |
| 675 | } | |
| 676 | case "set-runtime": | |
| 677 | void Haptics.selectionAsync(); | |
| 678 | onUpdateRuntimeMode(event.mode); | |
| 679 | return; | |
| 680 | } | |
| 681 | }, | |
| 682 | [ | |
| 683 | currentModelSelection, | |
| 684 | onUpdateModelSelection, | |
| 685 | onUpdateRuntimeMode, | |
| 686 | providerOptionDescriptors, | |
| 687 | settingsMenu, | |
| 688 | ], | |
| 689 | ); | |
| 690 | ||
| 626 | 691 | return ( |
| 627 | 692 | <Animated.View |
| 628 | 693 | className="px-4" |
| ... | ... | @@ -792,15 +857,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 792 | 857 | onPress={() => void props.onPickDraftImages()} |
| 793 | 858 | showChevron={false} |
| 794 | 859 | /> |
| 795 | <ComposerToolbarTrigger | |
| 796 | accessibilityLabel="Thread settings" | |
| 797 | iconNode={ | |
| 798 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | |
| 799 | } | |
| 800 | label={settingsSummaryLabel} | |
| 801 | maxWidth={320} | |
| 802 | onPress={settingsSheetPresentation.open} | |
| 803 | /> | |
| 860 | {settingsMenu ? ( | |
| 861 | <ControlPillMenu | |
| 862 | actions={settingsMenu.actions} | |
| 863 | onPressAction={({ nativeEvent }) => handleSettingsMenuAction(nativeEvent.event)} | |
| 864 | > | |
| 865 | <ComposerToolbarTrigger | |
| 866 | accessibilityLabel="Thread settings" | |
| 867 | iconNode={ | |
| 868 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | |
| 869 | } | |
| 870 | label={settingsSummaryLabel} | |
| 871 | maxWidth={320} | |
| 872 | /> | |
| 873 | </ControlPillMenu> | |
| 874 | ) : ( | |
| 875 | <ComposerToolbarTrigger | |
| 876 | accessibilityLabel="Thread settings" | |
| 877 | iconNode={ | |
| 878 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | |
| 879 | } | |
| 880 | label={settingsSummaryLabel} | |
| 881 | maxWidth={320} | |
| 882 | onPress={settingsSheetPresentation.open} | |
| 883 | /> | |
| 884 | )} | |
| 804 | 885 | {showStopAction ? ( |
| 805 | 886 | <ComposerToolbarButton |
| 806 | 887 | accessibilityLabel="Stop" |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+328-40| ... | ... | @@ -2,6 +2,7 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect |
| 2 | 2 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; |
| 3 | 3 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 4 | 4 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 5 | import { HeaderHeightContext } from "@react-navigation/elements"; | |
| 5 | 6 | import type { |
| 6 | 7 | ApprovalRequestId, |
| 7 | 8 | EnvironmentId, |
| ... | ... | @@ -13,18 +14,45 @@ import type { |
| 13 | 14 | RuntimeMode, |
| 14 | 15 | ServerConfig as T3ServerConfig, |
| 15 | 16 | ThreadId, |
| 17 | UserInputQuestion, | |
| 16 | 18 | } from "@t3tools/contracts"; |
| 17 | 19 | import * as Haptics from "expo-haptics"; |
| 18 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 19 | import { Platform, View, type GestureResponderEvent } from "react-native"; | |
| 20 | import { | |
| 21 | memo, | |
| 22 | useCallback, | |
| 23 | useContext, | |
| 24 | useEffect, | |
| 25 | useLayoutEffect, | |
| 26 | useMemo, | |
| 27 | useRef, | |
| 28 | useState, | |
| 29 | } from "react"; | |
| 30 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | |
| 31 | import { | |
| 32 | AppState, | |
| 33 | Keyboard, | |
| 34 | Platform, | |
| 35 | useColorScheme, | |
| 36 | useWindowDimensions, | |
| 37 | View, | |
| 38 | type GestureResponderEvent, | |
| 39 | } from "react-native"; | |
| 20 | 40 | import { |
| 21 | 41 | KeyboardController, |
| 22 | 42 | KeyboardStickyView, |
| 23 | 43 | useKeyboardState, |
| 24 | 44 | } from "react-native-keyboard-controller"; |
| 25 | import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; | |
| 45 | import Animated, { | |
| 46 | Easing, | |
| 47 | FadeInDown, | |
| 48 | FadeOut, | |
| 49 | useAnimatedReaction, | |
| 50 | useSharedValue, | |
| 51 | withTiming, | |
| 52 | } from "react-native-reanimated"; | |
| 26 | 53 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 27 | 54 | |
| 55 | import { ControlPill } from "../../components/ControlPill"; | |
| 28 | 56 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 29 | 57 | import type { StatusTone } from "../../components/StatusPill"; |
| 30 | 58 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| ... | ... | @@ -38,6 +66,11 @@ import type { |
| 38 | 66 | } from "../../lib/threadActivity"; |
| 39 | 67 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 40 | 68 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 69 | import { | |
| 70 | derivePendingUserInputMaxHeight, | |
| 71 | ESTIMATED_KEYBOARD_HEIGHT, | |
| 72 | USER_INPUT_TOGGLE_DURATION_MS, | |
| 73 | } from "./pendingUserInputLayout"; | |
| 41 | 74 | import { |
| 42 | 75 | COMPOSER_COLLAPSED_CHROME, |
| 43 | 76 | COMPOSER_EXPANDED_CHROME, |
| ... | ... | @@ -58,7 +91,7 @@ export interface ThreadDetailScreenProps { |
| 58 | 91 | readonly respondingApprovalId: ApprovalRequestId | null; |
| 59 | 92 | readonly activePendingUserInput: PendingUserInput | null; |
| 60 | 93 | readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; |
| 61 | readonly activePendingUserInputAnswers: Record<string, string> | null; | |
| 94 | readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null; | |
| 62 | 95 | readonly respondingUserInputId: ApprovalRequestId | null; |
| 63 | 96 | readonly draftMessage: string; |
| 64 | 97 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| ... | ... | @@ -93,7 +126,7 @@ export interface ThreadDetailScreenProps { |
| 93 | 126 | ) => Promise<unknown>; |
| 94 | 127 | readonly onSelectUserInputOption: ( |
| 95 | 128 | requestId: ApprovalRequestId, |
| 96 | questionId: string, | |
| 129 | question: UserInputQuestion, | |
| 97 | 130 | label: string, |
| 98 | 131 | ) => void; |
| 99 | 132 | readonly onChangeUserInputCustomAnswer: ( |
| ... | ... | @@ -175,8 +208,48 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE |
| 175 | 208 | }, [threadId, feed]); |
| 176 | 209 | } |
| 177 | 210 | |
| 211 | const USER_INPUT_TOGGLE_TIMING = { | |
| 212 | duration: USER_INPUT_TOGGLE_DURATION_MS, | |
| 213 | easing: Easing.out(Easing.cubic), | |
| 214 | }; | |
| 215 | ||
| 178 | 216 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 179 | 217 | const insets = useSafeAreaInsets(); |
| 218 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | |
| 219 | const liveKeyboardHeight = useKeyboardState((state) => state.height); | |
| 220 | // Android can swallow the IME hide callbacks when the app is backgrounded | |
| 221 | // mid keyboard-hide (the reported repro: send — which blurs and starts the | |
| 222 | // hide — then Home within a second). The keyboard library's height AND | |
| 223 | // visibility then stay frozen open, so gating the sticky translation on | |
| 224 | // visibility alone still strands the composer after resume. Quarantine the | |
| 225 | // translation on every Android resume instead; any sign of a live keyboard | |
| 226 | // stream — an owned input gaining focus, or any visibility/height movement — | |
| 227 | // lifts it. A healthy resume sees no visual difference (the translation is | |
| 228 | // already zero while the keyboard is closed). | |
| 229 | const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); | |
| 230 | useEffect(() => { | |
| 231 | if (Platform.OS !== "android") { | |
| 232 | return; | |
| 233 | } | |
| 234 | const subscription = AppState.addEventListener("change", (state) => { | |
| 235 | if (state === "active") { | |
| 236 | setKeyboardStateSuspect(true); | |
| 237 | } | |
| 238 | }); | |
| 239 | return () => { | |
| 240 | subscription.remove(); | |
| 241 | }; | |
| 242 | }, []); | |
| 243 | useEffect(() => { | |
| 244 | setKeyboardStateSuspect(false); | |
| 245 | }, [isKeyboardVisible, liveKeyboardHeight]); | |
| 246 | const handleOwnedInputFocusChange = useCallback((focused: boolean) => { | |
| 247 | if (focused) { | |
| 248 | setKeyboardStateSuspect(false); | |
| 249 | } | |
| 250 | }, []); | |
| 251 | const windowHeight = useWindowDimensions().height; | |
| 252 | const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; | |
| 180 | 253 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 181 | 254 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 182 | 255 | const composerEditorRef = useRef<ComposerEditorHandle>(null); |
| ... | ... | @@ -187,11 +260,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 187 | 260 | const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null); |
| 188 | 261 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 189 | 262 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); |
| 190 | // Key the safe-area padding on keyboard visibility, not focus: on Android | |
| 191 | // the back gesture closes the keyboard while the editor stays focused, and | |
| 192 | // a focus-keyed inset would leave the toolbar under the gesture bar. | |
| 193 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | |
| 194 | const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12); | |
| 263 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); | |
| 264 | // Android keys the safe-area padding on keyboard visibility (#5988): the | |
| 265 | // back gesture closes the keyboard while the editor stays focused, and a | |
| 266 | // focus-keyed inset would leave the toolbar under the gesture bar. iOS must | |
| 267 | // NOT use visibility — it only flips on keyboardDidHide, after the hide | |
| 268 | // animation, so the composer would ride down flush to the screen edge and | |
| 269 | // then snap up into the inset. On iOS blur precedes the hide, so the | |
| 270 | // focus-keyed inset is already in place while the composer rides down. | |
| 271 | const composerBottomInset = (Platform.OS === "android" ? isKeyboardVisible : composerExpanded) | |
| 272 | ? 0 | |
| 273 | : Math.max(insets.bottom, 12); | |
| 195 | 274 | const contentPresentationKind = props.contentPresentation.kind; |
| 196 | 275 | // The raw sync status enters "synchronizing" on every full fetch, cached or |
| 197 | 276 | // not. Whether messages are already on screen decides the pill label: no |
| ... | ... | @@ -212,6 +291,41 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 212 | 291 | const selectedThreadFeed = props.selectedThreadFeed; |
| 213 | 292 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 214 | 293 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| 294 | // While a user-input request is pending, the questionnaire owns the | |
| 295 | // composer slot outright: expanded it is the full card, collapsed it is a | |
| 296 | // composer-style bar in the same place (with its own stop control). The | |
| 297 | // composer never mounts into the transition, which keeps the collapse and | |
| 298 | // keyboard animations coherent. Collapse state is keyed by request id so a | |
| 299 | // new request re-expands automatically. | |
| 300 | const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = | |
| 301 | useState<ApprovalRequestId | null>(null); | |
| 302 | const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; | |
| 303 | const userInputCollapsed = | |
| 304 | activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; | |
| 305 | // The card's height RESERVES keyboard space at all times instead of | |
| 306 | // tracking the keyboard: transforms (the sticky translation) apply | |
| 307 | // same-frame on the UI thread while layout props lag a Yoga pass behind, | |
| 308 | // so any height that follows the keyboard flashes the card over the nav | |
| 309 | // header on the way up. With a constant height the keyboard transition is | |
| 310 | // pure translation — frame-perfect by construction — and the resting card | |
| 311 | // stays compact over the transcript. Before the first open the reserve is | |
| 312 | // an estimate; once a real height is known the card corrects once, | |
| 313 | // discretely. | |
| 314 | const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); | |
| 315 | useEffect(() => { | |
| 316 | if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { | |
| 317 | setLastKnownKeyboardHeight(liveKeyboardHeight); | |
| 318 | } | |
| 319 | }, [lastKnownKeyboardHeight, liveKeyboardHeight]); | |
| 320 | const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ | |
| 321 | windowHeight, | |
| 322 | keyboardHeight: | |
| 323 | lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT, | |
| 324 | navigationHeaderHeight, | |
| 325 | // The questionnaire owns the composer slot, so only the composer's | |
| 326 | // bottom inset still overlaps. | |
| 327 | composerOverlapHeight: composerBottomInset, | |
| 328 | }); | |
| 215 | 329 | const estimatedOverlayHeight = composerOverlapHeight; |
| 216 | 330 | // The overlay's measured height includes the home-indicator inset (the |
| 217 | 331 | // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes |
| ... | ... | @@ -228,7 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 228 | 342 | Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), |
| 229 | 343 | -nativeInsetOvercount, |
| 230 | 344 | ); |
| 345 | // The expanded questionnaire is an absolute overlay on iOS, so it never | |
| 346 | // changes the measured overlay height (that constancy is what keeps the | |
| 347 | // feed from snapping on collapse/expand). The toggle choreography runs on | |
| 348 | // SHARED VALUES set directly in the tap handler — one JS hop, then the | |
| 349 | // card's rise/sink and the feed's end-inset glide animate in lockstep on | |
| 350 | // the UI thread, keyboard-style, instead of waiting on React mount + | |
| 351 | // onLayout + state round trips. Coverage (how far the card extends above | |
| 352 | // the bar) is measured straight into a shared value by the card's | |
| 353 | // onLayout, with no re-render. | |
| 354 | const userInputCardProgress = useSharedValue(1); | |
| 355 | const userInputInsetProgress = useSharedValue(1); | |
| 356 | const userInputCardCoverage = useSharedValue(0); | |
| 357 | // Android renders the expanded card in-flow (it cannot hit-test the iOS | |
| 358 | // overlay outside the bar's bounds), so its measured overlay height already | |
| 359 | // includes the card — the coverage extra is iOS-only. | |
| 360 | const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null; | |
| 361 | const combinedContentInsetEndAdjustment = useSharedValue( | |
| 362 | Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), | |
| 363 | ); | |
| 364 | useAnimatedReaction( | |
| 365 | () => | |
| 366 | contentInsetEndAdjustment.value + | |
| 367 | (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), | |
| 368 | (value) => { | |
| 369 | combinedContentInsetEndAdjustment.value = value; | |
| 370 | }, | |
| 371 | [userInputCoverageApplies], | |
| 372 | ); | |
| 231 | 373 | const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); |
| 374 | const endFollowEnabledRef = useRef(true); | |
| 375 | endFollowEnabledRef.current = endFollowEnabled; | |
| 376 | const userInputRepinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 377 | // The list's own corrections for these inset changes drift on short | |
| 378 | // content (and the error compounds across toggles), so deterministically | |
| 379 | // re-pin the end once a toggle settles: a no-op when the resting position | |
| 380 | // is already right, corrective when it is not. Follow state is re-checked | |
| 381 | // inside the callback — the user may grab the list during the settle | |
| 382 | // window, and yanking them back would override a live gesture. | |
| 383 | const scheduleUserInputRepin = useCallback( | |
| 384 | (delayMs: number) => { | |
| 385 | if (userInputRepinTimerRef.current !== null) { | |
| 386 | clearTimeout(userInputRepinTimerRef.current); | |
| 387 | } | |
| 388 | userInputRepinTimerRef.current = setTimeout(() => { | |
| 389 | userInputRepinTimerRef.current = null; | |
| 390 | if (!endFollowEnabledRef.current) { | |
| 391 | return; | |
| 392 | } | |
| 393 | void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { | |
| 394 | freeze.set(false); | |
| 395 | }); | |
| 396 | }, delayMs); | |
| 397 | }, | |
| 398 | [freeze, scrollMessageToEnd], | |
| 399 | ); | |
| 400 | useEffect( | |
| 401 | () => () => { | |
| 402 | if (userInputRepinTimerRef.current !== null) { | |
| 403 | clearTimeout(userInputRepinTimerRef.current); | |
| 404 | } | |
| 405 | }, | |
| 406 | [], | |
| 407 | ); | |
| 408 | const handleToggleUserInputCollapsed = useCallback(() => { | |
| 409 | if (activeUserInputRequestId === null) { | |
| 410 | return; | |
| 411 | } | |
| 412 | if (userInputCollapsed) { | |
| 413 | // Expanding: card and feed glide start NOW, on the UI thread. | |
| 414 | userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); | |
| 415 | userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); | |
| 416 | setCollapsedUserInputRequestId(null); | |
| 417 | scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); | |
| 418 | } else { | |
| 419 | // Collapsing hides the custom-answer inputs; release the keyboard with | |
| 420 | // them instead of leaving it up over a dead responder. | |
| 421 | Keyboard.dismiss(); | |
| 422 | userInputCardProgress.value = withTiming(0, USER_INPUT_TOGGLE_TIMING); | |
| 423 | // Instant: the sinking card still covers the strip being revealed, and | |
| 424 | // animating the inset downward is what drifted the short-content end | |
| 425 | // anchor. | |
| 426 | userInputInsetProgress.value = 0; | |
| 427 | setCollapsedUserInputRequestId(activeUserInputRequestId); | |
| 428 | scheduleUserInputRepin(60); | |
| 429 | } | |
| 430 | }, [ | |
| 431 | activeUserInputRequestId, | |
| 432 | scheduleUserInputRepin, | |
| 433 | userInputCardProgress, | |
| 434 | userInputCollapsed, | |
| 435 | userInputInsetProgress, | |
| 436 | ]); | |
| 437 | useEffect(() => { | |
| 438 | // A new request always arrives expanded. | |
| 439 | userInputCardProgress.value = 1; | |
| 440 | userInputInsetProgress.value = 1; | |
| 441 | }, [activeUserInputRequestId, userInputCardProgress, userInputInsetProgress]); | |
| 232 | 442 | const showContent = props.showContent ?? true; |
| 233 | 443 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 234 | 444 | const isSplitLayout = layoutVariant === "split"; |
| ... | ... | @@ -249,6 +459,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 249 | 459 | useEffect(() => { |
| 250 | 460 | setAnchorMessageId(null); |
| 251 | 461 | lastScrolledAnchorMessageIdRef.current = null; |
| 462 | setEndFollowEnabled(true); | |
| 252 | 463 | freeze.set(false); |
| 253 | 464 | }, [freeze, selectedThreadKey]); |
| 254 | 465 | |
| ... | ... | @@ -320,6 +531,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 320 | 531 | composerEditorRef.current?.blur(); |
| 321 | 532 | }, []); |
| 322 | 533 | |
| 534 | const handleScrollToEnd = useCallback(() => { | |
| 535 | void Haptics.selectionAsync(); | |
| 536 | void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { | |
| 537 | freeze.set(false); | |
| 538 | }); | |
| 539 | }, [freeze, scrollMessageToEnd]); | |
| 540 | ||
| 541 | const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; | |
| 542 | const isDarkMode = useColorScheme() === "dark"; | |
| 543 | ||
| 323 | 544 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { |
| 324 | 545 | feedTouchStartRef.current = { |
| 325 | 546 | pageX: event.nativeEvent.pageX, |
| ... | ... | @@ -373,13 +594,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 373 | 594 | listRef={listRef} |
| 374 | 595 | freeze={freeze} |
| 375 | 596 | anchorMessageId={anchorMessageId} |
| 376 | contentInsetEndAdjustment={contentInsetEndAdjustment} | |
| 597 | contentInsetEndAdjustment={combinedContentInsetEndAdjustment} | |
| 377 | 598 | contentTopInset={0} |
| 378 | 599 | contentBottomInset={estimatedOverlayHeight} |
| 379 | 600 | contentMaxWidth={contentMaxWidth} |
| 380 | 601 | layoutVariant={layoutVariant} |
| 381 | 602 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} |
| 382 | 603 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} |
| 604 | onEndFollowEnabledChange={setEndFollowEnabled} | |
| 383 | 605 | skills={selectedProviderSkills} |
| 384 | 606 | loadEarlier={props.loadEarlier ?? null} |
| 385 | 607 | /> |
| ... | ... | @@ -391,6 +613,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 391 | 613 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} |
| 392 | 614 | {showContent ? ( |
| 393 | 615 | <KeyboardStickyView |
| 616 | // The animated keyboard height can remain stale after a dismissed | |
| 617 | // IME on both platforms. Visibility is the authoritative closed | |
| 618 | // state, so disable the translation rather than stranding the pill. | |
| 619 | enabled={isKeyboardVisible && !keyboardStateSuspect} | |
| 394 | 620 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 395 | 621 | offset={{ closed: 0, opened: 0 }} |
| 396 | 622 | > |
| ... | ... | @@ -398,10 +624,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 398 | 624 | list's bottom inset, so any padding above the pill/composer |
| 399 | 625 | pushes the resting content floor up by the same amount. */} |
| 400 | 626 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> |
| 627 | {showScrollToEndButton ? ( | |
| 628 | <Animated.View | |
| 629 | pointerEvents="box-none" | |
| 630 | className="absolute -top-11 left-0 right-0 z-20 items-center" | |
| 631 | entering={FadeInDown.duration(160)} | |
| 632 | exiting={FadeOut.duration(100)} | |
| 633 | > | |
| 634 | {isLiquidGlassSupported ? ( | |
| 635 | <LiquidGlassView | |
| 636 | colorScheme={isDarkMode ? "dark" : "light"} | |
| 637 | effect="regular" | |
| 638 | interactive | |
| 639 | // Interactive glass can render larger than the requested | |
| 640 | // box (minimum touch size), so center the pill instead of | |
| 641 | // relying on it filling the glass exactly. | |
| 642 | style={{ | |
| 643 | alignItems: "center", | |
| 644 | borderRadius: 18, | |
| 645 | height: 36, | |
| 646 | justifyContent: "center", | |
| 647 | overflow: "hidden", | |
| 648 | width: 36, | |
| 649 | }} | |
| 650 | > | |
| 651 | <ControlPill | |
| 652 | accessibilityLabel="Scroll to end" | |
| 653 | activateOnPressIn | |
| 654 | className="h-9 w-9 bg-transparent" | |
| 655 | icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} | |
| 656 | onPress={handleScrollToEnd} | |
| 657 | /> | |
| 658 | </LiquidGlassView> | |
| 659 | ) : ( | |
| 660 | <ControlPill | |
| 661 | accessibilityLabel="Scroll to end" | |
| 662 | activateOnPressIn | |
| 663 | className="h-9 w-9 border border-border bg-card shadow-md shadow-black/10" | |
| 664 | icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} | |
| 665 | onPress={handleScrollToEnd} | |
| 666 | /> | |
| 667 | )} | |
| 668 | </Animated.View> | |
| 669 | ) : null} | |
| 401 | 670 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> |
| 402 | 671 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 403 | 672 | <Animated.View |
| 404 | 673 | className="shrink-0 gap-3 px-4 pb-3" |
| 674 | // The questionnaire replaces the composer, so it must pad | |
| 675 | // the home indicator the composer normally covers. | |
| 676 | style={ | |
| 677 | activeUserInputRequestId !== null | |
| 678 | ? { paddingBottom: composerBottomInset } | |
| 679 | : undefined | |
| 680 | } | |
| 405 | 681 | entering={FadeInDown.duration(220)} |
| 406 | 682 | exiting={FadeOut.duration(140)} |
| 407 | 683 | > |
| ... | ... | @@ -415,6 +691,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 415 | 691 | {props.activePendingUserInput ? ( |
| 416 | 692 | <PendingUserInputCard |
| 417 | 693 | pendingUserInput={props.activePendingUserInput} |
| 694 | maxHeight={pendingUserInputMaxHeight} | |
| 695 | collapsed={userInputCollapsed} | |
| 696 | onToggleCollapsed={handleToggleUserInputCollapsed} | |
| 697 | onStopThread={props.onStopThread} | |
| 698 | cardProgress={userInputCardProgress} | |
| 699 | cardCoverage={userInputCardCoverage} | |
| 700 | onInputFocusChange={handleOwnedInputFocusChange} | |
| 418 | 701 | drafts={props.activePendingUserInputDrafts} |
| 419 | 702 | answers={props.activePendingUserInputAnswers} |
| 420 | 703 | respondingUserInputId={props.respondingUserInputId} |
| ... | ... | @@ -427,35 +710,40 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 427 | 710 | ) : null} |
| 428 | 711 | </View> |
| 429 | 712 | |
| 430 | <ThreadComposer | |
| 431 | editorRef={composerEditorRef} | |
| 432 | draftMessage={props.draftMessage} | |
| 433 | draftAttachments={props.draftAttachments} | |
| 434 | placeholder="Ask the repo agent, or run a command…" | |
| 435 | contentMaxWidth={contentMaxWidth} | |
| 436 | connectionState={props.connectionStateLabel} | |
| 437 | connectionError={props.connectionError} | |
| 438 | environmentLabel={props.environmentLabel} | |
| 439 | threadSyncPhase={threadSyncPhase} | |
| 440 | selectedThread={props.selectedThread} | |
| 441 | serverConfig={props.serverConfig} | |
| 442 | queueCount={props.selectedThreadQueueCount} | |
| 443 | activeThreadBusy={props.activeThreadBusy} | |
| 444 | environmentId={props.environmentId} | |
| 445 | projectCwd={props.projectWorkspaceRoot} | |
| 446 | bottomInset={composerBottomInset} | |
| 447 | onChangeDraftMessage={props.onChangeDraftMessage} | |
| 448 | onPickDraftImages={props.onPickDraftImages} | |
| 449 | onNativePasteImages={props.onNativePasteImages} | |
| 450 | onRemoveDraftImage={props.onRemoveDraftImage} | |
| 451 | onStopThread={props.onStopThread} | |
| 452 | onSendMessage={handleSendMessage} | |
| 453 | onReconnectEnvironment={props.onReconnectEnvironment} | |
| 454 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 455 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 456 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 457 | onExpandedChange={setComposerExpanded} | |
| 458 | /> | |
| 713 | {/* Hidden (not unmounted) while a user-input request owns the | |
| 714 | composer slot, so composer drafts and editor state survive. */} | |
| 715 | <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}> | |
| 716 | <ThreadComposer | |
| 717 | editorRef={composerEditorRef} | |
| 718 | draftMessage={props.draftMessage} | |
| 719 | draftAttachments={props.draftAttachments} | |
| 720 | placeholder="Ask the repo agent, or run a command…" | |
| 721 | contentMaxWidth={contentMaxWidth} | |
| 722 | connectionState={props.connectionStateLabel} | |
| 723 | connectionError={props.connectionError} | |
| 724 | environmentLabel={props.environmentLabel} | |
| 725 | threadSyncPhase={threadSyncPhase} | |
| 726 | selectedThread={props.selectedThread} | |
| 727 | serverConfig={props.serverConfig} | |
| 728 | queueCount={props.selectedThreadQueueCount} | |
| 729 | activeThreadBusy={props.activeThreadBusy} | |
| 730 | environmentId={props.environmentId} | |
| 731 | projectCwd={props.projectWorkspaceRoot} | |
| 732 | bottomInset={composerBottomInset} | |
| 733 | onChangeDraftMessage={props.onChangeDraftMessage} | |
| 734 | onPickDraftImages={props.onPickDraftImages} | |
| 735 | onNativePasteImages={props.onNativePasteImages} | |
| 736 | onRemoveDraftImage={props.onRemoveDraftImage} | |
| 737 | onStopThread={props.onStopThread} | |
| 738 | onSendMessage={handleSendMessage} | |
| 739 | onReconnectEnvironment={props.onReconnectEnvironment} | |
| 740 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 741 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 742 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 743 | onExpandedChange={setComposerExpanded} | |
| 744 | onEditorFocusChange={handleOwnedInputFocusChange} | |
| 745 | /> | |
| 746 | </View> | |
| 459 | 747 | </View> |
| 460 | 748 | </KeyboardStickyView> |
| 461 | 749 | ) : null} |
apps/mobile/src/features/threads/ThreadFeed.tsx+94-37| ... | ... | @@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 48 | 48 | import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; |
| 49 | 49 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 50 | 50 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 51 | import { scopedThreadKey } from "../../lib/scopedEntities"; | |
| 51 | 52 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 52 | 53 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 53 | 54 | import { |
| ... | ... | @@ -89,6 +90,10 @@ import { |
| 89 | 90 | type ThreadFeedLatestTurn, |
| 90 | 91 | } from "../../lib/threadActivity"; |
| 91 | 92 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 93 | import { | |
| 94 | resolveThreadFeedLiveFollow, | |
| 95 | type ThreadFeedLiveFollowEvent, | |
| 96 | } from "./thread-feed-live-follow"; | |
| 92 | 97 | import { |
| 93 | 98 | collapsedWorkLogHeight, |
| 94 | 99 | ThreadWorkGroupToggle, |
| ... | ... | @@ -149,6 +154,7 @@ export interface ThreadFeedProps { |
| 149 | 154 | readonly layoutVariant?: LayoutVariant; |
| 150 | 155 | readonly usesAutomaticContentInsets?: boolean; |
| 151 | 156 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; |
| 157 | readonly onEndFollowEnabledChange?: (enabled: boolean) => void; | |
| 152 | 158 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 153 | 159 | /** Non-null when older turns exist beyond the loaded window. */ |
| 154 | 160 | readonly loadEarlier?: { |
| ... | ... | @@ -1324,6 +1330,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1324 | 1330 | const disclosureAnchorKeyRef = useRef<string | null>(null); |
| 1325 | 1331 | const headerMaterialVisibleRef = useRef(false); |
| 1326 | 1332 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1333 | const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 1327 | 1334 | const { width: windowWidth } = useWindowDimensions(); |
| 1328 | 1335 | const { appearance } = useAppearancePreferences(); |
| 1329 | 1336 | const [viewportWidth, setViewportWidth] = useState(() => |
| ... | ... | @@ -1342,13 +1349,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1342 | 1349 | // momentum; only motion inside a session can break follow, so MVCP |
| 1343 | 1350 | // compensations and programmatic scrolls never strand a follower. |
| 1344 | 1351 | const userScrollSessionRef = useRef(false); |
| 1345 | const setEndFollow = useCallback((enabled: boolean) => { | |
| 1346 | if (endFollowEnabledRef.current === enabled) { | |
| 1347 | return; | |
| 1348 | } | |
| 1349 | endFollowEnabledRef.current = enabled; | |
| 1350 | setEndFollowEnabled(enabled); | |
| 1351 | }, []); | |
| 1352 | const setEndFollow = useCallback( | |
| 1353 | (enabled: boolean) => { | |
| 1354 | if (endFollowEnabledRef.current === enabled) { | |
| 1355 | return; | |
| 1356 | } | |
| 1357 | endFollowEnabledRef.current = enabled; | |
| 1358 | setEndFollowEnabled(enabled); | |
| 1359 | props.onEndFollowEnabledChange?.(enabled); | |
| 1360 | }, | |
| 1361 | [props.onEndFollowEnabledChange], | |
| 1362 | ); | |
| 1363 | const transitionEndFollow = useCallback( | |
| 1364 | (event: ThreadFeedLiveFollowEvent) => { | |
| 1365 | setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); | |
| 1366 | }, | |
| 1367 | [setEndFollow], | |
| 1368 | ); | |
| 1352 | 1369 | const [interactionState, setInteractionState] = useState<{ |
| 1353 | 1370 | readonly copiedRowId: string | null; |
| 1354 | 1371 | readonly expandedWorkGroups: Record<string, boolean>; |
| ... | ... | @@ -1460,40 +1477,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1460 | 1477 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the |
| 1461 | 1478 | // header height back or the material toggles a full header too late. |
| 1462 | 1479 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); |
| 1463 | // Latch bookkeeping. LegendList recomputes its inset-aware end distance | |
| 1464 | // before invoking this handler, so getState() is current. Returning to | |
| 1465 | // the end re-arms follow no matter who scrolled (the user, or our own | |
| 1466 | // scroll-to-end); moving away breaks it only during a user-initiated | |
| 1467 | // scroll session, so MVCP compensations and programmatic repositioning | |
| 1468 | // can never strand a follower. | |
| 1480 | // LegendList recomputes its inset-aware end distance before invoking | |
| 1481 | // this handler, so getState() is current. Only the actual end re-arms | |
| 1482 | // follow: its broader maintain-scroll threshold is large enough for a | |
| 1483 | // streaming chunk to pull a user back before their upward drag escapes. | |
| 1484 | // A live user-scroll session still wins even if the first scroll event | |
| 1485 | // remains inside LegendList's at-end tolerance. | |
| 1469 | 1486 | const listState = props.listRef.current?.getState(); |
| 1470 | 1487 | if (listState) { |
| 1471 | if (listState.isWithinMaintainScrollAtEndThreshold) { | |
| 1472 | setEndFollow(true); | |
| 1473 | } else if (userScrollSessionRef.current) { | |
| 1474 | setEndFollow(false); | |
| 1475 | } | |
| 1488 | transitionEndFollow({ | |
| 1489 | type: "scroll", | |
| 1490 | isAtEnd: listState.isAtEnd, | |
| 1491 | userScrollSessionActive: userScrollSessionRef.current, | |
| 1492 | }); | |
| 1476 | 1493 | } |
| 1477 | 1494 | }, |
| 1478 | [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, setEndFollow], | |
| 1495 | [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], | |
| 1479 | 1496 | ); |
| 1497 | const clearUserScrollSettle = useCallback(() => { | |
| 1498 | if (userScrollSettleTimerRef.current !== null) { | |
| 1499 | clearTimeout(userScrollSettleTimerRef.current); | |
| 1500 | userScrollSettleTimerRef.current = null; | |
| 1501 | } | |
| 1502 | }, []); | |
| 1480 | 1503 | const handleScrollBeginDrag = useCallback(() => { |
| 1504 | clearUserScrollSettle(); | |
| 1481 | 1505 | userScrollSessionRef.current = true; |
| 1482 | }, []); | |
| 1483 | // The session must survive past finger-lift so momentum that carries the | |
| 1484 | // user away from the end still breaks follow; a drag released with no | |
| 1485 | // momentum ends its session at the release itself, otherwise at momentum | |
| 1486 | // end. Leaving a session open would let a later animated maintain-scroll | |
| 1487 | // read as user motion and break follow spuriously. | |
| 1488 | const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { | |
| 1489 | const velocity = event.nativeEvent.velocity?.y ?? 0; | |
| 1490 | if (Math.abs(velocity) < 0.05) { | |
| 1506 | // Pause before the first scroll event. Otherwise a stream update can run | |
| 1507 | // maintainScrollAtEnd between touch-down and the drag leaving its threshold. | |
| 1508 | transitionEndFollow({ type: "user-scroll-begin" }); | |
| 1509 | }, [clearUserScrollSettle, transitionEndFollow]); | |
| 1510 | const finishUserScroll = useCallback( | |
| 1511 | (releaseIsAtEnd?: boolean) => { | |
| 1512 | clearUserScrollSettle(); | |
| 1513 | const userScrollSessionActive = userScrollSessionRef.current; | |
| 1491 | 1514 | userScrollSessionRef.current = false; |
| 1515 | transitionEndFollow({ | |
| 1516 | type: "user-scroll-end", | |
| 1517 | // With no momentum, preserve the finger-release position. Streaming | |
| 1518 | // growth during the native momentum-detection window must not turn a | |
| 1519 | // release at the live edge into an opt-out from follow. | |
| 1520 | isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false, | |
| 1521 | userScrollSessionActive, | |
| 1522 | }); | |
| 1523 | }, | |
| 1524 | [clearUserScrollSettle, props.listRef, transitionEndFollow], | |
| 1525 | ); | |
| 1526 | // Finger-lift velocity is not a reliable momentum signal: a gentle fling | |
| 1527 | // can report zero and still decelerate. Give native momentum a short window | |
| 1528 | // to announce itself; if it does, onMomentumScrollBegin cancels this fallback | |
| 1529 | // and the session survives until the settled momentum-end position. This | |
| 1530 | // mirrors the native-event handoff used by the home thread list's scroll gate. | |
| 1531 | const handleScrollEndDrag = useCallback(() => { | |
| 1532 | clearUserScrollSettle(); | |
| 1533 | const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false; | |
| 1534 | userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160); | |
| 1535 | }, [clearUserScrollSettle, finishUserScroll, props.listRef]); | |
| 1536 | const handleMomentumScrollBegin = useCallback(() => { | |
| 1537 | if (userScrollSessionRef.current) { | |
| 1538 | clearUserScrollSettle(); | |
| 1492 | 1539 | } |
| 1493 | }, []); | |
| 1540 | }, [clearUserScrollSettle]); | |
| 1494 | 1541 | const handleMomentumScrollEnd = useCallback(() => { |
| 1495 | userScrollSessionRef.current = false; | |
| 1496 | }, []); | |
| 1542 | finishUserScroll(); | |
| 1543 | }, [finishUserScroll]); | |
| 1544 | ||
| 1545 | useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); | |
| 1497 | 1546 | |
| 1498 | 1547 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { |
| 1499 | 1548 | const nextWidth = Math.round(event.nativeEvent.layout.width); |
| ... | ... | @@ -1502,23 +1551,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1502 | 1551 | setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); |
| 1503 | 1552 | }, []); |
| 1504 | 1553 | |
| 1554 | // Thread identity is env-scoped: two environments can hold the same | |
| 1555 | // ThreadId, and keying resets (or the list mount) on the bare id would | |
| 1556 | // carry stale scroll/follow state across an environment switch. | |
| 1557 | const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); | |
| 1558 | ||
| 1505 | 1559 | useEffect(() => { |
| 1506 | 1560 | reportHeaderMaterialVisibility(false); |
| 1507 | }, [props.threadId, reportHeaderMaterialVisibility]); | |
| 1561 | }, [feedThreadKey, reportHeaderMaterialVisibility]); | |
| 1508 | 1562 | |
| 1509 | 1563 | // A thread switch opens pinned to the end; a send explicitly returns to the |
| 1510 | 1564 | // live edge (ThreadDetailScreen scrolls the new message into place). Both |
| 1511 | 1565 | // re-arm follow regardless of where the user had scrolled before. |
| 1512 | 1566 | useEffect(() => { |
| 1567 | clearUserScrollSettle(); | |
| 1513 | 1568 | userScrollSessionRef.current = false; |
| 1514 | setEndFollow(true); | |
| 1515 | }, [props.threadId, setEndFollow]); | |
| 1569 | transitionEndFollow({ type: "reset" }); | |
| 1570 | }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); | |
| 1516 | 1571 | useEffect(() => { |
| 1517 | 1572 | if (props.anchorMessageId !== null) { |
| 1573 | clearUserScrollSettle(); | |
| 1518 | 1574 | userScrollSessionRef.current = false; |
| 1519 | setEndFollow(true); | |
| 1575 | transitionEndFollow({ type: "reset" }); | |
| 1520 | 1576 | } |
| 1521 | }, [props.anchorMessageId, setEndFollow]); | |
| 1577 | }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); | |
| 1522 | 1578 | |
| 1523 | 1579 | const expandedWorkGroupIds = useMemo(() => { |
| 1524 | 1580 | const ids = new Set<string>(); |
| ... | ... | @@ -1554,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1554 | 1610 | // initial scroll-to-end computes with a zero end inset and rests one |
| 1555 | 1611 | // composer-height short of the end. Layout effect: it must land before the |
| 1556 | 1612 | // list's first positioning tick or the one-shot initial scroll misses it. |
| 1557 | const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; | |
| 1613 | const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; | |
| 1558 | 1614 | useLayoutEffect(() => { |
| 1559 | 1615 | const bottom = props.contentInsetEndAdjustment.value; |
| 1560 | 1616 | if (bottom > 0) { |
| ... | ... | @@ -1921,6 +1977,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1921 | 1977 | onScroll={handleScroll} |
| 1922 | 1978 | onScrollBeginDrag={handleScrollBeginDrag} |
| 1923 | 1979 | onScrollEndDrag={handleScrollEndDrag} |
| 1980 | onMomentumScrollBegin={handleMomentumScrollBegin} | |
| 1924 | 1981 | onMomentumScrollEnd={handleMomentumScrollEnd} |
| 1925 | 1982 | scrollEventThrottle={16} |
| 1926 | 1983 | ListHeaderComponent={ |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+1-27| ... | ... | @@ -30,6 +30,7 @@ import { cn } from "../../lib/cn"; |
| 30 | 30 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 31 | 31 | import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; |
| 32 | 32 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 33 | import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; | |
| 33 | 34 | import { pendingModelAfterPress } from "./thread-settings-sheet-state"; |
| 34 | 35 | import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; |
| 35 | 36 | |
| ... | ... | @@ -40,26 +41,6 @@ import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet |
| 40 | 41 | */ |
| 41 | 42 | const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]); |
| 42 | 43 | |
| 43 | /** | |
| 44 | * Desktop-oriented effort keywords that don't belong in the phone picker. | |
| 45 | * Prompt-injected values (ultrathink and friends) are filtered from the | |
| 46 | * descriptor metadata; ultracode is a real option but a workflow trigger, not | |
| 47 | * a reasoning level. A value set elsewhere still displays, it just isn't | |
| 48 | * offered. | |
| 49 | */ | |
| 50 | const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]); | |
| 51 | ||
| 52 | const RUNTIME_MODE_CHOICES: ReadonlyArray<{ | |
| 53 | readonly mode: RuntimeMode; | |
| 54 | readonly label: string; | |
| 55 | readonly shortLabel: string; | |
| 56 | }> = [ | |
| 57 | { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, | |
| 58 | { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, | |
| 59 | { mode: "auto", label: "Auto", shortLabel: "Auto" }, | |
| 60 | { mode: "full-access", label: "Full access", shortLabel: "Full" }, | |
| 61 | ]; | |
| 62 | ||
| 63 | 44 | /** |
| 64 | 45 | * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, |
| 65 | 46 | * covering model, provider options, runtime mode, and plan mode in one label. |
| ... | ... | @@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: { |
| 79 | 60 | ].join(" · "); |
| 80 | 61 | } |
| 81 | 62 | |
| 82 | function selectableChoices(descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>) { | |
| 83 | const injected = new Set(descriptor.promptInjectedValues ?? []); | |
| 84 | return descriptor.options.filter( | |
| 85 | (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), | |
| 86 | ); | |
| 87 | } | |
| 88 | ||
| 89 | 63 | function ModelRow(props: { |
| 90 | 64 | readonly option: ModelOption; |
| 91 | 65 | readonly selected: boolean; |
apps/mobile/src/features/threads/pendingUserInputLayout.test.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout"; | |
| 4 | ||
| 5 | describe("derivePendingUserInputMaxHeight", () => { | |
| 6 | it("caps a tall portrait viewport", () => { | |
| 7 | expect( | |
| 8 | derivePendingUserInputMaxHeight({ | |
| 9 | windowHeight: 932, | |
| 10 | keyboardHeight: 0, | |
| 11 | navigationHeaderHeight: 103, | |
| 12 | composerOverlapHeight: 94, | |
| 13 | }), | |
| 14 | ).toBe(560); | |
| 15 | }); | |
| 16 | ||
| 17 | it("subtracts the keyboard while editing a custom answer", () => { | |
| 18 | expect( | |
| 19 | derivePendingUserInputMaxHeight({ | |
| 20 | windowHeight: 932, | |
| 21 | keyboardHeight: 336, | |
| 22 | navigationHeaderHeight: 103, | |
| 23 | composerOverlapHeight: 94, | |
| 24 | }), | |
| 25 | ).toBe(387); | |
| 26 | }); | |
| 27 | ||
| 28 | it("keeps the fixed action area usable in a short keyboard-open viewport", () => { | |
| 29 | expect( | |
| 30 | derivePendingUserInputMaxHeight({ | |
| 31 | windowHeight: 375, | |
| 32 | keyboardHeight: 240, | |
| 33 | navigationHeaderHeight: 44, | |
| 34 | composerOverlapHeight: 94, | |
| 35 | }), | |
| 36 | ).toBe(160); | |
| 37 | }); | |
| 38 | }); |
apps/mobile/src/features/threads/pendingUserInputLayout.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | const PENDING_USER_INPUT_MAX_HEIGHT = 560; | |
| 2 | const PENDING_USER_INPUT_MIN_HEIGHT = 160; | |
| 3 | const PENDING_USER_INPUT_VERTICAL_GAP = 12; | |
| 4 | ||
| 5 | /** | |
| 6 | * Reserve for a portrait iPhone keyboard with the QuickType bar until a real | |
| 7 | * height has been observed. Overestimating only costs card height; an | |
| 8 | * underestimate would let the card overshoot on the first keyboard open. | |
| 9 | */ | |
| 10 | export const ESTIMATED_KEYBOARD_HEIGHT = 336; | |
| 11 | ||
| 12 | /** | |
| 13 | * One clock for the questionnaire expand/collapse choreography: the card's | |
| 14 | * enter/exit and the feed-inset glide must share it or they visibly drift. | |
| 15 | * Sized for the near-full-height slide (the card travels its own height), | |
| 16 | * in the same class as the iOS keyboard's ~250ms. | |
| 17 | */ | |
| 18 | export const USER_INPUT_TOGGLE_DURATION_MS = 220; | |
| 19 | ||
| 20 | export function derivePendingUserInputMaxHeight(input: { | |
| 21 | readonly windowHeight: number; | |
| 22 | readonly keyboardHeight: number; | |
| 23 | readonly navigationHeaderHeight: number; | |
| 24 | readonly composerOverlapHeight: number; | |
| 25 | }): number { | |
| 26 | const availableHeight = | |
| 27 | input.windowHeight - | |
| 28 | Math.max(0, input.keyboardHeight) - | |
| 29 | Math.max(0, input.navigationHeaderHeight) - | |
| 30 | Math.max(0, input.composerOverlapHeight) - | |
| 31 | PENDING_USER_INPUT_VERTICAL_GAP; | |
| 32 | ||
| 33 | return Math.min( | |
| 34 | PENDING_USER_INPUT_MAX_HEIGHT, | |
| 35 | Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight), | |
| 36 | ); | |
| 37 | } |
apps/mobile/src/features/threads/thread-feed-live-follow.test.ts created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; | |
| 4 | ||
| 5 | describe("resolveThreadFeedLiveFollow", () => { | |
| 6 | it("pauses immediately when the user starts scrolling", () => { | |
| 7 | expect(resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" })).toBe(false); | |
| 8 | }); | |
| 9 | ||
| 10 | it("stays paused away from the actual end", () => { | |
| 11 | expect( | |
| 12 | resolveThreadFeedLiveFollow(false, { | |
| 13 | type: "scroll", | |
| 14 | isAtEnd: false, | |
| 15 | userScrollSessionActive: true, | |
| 16 | }), | |
| 17 | ).toBe(false); | |
| 18 | }); | |
| 19 | ||
| 20 | it("does not mistake programmatic layout compensation for a user scroll", () => { | |
| 21 | expect( | |
| 22 | resolveThreadFeedLiveFollow(true, { | |
| 23 | type: "scroll", | |
| 24 | isAtEnd: false, | |
| 25 | userScrollSessionActive: false, | |
| 26 | }), | |
| 27 | ).toBe(true); | |
| 28 | }); | |
| 29 | ||
| 30 | it("does not re-arm at the end while a user scroll session is active", () => { | |
| 31 | expect( | |
| 32 | resolveThreadFeedLiveFollow(false, { | |
| 33 | type: "scroll", | |
| 34 | isAtEnd: true, | |
| 35 | userScrollSessionActive: true, | |
| 36 | }), | |
| 37 | ).toBe(false); | |
| 38 | }); | |
| 39 | ||
| 40 | it("re-arms at the actual end only after the user scroll session ends", () => { | |
| 41 | expect( | |
| 42 | resolveThreadFeedLiveFollow(false, { | |
| 43 | type: "user-scroll-end", | |
| 44 | isAtEnd: true, | |
| 45 | userScrollSessionActive: true, | |
| 46 | }), | |
| 47 | ).toBe(true); | |
| 48 | expect( | |
| 49 | resolveThreadFeedLiveFollow(false, { | |
| 50 | type: "user-scroll-end", | |
| 51 | isAtEnd: false, | |
| 52 | userScrollSessionActive: true, | |
| 53 | }), | |
| 54 | ).toBe(false); | |
| 55 | }); | |
| 56 | ||
| 57 | it("ignores momentum-end events from programmatic scrolling", () => { | |
| 58 | expect( | |
| 59 | resolveThreadFeedLiveFollow(true, { | |
| 60 | type: "user-scroll-end", | |
| 61 | isAtEnd: false, | |
| 62 | userScrollSessionActive: false, | |
| 63 | }), | |
| 64 | ).toBe(true); | |
| 65 | }); | |
| 66 | ||
| 67 | it("re-arms after an explicit reset", () => { | |
| 68 | expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true); | |
| 69 | }); | |
| 70 | }); |
apps/mobile/src/features/threads/thread-feed-live-follow.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | export type ThreadFeedLiveFollowEvent = | |
| 2 | | { readonly type: "reset" } | |
| 3 | | { readonly type: "user-scroll-begin" } | |
| 4 | | { | |
| 5 | readonly type: "user-scroll-end"; | |
| 6 | readonly isAtEnd: boolean; | |
| 7 | readonly userScrollSessionActive: boolean; | |
| 8 | } | |
| 9 | | { | |
| 10 | readonly type: "scroll"; | |
| 11 | readonly isAtEnd: boolean; | |
| 12 | readonly userScrollSessionActive: boolean; | |
| 13 | }; | |
| 14 | ||
| 15 | export function resolveThreadFeedLiveFollow( | |
| 16 | current: boolean, | |
| 17 | event: ThreadFeedLiveFollowEvent, | |
| 18 | ): boolean { | |
| 19 | switch (event.type) { | |
| 20 | case "reset": | |
| 21 | return true; | |
| 22 | case "user-scroll-begin": | |
| 23 | return false; | |
| 24 | case "user-scroll-end": | |
| 25 | return event.userScrollSessionActive ? event.isAtEnd : current; | |
| 26 | case "scroll": | |
| 27 | if (event.userScrollSessionActive) { | |
| 28 | return false; | |
| 29 | } | |
| 30 | if (event.isAtEnd) { | |
| 31 | return true; | |
| 32 | } | |
| 33 | return current; | |
| 34 | } | |
| 35 | } |
apps/mobile/src/features/threads/thread-settings-menu.test.ts created+284| ... | ... | @@ -0,0 +1,284 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | |
| 6 | import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; | |
| 7 | ||
| 8 | function modelOption( | |
| 9 | model: string, | |
| 10 | overrides: Partial<Pick<ModelOption, "isDefault" | "isLegacy" | "providerKey">> = {}, | |
| 11 | ): ModelOption { | |
| 12 | const providerKey = overrides.providerKey ?? "codex"; | |
| 13 | return { | |
| 14 | key: `${providerKey}:${model}`, | |
| 15 | label: model, | |
| 16 | subtitle: providerKey, | |
| 17 | providerKey, | |
| 18 | providerLabel: providerKey === "codex" ? "Codex" : "Claude", | |
| 19 | providerDriver: providerKey === "codex" ? "codex" : "claudeAgent", | |
| 20 | isDefault: overrides.isDefault ?? false, | |
| 21 | isLegacy: overrides.isLegacy ?? false, | |
| 22 | capabilities: null, | |
| 23 | selection: { | |
| 24 | instanceId: ProviderInstanceId.make(providerKey), | |
| 25 | model, | |
| 26 | options: [], | |
| 27 | }, | |
| 28 | }; | |
| 29 | } | |
| 30 | ||
| 31 | function group(models: ReadonlyArray<ModelOption>): ProviderGroup { | |
| 32 | const first = models[0]; | |
| 33 | if (!first) { | |
| 34 | throw new Error("group requires at least one model"); | |
| 35 | } | |
| 36 | return { | |
| 37 | providerKey: first.providerKey, | |
| 38 | providerLabel: first.providerLabel, | |
| 39 | models, | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | const effortDescriptor: ProviderOptionDescriptor = { | |
| 44 | id: "effort", | |
| 45 | label: "Reasoning", | |
| 46 | type: "select", | |
| 47 | options: [ | |
| 48 | { id: "low", label: "Low" }, | |
| 49 | { id: "medium", label: "Medium", isDefault: true }, | |
| 50 | { id: "high", label: "High" }, | |
| 51 | { id: "ultrathink", label: "Ultrathink" }, | |
| 52 | { id: "ultracode", label: "Ultracode" }, | |
| 53 | ], | |
| 54 | currentValue: "high", | |
| 55 | promptInjectedValues: ["ultrathink"], | |
| 56 | }; | |
| 57 | ||
| 58 | const fastModeDescriptor: ProviderOptionDescriptor = { | |
| 59 | id: "fastMode", | |
| 60 | label: "Fast mode", | |
| 61 | type: "boolean", | |
| 62 | currentValue: false, | |
| 63 | }; | |
| 64 | ||
| 65 | function baseInput() { | |
| 66 | const models = [ | |
| 67 | modelOption("gpt-current", { isDefault: true }), | |
| 68 | modelOption("gpt-next"), | |
| 69 | modelOption("gpt-old", { isLegacy: true }), | |
| 70 | ]; | |
| 71 | return { | |
| 72 | providerGroups: [group(models)], | |
| 73 | selectedModel: models[0]?.selection ?? null, | |
| 74 | optionDescriptors: [effortDescriptor, fastModeDescriptor], | |
| 75 | runtimeMode: "auto", | |
| 76 | } as const; | |
| 77 | } | |
| 78 | ||
| 79 | function eventFor(menu: ReturnType<typeof buildThreadSettingsMenu>, id: string | undefined) { | |
| 80 | return id === undefined ? undefined : menu.events.get(id); | |
| 81 | } | |
| 82 | ||
| 83 | describe("buildThreadSettingsMenu", () => { | |
| 84 | it("orders the top level as model, options, runtime", () => { | |
| 85 | const menu = buildThreadSettingsMenu(baseInput()); | |
| 86 | ||
| 87 | expect(menu.actions.map((action) => action.title)).toEqual([ | |
| 88 | "Model", | |
| 89 | "Reasoning", | |
| 90 | "Fast mode", | |
| 91 | "Runtime", | |
| 92 | ]); | |
| 93 | }); | |
| 94 | ||
| 95 | it("summarizes the current choice on each submenu row", () => { | |
| 96 | const menu = buildThreadSettingsMenu(baseInput()); | |
| 97 | ||
| 98 | expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current"); | |
| 99 | expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High"); | |
| 100 | expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto"); | |
| 101 | }); | |
| 102 | ||
| 103 | it("checkmarks the selected model and resolves selection events", () => { | |
| 104 | const menu = buildThreadSettingsMenu(baseInput()); | |
| 105 | ||
| 106 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | |
| 107 | const current = modelItems.find((action) => action.title === "gpt-current"); | |
| 108 | expect(current?.state).toBe("on"); | |
| 109 | expect(current?.subtitle).toBe("Default"); | |
| 110 | expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off"); | |
| 111 | ||
| 112 | const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id); | |
| 113 | expect(event?.type).toBe("select-model"); | |
| 114 | expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next"); | |
| 115 | }); | |
| 116 | ||
| 117 | it("folds unselected legacy models behind a nested submenu", () => { | |
| 118 | const menu = buildThreadSettingsMenu(baseInput()); | |
| 119 | ||
| 120 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | |
| 121 | expect(modelItems.map((action) => action.title)).toEqual([ | |
| 122 | "gpt-current", | |
| 123 | "gpt-next", | |
| 124 | "Legacy Models", | |
| 125 | ]); | |
| 126 | expect( | |
| 127 | modelItems | |
| 128 | .find((action) => action.title === "Legacy Models") | |
| 129 | ?.subactions?.map((action) => action.title), | |
| 130 | ).toEqual(["gpt-old"]); | |
| 131 | }); | |
| 132 | ||
| 133 | it("keeps a selected legacy model in the main list", () => { | |
| 134 | const input = baseInput(); | |
| 135 | const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy); | |
| 136 | const menu = buildThreadSettingsMenu({ | |
| 137 | ...input, | |
| 138 | selectedModel: legacy?.selection ?? null, | |
| 139 | }); | |
| 140 | ||
| 141 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | |
| 142 | expect(modelItems.map((action) => action.title)).toEqual([ | |
| 143 | "gpt-current", | |
| 144 | "gpt-next", | |
| 145 | "gpt-old", | |
| 146 | ]); | |
| 147 | expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on"); | |
| 148 | }); | |
| 149 | ||
| 150 | it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => { | |
| 151 | const menu = buildThreadSettingsMenu({ | |
| 152 | ...baseInput(), | |
| 153 | optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }], | |
| 154 | }); | |
| 155 | ||
| 156 | const reasoning = menu.actions.find((action) => action.title === "Reasoning"); | |
| 157 | expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]); | |
| 158 | // The hidden value stays visible as the current summary; it just can't be | |
| 159 | // picked from the phone. | |
| 160 | expect(reasoning?.subtitle).toBe("Ultracode"); | |
| 161 | expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true); | |
| 162 | }); | |
| 163 | ||
| 164 | it("resolves select-option and runtime events with checkmarked current values", () => { | |
| 165 | const menu = buildThreadSettingsMenu(baseInput()); | |
| 166 | ||
| 167 | const reasoningItems = | |
| 168 | menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; | |
| 169 | expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on"); | |
| 170 | expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({ | |
| 171 | type: "set-option", | |
| 172 | optionId: "effort", | |
| 173 | value: "low", | |
| 174 | }); | |
| 175 | ||
| 176 | const runtimeItems = | |
| 177 | menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; | |
| 178 | expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on"); | |
| 179 | expect( | |
| 180 | eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id), | |
| 181 | ).toEqual({ type: "set-runtime", mode: "full-access" }); | |
| 182 | }); | |
| 183 | ||
| 184 | it("toggles boolean options with the inverted current value", () => { | |
| 185 | const menu = buildThreadSettingsMenu(baseInput()); | |
| 186 | ||
| 187 | const fastMode = menu.actions.find((action) => action.title === "Fast mode"); | |
| 188 | expect(fastMode?.state).toBe("off"); | |
| 189 | expect(fastMode?.subactions).toBeUndefined(); | |
| 190 | expect(eventFor(menu, fastMode?.id)).toEqual({ | |
| 191 | type: "set-option", | |
| 192 | optionId: "fastMode", | |
| 193 | value: true, | |
| 194 | }); | |
| 195 | ||
| 196 | const enabled = buildThreadSettingsMenu({ | |
| 197 | ...baseInput(), | |
| 198 | optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }], | |
| 199 | }); | |
| 200 | const enabledRow = enabled.actions.find((action) => action.title === "Fast mode"); | |
| 201 | expect(enabledRow?.state).toBe("on"); | |
| 202 | expect(eventFor(enabled, enabledRow?.id)).toEqual({ | |
| 203 | type: "set-option", | |
| 204 | optionId: "fastMode", | |
| 205 | value: false, | |
| 206 | }); | |
| 207 | }); | |
| 208 | ||
| 209 | it("keeps the menu presented only for top-level toggles", () => { | |
| 210 | const menu = buildThreadSettingsMenu(baseInput()); | |
| 211 | ||
| 212 | // Root-level boolean toggles refresh in place with clean chrome, so they | |
| 213 | // keep the menu presented. | |
| 214 | expect( | |
| 215 | menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, | |
| 216 | ).toBe(true); | |
| 217 | ||
| 218 | // Picks inside nested submenus close the menu: staying presented leaves | |
| 219 | // the submenu on screen with an expanded-submenu header, and the | |
| 220 | // bottom-anchored collapse back out drops by the levels' height delta. | |
| 221 | const expected = undefined; | |
| 222 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | |
| 223 | const reasoningItems = | |
| 224 | menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; | |
| 225 | const runtimeItems = | |
| 226 | menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; | |
| 227 | const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter( | |
| 228 | (action) => action.subactions === undefined, | |
| 229 | ); | |
| 230 | expect(nestedPicks.length).toBeGreaterThan(0); | |
| 231 | expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe( | |
| 232 | true, | |
| 233 | ); | |
| 234 | }); | |
| 235 | ||
| 236 | it("sections models by provider only when multiple groups are offered", () => { | |
| 237 | const codexModels = [modelOption("gpt-current", { isDefault: true })]; | |
| 238 | const claudeModels = [modelOption("fable-5", { providerKey: "claude" })]; | |
| 239 | const menu = buildThreadSettingsMenu({ | |
| 240 | providerGroups: [group(codexModels), group(claudeModels)], | |
| 241 | selectedModel: codexModels[0]?.selection ?? null, | |
| 242 | optionDescriptors: [], | |
| 243 | runtimeMode: "auto", | |
| 244 | }); | |
| 245 | ||
| 246 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | |
| 247 | expect( | |
| 248 | modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })), | |
| 249 | ).toEqual([ | |
| 250 | { title: "Codex", inline: true }, | |
| 251 | { title: "Claude", inline: true }, | |
| 252 | ]); | |
| 253 | const claudeSection = modelItems.find((action) => action.title === "Claude"); | |
| 254 | expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]); | |
| 255 | }); | |
| 256 | ||
| 257 | const eventTypes = (menu: ReturnType<typeof buildThreadSettingsMenu>) => { | |
| 258 | const types = new Set<ThreadSettingsMenuEvent["type"]>(); | |
| 259 | for (const event of menu.events.values()) { | |
| 260 | types.add(event.type); | |
| 261 | } | |
| 262 | return types; | |
| 263 | }; | |
| 264 | ||
| 265 | it("registers an event for every leaf action id", () => { | |
| 266 | const menu = buildThreadSettingsMenu(baseInput()); | |
| 267 | const leafIds: string[] = []; | |
| 268 | const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => { | |
| 269 | for (const item of items) { | |
| 270 | if (Array.isArray(item.subactions) && item.subactions.length > 0) { | |
| 271 | collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>); | |
| 272 | } else if (item.id !== undefined) { | |
| 273 | leafIds.push(item.id); | |
| 274 | } | |
| 275 | } | |
| 276 | }; | |
| 277 | collect(menu.actions); | |
| 278 | ||
| 279 | for (const id of leafIds) { | |
| 280 | expect(menu.events.get(id), `missing event for ${id}`).toBeDefined(); | |
| 281 | } | |
| 282 | expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"])); | |
| 283 | }); | |
| 284 | }); |
apps/mobile/src/features/threads/thread-settings-menu.ts created+202| ... | ... | @@ -0,0 +1,202 @@ |
| 1 | import type { MenuAction } from "@react-native-menu/menu"; | |
| 2 | import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | getProviderOptionCurrentLabel, | |
| 5 | getProviderOptionCurrentValue, | |
| 6 | } from "@t3tools/shared/model"; | |
| 7 | ||
| 8 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | |
| 9 | ||
| 10 | /** | |
| 11 | * Desktop-oriented effort keywords that don't belong in the phone picker. | |
| 12 | * Prompt-injected values (ultrathink and friends) are filtered from the | |
| 13 | * descriptor metadata; ultracode is a real option but a workflow trigger, not | |
| 14 | * a reasoning level. A value set elsewhere still displays, it just isn't | |
| 15 | * offered. | |
| 16 | */ | |
| 17 | export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]); | |
| 18 | ||
| 19 | export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ | |
| 20 | readonly mode: RuntimeMode; | |
| 21 | readonly label: string; | |
| 22 | readonly shortLabel: string; | |
| 23 | }> = [ | |
| 24 | { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, | |
| 25 | { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, | |
| 26 | { mode: "auto", label: "Auto", shortLabel: "Auto" }, | |
| 27 | { mode: "full-access", label: "Full access", shortLabel: "Full" }, | |
| 28 | ]; | |
| 29 | ||
| 30 | export function selectableChoices( | |
| 31 | descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>, | |
| 32 | ) { | |
| 33 | const injected = new Set(descriptor.promptInjectedValues ?? []); | |
| 34 | return descriptor.options.filter( | |
| 35 | (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), | |
| 36 | ); | |
| 37 | } | |
| 38 | ||
| 39 | export type ThreadSettingsMenuEvent = | |
| 40 | | { readonly type: "select-model"; readonly option: ModelOption } | |
| 41 | | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } | |
| 42 | | { readonly type: "set-runtime"; readonly mode: RuntimeMode }; | |
| 43 | ||
| 44 | export type ThreadSettingsMenu = { | |
| 45 | readonly actions: MenuAction[]; | |
| 46 | /** Menu action id → the change it applies, for the onPressAction dispatch. */ | |
| 47 | readonly events: ReadonlyMap<string, ThreadSettingsMenuEvent>; | |
| 48 | }; | |
| 49 | ||
| 50 | /** | |
| 51 | * Native menu replacement for the thread settings sheet (model, select and | |
| 52 | * boolean provider options, runtime mode). The menu presents from the | |
| 53 | * composer pill without resigning the keyboard, so adjusting settings never | |
| 54 | * bounces focus. A thread is bound to one harness, so the menu covers the | |
| 55 | * sheet's full surface for existing threads; the sheet remains the Android | |
| 56 | * and new-task-draft surface. | |
| 57 | * | |
| 58 | * Selections apply immediately — the sheet's stage-then-Save flow only exists | |
| 59 | * because the sheet batches a model change with its option edits. | |
| 60 | */ | |
| 61 | export function buildThreadSettingsMenu(input: { | |
| 62 | readonly providerGroups: ReadonlyArray<ProviderGroup>; | |
| 63 | readonly selectedModel: ModelSelection | null; | |
| 64 | readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; | |
| 65 | readonly runtimeMode: RuntimeMode; | |
| 66 | }): ThreadSettingsMenu { | |
| 67 | const events = new Map<string, ThreadSettingsMenuEvent>(); | |
| 68 | const actions: MenuAction[] = []; | |
| 69 | ||
| 70 | const isSelected = (option: ModelOption) => | |
| 71 | option.selection.instanceId === input.selectedModel?.instanceId && | |
| 72 | option.selection.model === input.selectedModel.model; | |
| 73 | ||
| 74 | // Only top-level leaves (boolean toggles) keep the menu presented (iOS | |
| 75 | // 16+): the root refreshes in place with clean chrome. Picks inside nested | |
| 76 | // submenus close the menu — keeping the submenu presented renders an | |
| 77 | // expanded-submenu header with no way to pop back to the root, and the | |
| 78 | // bottom-anchored collapse back out travels the levels' height difference. | |
| 79 | const keepPresented = { keepsMenuPresented: true } as const; | |
| 80 | ||
| 81 | const modelAction = (option: ModelOption, id: string): MenuAction => { | |
| 82 | events.set(id, { type: "select-model", option }); | |
| 83 | return { | |
| 84 | id, | |
| 85 | title: option.label, | |
| 86 | ...(option.isDefault ? { subtitle: "Default" } : {}), | |
| 87 | state: isSelected(option) ? "on" : "off", | |
| 88 | }; | |
| 89 | }; | |
| 90 | ||
| 91 | const modelItems: MenuAction[] = []; | |
| 92 | const legacyItems: MenuAction[] = []; | |
| 93 | let selectedModelLabel: string | undefined; | |
| 94 | input.providerGroups.forEach((group, groupIndex) => { | |
| 95 | const groupItems: MenuAction[] = []; | |
| 96 | group.models.forEach((option, modelIndex) => { | |
| 97 | if (isSelected(option)) { | |
| 98 | selectedModelLabel = option.label; | |
| 99 | } | |
| 100 | const id = `model:${groupIndex}:${modelIndex}`; | |
| 101 | // A highlighted legacy model stays in the main list (mirroring the | |
| 102 | // sheet) so the checkmark isn't hidden behind the Legacy fold. | |
| 103 | if (option.isLegacy && !isSelected(option)) { | |
| 104 | legacyItems.push(modelAction(option, id)); | |
| 105 | } else { | |
| 106 | groupItems.push(modelAction(option, id)); | |
| 107 | } | |
| 108 | }); | |
| 109 | if (groupItems.length === 0) { | |
| 110 | return; | |
| 111 | } | |
| 112 | // A thread is bound to one harness, so provider sections only appear for | |
| 113 | // multi-group callers (the new-task draft, if it ever adopts the menu). | |
| 114 | if (input.providerGroups.length > 1) { | |
| 115 | modelItems.push({ | |
| 116 | id: `model-group:${groupIndex}`, | |
| 117 | title: group.providerLabel, | |
| 118 | displayInline: true, | |
| 119 | subactions: groupItems, | |
| 120 | }); | |
| 121 | } else { | |
| 122 | modelItems.push(...groupItems); | |
| 123 | } | |
| 124 | }); | |
| 125 | if (legacyItems.length > 0) { | |
| 126 | modelItems.push({ | |
| 127 | id: "legacy-models", | |
| 128 | title: "Legacy Models", | |
| 129 | subactions: legacyItems, | |
| 130 | }); | |
| 131 | } | |
| 132 | if (modelItems.length > 0) { | |
| 133 | actions.push({ | |
| 134 | id: "model", | |
| 135 | title: "Model", | |
| 136 | ...(selectedModelLabel === undefined | |
| 137 | ? input.selectedModel | |
| 138 | ? { subtitle: input.selectedModel.model } | |
| 139 | : {} | |
| 140 | : { subtitle: selectedModelLabel }), | |
| 141 | subactions: modelItems, | |
| 142 | }); | |
| 143 | } | |
| 144 | ||
| 145 | for (const descriptor of input.optionDescriptors) { | |
| 146 | if (descriptor.type === "boolean") { | |
| 147 | const id = `option:${descriptor.id}`; | |
| 148 | events.set(id, { | |
| 149 | type: "set-option", | |
| 150 | optionId: descriptor.id, | |
| 151 | value: !(descriptor.currentValue ?? false), | |
| 152 | }); | |
| 153 | actions.push({ | |
| 154 | id, | |
| 155 | title: descriptor.label, | |
| 156 | state: descriptor.currentValue ? "on" : "off", | |
| 157 | attributes: keepPresented, | |
| 158 | }); | |
| 159 | continue; | |
| 160 | } | |
| 161 | const currentValue = getProviderOptionCurrentValue(descriptor); | |
| 162 | const choices = selectableChoices(descriptor).map((choice): MenuAction => { | |
| 163 | const id = `option:${descriptor.id}:${choice.id}`; | |
| 164 | events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id }); | |
| 165 | return { | |
| 166 | id, | |
| 167 | title: choice.label, | |
| 168 | state: choice.id === currentValue ? "on" : "off", | |
| 169 | }; | |
| 170 | }); | |
| 171 | if (choices.length === 0) { | |
| 172 | continue; | |
| 173 | } | |
| 174 | const currentLabel = getProviderOptionCurrentLabel(descriptor); | |
| 175 | actions.push({ | |
| 176 | id: `option:${descriptor.id}`, | |
| 177 | title: descriptor.label, | |
| 178 | ...(currentLabel === undefined ? {} : { subtitle: currentLabel }), | |
| 179 | subactions: choices, | |
| 180 | }); | |
| 181 | } | |
| 182 | ||
| 183 | const runtimeLabel = RUNTIME_MODE_CHOICES.find( | |
| 184 | (choice) => choice.mode === input.runtimeMode, | |
| 185 | )?.label; | |
| 186 | actions.push({ | |
| 187 | id: "runtime", | |
| 188 | title: "Runtime", | |
| 189 | ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }), | |
| 190 | subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => { | |
| 191 | const id = `runtime:${choice.mode}`; | |
| 192 | events.set(id, { type: "set-runtime", mode: choice.mode }); | |
| 193 | return { | |
| 194 | id, | |
| 195 | title: choice.label, | |
| 196 | state: choice.mode === input.runtimeMode ? "on" : "off", | |
| 197 | }; | |
| 198 | }), | |
| 199 | }); | |
| 200 | ||
| 201 | return { actions, events }; | |
| 202 | } |
apps/mobile/src/features/updates/app-updates.test.ts+39| ... | ... | @@ -32,6 +32,19 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | describe("runAppUpdateCheck", () => { |
| 35 | it("does nothing while running from the Metro development server", async () => { | |
| 36 | vi.stubGlobal("__DEV__", true); | |
| 37 | const client = makeUpdateClient(); | |
| 38 | ||
| 39 | try { | |
| 40 | await runAppUpdateCheck({ client }); | |
| 41 | } finally { | |
| 42 | vi.unstubAllGlobals(); | |
| 43 | } | |
| 44 | ||
| 45 | expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); | |
| 46 | }); | |
| 47 | ||
| 35 | 48 | it("downloads and restarts when a new update is available", async () => { |
| 36 | 49 | const client = makeUpdateClient({ |
| 37 | 50 | checkForUpdateAsync: vi.fn(async () => ({ |
| ... | ... | @@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => { |
| 100 | 113 | reportError.mockRestore(); |
| 101 | 114 | }); |
| 102 | 115 | |
| 116 | it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])( | |
| 117 | "treats Expo's %s failure as an unavailable update check", | |
| 118 | async (code) => { | |
| 119 | const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 120 | const error = Object.assign(new Error("Updates are unavailable"), { code }); | |
| 121 | const client = makeUpdateClient({ | |
| 122 | checkForUpdateAsync: vi.fn(async () => { | |
| 123 | throw error; | |
| 124 | }), | |
| 125 | }); | |
| 126 | const failures: string[] = []; | |
| 127 | const states: AppUpdateCheckState[] = []; | |
| 128 | ||
| 129 | await runAppUpdateCheck({ | |
| 130 | client, | |
| 131 | onFailure: (message) => failures.push(message), | |
| 132 | onStateChange: (state) => states.push(state), | |
| 133 | }); | |
| 134 | ||
| 135 | expect(reportError).not.toHaveBeenCalled(); | |
| 136 | expect(failures).toEqual([]); | |
| 137 | expect(states).toEqual(["checking", "idle"]); | |
| 138 | reportError.mockRestore(); | |
| 139 | }, | |
| 140 | ); | |
| 141 | ||
| 103 | 142 | it("coalesces overlapping launch and manual checks", async () => { |
| 104 | 143 | let resolveCheck!: (result: { |
| 105 | 144 | readonly isAvailable: boolean; |
apps/mobile/src/features/updates/app-updates.ts+20-3| ... | ... | @@ -48,8 +48,17 @@ interface Deferred { |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | const HIDDEN_UPDATE_TAP_COUNT = 5; |
| 51 | const UPDATE_CHECK_UNAVAILABLE_ERROR_CODES = new Set([ | |
| 52 | "ERR_NOT_AVAILABLE_IN_DEV_CLIENT", | |
| 53 | "ERR_UPDATES_DISABLED", | |
| 54 | ]); | |
| 51 | 55 | let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; |
| 52 | 56 | |
| 57 | /** Expo's development launcher reports updates as enabled even though its OTA APIs reject. */ | |
| 58 | export function isAppUpdateCheckAvailable(client: Pick<AppUpdateClient, "isEnabled"> = Updates) { | |
| 59 | return client.isEnabled && !(typeof __DEV__ !== "undefined" && __DEV__); | |
| 60 | } | |
| 61 | ||
| 53 | 62 | /** |
| 54 | 63 | * Keeps the manual update affordance discoverable only to someone deliberately |
| 55 | 64 | * tapping the version row five times. |
| ... | ... | @@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): { |
| 73 | 82 | |
| 74 | 83 | export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> { |
| 75 | 84 | const client = options.client ?? Updates; |
| 76 | if (!client.isEnabled) return; | |
| 85 | if (!isAppUpdateCheckAvailable(client)) return; | |
| 77 | 86 | |
| 78 | 87 | if (appUpdateCheckInFlight) { |
| 79 | 88 | await observeAppUpdateCheck(appUpdateCheckInFlight, options); |
| ... | ... | @@ -207,19 +216,27 @@ function reportUpdateFailure( |
| 207 | 216 | fallback: string, |
| 208 | 217 | onFailure: AppUpdateCheckOptions["onFailure"], |
| 209 | 218 | ): void { |
| 210 | reportAtomCommandResult(result, { label: "app update check" }); | |
| 211 | 219 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; |
| 212 | 220 | const error = squashAtomCommandFailure(result); |
| 221 | if (isAppUpdateUnavailableError(error)) return; | |
| 222 | ||
| 223 | reportAtomCommandResult(result, { label: "app update check" }); | |
| 213 | 224 | onFailure?.(error instanceof Error ? error.message : fallback); |
| 214 | 225 | } |
| 215 | 226 | |
| 227 | function isAppUpdateUnavailableError(error: unknown): boolean { | |
| 228 | if (typeof error !== "object" || error === null || !("code" in error)) return false; | |
| 229 | const code = error.code; | |
| 230 | return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code); | |
| 231 | } | |
| 232 | ||
| 216 | 233 | export function createAppUpdateLaunchCheck( |
| 217 | 234 | client: AppUpdateClient = Updates, |
| 218 | 235 | ): () => Promise<void> | undefined { |
| 219 | 236 | let started = false; |
| 220 | 237 | |
| 221 | 238 | return () => { |
| 222 | if (started || !client.isEnabled) return undefined; | |
| 239 | if (started || !isAppUpdateCheckAvailable(client)) return undefined; | |
| 223 | 240 | started = true; |
| 224 | 241 | return runAppUpdateCheck({ client }); |
| 225 | 242 | }; |
apps/mobile/src/lib/threadActivity.test.ts+95| ... | ... | @@ -12,12 +12,107 @@ import { |
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | 13 | |
| 14 | 14 | import { |
| 15 | buildPendingUserInputAnswers, | |
| 15 | 16 | buildThreadFeed, |
| 16 | 17 | deriveThreadFeedPresentation, |
| 18 | isPendingUserInputOptionSelected, | |
| 19 | setPendingUserInputCustomAnswer, | |
| 20 | togglePendingUserInputOptionSelection, | |
| 17 | 21 | type ThreadFeedActivity, |
| 18 | 22 | type ThreadFeedEntry, |
| 19 | 23 | } from "./threadActivity"; |
| 20 | 24 | |
| 25 | const singleSelectQuestion = { | |
| 26 | id: "runtime", | |
| 27 | header: "Runtime", | |
| 28 | question: "Which runtime should be used?", | |
| 29 | options: [ | |
| 30 | { label: "Go", description: "One binary" }, | |
| 31 | { label: "Node.js", description: "Reuse TypeScript" }, | |
| 32 | ], | |
| 33 | multiSelect: false, | |
| 34 | } as const; | |
| 35 | ||
| 36 | const multiSelectQuestion = { | |
| 37 | id: "scope", | |
| 38 | header: "Scope", | |
| 39 | question: "Which data should be collected?", | |
| 40 | options: [ | |
| 41 | { label: "Orders", description: "Receipts" }, | |
| 42 | { label: "Listings", description: "Inventory" }, | |
| 43 | ], | |
| 44 | multiSelect: true, | |
| 45 | } as const; | |
| 46 | ||
| 47 | describe("pending user input answers", () => { | |
| 48 | it("replaces single-select options and toggles multi-select options", () => { | |
| 49 | expect( | |
| 50 | togglePendingUserInputOptionSelection( | |
| 51 | singleSelectQuestion, | |
| 52 | { selectedOptionLabels: ["Go"] }, | |
| 53 | "Node.js", | |
| 54 | ), | |
| 55 | ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] }); | |
| 56 | ||
| 57 | const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); | |
| 58 | const ordersAndListings = togglePendingUserInputOptionSelection( | |
| 59 | multiSelectQuestion, | |
| 60 | orders, | |
| 61 | "Listings", | |
| 62 | ); | |
| 63 | expect(ordersAndListings).toEqual({ | |
| 64 | customAnswer: "", | |
| 65 | selectedOptionLabels: ["Orders", "Listings"], | |
| 66 | }); | |
| 67 | expect( | |
| 68 | togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), | |
| 69 | ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] }); | |
| 70 | ||
| 71 | const paddedOrders = togglePendingUserInputOptionSelection( | |
| 72 | multiSelectQuestion, | |
| 73 | undefined, | |
| 74 | " Orders ", | |
| 75 | ); | |
| 76 | expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] }); | |
| 77 | expect( | |
| 78 | togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), | |
| 79 | ).toEqual({ customAnswer: "" }); | |
| 80 | }); | |
| 81 | ||
| 82 | it("builds array answers for multi-select questions", () => { | |
| 83 | expect( | |
| 84 | buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { | |
| 85 | runtime: { selectedOptionLabels: ["Go"] }, | |
| 86 | scope: { selectedOptionLabels: ["Orders", "Listings"] }, | |
| 87 | }), | |
| 88 | ).toEqual({ | |
| 89 | runtime: "Go", | |
| 90 | scope: ["Orders", "Listings"], | |
| 91 | }); | |
| 92 | }); | |
| 93 | ||
| 94 | it("clears selected options while a custom answer is active", () => { | |
| 95 | expect( | |
| 96 | setPendingUserInputCustomAnswer( | |
| 97 | { selectedOptionLabels: ["Orders", "Listings"] }, | |
| 98 | "Orders first", | |
| 99 | ), | |
| 100 | ).toEqual({ customAnswer: "Orders first" }); | |
| 101 | }); | |
| 102 | ||
| 103 | it("matches selected chips against normalized option labels", () => { | |
| 104 | expect( | |
| 105 | isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "), | |
| 106 | ).toBe(true); | |
| 107 | expect( | |
| 108 | isPendingUserInputOptionSelected( | |
| 109 | { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" }, | |
| 110 | " Orders ", | |
| 111 | ), | |
| 112 | ).toBe(false); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 21 | 116 | function makeActivity( |
| 22 | 117 | input: Partial<OrchestrationThreadActivity> & |
| 23 | 118 | Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">, |
apps/mobile/src/lib/threadActivity.ts+67-9| ... | ... | @@ -26,7 +26,7 @@ export interface PendingUserInput { |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | export interface PendingUserInputDraftAnswer { |
| 29 | readonly selectedOptionLabel?: string; | |
| 29 | readonly selectedOptionLabels?: ReadonlyArray<string>; | |
| 30 | 30 | readonly customAnswer?: string; |
| 31 | 31 | } |
| 32 | 32 | |
| ... | ... | @@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null { |
| 227 | 227 | return trimmed.length > 0 ? trimmed : null; |
| 228 | 228 | } |
| 229 | 229 | |
| 230 | function normalizeSelectedOptionLabels( | |
| 231 | value: ReadonlyArray<string> | undefined, | |
| 232 | ): ReadonlyArray<string> { | |
| 233 | if (!Array.isArray(value)) { | |
| 234 | return []; | |
| 235 | } | |
| 236 | ||
| 237 | return Array.from( | |
| 238 | new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), | |
| 239 | ); | |
| 240 | } | |
| 241 | ||
| 230 | 242 | function resolvePendingUserInputAnswer( |
| 243 | question: UserInputQuestion, | |
| 231 | 244 | draft: PendingUserInputDraftAnswer | undefined, |
| 232 | ): string | null { | |
| 245 | ): string | ReadonlyArray<string> | null { | |
| 233 | 246 | const customAnswer = normalizeDraftAnswer(draft?.customAnswer); |
| 234 | 247 | if (customAnswer) { |
| 235 | 248 | return customAnswer; |
| 236 | 249 | } |
| 237 | return normalizeDraftAnswer(draft?.selectedOptionLabel); | |
| 250 | ||
| 251 | const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); | |
| 252 | if (question.multiSelect) { | |
| 253 | return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; | |
| 254 | } | |
| 255 | return selectedOptionLabels[0] ?? null; | |
| 238 | 256 | } |
| 239 | 257 | |
| 240 | 258 | /** Codex children settle via task.updated (idle/failed/interrupted), never |
| ... | ... | @@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer( |
| 1428 | 1446 | draft: PendingUserInputDraftAnswer | undefined, |
| 1429 | 1447 | customAnswer: string, |
| 1430 | 1448 | ): PendingUserInputDraftAnswer { |
| 1431 | const selectedOptionLabel = | |
| 1432 | customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; | |
| 1449 | const selectedOptionLabels = | |
| 1450 | customAnswer.trim().length > 0 | |
| 1451 | ? undefined | |
| 1452 | : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); | |
| 1433 | 1453 | return { |
| 1434 | 1454 | customAnswer, |
| 1435 | ...(selectedOptionLabel ? { selectedOptionLabel } : {}), | |
| 1455 | ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), | |
| 1456 | }; | |
| 1457 | } | |
| 1458 | ||
| 1459 | export function isPendingUserInputOptionSelected( | |
| 1460 | draft: PendingUserInputDraftAnswer | undefined, | |
| 1461 | optionLabel: string, | |
| 1462 | ): boolean { | |
| 1463 | if (normalizeDraftAnswer(draft?.customAnswer)) { | |
| 1464 | return false; | |
| 1465 | } | |
| 1466 | ||
| 1467 | return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim()); | |
| 1468 | } | |
| 1469 | ||
| 1470 | export function togglePendingUserInputOptionSelection( | |
| 1471 | question: UserInputQuestion, | |
| 1472 | draft: PendingUserInputDraftAnswer | undefined, | |
| 1473 | optionLabel: string, | |
| 1474 | ): PendingUserInputDraftAnswer { | |
| 1475 | const normalizedOptionLabel = optionLabel.trim(); | |
| 1476 | ||
| 1477 | if (question.multiSelect) { | |
| 1478 | const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); | |
| 1479 | const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel) | |
| 1480 | ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel) | |
| 1481 | : [...selectedOptionLabels, normalizedOptionLabel]; | |
| 1482 | ||
| 1483 | return { | |
| 1484 | customAnswer: "", | |
| 1485 | ...(nextSelectedOptionLabels.length > 0 | |
| 1486 | ? { selectedOptionLabels: nextSelectedOptionLabels } | |
| 1487 | : {}), | |
| 1488 | }; | |
| 1489 | } | |
| 1490 | ||
| 1491 | return { | |
| 1492 | customAnswer: "", | |
| 1493 | selectedOptionLabels: [normalizedOptionLabel], | |
| 1436 | 1494 | }; |
| 1437 | 1495 | } |
| 1438 | 1496 | |
| 1439 | 1497 | export function buildPendingUserInputAnswers( |
| 1440 | 1498 | questions: ReadonlyArray<UserInputQuestion>, |
| 1441 | 1499 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 1442 | ): Record<string, string> | null { | |
| 1443 | const answers: Record<string, string> = {}; | |
| 1500 | ): Record<string, string | ReadonlyArray<string>> | null { | |
| 1501 | const answers: Record<string, string | ReadonlyArray<string>> = {}; | |
| 1444 | 1502 | |
| 1445 | 1503 | for (const question of questions) { |
| 1446 | const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); | |
| 1504 | const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); | |
| 1447 | 1505 | if (!answer) { |
| 1448 | 1506 | return null; |
| 1449 | 1507 | } |
apps/mobile/src/state/use-selected-thread-requests.ts+18-7| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | 3 | |
| 4 | import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | ApprovalRequestId, | |
| 6 | type ProviderApprovalDecision, | |
| 7 | type UserInputQuestion, | |
| 8 | } from "@t3tools/contracts"; | |
| 5 | 9 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 10 | |
| 7 | 11 | import { threadEnvironment } from "../state/threads"; |
| ... | ... | @@ -12,6 +16,7 @@ import { |
| 12 | 16 | derivePendingUserInputs, |
| 13 | 17 | setPendingUserInputCustomAnswer, |
| 14 | 18 | sortThreadActivities, |
| 19 | togglePendingUserInputOptionSelection, | |
| 15 | 20 | type PendingUserInputDraftAnswer, |
| 16 | 21 | } from "../lib/threadActivity"; |
| 17 | 22 | import { appAtomRegistry } from "./atom-registry"; |
| ... | ... | @@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< |
| 23 | 28 | Record<string, Record<string, PendingUserInputDraftAnswer>> |
| 24 | 29 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); |
| 25 | 30 | |
| 26 | function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { | |
| 31 | function setUserInputDraftOption( | |
| 32 | requestKey: string, | |
| 33 | question: UserInputQuestion, | |
| 34 | label: string, | |
| 35 | ): void { | |
| 27 | 36 | const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); |
| 28 | 37 | appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { |
| 29 | 38 | ...current, |
| 30 | 39 | [requestKey]: { |
| 31 | 40 | ...current[requestKey], |
| 32 | [questionId]: { | |
| 33 | selectedOptionLabel: label, | |
| 34 | }, | |
| 41 | [question.id]: togglePendingUserInputOptionSelection( | |
| 42 | question, | |
| 43 | current[requestKey]?.[question.id], | |
| 44 | label, | |
| 45 | ), | |
| 35 | 46 | }, |
| 36 | 47 | }); |
| 37 | 48 | } |
| ... | ... | @@ -97,13 +108,13 @@ export function useSelectedThreadRequests() { |
| 97 | 108 | : null; |
| 98 | 109 | |
| 99 | 110 | const onSelectUserInputOption = useCallback( |
| 100 | (requestId: ApprovalRequestId, questionId: string, label: string) => { | |
| 111 | (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => { | |
| 101 | 112 | if (!selectedThreadShell) { |
| 102 | 113 | return; |
| 103 | 114 | } |
| 104 | 115 | |
| 105 | 116 | const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); |
| 106 | setUserInputDraftOption(requestKey, questionId, label); | |
| 117 | setUserInputDraftOption(requestKey, question, label); | |
| 107 | 118 | }, |
| 108 | 119 | [selectedThreadShell], |
| 109 | 120 | ); |
apps/web/package.json+1-1| ... | ... | @@ -21,7 +21,7 @@ |
| 21 | 21 | "@dnd-kit/utilities": "^3.2.2", |
| 22 | 22 | "@effect/atom-react": "catalog:", |
| 23 | 23 | "@formkit/auto-animate": "^0.9.0", |
| 24 | "@legendapp/list": "3.3.3", | |
| 24 | "@legendapp/list": "catalog:", | |
| 25 | 25 | "@lexical/react": "^0.41.0", |
| 26 | 26 | "@pierre/diffs": "catalog:", |
| 27 | 27 | "@pierre/trees": "1.0.0-beta.4", |
patches/@clerk__expo@4.2.0.patch created+79| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | diff --git a/ios/ClerkAuthNativeView.swift b/ios/ClerkAuthNativeView.swift | |
| 2 | index e76a8be1b1c8faa64ec6dfa83764764094133aff..17b36ad1319765e2b6db0551d32e07d7140e482f 100644 | |
| 3 | --- a/ios/ClerkAuthNativeView.swift | |
| 4 | +++ b/ios/ClerkAuthNativeView.swift | |
| 5 | @@ -108,7 +108,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { | |
| 6 | ||
| 7 | override func makeHostedController() -> UIViewController? { | |
| 8 | let hostBackAction: (() -> Void)? = currentHostBackButton | |
| 9 | - ? { [weak self] in self?.onHostBack([:]) } | |
| 10 | + ? { [weak self] in | |
| 11 | + guard let self else { return } | |
| 12 | + if !self.popEnclosingNavigationRoute() { | |
| 13 | + self.onHostBack([:]) | |
| 14 | + } | |
| 15 | + } | |
| 16 | : nil | |
| 17 | ||
| 18 | return ClerkNativeBridge.shared.makeAuthViewController( | |
| 19 | diff --git a/ios/ClerkNativeViewHost.swift b/ios/ClerkNativeViewHost.swift | |
| 20 | index 0d91f0e749f121595c17bc803663df6ac90e4163..8f8a89df97a54168b8b45d0e9c197ca3953b7028 100644 | |
| 21 | --- a/ios/ClerkNativeViewHost.swift | |
| 22 | +++ b/ios/ClerkNativeViewHost.swift | |
| 23 | @@ -5,6 +5,7 @@ public class ClerkNativeViewHost: ExpoView { | |
| 24 | private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) | |
| 25 | private var hasInitialized: Bool = false | |
| 26 | private var configuredObserver: NSObjectProtocol? | |
| 27 | + private var isPoppingHostRoute = false | |
| 28 | ||
| 29 | public required init(appContext: AppContext? = nil) { | |
| 30 | super.init(appContext: appContext) | |
| 31 | @@ -58,6 +59,30 @@ public class ClerkNativeViewHost: ExpoView { | |
| 32 | ||
| 33 | func hostedViewDidDetachFromWindow() {} | |
| 34 | ||
| 35 | + /// Pops the React Navigation route that contains this view without waiting for | |
| 36 | + /// the JavaScript event loop. React Native Screens reports the native dismissal | |
| 37 | + /// back to React Navigation so its state remains synchronized. | |
| 38 | + func popEnclosingNavigationRoute() -> Bool { | |
| 39 | + guard !isPoppingHostRoute else { return true } | |
| 40 | + | |
| 41 | + var responder: UIResponder? = self | |
| 42 | + | |
| 43 | + while let nextResponder = responder?.next { | |
| 44 | + if let viewController = nextResponder as? UIViewController, | |
| 45 | + let navigationController = viewController.navigationController, | |
| 46 | + navigationController.viewControllers.count > 1 { | |
| 47 | + isPoppingHostRoute = true | |
| 48 | + if navigationController.popViewController(animated: true) != nil { | |
| 49 | + return true | |
| 50 | + } | |
| 51 | + isPoppingHostRoute = false | |
| 52 | + } | |
| 53 | + responder = nextResponder | |
| 54 | + } | |
| 55 | + | |
| 56 | + return false | |
| 57 | + } | |
| 58 | + | |
| 59 | private func addConfiguredObserver() { | |
| 60 | guard configuredObserver == nil else { return } | |
| 61 | ||
| 62 | diff --git a/ios/ClerkUserProfileNativeView.swift b/ios/ClerkUserProfileNativeView.swift | |
| 63 | index 12d6248b1dc4b4779b252c9954c2f89145907310..d838283b7adcf49fec2a63bff53251b9bee31bf8 100644 | |
| 64 | --- a/ios/ClerkUserProfileNativeView.swift | |
| 65 | +++ b/ios/ClerkUserProfileNativeView.swift | |
| 66 | @@ -44,7 +44,12 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { | |
| 67 | ||
| 68 | override func makeHostedController() -> UIViewController? { | |
| 69 | let hostBackAction: (() -> Void)? = currentHostBackButton | |
| 70 | - ? { [weak self] in self?.onHostBack([:]) } | |
| 71 | + ? { [weak self] in | |
| 72 | + guard let self else { return } | |
| 73 | + if !self.popEnclosingNavigationRoute() { | |
| 74 | + self.onHostBack([:]) | |
| 75 | + } | |
| 76 | + } | |
| 77 | : nil | |
| 78 | ||
| 79 | return ClerkNativeBridge.shared.makeUserProfileViewController( |
patches/@legendapp__list@3.3.3.patch deleted-1014| ... | ... | @@ -1,1014 +0,0 @@ |
| 1 | diff --git a/keyboard.d.ts b/keyboard.d.ts | |
| 2 | index 7bc3bb8..75ec120 100644 | |
| 3 | --- a/keyboard.d.ts | |
| 4 | +++ b/keyboard.d.ts | |
| 5 | @@ -277,7 +277,7 @@ type KeyboardChatComposerInsetListRef = { | |
| 6 | type KeyboardChatComposerRef = { | |
| 7 | current: Pick<View, "measure"> | null; | |
| 8 | }; | |
| 9 | -declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { | |
| 10 | +declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { | |
| 11 | contentInsetEndAdjustment: SharedValue<number>; | |
| 12 | onComposerLayout: (event: LayoutChangeEvent) => void; | |
| 13 | }; | |
| 14 | @@ -286,8 +286,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb | |
| 15 | scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>; | |
| 16 | }; | |
| 17 | declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { | |
| 18 | + adjustedInsetCompensation?: number; | |
| 19 | anchoredEndSpace?: AnchoredEndSpaceConfig; | |
| 20 | contentInsetEndAdjustment?: SharedValue<number>; | |
| 21 | + contentInsetEndStaticAdjustment?: number; | |
| 22 | keyboardOffset?: number; | |
| 23 | } & React.RefAttributes<LegendListRef>) => React.ReactElement | null; | |
| 24 | ||
| 25 | diff --git a/keyboard.js b/keyboard.js | |
| 26 | index 736286a..8218172 100644 | |
| 27 | --- a/keyboard.js | |
| 28 | +++ b/keyboard.js | |
| 29 | @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. | |
| 30 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | |
| 31 | ); | |
| 32 | } | |
| 33 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | |
| 34 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | |
| 35 | const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); | |
| 36 | const lastHeightRef = React.useRef(void 0); | |
| 37 | const reportHeight = React.useCallback( | |
| 38 | - (height) => { | |
| 39 | + (rawHeight) => { | |
| 40 | + const height = Math.max(0, rawHeight + heightAdjustment); | |
| 41 | var _a; | |
| 42 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | |
| 43 | lastHeightRef.current = height; | |
| 44 | contentInsetEndAdjustment.value = height; | |
| 45 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | |
| 46 | } | |
| 47 | }, | |
| 48 | - [contentInsetEndAdjustment, listRef] | |
| 49 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 50 | ); | |
| 51 | React.useLayoutEffect(() => { | |
| 52 | var _a; | |
| 53 | @@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | |
| 54 | } | |
| 55 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | |
| 56 | const { | |
| 57 | + adjustedInsetCompensation, | |
| 58 | anchoredEndSpace, | |
| 59 | applyWorkaroundForContentInsetHitTestBug, | |
| 60 | contentInsetEndAdjustment, | |
| 61 | + contentInsetEndStaticAdjustment, | |
| 62 | freeze, | |
| 63 | keyboardLiftBehavior, | |
| 64 | keyboardOffset, | |
| 65 | @@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 66 | includeInEndInset: true, | |
| 67 | onSizeChanged: (size) => { | |
| 68 | var _a; | |
| 69 | - blankSpace.value = size; | |
| 70 | + // The anchored blank is sized against the ADJUSTED viewport, but the | |
| 71 | + // scroll component writes it as a raw contentInset that UIKit tops up | |
| 72 | + // with the safe-area bottom — write it net of that or the end rest | |
| 73 | + // (and the anchored message) sinks one safe-area under the header. | |
| 74 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | |
| 75 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | |
| 76 | } | |
| 77 | }; | |
| 78 | - }, [anchoredEndSpace, blankSpace]); | |
| 79 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | |
| 80 | const onContentInsetChange = React.useCallback((insets) => { | |
| 81 | var _a; | |
| 82 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | |
| 83 | @@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 84 | reactNativeKeyboardController.KeyboardChatScrollView, | |
| 85 | { | |
| 86 | ...scrollProps, | |
| 87 | + adjustedInsetCompensation, | |
| 88 | applyWorkaroundForContentInsetHitTestBug, | |
| 89 | blankSpace, | |
| 90 | extraContentPadding: contentInsetEndAdjustment, | |
| 91 | @@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 92 | ); | |
| 93 | }, | |
| 94 | [ | |
| 95 | + adjustedInsetCompensation, | |
| 96 | applyWorkaroundForContentInsetHitTestBug, | |
| 97 | blankSpace, | |
| 98 | contentInsetEndAdjustment, | |
| 99 | @@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 100 | AnimatedLegendListInternal, | |
| 101 | { | |
| 102 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | |
| 103 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | |
| 104 | ref: combinedRef, | |
| 105 | renderScrollComponent: memoList, | |
| 106 | ...rest | |
| 107 | diff --git a/keyboard.mjs b/keyboard.mjs | |
| 108 | index c1dd270..cb0d142 100644 | |
| 109 | --- a/keyboard.mjs | |
| 110 | +++ b/keyboard.mjs | |
| 111 | @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { | |
| 112 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | |
| 113 | ); | |
| 114 | } | |
| 115 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | |
| 116 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | |
| 117 | const contentInsetEndAdjustment = useSharedValue(initialHeight); | |
| 118 | const lastHeightRef = useRef(void 0); | |
| 119 | const reportHeight = useCallback( | |
| 120 | - (height) => { | |
| 121 | + (rawHeight) => { | |
| 122 | + const height = Math.max(0, rawHeight + heightAdjustment); | |
| 123 | var _a; | |
| 124 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | |
| 125 | lastHeightRef.current = height; | |
| 126 | contentInsetEndAdjustment.value = height; | |
| 127 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | |
| 128 | } | |
| 129 | }, | |
| 130 | - [contentInsetEndAdjustment, listRef] | |
| 131 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 132 | ); | |
| 133 | useLayoutEffect(() => { | |
| 134 | var _a; | |
| 135 | @@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | |
| 136 | } | |
| 137 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | |
| 138 | const { | |
| 139 | + adjustedInsetCompensation, | |
| 140 | anchoredEndSpace, | |
| 141 | applyWorkaroundForContentInsetHitTestBug, | |
| 142 | contentInsetEndAdjustment, | |
| 143 | + contentInsetEndStaticAdjustment, | |
| 144 | freeze, | |
| 145 | keyboardLiftBehavior, | |
| 146 | keyboardOffset, | |
| 147 | @@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 148 | includeInEndInset: true, | |
| 149 | onSizeChanged: (size) => { | |
| 150 | var _a; | |
| 151 | - blankSpace.value = size; | |
| 152 | + // The anchored blank is sized against the ADJUSTED viewport, but the | |
| 153 | + // scroll component writes it as a raw contentInset that UIKit tops up | |
| 154 | + // with the safe-area bottom — write it net of that or the end rest | |
| 155 | + // (and the anchored message) sinks one safe-area under the header. | |
| 156 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | |
| 157 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | |
| 158 | } | |
| 159 | }; | |
| 160 | - }, [anchoredEndSpace, blankSpace]); | |
| 161 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | |
| 162 | const onContentInsetChange = useCallback((insets) => { | |
| 163 | var _a; | |
| 164 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | |
| 165 | @@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 166 | KeyboardChatScrollView, | |
| 167 | { | |
| 168 | ...scrollProps, | |
| 169 | + adjustedInsetCompensation, | |
| 170 | applyWorkaroundForContentInsetHitTestBug, | |
| 171 | blankSpace, | |
| 172 | extraContentPadding: contentInsetEndAdjustment, | |
| 173 | @@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 174 | ); | |
| 175 | }, | |
| 176 | [ | |
| 177 | + adjustedInsetCompensation, | |
| 178 | applyWorkaroundForContentInsetHitTestBug, | |
| 179 | blankSpace, | |
| 180 | contentInsetEndAdjustment, | |
| 181 | @@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 182 | AnimatedLegendListInternal, | |
| 183 | { | |
| 184 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | |
| 185 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | |
| 186 | ref: combinedRef, | |
| 187 | renderScrollComponent: memoList, | |
| 188 | ...rest | |
| 189 | diff --git a/react-native.d.ts b/react-native.d.ts | |
| 190 | index 8204015..cdeaab7 100644 | |
| 191 | --- a/react-native.d.ts | |
| 192 | +++ b/react-native.d.ts | |
| 193 | @@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | |
| 194 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | |
| 195 | */ | |
| 196 | contentInsetEndAdjustment?: number; | |
| 197 | + /** | |
| 198 | + * Width/height of a leading content inset applied natively outside the list's knowledge | |
| 199 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | |
| 200 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | |
| 201 | + */ | |
| 202 | + contentInsetStartAdjustment?: number; | |
| 203 | /** | |
| 204 | * Number of columns to render items in. | |
| 205 | * @default 1 | |
| 206 | diff --git a/react-native.js b/react-native.js | |
| 207 | index 229f09a..2a1ceb6 100644 | |
| 208 | --- a/react-native.js | |
| 209 | +++ b/react-native.js | |
| 210 | @@ -930,7 +930,7 @@ function setInitialRenderState(ctx, { | |
| 211 | if (didInitialScroll) { | |
| 212 | state.didFinishInitialScroll = true; | |
| 213 | } | |
| 214 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | |
| 215 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | |
| 216 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | |
| 217 | set$(ctx, "readyToRender", true); | |
| 218 | setAdaptiveRender(ctx, "normal", "ready"); | |
| 219 | @@ -1259,18 +1259,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 220 | } | |
| 221 | ||
| 222 | // src/core/clampScrollOffset.ts | |
| 223 | +function getContentInsetStartAdjustment(ctx) { | |
| 224 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | |
| 225 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | |
| 226 | +} | |
| 227 | function clampScrollOffset(ctx, offset, scrollTarget) { | |
| 228 | const state = ctx.state; | |
| 229 | const contentSize = getContentSize(ctx); | |
| 230 | + const minOffset = -getContentInsetStartAdjustment(ctx); | |
| 231 | let clampedOffset = offset; | |
| 232 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | |
| 233 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | |
| 234 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | |
| 235 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | |
| 236 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | |
| 237 | const maxOffset = baseMaxOffset + extraEndOffset; | |
| 238 | clampedOffset = Math.min(offset, maxOffset); | |
| 239 | } | |
| 240 | - clampedOffset = Math.max(0, clampedOffset); | |
| 241 | + clampedOffset = Math.max(minOffset, clampedOffset); | |
| 242 | return clampedOffset; | |
| 243 | } | |
| 244 | ||
| 245 | @@ -1406,10 +1411,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 246 | finishScrollTo(ctx); | |
| 247 | } | |
| 248 | } | |
| 249 | -function scrollToFallbackOffset(ctx, offset) { | |
| 250 | +function scrollToFallbackOffset(ctx, offset, animated) { | |
| 251 | var _a3; | |
| 252 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | |
| 253 | - animated: false, | |
| 254 | + animated: !!animated, | |
| 255 | x: ctx.state.props.horizontal ? offset : 0, | |
| 256 | y: ctx.state.props.horizontal ? 0 : offset | |
| 257 | }); | |
| 258 | @@ -1456,7 +1461,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 259 | }); | |
| 260 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | |
| 261 | } else if (shouldRetryUnalignedEndScroll) { | |
| 262 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | |
| 263 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | |
| 264 | + if (!isActivelyAnimatingToEnd) { | |
| 265 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | |
| 266 | + } | |
| 267 | scheduleFallbackCheck(100); | |
| 268 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | |
| 269 | finishScrollTo(ctx); | |
| 270 | @@ -1517,9 +1525,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 271 | } | |
| 272 | if (shouldMaintainScrollAtEnd) { | |
| 273 | state.pendingMaintainScrollAtEnd = false; | |
| 274 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | |
| 275 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | |
| 276 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | |
| 277 | + return false; | |
| 278 | + } | |
| 279 | const contentSize = getContentSize(ctx); | |
| 280 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 281 | if (contentSize < state.scrollLength) { | |
| 282 | - state.scroll = 0; | |
| 283 | + state.scroll = -maintainInsetStartAdjustment; | |
| 284 | + if (maintainInsetStartAdjustment > 0) { | |
| 285 | + return true; | |
| 286 | + } | |
| 287 | } | |
| 288 | if (!state.maintainingScrollAtEnd) { | |
| 289 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | |
| 290 | @@ -1539,9 +1556,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 291 | y: 0 | |
| 292 | }); | |
| 293 | } else { | |
| 294 | - scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 295 | - animated: maintainScrollAtEnd.animated | |
| 296 | - }); | |
| 297 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 298 | + if (insetStartAdjustment > 0) { | |
| 299 | + scroller == null ? void 0 : scroller.scrollTo({ | |
| 300 | + animated: maintainScrollAtEnd.animated, | |
| 301 | + x: 0, | |
| 302 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | |
| 303 | + }); | |
| 304 | + } else { | |
| 305 | + scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 306 | + animated: maintainScrollAtEnd.animated | |
| 307 | + }); | |
| 308 | + } | |
| 309 | } | |
| 310 | setTimeout( | |
| 311 | () => { | |
| 312 | @@ -1571,6 +1597,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 313 | function requestAdjust(ctx, positionDiff, dataChanged) { | |
| 314 | const state = ctx.state; | |
| 315 | if (Math.abs(positionDiff) > 0.1) { | |
| 316 | + // Timestamp for ReanimatedPositionView: repositions caused by an MVCP | |
| 317 | + // size adjustment are already compensated by a contentOffset shift, so | |
| 318 | + // animating them would make rows visibly lurch and slide back. | |
| 319 | + state.lastMVCPAdjustTime = Date.now(); | |
| 320 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; | |
| 321 | const doit = () => { | |
| 322 | if (needsScrollWorkaround) { | |
| 323 | @@ -1674,7 +1704,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 324 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | |
| 325 | return 0; | |
| 326 | } | |
| 327 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 328 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | |
| 329 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | |
| 330 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | |
| 331 | const clampDelta = maxScroll - state.scroll; | |
| 332 | if (unresolvedAmount < 0) { | |
| 333 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | |
| 334 | @@ -1736,7 +1768,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 335 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | |
| 336 | return true; | |
| 337 | } | |
| 338 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | |
| 339 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | |
| 340 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | |
| 341 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | |
| 342 | if (isAtExpectedNativeClamp) { | |
| 343 | @@ -1869,7 +1901,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 344 | if (diff > 0) { | |
| 345 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | |
| 346 | } else { | |
| 347 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 348 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | |
| 349 | state.scroll = maxScroll; | |
| 350 | state.scrollPending = maxScroll; | |
| 351 | diff = 0; | |
| 352 | @@ -2274,8 +2306,121 @@ function scrollToIndex(ctx, { | |
| 353 | } | |
| 354 | ||
| 355 | // src/core/initialScroll.ts | |
| 356 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | |
| 357 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | |
| 358 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | |
| 359 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | |
| 360 | +function startInsetEndSettleWatchdog(ctx) { | |
| 361 | + const state = ctx.state; | |
| 362 | + if (state.insetEndSettleWatchdogActive) { | |
| 363 | + return; | |
| 364 | + } | |
| 365 | + state.insetEndSettleWatchdogActive = true; | |
| 366 | + state.didUserDrag = false; | |
| 367 | + // Hold the readyToRender opacity gate until the end landing is stable, so | |
| 368 | + // the estimated-to-measured settle chase happens before first VISIBLE | |
| 369 | + // paint instead of in front of the user. Capped so slow measurement can | |
| 370 | + // never hide content for long. | |
| 371 | + state.insetEndRevealHold = true; | |
| 372 | + let frames = 0; | |
| 373 | + let settledFrames = 0; | |
| 374 | + let revealStableFrames = 0; | |
| 375 | + const releaseRevealHold = () => { | |
| 376 | + if (state.insetEndRevealHold) { | |
| 377 | + state.insetEndRevealHold = false; | |
| 378 | + setInitialRenderState(ctx, {}); | |
| 379 | + } | |
| 380 | + }; | |
| 381 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | |
| 382 | + // can move the end target one more time — do that upgrade while still | |
| 383 | + // hidden (phase 1), then require stability again before revealing (phase 2) | |
| 384 | + // so the first visible frame is the settled one. | |
| 385 | + let revealPhase = 1; | |
| 386 | + const onRevealStability = () => { | |
| 387 | + var _a3; | |
| 388 | + if (!state.insetEndRevealHold) { | |
| 389 | + return; | |
| 390 | + } | |
| 391 | + if (revealPhase === 1) { | |
| 392 | + revealPhase = 2; | |
| 393 | + revealStableFrames = 0; | |
| 394 | + setAdaptiveRender(ctx, "normal"); | |
| 395 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | |
| 396 | + return; | |
| 397 | + } | |
| 398 | + releaseRevealHold(); | |
| 399 | + }; | |
| 400 | + const tick = () => { | |
| 401 | + // The user owns the scroll from their first drag — a re-pin here would | |
| 402 | + // pin the list under their finger. | |
| 403 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | |
| 404 | + state.insetEndSettleWatchdogActive = false; | |
| 405 | + releaseRevealHold(); | |
| 406 | + return; | |
| 407 | + } | |
| 408 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | |
| 409 | + releaseRevealHold(); | |
| 410 | + } | |
| 411 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 412 | + const contentSize = getContentSize(ctx); | |
| 413 | + const scrollLength = state.scrollLength; | |
| 414 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | |
| 415 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | |
| 416 | + const distance = endOffset - state.scroll; | |
| 417 | + // Estimated row sizes converging to measured ones can strand the initial | |
| 418 | + // end landing when the library's own end-anchor bookkeeping gives up. | |
| 419 | + // While still near the end (never fighting a user who scrolled away), | |
| 420 | + // re-pin to the current true end until sizes stop changing. | |
| 421 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | |
| 422 | + settledFrames = 0; | |
| 423 | + revealStableFrames = 0; | |
| 424 | + const scroller = state.refScroller.current; | |
| 425 | + if (scroller) { | |
| 426 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | |
| 427 | + } | |
| 428 | + } else { | |
| 429 | + settledFrames++; | |
| 430 | + revealStableFrames++; | |
| 431 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 432 | + onRevealStability(); | |
| 433 | + } | |
| 434 | + } | |
| 435 | + } else { | |
| 436 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | |
| 437 | + // programmatic scroll) count toward stability for the reveal. | |
| 438 | + revealStableFrames++; | |
| 439 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 440 | + onRevealStability(); | |
| 441 | + } | |
| 442 | + } | |
| 443 | + requestAnimationFrame(tick); | |
| 444 | + }; | |
| 445 | + requestAnimationFrame(tick); | |
| 446 | +} | |
| 447 | function dispatchInitialScroll(ctx, params) { | |
| 448 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | |
| 449 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 450 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | |
| 451 | + if (ctx.state.scrollLength <= 0) { | |
| 452 | + // The scroll length is unknown, so the resolved target is meaningless; | |
| 453 | + // the initial-scroll machinery re-advances after layout. | |
| 454 | + return; | |
| 455 | + } | |
| 456 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | |
| 457 | + // Content underflows the viewport and nothing has scrolled yet: the | |
| 458 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | |
| 459 | + // end position. Dispatching a negative scroll here would race UIKit's | |
| 460 | + // inset application during screen attach, so just finish the session. | |
| 461 | + finishInitialScroll(ctx, { | |
| 462 | + resolvedOffset | |
| 463 | + }); | |
| 464 | + return; | |
| 465 | + } | |
| 466 | + ctx.state.didDispatchInsetInitialScroll = true; | |
| 467 | + if (target.viewPosition === 1) { | |
| 468 | + startInsetEndSettleWatchdog(ctx); | |
| 469 | + } | |
| 470 | + } | |
| 471 | const requestedIndex = target.index; | |
| 472 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | |
| 473 | const itemSize = getItemSizeAtIndex(ctx, index); | |
| 474 | @@ -2704,7 +2849,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 475 | return; | |
| 476 | } | |
| 477 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | |
| 478 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | |
| 479 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | |
| 480 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | |
| 481 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | |
| 482 | if (!shouldKeepEndTargetAlive) { | |
| 483 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | |
| 484 | clearPendingInitialScrollFooterLayout(ctx, { | |
| 485 | @@ -4637,7 +4784,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 486 | } | |
| 487 | contentBelowAnchor += footerSize + stylePaddingBottom; | |
| 488 | isReady = !hasUnknownTailSize; | |
| 489 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 490 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 491 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | |
| 492 | } else if (anchorIndex >= 0) { | |
| 493 | isReady = false; | |
| 494 | } | |
| 495 | @@ -4655,6 +4803,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 496 | updateScroll(ctx, state.scroll, true); | |
| 497 | } | |
| 498 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | |
| 499 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | |
| 500 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | |
| 501 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | |
| 502 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | |
| 503 | + updateScroll(ctx, state.scroll, true); | |
| 504 | + } | |
| 505 | } | |
| 506 | return nextSize; | |
| 507 | } | |
| 508 | @@ -6960,6 +7114,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 509 | dataVersion, | |
| 510 | drawDistance = 250, | |
| 511 | contentInsetEndAdjustment, | |
| 512 | + contentInsetStartAdjustment, | |
| 513 | estimatedItemSize = 100, | |
| 514 | estimatedListSize, | |
| 515 | extraData, | |
| 516 | @@ -6990,6 +7145,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 517 | onLayout: onLayoutProp, | |
| 518 | onLoad, | |
| 519 | onMomentumScrollEnd, | |
| 520 | + onScrollBeginDrag, | |
| 521 | onRefresh, | |
| 522 | onScroll: onScrollProp, | |
| 523 | onScrollBeginDrag, | |
| 524 | @@ -7076,7 +7232,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 525 | const combinedRef = useCombinedRef(refScroller, refScrollView); | |
| 526 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | |
| 527 | const stickyHeaderIndices = stickyHeaderIndicesProp; | |
| 528 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; | |
| 529 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; | |
| 530 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); | |
| 531 | const alwaysRenderIndices = React2.useMemo(() => { | |
| 532 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | |
| 533 | @@ -7215,6 +7371,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 534 | contentContainerAlignItems: contentContainerStyle.alignItems, | |
| 535 | contentInset, | |
| 536 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | |
| 537 | + contentInsetStartAdjustment, | |
| 538 | data: dataProp, | |
| 539 | dataKey, | |
| 540 | dataVersion, | |
| 541 | @@ -7303,6 +7460,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 542 | return void 0; | |
| 543 | } | |
| 544 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | |
| 545 | + if (getContentInsetStartAdjustment(ctx) > 0) { | |
| 546 | + // With a native leading inset (transparent header + automatic insets), | |
| 547 | + // any mount-time contentOffset races UIKit's adjustedContentInset | |
| 548 | + // application. The gated initial-scroll dispatch positions the list once | |
| 549 | + // real sizes are known instead. | |
| 550 | + return void 0; | |
| 551 | + } | |
| 552 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | |
| 553 | }, [usesBootstrapInitialScroll]); | |
| 554 | React2.useLayoutEffect(() => { | |
| 555 | @@ -7526,6 +7690,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 556 | onScroll: (event) => onScroll(ctx, event), | |
| 557 | onScrollBeginDrag: (event) => { | |
| 558 | var _a4, _b2; | |
| 559 | + ctx.state.didUserDrag = true; | |
| 560 | prepareReachedEdgeForNextUserScroll(ctx); | |
| 561 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); | |
| 562 | }, | |
| 563 | @@ -7555,6 +7720,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 564 | onLayout, | |
| 565 | onLayoutFooter, | |
| 566 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | |
| 567 | + onScrollBeginDrag: fns.onScrollBeginDrag, | |
| 568 | onScroll: onScrollHandler, | |
| 569 | recycleItems, | |
| 570 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { | |
| 571 | diff --git a/react-native.mjs b/react-native.mjs | |
| 572 | index c2e0f38..5313086 100644 | |
| 573 | --- a/react-native.mjs | |
| 574 | +++ b/react-native.mjs | |
| 575 | @@ -909,7 +909,7 @@ function setInitialRenderState(ctx, { | |
| 576 | if (didInitialScroll) { | |
| 577 | state.didFinishInitialScroll = true; | |
| 578 | } | |
| 579 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | |
| 580 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | |
| 581 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | |
| 582 | set$(ctx, "readyToRender", true); | |
| 583 | setAdaptiveRender(ctx, "normal", "ready"); | |
| 584 | @@ -1238,18 +1238,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 585 | } | |
| 586 | ||
| 587 | // src/core/clampScrollOffset.ts | |
| 588 | +function getContentInsetStartAdjustment(ctx) { | |
| 589 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | |
| 590 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | |
| 591 | +} | |
| 592 | function clampScrollOffset(ctx, offset, scrollTarget) { | |
| 593 | const state = ctx.state; | |
| 594 | const contentSize = getContentSize(ctx); | |
| 595 | + const minOffset = -getContentInsetStartAdjustment(ctx); | |
| 596 | let clampedOffset = offset; | |
| 597 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | |
| 598 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | |
| 599 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | |
| 600 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | |
| 601 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | |
| 602 | const maxOffset = baseMaxOffset + extraEndOffset; | |
| 603 | clampedOffset = Math.min(offset, maxOffset); | |
| 604 | } | |
| 605 | - clampedOffset = Math.max(0, clampedOffset); | |
| 606 | + clampedOffset = Math.max(minOffset, clampedOffset); | |
| 607 | return clampedOffset; | |
| 608 | } | |
| 609 | ||
| 610 | @@ -1385,10 +1390,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 611 | finishScrollTo(ctx); | |
| 612 | } | |
| 613 | } | |
| 614 | -function scrollToFallbackOffset(ctx, offset) { | |
| 615 | +function scrollToFallbackOffset(ctx, offset, animated) { | |
| 616 | var _a3; | |
| 617 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | |
| 618 | - animated: false, | |
| 619 | + animated: !!animated, | |
| 620 | x: ctx.state.props.horizontal ? offset : 0, | |
| 621 | y: ctx.state.props.horizontal ? 0 : offset | |
| 622 | }); | |
| 623 | @@ -1435,7 +1440,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 624 | }); | |
| 625 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | |
| 626 | } else if (shouldRetryUnalignedEndScroll) { | |
| 627 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | |
| 628 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | |
| 629 | + if (!isActivelyAnimatingToEnd) { | |
| 630 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | |
| 631 | + } | |
| 632 | scheduleFallbackCheck(100); | |
| 633 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | |
| 634 | finishScrollTo(ctx); | |
| 635 | @@ -1496,9 +1504,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 636 | } | |
| 637 | if (shouldMaintainScrollAtEnd) { | |
| 638 | state.pendingMaintainScrollAtEnd = false; | |
| 639 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | |
| 640 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | |
| 641 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | |
| 642 | + return false; | |
| 643 | + } | |
| 644 | const contentSize = getContentSize(ctx); | |
| 645 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 646 | if (contentSize < state.scrollLength) { | |
| 647 | - state.scroll = 0; | |
| 648 | + state.scroll = -maintainInsetStartAdjustment; | |
| 649 | + if (maintainInsetStartAdjustment > 0) { | |
| 650 | + return true; | |
| 651 | + } | |
| 652 | } | |
| 653 | if (!state.maintainingScrollAtEnd) { | |
| 654 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | |
| 655 | @@ -1518,9 +1535,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 656 | y: 0 | |
| 657 | }); | |
| 658 | } else { | |
| 659 | - scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 660 | - animated: maintainScrollAtEnd.animated | |
| 661 | - }); | |
| 662 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 663 | + if (insetStartAdjustment > 0) { | |
| 664 | + scroller == null ? void 0 : scroller.scrollTo({ | |
| 665 | + animated: maintainScrollAtEnd.animated, | |
| 666 | + x: 0, | |
| 667 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | |
| 668 | + }); | |
| 669 | + } else { | |
| 670 | + scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 671 | + animated: maintainScrollAtEnd.animated | |
| 672 | + }); | |
| 673 | + } | |
| 674 | } | |
| 675 | setTimeout( | |
| 676 | () => { | |
| 677 | @@ -1550,6 +1576,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 678 | function requestAdjust(ctx, positionDiff, dataChanged) { | |
| 679 | const state = ctx.state; | |
| 680 | if (Math.abs(positionDiff) > 0.1) { | |
| 681 | + // Timestamp for ReanimatedPositionView: repositions caused by an MVCP | |
| 682 | + // size adjustment are already compensated by a contentOffset shift, so | |
| 683 | + // animating them would make rows visibly lurch and slide back. | |
| 684 | + state.lastMVCPAdjustTime = Date.now(); | |
| 685 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; | |
| 686 | const doit = () => { | |
| 687 | if (needsScrollWorkaround) { | |
| 688 | @@ -1653,7 +1683,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 689 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | |
| 690 | return 0; | |
| 691 | } | |
| 692 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 693 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | |
| 694 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | |
| 695 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | |
| 696 | const clampDelta = maxScroll - state.scroll; | |
| 697 | if (unresolvedAmount < 0) { | |
| 698 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | |
| 699 | @@ -1715,7 +1747,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 700 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | |
| 701 | return true; | |
| 702 | } | |
| 703 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | |
| 704 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | |
| 705 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | |
| 706 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | |
| 707 | if (isAtExpectedNativeClamp) { | |
| 708 | @@ -1848,7 +1880,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 709 | if (diff > 0) { | |
| 710 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | |
| 711 | } else { | |
| 712 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 713 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | |
| 714 | state.scroll = maxScroll; | |
| 715 | state.scrollPending = maxScroll; | |
| 716 | diff = 0; | |
| 717 | @@ -2253,8 +2285,121 @@ function scrollToIndex(ctx, { | |
| 718 | } | |
| 719 | ||
| 720 | // src/core/initialScroll.ts | |
| 721 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | |
| 722 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | |
| 723 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | |
| 724 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | |
| 725 | +function startInsetEndSettleWatchdog(ctx) { | |
| 726 | + const state = ctx.state; | |
| 727 | + if (state.insetEndSettleWatchdogActive) { | |
| 728 | + return; | |
| 729 | + } | |
| 730 | + state.insetEndSettleWatchdogActive = true; | |
| 731 | + state.didUserDrag = false; | |
| 732 | + // Hold the readyToRender opacity gate until the end landing is stable, so | |
| 733 | + // the estimated-to-measured settle chase happens before first VISIBLE | |
| 734 | + // paint instead of in front of the user. Capped so slow measurement can | |
| 735 | + // never hide content for long. | |
| 736 | + state.insetEndRevealHold = true; | |
| 737 | + let frames = 0; | |
| 738 | + let settledFrames = 0; | |
| 739 | + let revealStableFrames = 0; | |
| 740 | + const releaseRevealHold = () => { | |
| 741 | + if (state.insetEndRevealHold) { | |
| 742 | + state.insetEndRevealHold = false; | |
| 743 | + setInitialRenderState(ctx, {}); | |
| 744 | + } | |
| 745 | + }; | |
| 746 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | |
| 747 | + // can move the end target one more time — do that upgrade while still | |
| 748 | + // hidden (phase 1), then require stability again before revealing (phase 2) | |
| 749 | + // so the first visible frame is the settled one. | |
| 750 | + let revealPhase = 1; | |
| 751 | + const onRevealStability = () => { | |
| 752 | + var _a3; | |
| 753 | + if (!state.insetEndRevealHold) { | |
| 754 | + return; | |
| 755 | + } | |
| 756 | + if (revealPhase === 1) { | |
| 757 | + revealPhase = 2; | |
| 758 | + revealStableFrames = 0; | |
| 759 | + setAdaptiveRender(ctx, "normal"); | |
| 760 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | |
| 761 | + return; | |
| 762 | + } | |
| 763 | + releaseRevealHold(); | |
| 764 | + }; | |
| 765 | + const tick = () => { | |
| 766 | + // The user owns the scroll from their first drag — a re-pin here would | |
| 767 | + // pin the list under their finger. | |
| 768 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | |
| 769 | + state.insetEndSettleWatchdogActive = false; | |
| 770 | + releaseRevealHold(); | |
| 771 | + return; | |
| 772 | + } | |
| 773 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | |
| 774 | + releaseRevealHold(); | |
| 775 | + } | |
| 776 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 777 | + const contentSize = getContentSize(ctx); | |
| 778 | + const scrollLength = state.scrollLength; | |
| 779 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | |
| 780 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | |
| 781 | + const distance = endOffset - state.scroll; | |
| 782 | + // Estimated row sizes converging to measured ones can strand the initial | |
| 783 | + // end landing when the library's own end-anchor bookkeeping gives up. | |
| 784 | + // While still near the end (never fighting a user who scrolled away), | |
| 785 | + // re-pin to the current true end until sizes stop changing. | |
| 786 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | |
| 787 | + settledFrames = 0; | |
| 788 | + revealStableFrames = 0; | |
| 789 | + const scroller = state.refScroller.current; | |
| 790 | + if (scroller) { | |
| 791 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | |
| 792 | + } | |
| 793 | + } else { | |
| 794 | + settledFrames++; | |
| 795 | + revealStableFrames++; | |
| 796 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 797 | + onRevealStability(); | |
| 798 | + } | |
| 799 | + } | |
| 800 | + } else { | |
| 801 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | |
| 802 | + // programmatic scroll) count toward stability for the reveal. | |
| 803 | + revealStableFrames++; | |
| 804 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 805 | + onRevealStability(); | |
| 806 | + } | |
| 807 | + } | |
| 808 | + requestAnimationFrame(tick); | |
| 809 | + }; | |
| 810 | + requestAnimationFrame(tick); | |
| 811 | +} | |
| 812 | function dispatchInitialScroll(ctx, params) { | |
| 813 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | |
| 814 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 815 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | |
| 816 | + if (ctx.state.scrollLength <= 0) { | |
| 817 | + // The scroll length is unknown, so the resolved target is meaningless; | |
| 818 | + // the initial-scroll machinery re-advances after layout. | |
| 819 | + return; | |
| 820 | + } | |
| 821 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | |
| 822 | + // Content underflows the viewport and nothing has scrolled yet: the | |
| 823 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | |
| 824 | + // end position. Dispatching a negative scroll here would race UIKit's | |
| 825 | + // inset application during screen attach, so just finish the session. | |
| 826 | + finishInitialScroll(ctx, { | |
| 827 | + resolvedOffset | |
| 828 | + }); | |
| 829 | + return; | |
| 830 | + } | |
| 831 | + ctx.state.didDispatchInsetInitialScroll = true; | |
| 832 | + if (target.viewPosition === 1) { | |
| 833 | + startInsetEndSettleWatchdog(ctx); | |
| 834 | + } | |
| 835 | + } | |
| 836 | const requestedIndex = target.index; | |
| 837 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | |
| 838 | const itemSize = getItemSizeAtIndex(ctx, index); | |
| 839 | @@ -2683,7 +2828,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 840 | return; | |
| 841 | } | |
| 842 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | |
| 843 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | |
| 844 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | |
| 845 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | |
| 846 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | |
| 847 | if (!shouldKeepEndTargetAlive) { | |
| 848 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | |
| 849 | clearPendingInitialScrollFooterLayout(ctx, { | |
| 850 | @@ -4616,7 +4763,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 851 | } | |
| 852 | contentBelowAnchor += footerSize + stylePaddingBottom; | |
| 853 | isReady = !hasUnknownTailSize; | |
| 854 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 855 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 856 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | |
| 857 | } else if (anchorIndex >= 0) { | |
| 858 | isReady = false; | |
| 859 | } | |
| 860 | @@ -4634,6 +4782,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 861 | updateScroll(ctx, state.scroll, true); | |
| 862 | } | |
| 863 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | |
| 864 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | |
| 865 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | |
| 866 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | |
| 867 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | |
| 868 | + updateScroll(ctx, state.scroll, true); | |
| 869 | + } | |
| 870 | } | |
| 871 | return nextSize; | |
| 872 | } | |
| 873 | @@ -6939,6 +7093,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 874 | dataVersion, | |
| 875 | drawDistance = 250, | |
| 876 | contentInsetEndAdjustment, | |
| 877 | + contentInsetStartAdjustment, | |
| 878 | estimatedItemSize = 100, | |
| 879 | estimatedListSize, | |
| 880 | extraData, | |
| 881 | @@ -7055,7 +7210,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 882 | const combinedRef = useCombinedRef(refScroller, refScrollView); | |
| 883 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | |
| 884 | const stickyHeaderIndices = stickyHeaderIndicesProp; | |
| 885 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; | |
| 886 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; | |
| 887 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); | |
| 888 | const alwaysRenderIndices = useMemo(() => { | |
| 889 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | |
| 890 | @@ -7194,6 +7349,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 891 | contentContainerAlignItems: contentContainerStyle.alignItems, | |
| 892 | contentInset, | |
| 893 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | |
| 894 | + contentInsetStartAdjustment, | |
| 895 | data: dataProp, | |
| 896 | dataKey, | |
| 897 | dataVersion, | |
| 898 | @@ -7282,6 +7438,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 899 | return void 0; | |
| 900 | } | |
| 901 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | |
| 902 | + if (getContentInsetStartAdjustment(ctx) > 0) { | |
| 903 | + // With a native leading inset (transparent header + automatic insets), | |
| 904 | + // any mount-time contentOffset races UIKit's adjustedContentInset | |
| 905 | + // application. The gated initial-scroll dispatch positions the list once | |
| 906 | + // real sizes are known instead. | |
| 907 | + return void 0; | |
| 908 | + } | |
| 909 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | |
| 910 | }, [usesBootstrapInitialScroll]); | |
| 911 | useLayoutEffect(() => { | |
| 912 | @@ -7505,6 +7668,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 913 | onScroll: (event) => onScroll(ctx, event), | |
| 914 | onScrollBeginDrag: (event) => { | |
| 915 | var _a4, _b2; | |
| 916 | + ctx.state.didUserDrag = true; | |
| 917 | prepareReachedEdgeForNextUserScroll(ctx); | |
| 918 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); | |
| 919 | }, | |
| 920 | @@ -7534,6 +7698,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 921 | onLayout, | |
| 922 | onLayoutFooter, | |
| 923 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | |
| 924 | + onScrollBeginDrag: fns.onScrollBeginDrag, | |
| 925 | onScroll: onScrollHandler, | |
| 926 | recycleItems, | |
| 927 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { | |
| 928 | diff --git a/reanimated.d.ts b/reanimated.d.ts | |
| 929 | index 940da28..28dccbe 100644 | |
| 930 | --- a/reanimated.d.ts | |
| 931 | +++ b/reanimated.d.ts | |
| 932 | @@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | |
| 933 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | |
| 934 | */ | |
| 935 | contentInsetEndAdjustment?: number; | |
| 936 | + /** | |
| 937 | + * Width/height of a leading content inset applied natively outside the list's knowledge | |
| 938 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | |
| 939 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | |
| 940 | + */ | |
| 941 | + contentInsetStartAdjustment?: number; | |
| 942 | /** | |
| 943 | * Number of columns to render items in. | |
| 944 | * @default 1 | |
| 945 | diff --git a/reanimated.js b/reanimated.js | |
| 946 | index f1265fa..16dcef0 100644 | |
| 947 | --- a/reanimated.js | |
| 948 | +++ b/reanimated.js | |
| 949 | @@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 950 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); | |
| 951 | const prevItemKeyRef = React__namespace.useRef(void 0); | |
| 952 | let shouldSkipTransitionForRecycleReuse = false; | |
| 953 | - if (recycleItems && layoutTransition) { | |
| 954 | + if (layoutTransition) { | |
| 955 | const itemKeySignal = `containerItemKey${id}`; | |
| 956 | const itemKey = peek$(ctx, itemKeySignal); | |
| 957 | shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; | |
| 958 | @@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 959 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], | |
| 960 | [horizontal, positionValue, style] | |
| 961 | ); | |
| 962 | + // Two kinds of repositions must not animate: (1) MVCP size adjustments, | |
| 963 | + // which are compensated by an equal contentOffset shift so the row should | |
| 964 | + // not visibly move — animating turns the invisible correction into a | |
| 965 | + // lurch-and-settle; (2) any reposition while the user is actively | |
| 966 | + // scrolling, where rows shifting under the finger reads as jank. The | |
| 967 | + // transition exists to smooth at-rest shifts (streaming text, work-log | |
| 968 | + // folds), so gate it to at-rest moments. | |
| 969 | + const now = Date.now(); | |
| 970 | + const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || | |
| 971 | + now - (ctx.state.lastNativeScrollTime || 0) < 300; | |
| 972 | return /* @__PURE__ */ React__namespace.createElement( | |
| 973 | Reanimated__default.default.View, | |
| 974 | { | |
| 975 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, | |
| 976 | + layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, | |
| 977 | ref: refView, | |
| 978 | style: viewStyle, | |
| 979 | ...rest | |
| 980 | diff --git a/reanimated.mjs b/reanimated.mjs | |
| 981 | index 29a00d5..9c25ec5 100644 | |
| 982 | --- a/reanimated.mjs | |
| 983 | +++ b/reanimated.mjs | |
| 984 | @@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 985 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); | |
| 986 | const prevItemKeyRef = React.useRef(void 0); | |
| 987 | let shouldSkipTransitionForRecycleReuse = false; | |
| 988 | - if (recycleItems && layoutTransition) { | |
| 989 | + if (layoutTransition) { | |
| 990 | const itemKeySignal = `containerItemKey${id}`; | |
| 991 | const itemKey = peek$(ctx, itemKeySignal); | |
| 992 | shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; | |
| 993 | @@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 994 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], | |
| 995 | [horizontal, positionValue, style] | |
| 996 | ); | |
| 997 | + // Two kinds of repositions must not animate: (1) MVCP size adjustments, | |
| 998 | + // which are compensated by an equal contentOffset shift so the row should | |
| 999 | + // not visibly move — animating turns the invisible correction into a | |
| 1000 | + // lurch-and-settle; (2) any reposition while the user is actively | |
| 1001 | + // scrolling, where rows shifting under the finger reads as jank. The | |
| 1002 | + // transition exists to smooth at-rest shifts (streaming text, work-log | |
| 1003 | + // folds), so gate it to at-rest moments. | |
| 1004 | + const now = Date.now(); | |
| 1005 | + const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || | |
| 1006 | + now - (ctx.state.lastNativeScrollTime || 0) < 300; | |
| 1007 | return /* @__PURE__ */ React.createElement( | |
| 1008 | Reanimated.View, | |
| 1009 | { | |
| 1010 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, | |
| 1011 | + layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, | |
| 1012 | ref: refView, | |
| 1013 | style: viewStyle, | |
| 1014 | ...rest |
patches/@legendapp__list@3.3.5.patch created+1014| ... | ... | @@ -0,0 +1,1014 @@ |
| 1 | diff --git a/keyboard.d.ts b/keyboard.d.ts | |
| 2 | index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 100644 | |
| 3 | --- a/keyboard.d.ts | |
| 4 | +++ b/keyboard.d.ts | |
| 5 | @@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { | |
| 6 | type KeyboardChatComposerRef = { | |
| 7 | current: Pick<View, "measure"> | null; | |
| 8 | }; | |
| 9 | -declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { | |
| 10 | +declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { | |
| 11 | contentInsetEndAdjustment: SharedValue<number>; | |
| 12 | onComposerLayout: (event: LayoutChangeEvent) => void; | |
| 13 | }; | |
| 14 | @@ -288,8 +288,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb | |
| 15 | scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>; | |
| 16 | }; | |
| 17 | declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { | |
| 18 | + adjustedInsetCompensation?: number; | |
| 19 | anchoredEndSpace?: AnchoredEndSpaceConfig; | |
| 20 | contentInsetEndAdjustment?: SharedValue<number>; | |
| 21 | + contentInsetEndStaticAdjustment?: number; | |
| 22 | keyboardOffset?: number; | |
| 23 | } & React.RefAttributes<LegendListRef>) => React.ReactElement | null; | |
| 24 | ||
| 25 | diff --git a/keyboard.js b/keyboard.js | |
| 26 | index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 100644 | |
| 27 | --- a/keyboard.js | |
| 28 | +++ b/keyboard.js | |
| 29 | @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. | |
| 30 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | |
| 31 | ); | |
| 32 | } | |
| 33 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | |
| 34 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | |
| 35 | const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); | |
| 36 | const lastHeightRef = React.useRef(void 0); | |
| 37 | const reportHeight = React.useCallback( | |
| 38 | - (height) => { | |
| 39 | + (rawHeight) => { | |
| 40 | + const height = Math.max(0, rawHeight + heightAdjustment); | |
| 41 | var _a; | |
| 42 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | |
| 43 | lastHeightRef.current = height; | |
| 44 | contentInsetEndAdjustment.value = height; | |
| 45 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | |
| 46 | } | |
| 47 | }, | |
| 48 | - [contentInsetEndAdjustment, listRef] | |
| 49 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 50 | ); | |
| 51 | React.useLayoutEffect(() => { | |
| 52 | var _a; | |
| 53 | @@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | |
| 54 | } | |
| 55 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | |
| 56 | const { | |
| 57 | + adjustedInsetCompensation, | |
| 58 | anchoredEndSpace, | |
| 59 | applyWorkaroundForContentInsetHitTestBug, | |
| 60 | contentInsetEndAdjustment, | |
| 61 | + contentInsetEndStaticAdjustment, | |
| 62 | freeze, | |
| 63 | keyboardLiftBehavior, | |
| 64 | keyboardOffset, | |
| 65 | @@ -108,11 +110,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 66 | ...anchoredEndSpace, | |
| 67 | onSizeChanged: (size) => { | |
| 68 | var _a; | |
| 69 | - blankSpace.value = size; | |
| 70 | + // The anchored blank is sized against the ADJUSTED viewport, but the | |
| 71 | + // scroll component writes it as a raw contentInset that UIKit tops up | |
| 72 | + // with the safe-area bottom — write it net of that or the end rest | |
| 73 | + // (and the anchored message) sinks one safe-area under the header. | |
| 74 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | |
| 75 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | |
| 76 | } | |
| 77 | }; | |
| 78 | - }, [anchoredEndSpace, blankSpace]); | |
| 79 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | |
| 80 | const onContentInsetChange = React.useCallback((insets) => { | |
| 81 | var _a; | |
| 82 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | |
| 83 | @@ -123,6 +129,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 84 | reactNativeKeyboardController.KeyboardChatScrollView, | |
| 85 | { | |
| 86 | ...scrollProps, | |
| 87 | + adjustedInsetCompensation, | |
| 88 | applyWorkaroundForContentInsetHitTestBug, | |
| 89 | blankSpace, | |
| 90 | extraContentPadding: contentInsetEndAdjustment, | |
| 91 | @@ -134,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 92 | ); | |
| 93 | }, | |
| 94 | [ | |
| 95 | + adjustedInsetCompensation, | |
| 96 | applyWorkaroundForContentInsetHitTestBug, | |
| 97 | blankSpace, | |
| 98 | contentInsetEndAdjustment, | |
| 99 | @@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 100 | { | |
| 101 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | |
| 102 | anchoredEndSpaceOwnerInternal: "scroll", | |
| 103 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | |
| 104 | ref: combinedRef, | |
| 105 | renderScrollComponent: memoList, | |
| 106 | ...rest | |
| 107 | diff --git a/keyboard.mjs b/keyboard.mjs | |
| 108 | index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644 | |
| 109 | --- a/keyboard.mjs | |
| 110 | +++ b/keyboard.mjs | |
| 111 | @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { | |
| 112 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | |
| 113 | ); | |
| 114 | } | |
| 115 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | |
| 116 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | |
| 117 | const contentInsetEndAdjustment = useSharedValue(initialHeight); | |
| 118 | const lastHeightRef = useRef(void 0); | |
| 119 | const reportHeight = useCallback( | |
| 120 | - (height) => { | |
| 121 | + (rawHeight) => { | |
| 122 | + const height = Math.max(0, rawHeight + heightAdjustment); | |
| 123 | var _a; | |
| 124 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | |
| 125 | lastHeightRef.current = height; | |
| 126 | contentInsetEndAdjustment.value = height; | |
| 127 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | |
| 128 | } | |
| 129 | }, | |
| 130 | - [contentInsetEndAdjustment, listRef] | |
| 131 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 132 | ); | |
| 133 | useLayoutEffect(() => { | |
| 134 | var _a; | |
| 135 | @@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | |
| 136 | } | |
| 137 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | |
| 138 | const { | |
| 139 | + adjustedInsetCompensation, | |
| 140 | anchoredEndSpace, | |
| 141 | applyWorkaroundForContentInsetHitTestBug, | |
| 142 | contentInsetEndAdjustment, | |
| 143 | + contentInsetEndStaticAdjustment, | |
| 144 | freeze, | |
| 145 | keyboardLiftBehavior, | |
| 146 | keyboardOffset, | |
| 147 | @@ -87,11 +89,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 148 | ...anchoredEndSpace, | |
| 149 | onSizeChanged: (size) => { | |
| 150 | var _a; | |
| 151 | - blankSpace.value = size; | |
| 152 | + // The anchored blank is sized against the ADJUSTED viewport, but the | |
| 153 | + // scroll component writes it as a raw contentInset that UIKit tops up | |
| 154 | + // with the safe-area bottom — write it net of that or the end rest | |
| 155 | + // (and the anchored message) sinks one safe-area under the header. | |
| 156 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | |
| 157 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | |
| 158 | } | |
| 159 | }; | |
| 160 | - }, [anchoredEndSpace, blankSpace]); | |
| 161 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | |
| 162 | const onContentInsetChange = useCallback((insets) => { | |
| 163 | var _a; | |
| 164 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | |
| 165 | @@ -102,6 +108,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 166 | KeyboardChatScrollView, | |
| 167 | { | |
| 168 | ...scrollProps, | |
| 169 | + adjustedInsetCompensation, | |
| 170 | applyWorkaroundForContentInsetHitTestBug, | |
| 171 | blankSpace, | |
| 172 | extraContentPadding: contentInsetEndAdjustment, | |
| 173 | @@ -113,6 +120,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 174 | ); | |
| 175 | }, | |
| 176 | [ | |
| 177 | + adjustedInsetCompensation, | |
| 178 | applyWorkaroundForContentInsetHitTestBug, | |
| 179 | blankSpace, | |
| 180 | contentInsetEndAdjustment, | |
| 181 | @@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 182 | { | |
| 183 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | |
| 184 | anchoredEndSpaceOwnerInternal: "scroll", | |
| 185 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | |
| 186 | ref: combinedRef, | |
| 187 | renderScrollComponent: memoList, | |
| 188 | ...rest | |
| 189 | diff --git a/react-native.d.ts b/react-native.d.ts | |
| 190 | index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644 | |
| 191 | --- a/react-native.d.ts | |
| 192 | +++ b/react-native.d.ts | |
| 193 | @@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | |
| 194 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | |
| 195 | */ | |
| 196 | contentInsetEndAdjustment?: number; | |
| 197 | + /** | |
| 198 | + * Width/height of a leading content inset applied natively outside the list's knowledge | |
| 199 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | |
| 200 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | |
| 201 | + */ | |
| 202 | + contentInsetStartAdjustment?: number; | |
| 203 | /** | |
| 204 | * Number of columns to render items in. | |
| 205 | * @default 1 | |
| 206 | diff --git a/react-native.js b/react-native.js | |
| 207 | index b3c5a306b293f797a8b338adfca3060c0f6db22b..89077be4d6833cfaabf9d6d6205d9551505e32b4 100644 | |
| 208 | --- a/react-native.js | |
| 209 | +++ b/react-native.js | |
| 210 | @@ -954,7 +954,7 @@ function setInitialRenderState(ctx, { | |
| 211 | if (didInitialScroll) { | |
| 212 | state.didFinishInitialScroll = true; | |
| 213 | } | |
| 214 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | |
| 215 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | |
| 216 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | |
| 217 | set$(ctx, "readyToRender", true); | |
| 218 | setAdaptiveRender(ctx, "normal", "ready"); | |
| 219 | @@ -1304,18 +1304,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 220 | } | |
| 221 | ||
| 222 | // src/core/clampScrollOffset.ts | |
| 223 | +function getContentInsetStartAdjustment(ctx) { | |
| 224 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | |
| 225 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | |
| 226 | +} | |
| 227 | function clampScrollOffset(ctx, offset, scrollTarget) { | |
| 228 | const state = ctx.state; | |
| 229 | const contentSize = getContentSize(ctx); | |
| 230 | + const minOffset = -getContentInsetStartAdjustment(ctx); | |
| 231 | let clampedOffset = offset; | |
| 232 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | |
| 233 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | |
| 234 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | |
| 235 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | |
| 236 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | |
| 237 | const maxOffset = baseMaxOffset + extraEndOffset; | |
| 238 | clampedOffset = Math.min(offset, maxOffset); | |
| 239 | } | |
| 240 | - clampedOffset = Math.max(0, clampedOffset); | |
| 241 | + clampedOffset = Math.max(minOffset, clampedOffset); | |
| 242 | return clampedOffset; | |
| 243 | } | |
| 244 | ||
| 245 | @@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 246 | finishScrollTo(ctx); | |
| 247 | } | |
| 248 | } | |
| 249 | -function scrollToFallbackOffset(ctx, offset) { | |
| 250 | +function scrollToFallbackOffset(ctx, offset, animated) { | |
| 251 | var _a3; | |
| 252 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | |
| 253 | - animated: false, | |
| 254 | + animated: !!animated, | |
| 255 | x: ctx.state.props.horizontal ? offset : 0, | |
| 256 | y: ctx.state.props.horizontal ? 0 : offset | |
| 257 | }); | |
| 258 | @@ -1503,7 +1508,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 259 | ); | |
| 260 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | |
| 261 | } else if (shouldRetryUnalignedEndScroll) { | |
| 262 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | |
| 263 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | |
| 264 | + if (!isActivelyAnimatingToEnd) { | |
| 265 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | |
| 266 | + } | |
| 267 | scheduleFallbackCheck(100); | |
| 268 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | |
| 269 | finishScrollTo(ctx); | |
| 270 | @@ -1566,9 +1574,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 271 | } | |
| 272 | if (shouldMaintainScrollAtEnd) { | |
| 273 | state.pendingMaintainScrollAtEnd = false; | |
| 274 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | |
| 275 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | |
| 276 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | |
| 277 | + return false; | |
| 278 | + } | |
| 279 | const contentSize = getContentSize(ctx); | |
| 280 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 281 | if (contentSize < state.scrollLength) { | |
| 282 | - state.scroll = 0; | |
| 283 | + state.scroll = -maintainInsetStartAdjustment; | |
| 284 | + if (maintainInsetStartAdjustment > 0) { | |
| 285 | + return true; | |
| 286 | + } | |
| 287 | } | |
| 288 | if (!state.maintainingScrollAtEnd) { | |
| 289 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | |
| 290 | @@ -1591,9 +1608,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 291 | y: 0 | |
| 292 | }); | |
| 293 | } else { | |
| 294 | - scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 295 | - animated: maintainScrollAtEnd.animated | |
| 296 | - }); | |
| 297 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 298 | + if (insetStartAdjustment > 0) { | |
| 299 | + scroller == null ? void 0 : scroller.scrollTo({ | |
| 300 | + animated: maintainScrollAtEnd.animated, | |
| 301 | + x: 0, | |
| 302 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | |
| 303 | + }); | |
| 304 | + } else { | |
| 305 | + scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 306 | + animated: maintainScrollAtEnd.animated | |
| 307 | + }); | |
| 308 | + } | |
| 309 | } | |
| 310 | setTimeout( | |
| 311 | () => { | |
| 312 | @@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 313 | function requestAdjust(ctx, positionDiff, dataChanged) { | |
| 314 | const state = ctx.state; | |
| 315 | if (Math.abs(positionDiff) > 0.1) { | |
| 316 | + // Timestamp for ReanimatedPositionView: repositions caused by an MVCP | |
| 317 | + // size adjustment are already compensated by a contentOffset shift, so | |
| 318 | + // animating them would make rows visibly lurch and slide back. | |
| 319 | + state.lastMVCPAdjustTime = Date.now(); | |
| 320 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; | |
| 321 | const doit = () => { | |
| 322 | if (needsScrollWorkaround) { | |
| 323 | @@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 324 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | |
| 325 | return 0; | |
| 326 | } | |
| 327 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 328 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | |
| 329 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | |
| 330 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | |
| 331 | const clampDelta = maxScroll - state.scroll; | |
| 332 | if (unresolvedAmount < 0) { | |
| 333 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | |
| 334 | @@ -1790,7 +1822,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 335 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | |
| 336 | return true; | |
| 337 | } | |
| 338 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | |
| 339 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | |
| 340 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | |
| 341 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | |
| 342 | if (isAtExpectedNativeClamp) { | |
| 343 | @@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 344 | if (diff > 0) { | |
| 345 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | |
| 346 | } else { | |
| 347 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 348 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | |
| 349 | state.scroll = maxScroll; | |
| 350 | state.scrollPending = maxScroll; | |
| 351 | diff = 0; | |
| 352 | @@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, { | |
| 353 | } | |
| 354 | ||
| 355 | // src/core/initialScroll.ts | |
| 356 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | |
| 357 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | |
| 358 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | |
| 359 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | |
| 360 | +function startInsetEndSettleWatchdog(ctx) { | |
| 361 | + const state = ctx.state; | |
| 362 | + if (state.insetEndSettleWatchdogActive) { | |
| 363 | + return; | |
| 364 | + } | |
| 365 | + state.insetEndSettleWatchdogActive = true; | |
| 366 | + state.didUserDrag = false; | |
| 367 | + // Hold the readyToRender opacity gate until the end landing is stable, so | |
| 368 | + // the estimated-to-measured settle chase happens before first VISIBLE | |
| 369 | + // paint instead of in front of the user. Capped so slow measurement can | |
| 370 | + // never hide content for long. | |
| 371 | + state.insetEndRevealHold = true; | |
| 372 | + let frames = 0; | |
| 373 | + let settledFrames = 0; | |
| 374 | + let revealStableFrames = 0; | |
| 375 | + const releaseRevealHold = () => { | |
| 376 | + if (state.insetEndRevealHold) { | |
| 377 | + state.insetEndRevealHold = false; | |
| 378 | + setInitialRenderState(ctx, {}); | |
| 379 | + } | |
| 380 | + }; | |
| 381 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | |
| 382 | + // can move the end target one more time — do that upgrade while still | |
| 383 | + // hidden (phase 1), then require stability again before revealing (phase 2) | |
| 384 | + // so the first visible frame is the settled one. | |
| 385 | + let revealPhase = 1; | |
| 386 | + const onRevealStability = () => { | |
| 387 | + var _a3; | |
| 388 | + if (!state.insetEndRevealHold) { | |
| 389 | + return; | |
| 390 | + } | |
| 391 | + if (revealPhase === 1) { | |
| 392 | + revealPhase = 2; | |
| 393 | + revealStableFrames = 0; | |
| 394 | + setAdaptiveRender(ctx, "normal"); | |
| 395 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | |
| 396 | + return; | |
| 397 | + } | |
| 398 | + releaseRevealHold(); | |
| 399 | + }; | |
| 400 | + const tick = () => { | |
| 401 | + // The user owns the scroll from their first drag — a re-pin here would | |
| 402 | + // pin the list under their finger. | |
| 403 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | |
| 404 | + state.insetEndSettleWatchdogActive = false; | |
| 405 | + releaseRevealHold(); | |
| 406 | + return; | |
| 407 | + } | |
| 408 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | |
| 409 | + releaseRevealHold(); | |
| 410 | + } | |
| 411 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 412 | + const contentSize = getContentSize(ctx); | |
| 413 | + const scrollLength = state.scrollLength; | |
| 414 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | |
| 415 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | |
| 416 | + const distance = endOffset - state.scroll; | |
| 417 | + // Estimated row sizes converging to measured ones can strand the initial | |
| 418 | + // end landing when the library's own end-anchor bookkeeping gives up. | |
| 419 | + // While still near the end (never fighting a user who scrolled away), | |
| 420 | + // re-pin to the current true end until sizes stop changing. | |
| 421 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | |
| 422 | + settledFrames = 0; | |
| 423 | + revealStableFrames = 0; | |
| 424 | + const scroller = state.refScroller.current; | |
| 425 | + if (scroller) { | |
| 426 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | |
| 427 | + } | |
| 428 | + } else { | |
| 429 | + settledFrames++; | |
| 430 | + revealStableFrames++; | |
| 431 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 432 | + onRevealStability(); | |
| 433 | + } | |
| 434 | + } | |
| 435 | + } else { | |
| 436 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | |
| 437 | + // programmatic scroll) count toward stability for the reveal. | |
| 438 | + revealStableFrames++; | |
| 439 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 440 | + onRevealStability(); | |
| 441 | + } | |
| 442 | + } | |
| 443 | + requestAnimationFrame(tick); | |
| 444 | + }; | |
| 445 | + requestAnimationFrame(tick); | |
| 446 | +} | |
| 447 | function dispatchInitialScroll(ctx, params) { | |
| 448 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | |
| 449 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 450 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | |
| 451 | + if (ctx.state.scrollLength <= 0) { | |
| 452 | + // The scroll length is unknown, so the resolved target is meaningless; | |
| 453 | + // the initial-scroll machinery re-advances after layout. | |
| 454 | + return; | |
| 455 | + } | |
| 456 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | |
| 457 | + // Content underflows the viewport and nothing has scrolled yet: the | |
| 458 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | |
| 459 | + // end position. Dispatching a negative scroll here would race UIKit's | |
| 460 | + // inset application during screen attach, so just finish the session. | |
| 461 | + finishInitialScroll(ctx, { | |
| 462 | + resolvedOffset | |
| 463 | + }); | |
| 464 | + return; | |
| 465 | + } | |
| 466 | + ctx.state.didDispatchInsetInitialScroll = true; | |
| 467 | + if (target.viewPosition === 1) { | |
| 468 | + startInsetEndSettleWatchdog(ctx); | |
| 469 | + } | |
| 470 | + } | |
| 471 | const requestedIndex = target.index; | |
| 472 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | |
| 473 | const itemSize = getItemSizeAtIndex(ctx, index); | |
| 474 | @@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 475 | return; | |
| 476 | } | |
| 477 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | |
| 478 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | |
| 479 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | |
| 480 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | |
| 481 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | |
| 482 | if (!shouldKeepEndTargetAlive) { | |
| 483 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | |
| 484 | clearPendingInitialScrollFooterLayout(ctx, { | |
| 485 | @@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 486 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); | |
| 487 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); | |
| 488 | isReady = !hasUnknownTailSize; | |
| 489 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 490 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 491 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | |
| 492 | } else if (anchorIndex >= 0) { | |
| 493 | isReady = false; | |
| 494 | } | |
| 495 | @@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 496 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); | |
| 497 | } | |
| 498 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | |
| 499 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | |
| 500 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | |
| 501 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | |
| 502 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | |
| 503 | + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); | |
| 504 | + } | |
| 505 | } | |
| 506 | return nextSize; | |
| 507 | } | |
| 508 | @@ -7075,6 +7229,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 509 | dataVersion, | |
| 510 | drawDistance = 250, | |
| 511 | contentInsetEndAdjustment, | |
| 512 | + contentInsetStartAdjustment, | |
| 513 | estimatedItemSize = 100, | |
| 514 | estimatedListSize, | |
| 515 | extraData, | |
| 516 | @@ -7105,6 +7260,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 517 | onLayout: onLayoutProp, | |
| 518 | onLoad, | |
| 519 | onMomentumScrollEnd, | |
| 520 | + onScrollBeginDrag, | |
| 521 | onRefresh, | |
| 522 | onScroll: onScrollProp, | |
| 523 | onScrollBeginDrag, | |
| 524 | @@ -7200,7 +7356,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 525 | const combinedRef = useCombinedRef(refScroller, refScrollView); | |
| 526 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | |
| 527 | const stickyHeaderIndices = stickyHeaderIndicesProp; | |
| 528 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; | |
| 529 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; | |
| 530 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); | |
| 531 | const alwaysRenderIndices = React2.useMemo(() => { | |
| 532 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | |
| 533 | @@ -7341,6 +7497,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 534 | contentContainerAlignItems: contentContainerStyle.alignItems, | |
| 535 | contentInset, | |
| 536 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | |
| 537 | + contentInsetStartAdjustment, | |
| 538 | data: dataProp, | |
| 539 | dataKey, | |
| 540 | dataVersion, | |
| 541 | @@ -7423,6 +7580,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 542 | return void 0; | |
| 543 | } | |
| 544 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | |
| 545 | + if (getContentInsetStartAdjustment(ctx) > 0) { | |
| 546 | + // With a native leading inset (transparent header + automatic insets), | |
| 547 | + // any mount-time contentOffset races UIKit's adjustedContentInset | |
| 548 | + // application. The gated initial-scroll dispatch positions the list once | |
| 549 | + // real sizes are known instead. | |
| 550 | + return void 0; | |
| 551 | + } | |
| 552 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | |
| 553 | }, [usesBootstrapInitialScroll]); | |
| 554 | React2.useLayoutEffect(() => { | |
| 555 | @@ -7651,6 +7815,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 556 | onScroll: (event) => onScroll(ctx, event), | |
| 557 | onScrollBeginDrag: (event) => { | |
| 558 | var _a4, _b2; | |
| 559 | + ctx.state.didUserDrag = true; | |
| 560 | prepareReachedEdgeForNextUserScroll(ctx); | |
| 561 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); | |
| 562 | }, | |
| 563 | @@ -7681,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 564 | onLayout, | |
| 565 | onLayoutFooter, | |
| 566 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | |
| 567 | + onScrollBeginDrag: fns.onScrollBeginDrag, | |
| 568 | onScroll: onScrollHandler, | |
| 569 | recycleItems, | |
| 570 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { | |
| 571 | diff --git a/react-native.mjs b/react-native.mjs | |
| 572 | index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644 | |
| 573 | --- a/react-native.mjs | |
| 574 | +++ b/react-native.mjs | |
| 575 | @@ -933,7 +933,7 @@ function setInitialRenderState(ctx, { | |
| 576 | if (didInitialScroll) { | |
| 577 | state.didFinishInitialScroll = true; | |
| 578 | } | |
| 579 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | |
| 580 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | |
| 581 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | |
| 582 | set$(ctx, "readyToRender", true); | |
| 583 | setAdaptiveRender(ctx, "normal", "ready"); | |
| 584 | @@ -1283,18 +1283,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 585 | } | |
| 586 | ||
| 587 | // src/core/clampScrollOffset.ts | |
| 588 | +function getContentInsetStartAdjustment(ctx) { | |
| 589 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | |
| 590 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | |
| 591 | +} | |
| 592 | function clampScrollOffset(ctx, offset, scrollTarget) { | |
| 593 | const state = ctx.state; | |
| 594 | const contentSize = getContentSize(ctx); | |
| 595 | + const minOffset = -getContentInsetStartAdjustment(ctx); | |
| 596 | let clampedOffset = offset; | |
| 597 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | |
| 598 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | |
| 599 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | |
| 600 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | |
| 601 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | |
| 602 | const maxOffset = baseMaxOffset + extraEndOffset; | |
| 603 | clampedOffset = Math.min(offset, maxOffset); | |
| 604 | } | |
| 605 | - clampedOffset = Math.max(0, clampedOffset); | |
| 606 | + clampedOffset = Math.max(minOffset, clampedOffset); | |
| 607 | return clampedOffset; | |
| 608 | } | |
| 609 | ||
| 610 | @@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 611 | finishScrollTo(ctx); | |
| 612 | } | |
| 613 | } | |
| 614 | -function scrollToFallbackOffset(ctx, offset) { | |
| 615 | +function scrollToFallbackOffset(ctx, offset, animated) { | |
| 616 | var _a3; | |
| 617 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | |
| 618 | - animated: false, | |
| 619 | + animated: !!animated, | |
| 620 | x: ctx.state.props.horizontal ? offset : 0, | |
| 621 | y: ctx.state.props.horizontal ? 0 : offset | |
| 622 | }); | |
| 623 | @@ -1482,7 +1487,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 624 | ); | |
| 625 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | |
| 626 | } else if (shouldRetryUnalignedEndScroll) { | |
| 627 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | |
| 628 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | |
| 629 | + if (!isActivelyAnimatingToEnd) { | |
| 630 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | |
| 631 | + } | |
| 632 | scheduleFallbackCheck(100); | |
| 633 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | |
| 634 | finishScrollTo(ctx); | |
| 635 | @@ -1545,9 +1553,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 636 | } | |
| 637 | if (shouldMaintainScrollAtEnd) { | |
| 638 | state.pendingMaintainScrollAtEnd = false; | |
| 639 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | |
| 640 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | |
| 641 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | |
| 642 | + return false; | |
| 643 | + } | |
| 644 | const contentSize = getContentSize(ctx); | |
| 645 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 646 | if (contentSize < state.scrollLength) { | |
| 647 | - state.scroll = 0; | |
| 648 | + state.scroll = -maintainInsetStartAdjustment; | |
| 649 | + if (maintainInsetStartAdjustment > 0) { | |
| 650 | + return true; | |
| 651 | + } | |
| 652 | } | |
| 653 | if (!state.maintainingScrollAtEnd) { | |
| 654 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | |
| 655 | @@ -1570,9 +1587,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 656 | y: 0 | |
| 657 | }); | |
| 658 | } else { | |
| 659 | - scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 660 | - animated: maintainScrollAtEnd.animated | |
| 661 | - }); | |
| 662 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 663 | + if (insetStartAdjustment > 0) { | |
| 664 | + scroller == null ? void 0 : scroller.scrollTo({ | |
| 665 | + animated: maintainScrollAtEnd.animated, | |
| 666 | + x: 0, | |
| 667 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | |
| 668 | + }); | |
| 669 | + } else { | |
| 670 | + scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 671 | + animated: maintainScrollAtEnd.animated | |
| 672 | + }); | |
| 673 | + } | |
| 674 | } | |
| 675 | setTimeout( | |
| 676 | () => { | |
| 677 | @@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 678 | function requestAdjust(ctx, positionDiff, dataChanged) { | |
| 679 | const state = ctx.state; | |
| 680 | if (Math.abs(positionDiff) > 0.1) { | |
| 681 | + // Timestamp for ReanimatedPositionView: repositions caused by an MVCP | |
| 682 | + // size adjustment are already compensated by a contentOffset shift, so | |
| 683 | + // animating them would make rows visibly lurch and slide back. | |
| 684 | + state.lastMVCPAdjustTime = Date.now(); | |
| 685 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; | |
| 686 | const doit = () => { | |
| 687 | if (needsScrollWorkaround) { | |
| 688 | @@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 689 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | |
| 690 | return 0; | |
| 691 | } | |
| 692 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 693 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | |
| 694 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | |
| 695 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | |
| 696 | const clampDelta = maxScroll - state.scroll; | |
| 697 | if (unresolvedAmount < 0) { | |
| 698 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | |
| 699 | @@ -1769,7 +1801,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 700 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | |
| 701 | return true; | |
| 702 | } | |
| 703 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | |
| 704 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | |
| 705 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | |
| 706 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | |
| 707 | if (isAtExpectedNativeClamp) { | |
| 708 | @@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 709 | if (diff > 0) { | |
| 710 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | |
| 711 | } else { | |
| 712 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 713 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | |
| 714 | state.scroll = maxScroll; | |
| 715 | state.scrollPending = maxScroll; | |
| 716 | diff = 0; | |
| 717 | @@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, { | |
| 718 | } | |
| 719 | ||
| 720 | // src/core/initialScroll.ts | |
| 721 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | |
| 722 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | |
| 723 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | |
| 724 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | |
| 725 | +function startInsetEndSettleWatchdog(ctx) { | |
| 726 | + const state = ctx.state; | |
| 727 | + if (state.insetEndSettleWatchdogActive) { | |
| 728 | + return; | |
| 729 | + } | |
| 730 | + state.insetEndSettleWatchdogActive = true; | |
| 731 | + state.didUserDrag = false; | |
| 732 | + // Hold the readyToRender opacity gate until the end landing is stable, so | |
| 733 | + // the estimated-to-measured settle chase happens before first VISIBLE | |
| 734 | + // paint instead of in front of the user. Capped so slow measurement can | |
| 735 | + // never hide content for long. | |
| 736 | + state.insetEndRevealHold = true; | |
| 737 | + let frames = 0; | |
| 738 | + let settledFrames = 0; | |
| 739 | + let revealStableFrames = 0; | |
| 740 | + const releaseRevealHold = () => { | |
| 741 | + if (state.insetEndRevealHold) { | |
| 742 | + state.insetEndRevealHold = false; | |
| 743 | + setInitialRenderState(ctx, {}); | |
| 744 | + } | |
| 745 | + }; | |
| 746 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | |
| 747 | + // can move the end target one more time — do that upgrade while still | |
| 748 | + // hidden (phase 1), then require stability again before revealing (phase 2) | |
| 749 | + // so the first visible frame is the settled one. | |
| 750 | + let revealPhase = 1; | |
| 751 | + const onRevealStability = () => { | |
| 752 | + var _a3; | |
| 753 | + if (!state.insetEndRevealHold) { | |
| 754 | + return; | |
| 755 | + } | |
| 756 | + if (revealPhase === 1) { | |
| 757 | + revealPhase = 2; | |
| 758 | + revealStableFrames = 0; | |
| 759 | + setAdaptiveRender(ctx, "normal"); | |
| 760 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | |
| 761 | + return; | |
| 762 | + } | |
| 763 | + releaseRevealHold(); | |
| 764 | + }; | |
| 765 | + const tick = () => { | |
| 766 | + // The user owns the scroll from their first drag — a re-pin here would | |
| 767 | + // pin the list under their finger. | |
| 768 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | |
| 769 | + state.insetEndSettleWatchdogActive = false; | |
| 770 | + releaseRevealHold(); | |
| 771 | + return; | |
| 772 | + } | |
| 773 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | |
| 774 | + releaseRevealHold(); | |
| 775 | + } | |
| 776 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 777 | + const contentSize = getContentSize(ctx); | |
| 778 | + const scrollLength = state.scrollLength; | |
| 779 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | |
| 780 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | |
| 781 | + const distance = endOffset - state.scroll; | |
| 782 | + // Estimated row sizes converging to measured ones can strand the initial | |
| 783 | + // end landing when the library's own end-anchor bookkeeping gives up. | |
| 784 | + // While still near the end (never fighting a user who scrolled away), | |
| 785 | + // re-pin to the current true end until sizes stop changing. | |
| 786 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | |
| 787 | + settledFrames = 0; | |
| 788 | + revealStableFrames = 0; | |
| 789 | + const scroller = state.refScroller.current; | |
| 790 | + if (scroller) { | |
| 791 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | |
| 792 | + } | |
| 793 | + } else { | |
| 794 | + settledFrames++; | |
| 795 | + revealStableFrames++; | |
| 796 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 797 | + onRevealStability(); | |
| 798 | + } | |
| 799 | + } | |
| 800 | + } else { | |
| 801 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | |
| 802 | + // programmatic scroll) count toward stability for the reveal. | |
| 803 | + revealStableFrames++; | |
| 804 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 805 | + onRevealStability(); | |
| 806 | + } | |
| 807 | + } | |
| 808 | + requestAnimationFrame(tick); | |
| 809 | + }; | |
| 810 | + requestAnimationFrame(tick); | |
| 811 | +} | |
| 812 | function dispatchInitialScroll(ctx, params) { | |
| 813 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | |
| 814 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 815 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | |
| 816 | + if (ctx.state.scrollLength <= 0) { | |
| 817 | + // The scroll length is unknown, so the resolved target is meaningless; | |
| 818 | + // the initial-scroll machinery re-advances after layout. | |
| 819 | + return; | |
| 820 | + } | |
| 821 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | |
| 822 | + // Content underflows the viewport and nothing has scrolled yet: the | |
| 823 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | |
| 824 | + // end position. Dispatching a negative scroll here would race UIKit's | |
| 825 | + // inset application during screen attach, so just finish the session. | |
| 826 | + finishInitialScroll(ctx, { | |
| 827 | + resolvedOffset | |
| 828 | + }); | |
| 829 | + return; | |
| 830 | + } | |
| 831 | + ctx.state.didDispatchInsetInitialScroll = true; | |
| 832 | + if (target.viewPosition === 1) { | |
| 833 | + startInsetEndSettleWatchdog(ctx); | |
| 834 | + } | |
| 835 | + } | |
| 836 | const requestedIndex = target.index; | |
| 837 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | |
| 838 | const itemSize = getItemSizeAtIndex(ctx, index); | |
| 839 | @@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 840 | return; | |
| 841 | } | |
| 842 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | |
| 843 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | |
| 844 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | |
| 845 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | |
| 846 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | |
| 847 | if (!shouldKeepEndTargetAlive) { | |
| 848 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | |
| 849 | clearPendingInitialScrollFooterLayout(ctx, { | |
| 850 | @@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 851 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); | |
| 852 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); | |
| 853 | isReady = !hasUnknownTailSize; | |
| 854 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 855 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 856 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | |
| 857 | } else if (anchorIndex >= 0) { | |
| 858 | isReady = false; | |
| 859 | } | |
| 860 | @@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 861 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); | |
| 862 | } | |
| 863 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | |
| 864 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | |
| 865 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | |
| 866 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | |
| 867 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | |
| 868 | + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); | |
| 869 | + } | |
| 870 | } | |
| 871 | return nextSize; | |
| 872 | } | |
| 873 | @@ -7054,6 +7208,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 874 | dataVersion, | |
| 875 | drawDistance = 250, | |
| 876 | contentInsetEndAdjustment, | |
| 877 | + contentInsetStartAdjustment, | |
| 878 | estimatedItemSize = 100, | |
| 879 | estimatedListSize, | |
| 880 | extraData, | |
| 881 | @@ -7179,7 +7334,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 882 | const combinedRef = useCombinedRef(refScroller, refScrollView); | |
| 883 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | |
| 884 | const stickyHeaderIndices = stickyHeaderIndicesProp; | |
| 885 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; | |
| 886 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; | |
| 887 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); | |
| 888 | const alwaysRenderIndices = useMemo(() => { | |
| 889 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | |
| 890 | @@ -7320,6 +7475,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 891 | contentContainerAlignItems: contentContainerStyle.alignItems, | |
| 892 | contentInset, | |
| 893 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | |
| 894 | + contentInsetStartAdjustment, | |
| 895 | data: dataProp, | |
| 896 | dataKey, | |
| 897 | dataVersion, | |
| 898 | @@ -7402,6 +7558,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 899 | return void 0; | |
| 900 | } | |
| 901 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | |
| 902 | + if (getContentInsetStartAdjustment(ctx) > 0) { | |
| 903 | + // With a native leading inset (transparent header + automatic insets), | |
| 904 | + // any mount-time contentOffset races UIKit's adjustedContentInset | |
| 905 | + // application. The gated initial-scroll dispatch positions the list once | |
| 906 | + // real sizes are known instead. | |
| 907 | + return void 0; | |
| 908 | + } | |
| 909 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | |
| 910 | }, [usesBootstrapInitialScroll]); | |
| 911 | useLayoutEffect(() => { | |
| 912 | @@ -7630,6 +7793,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 913 | onScroll: (event) => onScroll(ctx, event), | |
| 914 | onScrollBeginDrag: (event) => { | |
| 915 | var _a4, _b2; | |
| 916 | + ctx.state.didUserDrag = true; | |
| 917 | prepareReachedEdgeForNextUserScroll(ctx); | |
| 918 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); | |
| 919 | }, | |
| 920 | @@ -7660,6 +7824,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 921 | onLayout, | |
| 922 | onLayoutFooter, | |
| 923 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | |
| 924 | + onScrollBeginDrag: fns.onScrollBeginDrag, | |
| 925 | onScroll: onScrollHandler, | |
| 926 | recycleItems, | |
| 927 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { | |
| 928 | diff --git a/reanimated.d.ts b/reanimated.d.ts | |
| 929 | index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 100644 | |
| 930 | --- a/reanimated.d.ts | |
| 931 | +++ b/reanimated.d.ts | |
| 932 | @@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | |
| 933 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | |
| 934 | */ | |
| 935 | contentInsetEndAdjustment?: number; | |
| 936 | + /** | |
| 937 | + * Width/height of a leading content inset applied natively outside the list's knowledge | |
| 938 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | |
| 939 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | |
| 940 | + */ | |
| 941 | + contentInsetStartAdjustment?: number; | |
| 942 | /** | |
| 943 | * Number of columns to render items in. | |
| 944 | * @default 1 | |
| 945 | diff --git a/reanimated.js b/reanimated.js | |
| 946 | index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 100644 | |
| 947 | --- a/reanimated.js | |
| 948 | +++ b/reanimated.js | |
| 949 | @@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 950 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); | |
| 951 | const prevItemKeyRef = React__namespace.useRef(void 0); | |
| 952 | let shouldSkipTransitionForRecycleReuse = false; | |
| 953 | - if (recycleItems && layoutTransition) { | |
| 954 | + if (layoutTransition) { | |
| 955 | const itemKeySignal = `containerItemKey${id}`; | |
| 956 | const itemKey = peek$(ctx, itemKeySignal); | |
| 957 | shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; | |
| 958 | @@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 959 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], | |
| 960 | [horizontal, positionValue, style] | |
| 961 | ); | |
| 962 | + // Two kinds of repositions must not animate: (1) MVCP size adjustments, | |
| 963 | + // which are compensated by an equal contentOffset shift so the row should | |
| 964 | + // not visibly move — animating turns the invisible correction into a | |
| 965 | + // lurch-and-settle; (2) any reposition while the user is actively | |
| 966 | + // scrolling, where rows shifting under the finger reads as jank. The | |
| 967 | + // transition exists to smooth at-rest shifts (streaming text, work-log | |
| 968 | + // folds), so gate it to at-rest moments. | |
| 969 | + const now = Date.now(); | |
| 970 | + const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || | |
| 971 | + now - (ctx.state.lastNativeScrollTime || 0) < 300; | |
| 972 | return /* @__PURE__ */ React__namespace.createElement( | |
| 973 | Reanimated__default.default.View, | |
| 974 | { | |
| 975 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, | |
| 976 | + layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, | |
| 977 | ref: refView, | |
| 978 | style: viewStyle, | |
| 979 | ...rest | |
| 980 | diff --git a/reanimated.mjs b/reanimated.mjs | |
| 981 | index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 100644 | |
| 982 | --- a/reanimated.mjs | |
| 983 | +++ b/reanimated.mjs | |
| 984 | @@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 985 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); | |
| 986 | const prevItemKeyRef = React.useRef(void 0); | |
| 987 | let shouldSkipTransitionForRecycleReuse = false; | |
| 988 | - if (recycleItems && layoutTransition) { | |
| 989 | + if (layoutTransition) { | |
| 990 | const itemKeySignal = `containerItemKey${id}`; | |
| 991 | const itemKey = peek$(ctx, itemKeySignal); | |
| 992 | shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; | |
| 993 | @@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 994 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], | |
| 995 | [horizontal, positionValue, style] | |
| 996 | ); | |
| 997 | + // Two kinds of repositions must not animate: (1) MVCP size adjustments, | |
| 998 | + // which are compensated by an equal contentOffset shift so the row should | |
| 999 | + // not visibly move — animating turns the invisible correction into a | |
| 1000 | + // lurch-and-settle; (2) any reposition while the user is actively | |
| 1001 | + // scrolling, where rows shifting under the finger reads as jank. The | |
| 1002 | + // transition exists to smooth at-rest shifts (streaming text, work-log | |
| 1003 | + // folds), so gate it to at-rest moments. | |
| 1004 | + const now = Date.now(); | |
| 1005 | + const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || | |
| 1006 | + now - (ctx.state.lastNativeScrollTime || 0) < 300; | |
| 1007 | return /* @__PURE__ */ React.createElement( | |
| 1008 | Reanimated.View, | |
| 1009 | { | |
| 1010 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, | |
| 1011 | + layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, | |
| 1012 | ref: refView, | |
| 1013 | style: viewStyle, | |
| 1014 | ...rest |
patches/@react-native-menu__menu@2.0.0.patch+225-2| ... | ... | @@ -1,9 +1,117 @@ |
| 1 | diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm | |
| 2 | index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644 | |
| 3 | --- a/ios/NewArch/MenuView.mm | |
| 4 | +++ b/ios/NewArch/MenuView.mm | |
| 5 | @@ -105,6 +105,27 @@ - (void)onOpenMenu { | |
| 6 | NSMutableArray<NSDictionary *> *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()]; | |
| 7 | if (action.subactions.size() > 0) { | |
| 8 | for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) { | |
| 9 | + NSMutableArray<NSDictionary *> *subSubactionsArray = | |
| 10 | + [NSMutableArray arrayWithCapacity:subaction.subactions.size()]; | |
| 11 | + for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) { | |
| 12 | + NSDictionary *subSubactionDict = @{ | |
| 13 | + @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()], | |
| 14 | + @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()], | |
| 15 | + @"titleColor": @(subSubaction.titleColor), | |
| 16 | + @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()], | |
| 17 | + @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()], | |
| 18 | + @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()], | |
| 19 | + @"imageColor": @(subSubaction.imageColor), | |
| 20 | + @"displayInline": @(subSubaction.displayInline), | |
| 21 | + @"attributes": @{ | |
| 22 | + @"destructive": @(subSubaction.attributes.destructive), | |
| 23 | + @"disabled": @(subSubaction.attributes.disabled), | |
| 24 | + @"hidden": @(subSubaction.attributes.hidden), | |
| 25 | + @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented), | |
| 26 | + }, | |
| 27 | + }; | |
| 28 | + [subSubactionsArray addObject:subSubactionDict]; | |
| 29 | + } | |
| 30 | NSDictionary *subactionDict = @{ | |
| 31 | @"id": [NSString stringWithUTF8String:subaction.id.c_str()], | |
| 32 | @"title": [NSString stringWithUTF8String:subaction.title.c_str()], | |
| 33 | @@ -118,7 +139,9 @@ - (void)onOpenMenu { | |
| 34 | @"destructive": @(subaction.attributes.destructive), | |
| 35 | @"disabled": @(subaction.attributes.disabled), | |
| 36 | @"hidden": @(subaction.attributes.hidden), | |
| 37 | + @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented), | |
| 38 | }, | |
| 39 | + @"subactions": subSubactionsArray, | |
| 40 | }; | |
| 41 | [subactionsArray addObject:subactionDict]; | |
| 42 | } | |
| 43 | @@ -138,6 +161,7 @@ - (void)onOpenMenu { | |
| 44 | @"destructive": @(action.attributes.destructive), | |
| 45 | @"disabled": @(action.attributes.disabled), | |
| 46 | @"hidden": @(action.attributes.hidden), | |
| 47 | + @"keepsMenuPresented": @(action.attributes.keepsMenuPresented), | |
| 48 | }, | |
| 49 | @"subactions": subactionsArray, | |
| 50 | }; | |
| 1 | 51 | diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift |
| 2 | index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644 | |
| 52 | index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644 | |
| 3 | 53 | --- a/ios/Shared/MenuViewImplementation.swift |
| 4 | 54 | +++ b/ios/Shared/MenuViewImplementation.swift |
| 5 | @@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton { | |
| 55 | @@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton { | |
| 56 | self.setup() | |
| 57 | } | |
| 58 | ||
| 59 | + // Presentation is tracked from the two delegate methods the class already | |
| 60 | + // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping) | |
| 61 | + // shadows UIButton's own implementation and degrades the button-anchored | |
| 62 | + // presentation into generic context-menu chrome — an empty header row with | |
| 63 | + // a dismiss chevron appears above the actions. | |
| 64 | public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { | |
| 65 | + // Flush updates deferred by the presented-guard before the action | |
| 66 | + // provider snapshots self.menu (covers a stuck flag from an | |
| 67 | + // interaction that never ended cleanly). | |
| 68 | + if pendingMenu != nil { | |
| 69 | + pendingMenu = nil | |
| 70 | + isMenuPresented = false | |
| 71 | + self.setup() | |
| 72 | + } | |
| 73 | + isMenuPresented = true | |
| 74 | sendMenuOpen() | |
| 75 | return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in | |
| 76 | guard let self = self else { return nil } | |
| 77 | return self.menu | |
| 78 | } | |
| 79 | } | |
| 80 | - | |
| 81 | + | |
| 82 | public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { | |
| 83 | sendMenuClose() | |
| 84 | + isMenuPresented = false | |
| 85 | + if pendingMenu != nil { | |
| 86 | + // Re-run the full assignment now that presentation is over, so | |
| 87 | + // any props deferred by the presented-guard (menu contents, press | |
| 88 | + // mode) land on the button. | |
| 89 | + pendingMenu = nil | |
| 90 | + self.setup() | |
| 91 | + } | |
| 92 | } | |
| 6 | 93 | |
| 94 | + private var isMenuPresented = false | |
| 95 | + private var pendingMenu: UIMenu? | |
| 96 | + | |
| 97 | func setup () { | |
| 98 | let menu = UIMenu(title: _title, | |
| 99 | identifier: nil, | |
| 100 | @@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton { | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | + if isMenuPresented { | |
| 105 | + // An action fired with keepsMenuPresented leaves the menu on | |
| 106 | + // screen, and reassigning self.menu while it is presented makes | |
| 107 | + // UIKit dismiss it — the update triggered by a selection would | |
| 108 | + // defeat the keep-presented attribute. Update the visible copy in | |
| 109 | + // place instead and defer the button assignment to dismissal. | |
| 110 | + pendingMenu = menu | |
| 111 | + self.refreshPresentedMenu(menu) | |
| 112 | + return | |
| 113 | + } | |
| 114 | + | |
| 7 | 115 | self.menu = menu |
| 8 | 116 | self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress |
| 9 | 117 | + // In long-press mode the button must not intercept touches: as a |
| ... | ... | @@ -17,6 +125,50 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 |
| 17 | 125 | + self.updateLongPressInteraction() |
| 18 | 126 | + } |
| 19 | 127 | + |
| 128 | + private func refreshPresentedMenu(_ menu: UIMenu) { | |
| 129 | + // Tap mode presents through the button's built-in interaction, | |
| 130 | + // long-press mode through the superview-hosted one; cover both (plus | |
| 131 | + // the interaction added in init) and dedupe by identity. | |
| 132 | + var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction } | |
| 133 | + if let builtin = self.contextMenuInteraction { | |
| 134 | + candidates.append(builtin) | |
| 135 | + } | |
| 136 | + if let host = longPressInteraction { | |
| 137 | + candidates.append(host) | |
| 138 | + } | |
| 139 | + var visited: Set<ObjectIdentifier> = [] | |
| 140 | + for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted { | |
| 141 | + // The block receives whichever menu level is currently on screen — | |
| 142 | + // the navigated submenu when the user picked inside one, not the | |
| 143 | + // root. Swap in the matching node from the rebuilt tree (stable | |
| 144 | + // identifiers from the JS action ids) so that level updates in | |
| 145 | + // place; returning an unrelated menu instead makes UIKit render it | |
| 146 | + // as navigation into a foreign menu, with a stale or blank | |
| 147 | + // expanded-submenu header row above the actions. The root carries | |
| 148 | + // an auto-generated identifier that never matches, so it falls | |
| 149 | + // through to a children-only replacement. | |
| 150 | + interaction.updateVisibleMenu { [weak self] visibleMenu in | |
| 151 | + guard let self = self else { return visibleMenu } | |
| 152 | + if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) { | |
| 153 | + return replacement | |
| 154 | + } | |
| 155 | + return visibleMenu.replacingChildren(menu.children) | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? { | |
| 161 | + if menu.identifier == identifier { | |
| 162 | + return menu | |
| 163 | + } | |
| 164 | + for element in menu.children { | |
| 165 | + if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) { | |
| 166 | + return match | |
| 167 | + } | |
| 168 | + } | |
| 169 | + return nil | |
| 170 | + } | |
| 171 | + | |
| 20 | 172 | + private var longPressInteraction: UIContextMenuInteraction? |
| 21 | 173 | + |
| 22 | 174 | + public override func didMoveToSuperview() { |
| ... | ... | @@ -44,3 +196,74 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 |
| 44 | 196 | } |
| 45 | 197 | |
| 46 | 198 | public override func reactSetFrame(_ frame: CGRect) { |
| 199 | diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift | |
| 200 | index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644 | |
| 201 | --- a/ios/Shared/RCTMenuItem.swift | |
| 202 | +++ b/ios/Shared/RCTMenuItem.swift | |
| 203 | @@ -103,10 +103,18 @@ class RCTMenuAction { | |
| 204 | subMenuActions.append(subaction.createUIMenuElement(handler)) | |
| 205 | } | |
| 206 | var menu: UIMenu; | |
| 207 | + // Stable identifiers let updateVisibleMenu match submenu nodes in | |
| 208 | + // place, so a refresh while the menu is presented keeps the user's | |
| 209 | + // current submenu level instead of popping back to the root. | |
| 210 | + // Inline sections don't navigate, so they stay stock. | |
| 211 | + let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) } | |
| 212 | if self.displayInline { | |
| 213 | menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions) | |
| 214 | } else { | |
| 215 | - menu = UIMenu(title: title, image: image, children: subMenuActions) | |
| 216 | + menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions) | |
| 217 | + } | |
| 218 | + if #available(iOS 15.0, *) { | |
| 219 | + menu.subtitle = subtitle | |
| 220 | } | |
| 221 | ||
| 222 | if #available(iOS 16.0, *) { | |
| 223 | diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts | |
| 224 | index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644 | |
| 225 | --- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts | |
| 226 | +++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts | |
| 227 | @@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati | |
| 228 | types here, to avoid issues while `pod install` takes place. | |
| 229 | */ | |
| 230 | ||
| 231 | +type SubSubAction = { | |
| 232 | +	id?: string; | |
| 233 | +	title: string; | |
| 234 | +	titleColor?: Int32; | |
| 235 | +	subtitle?: string; | |
| 236 | +	state?: string; | |
| 237 | +	image?: string; | |
| 238 | +	imageColor?: Int32; | |
| 239 | +	displayInline?: boolean; | |
| 240 | +	attributes?: { | |
| 241 | +		destructive?: boolean; | |
| 242 | +		disabled?: boolean; | |
| 243 | +		hidden?: boolean; | |
| 244 | +		keepsMenuPresented?: boolean; | |
| 245 | +	}; | |
| 246 | +}; | |
| 247 | type SubAction = { | |
| 248 | 	id?: string; | |
| 249 | 	title: string; | |
| 250 | @@ -26,7 +42,11 @@ type SubAction = { | |
| 251 | 		destructive?: boolean; | |
| 252 | 		disabled?: boolean; | |
| 253 | 		hidden?: boolean; | |
| 254 | +		keepsMenuPresented?: boolean; | |
| 255 | 	}; | |
| 256 | +	// One extra nesting level (menu → submenu → nested submenu leaves); the | |
| 257 | +	// codegen structs can't recurse, so depth is capped explicitly. | |
| 258 | +	subactions?: Array<SubSubAction>; | |
| 259 | }; | |
| 260 | type MenuAction = { | |
| 261 | 	id?: string; | |
| 262 | @@ -41,6 +61,7 @@ type MenuAction = { | |
| 263 | 		destructive?: boolean; | |
| 264 | 		disabled?: boolean; | |
| 265 | 		hidden?: boolean; | |
| 266 | +		keepsMenuPresented?: boolean; | |
| 267 | 	}; | |
| 268 | 	subactions?: Array<SubAction>; | |
| 269 | }; |
patches/react-native-screens@4.25.2.patch+175-54| ... | ... | @@ -140,11 +140,24 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 |
| 140 | 140 | |
| 141 | 141 | NS_ASSUME_NONNULL_END |
| 142 | 142 | diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm |
| 143 | index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644 | |
| 143 | index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb2199dcbd58 100644 | |
| 144 | 144 | --- a/ios/RNSScreenStackHeaderConfig.mm |
| 145 | 145 | +++ b/ios/RNSScreenStackHeaderConfig.mm |
| 146 | @@ -30,6 +30,20 @@ | |
| 146 | @@ -25,11 +25,33 @@ | |
| 147 | #import "RNSSearchBar.h" | |
| 148 | #import "UINavigationBar+RNSUtility.h" | |
| 149 | ||
| 150 | +#import <objc/runtime.h> | |
| 151 | + | |
| 152 | namespace react = facebook::react; | |
| 153 | ||
| 147 | 154 | static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; |
| 155 | + | |
| 156 | +// Keys for the last-applied JS bar button configs, associated with the | |
| 157 | +// navigation item so unrelated header updates (title, subtitle, tint) don't | |
| 158 | +// recreate the native buttons they configure. | |
| 159 | +static char RNSAppliedHeaderBarButtonConfigsKey; | |
| 160 | +static char RNSAppliedToolbarConfigsKey; | |
| 148 | 161 | static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; |
| 149 | 162 | |
| 150 | 163 | +static NSInteger navigationItemStyleFromCppEquivalent( |
| ... | ... | @@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 164 | 177 | @interface RCTImageLoader (Private) |
| 165 | 178 | - (id<RCTImageCache>)imageCache; |
| 166 | 179 | @end |
| 167 | @@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string | |
| 180 | @@ -47,6 +69,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string | |
| 168 | 181 | @end |
| 169 | 182 | |
| 170 | 183 | @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving> |
| ... | ... | @@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 174 | 187 | @end |
| 175 | 188 | |
| 176 | 189 | @implementation RNSScreenStackHeaderConfig { |
| 177 | @@ -81,6 +98,7 @@ - (void)initProps | |
| 190 | @@ -81,6 +106,7 @@ - (void)initProps | |
| 178 | 191 | self.hidden = YES; |
| 179 | 192 | _reactSubviews = [NSMutableArray new]; |
| 180 | 193 | _backTitleVisible = YES; |
| ... | ... | @@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 182 | 195 | _blurEffect = RNSBlurEffectStyleNone; |
| 183 | 196 | } |
| 184 | 197 | |
| 185 | @@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc | |
| 198 | @@ -496,6 +522,10 @@ + (void)updateViewController:(UIViewController *)vc | |
| 186 | 199 | |
| 187 | 200 | if (shouldHide) { |
| 188 | 201 | navitem.title = config.title; |
| ... | ... | @@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 193 | 206 | |
| 194 | 207 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. |
| 195 | 208 | [navctr setNavigationBarHidden:YES animated:animated]; |
| 196 | @@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc | |
| 209 | @@ -512,11 +542,19 @@ + (void)updateViewController:(UIViewController *)vc | |
| 197 | 210 | } |
| 198 | 211 | navitem.largeTitleDisplayMode = |
| 199 | 212 | config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; |
| ... | ... | @@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 213 | 226 | |
| 214 | 227 | // appearance does not apply to the tvOS so we need to use lagacy customization |
| 215 | 228 | #if TARGET_OS_TV |
| 216 | @@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc | |
| 229 | @@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc | |
| 217 | 230 | // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). |
| 218 | 231 | // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) |
| 219 | 232 | navitem.title = config.title; |
| ... | ... | @@ -221,32 +234,62 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 221 | 234 | - withCurrentItems:navitem.leftBarButtonItems]; |
| 222 | 235 | - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems |
| 223 | 236 | - withCurrentItems:navitem.rightBarButtonItems]; |
| 224 | + NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems | |
| 225 | + withCurrentItems:navitem.leftBarButtonItems | |
| 226 | + navigationItem:navitem]; | |
| 227 | + NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | |
| 228 | + withCurrentItems:navitem.rightBarButtonItems | |
| 229 | + navigationItem:navitem]; | |
| 230 | + NSArray<UIBarButtonItem *> *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems | |
| 231 | + withCurrentItems:@[] | |
| 237 | + NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; | |
| 238 | + NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; | |
| 239 | + NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; | |
| 240 | + NSArray<UIBarButtonItem *> *subviewLeftItems = navitem.leftBarButtonItems ?: @[]; | |
| 241 | + NSArray<UIBarButtonItem *> *subviewRightItems = navitem.rightBarButtonItems ?: @[]; | |
| 242 | + // The key includes the config instance's identity: cached items capture | |
| 243 | + // this config's event emitter in their press handlers, so a remounted | |
| 244 | + // header-config view with value-equal configs must still rebuild — reusing | |
| 245 | + // the old items would dispatch presses into the dead config's emitter. | |
| 246 | + NSArray *headerItemsKey = | |
| 247 | + @[ @((uintptr_t)config), headerLeftConfigs, headerRightConfigs, headerCenterConfigs ]; | |
| 248 | + // Rebuilding bar button items creates brand-new native buttons (glass | |
| 249 | + // UIButton custom views on iOS 26). Replacing them while UIKit animates an | |
| 250 | + // existing one (menu capsule morph, push/pop glass transitions) strands the | |
| 251 | + // animation overlay — a stuck expanded capsule or an unmasked square back | |
| 252 | + // button. When the JS configs are unchanged, keep the already-applied items. | |
| 253 | + // Subview-backed items are re-derived every pass, so their presence forces | |
| 254 | + // the rebuild path. | |
| 255 | + BOOL reuseHeaderBarButtonItems = subviewLeftItems.count == 0 && subviewRightItems.count == 0 && | |
| 256 | + [objc_getAssociatedObject(navitem, &RNSAppliedHeaderBarButtonConfigsKey) isEqual:headerItemsKey]; | |
| 257 | + if (!reuseHeaderBarButtonItems) { | |
| 258 | + NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems | |
| 259 | + withCurrentItems:navitem.leftBarButtonItems | |
| 232 | 260 | + navigationItem:navitem]; |
| 261 | + NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | |
| 262 | + withCurrentItems:navitem.rightBarButtonItems | |
| 263 | + navigationItem:navitem]; | |
| 264 | + NSArray<UIBarButtonItem *> *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems | |
| 265 | + withCurrentItems:@[] | |
| 266 | + navigationItem:navitem]; | |
| 233 | 267 | +#if !TARGET_OS_TV |
| 234 | + if (@available(iOS 16.0, *)) { | |
| 235 | + navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; | |
| 236 | + navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; | |
| 237 | + if (@available(iOS 26.0, *)) { | |
| 238 | + navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; | |
| 268 | + if (@available(iOS 16.0, *)) { | |
| 269 | + navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; | |
| 270 | + navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; | |
| 271 | + if (@available(iOS 26.0, *)) { | |
| 272 | + navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; | |
| 273 | + } | |
| 274 | + navitem.leftBarButtonItems = nil; | |
| 275 | + navitem.rightBarButtonItems = nil; | |
| 276 | + } else { | |
| 277 | + navitem.leftBarButtonItems = leftBarButtonItems; | |
| 278 | + navitem.rightBarButtonItems = rightBarButtonItems; | |
| 239 | 279 | + } |
| 240 | + navitem.leftBarButtonItems = nil; | |
| 241 | + navitem.rightBarButtonItems = nil; | |
| 242 | + } else { | |
| 280 | +#else | |
| 243 | 281 | + navitem.leftBarButtonItems = leftBarButtonItems; |
| 244 | 282 | + navitem.rightBarButtonItems = rightBarButtonItems; |
| 245 | + } | |
| 246 | +#else | |
| 247 | + navitem.leftBarButtonItems = leftBarButtonItems; | |
| 248 | + navitem.rightBarButtonItems = rightBarButtonItems; | |
| 249 | 283 | +#endif |
| 284 | + // Only dict-driven items can be reused: with subview-backed items in the | |
| 285 | + // mix the applied state depends on view identity, so clear the key to | |
| 286 | + // force a rebuild on the next pass. | |
| 287 | + objc_setAssociatedObject( | |
| 288 | + navitem, | |
| 289 | + &RNSAppliedHeaderBarButtonConfigsKey, | |
| 290 | + subviewLeftItems.count == 0 && subviewRightItems.count == 0 ? headerItemsKey : nil, | |
| 291 | + OBJC_ASSOCIATION_RETAIN_NONATOMIC); | |
| 292 | + } | |
| 250 | 293 | + NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil; |
| 251 | 294 | + for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) { |
| 252 | 295 | + if (toolbarConfig[@"mailSearchToolbar"]) { |
| ... | ... | @@ -269,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 269 | 312 | + if (existingMailSearchToolbar == nil) { |
| 270 | 313 | + existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; |
| 271 | 314 | + } |
| 272 | + [existingMailSearchToolbar removeFromSuperview]; | |
| 315 | + // Reuse the live toolbar when nothing it was built from changed: rebuilding | |
| 316 | + // replaces the fallback UISearchTextField with a fresh empty one, dropping | |
| 317 | + // the user's in-progress search text and first responder on every unrelated | |
| 318 | + // header update. Keyed on the config instance (the button/search blocks | |
| 319 | + // capture its event emitter), the toolbar config values, and the host width | |
| 320 | + // (the width constraint constant is resolved from it at build time). | |
| 321 | + static char RNSAppliedMailSearchToolbarConfigKey; | |
| 322 | + NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil | |
| 323 | + ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ] | |
| 324 | + : nil; | |
| 325 | + BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil && | |
| 326 | + [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey) | |
| 327 | + isEqual:mailSearchToolbarKey]; | |
| 328 | + if (!reuseMailSearchToolbar) { | |
| 329 | + [existingMailSearchToolbar removeFromSuperview]; | |
| 330 | + } | |
| 273 | 331 | + |
| 274 | + if (mailSearchToolbarConfig != nil) { | |
| 332 | + if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) { | |
| 275 | 333 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) |
| 276 | 334 | + if (@available(iOS 26.0, *)) { |
| 277 | 335 | + CGFloat horizontalInset = 18.0; |
| ... | ... | @@ -304,21 +362,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 304 | 362 | + |
| 305 | 363 | + UIView *toolbarHost = [[UIView alloc] init]; |
| 306 | 364 | + toolbarHost.tag = RNSMailSearchToolbarViewTag; |
| 365 | + objc_setAssociatedObject( | |
| 366 | + toolbarHost, | |
| 367 | + &RNSAppliedMailSearchToolbarConfigKey, | |
| 368 | + mailSearchToolbarKey, | |
| 369 | + OBJC_ASSOCIATION_RETAIN_NONATOMIC); | |
| 307 | 370 | + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; |
| 308 | 371 | + [chromeHostView addSubview:toolbarHost]; |
| 309 | + // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard | |
| 310 | + // layout guide no longer rests at the bottom safe-area edge while the | |
| 311 | + // keyboard is hidden, which pushed the toolbar offscreen. The required | |
| 312 | + // resting position is the safe area; the keyboard guide only pulls the | |
| 313 | + // toolbar up when it actually tracks a visible keyboard. | |
| 372 | + // The screen stays mounted beneath pushed routes, so its keyboard layout | |
| 373 | + // guide can track a keyboard owned by another screen. Keep the toolbar at | |
| 374 | + // rest unless its own search field is editing. | |
| 314 | 375 | + NSLayoutConstraint *keyboardAvoidConstraint = |
| 315 | 376 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor |
| 316 | 377 | + constant:-toolbarBottomSpacing]; |
| 317 | + keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; | |
| 378 | + keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; | |
| 318 | 379 | + NSLayoutConstraint *restingBottomConstraint = |
| 319 | 380 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor |
| 320 | 381 | + constant:-toolbarBottomSpacing]; |
| 321 | + restingBottomConstraint.priority = UILayoutPriorityDefaultLow; | |
| 382 | + restingBottomConstraint.priority = UILayoutPriorityDefaultHigh; | |
| 322 | 383 | + [NSLayoutConstraint activateConstraints:@[ |
| 323 | 384 | + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], |
| 324 | 385 | + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor |
| ... | ... | @@ -330,6 +391,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 330 | 391 | + ]]; |
| 331 | 392 | + [chromeHostView bringSubviewToFront:toolbarHost]; |
| 332 | 393 | + |
| 394 | + void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) { | |
| 395 | + BOOL isEditing = textField.isFirstResponder; | |
| 396 | + keyboardAvoidConstraint.priority = | |
| 397 | + isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; | |
| 398 | + restingBottomConstraint.priority = | |
| 399 | + isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; | |
| 400 | + | |
| 401 | + __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; | |
| 402 | + __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; | |
| 403 | + __weak UIView *weakChromeHostView = chromeHostView; | |
| 404 | + NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; | |
| 405 | + NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; | |
| 406 | + [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin]; | |
| 407 | + [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd]; | |
| 408 | + [textField addAction:[UIAction actionWithTitle:@"" | |
| 409 | + image:nil | |
| 410 | + identifier:beginActionIdentifier | |
| 411 | + handler:^(__kindof UIAction *_Nonnull action) { | |
| 412 | + weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow; | |
| 413 | + weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; | |
| 414 | + [weakChromeHostView setNeedsLayout]; | |
| 415 | + }] | |
| 416 | + forControlEvents:UIControlEventEditingDidBegin]; | |
| 417 | + [textField addAction:[UIAction actionWithTitle:@"" | |
| 418 | + image:nil | |
| 419 | + identifier:endActionIdentifier | |
| 420 | + handler:^(__kindof UIAction *_Nonnull action) { | |
| 421 | + weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; | |
| 422 | + weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh; | |
| 423 | + [weakChromeHostView setNeedsLayout]; | |
| 424 | + }] | |
| 425 | + forControlEvents:UIControlEventEditingDidEnd]; | |
| 426 | + }; | |
| 427 | + | |
| 333 | 428 | + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; |
| 334 | 429 | + glassEffect.interactive = YES; |
| 335 | 430 | + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; |
| ... | ... | @@ -411,6 +506,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 411 | 506 | + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; |
| 412 | 507 | + searchBar.searchTextField.textColor = UIColor.labelColor; |
| 413 | 508 | + searchBar.searchTextField.tintColor = UIColor.labelColor; |
| 509 | + configureKeyboardTracking(searchBar.searchTextField); | |
| 414 | 510 | + if (placeholder != nil) { |
| 415 | 511 | + searchBar.searchTextField.attributedPlaceholder = |
| 416 | 512 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; |
| ... | ... | @@ -443,6 +539,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 443 | 539 | + searchField.adjustsFontForContentSizeCategory = YES; |
| 444 | 540 | + searchField.textColor = UIColor.labelColor; |
| 445 | 541 | + searchField.tintColor = UIColor.labelColor; |
| 542 | + configureKeyboardTracking(searchField); | |
| 446 | 543 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; |
| 447 | 544 | + [glassView.contentView addSubview:searchField]; |
| 448 | 545 | + [NSLayoutConstraint activateConstraints:@[ |
| ... | ... | @@ -491,20 +588,34 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 491 | 588 | + navigationToolbarConfigs = @[]; |
| 492 | 589 | + } |
| 493 | 590 | + |
| 494 | + NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs | |
| 495 | + withCurrentItems:@[] | |
| 496 | + navigationItem:navitem]; | |
| 497 | + if (toolbarItems.count > 0) { | |
| 498 | + vc.toolbarItems = toolbarItems; | |
| 499 | + [navctr setToolbarHidden:NO animated:animated]; | |
| 500 | + } else { | |
| 501 | + vc.toolbarItems = nil; | |
| 502 | + [navctr setToolbarHidden:YES animated:animated]; | |
| 591 | + NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[]; | |
| 592 | + // Same reuse rule as the header item groups above (including the config | |
| 593 | + // identity — cached toolbar items capture this config's event emitter). | |
| 594 | + // The top-view-controller and hidden-state checks scope the skip to | |
| 595 | + // same-screen refreshes, so transitions between screens with different | |
| 596 | + // toolbars still reapply. | |
| 597 | + NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ]; | |
| 598 | + BOOL reuseToolbarItems = navctr.topViewController == vc && | |
| 599 | + navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) && | |
| 600 | + [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey]; | |
| 601 | + if (!reuseToolbarItems) { | |
| 602 | + NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs | |
| 603 | + withCurrentItems:@[] | |
| 604 | + navigationItem:navitem]; | |
| 605 | + if (toolbarItems.count > 0) { | |
| 606 | + vc.toolbarItems = toolbarItems; | |
| 607 | + [navctr setToolbarHidden:NO animated:animated]; | |
| 608 | + } else { | |
| 609 | + vc.toolbarItems = nil; | |
| 610 | + [navctr setToolbarHidden:YES animated:animated]; | |
| 611 | + } | |
| 612 | + objc_setAssociatedObject( | |
| 613 | + navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); | |
| 503 | 614 | + } |
| 504 | 615 | |
| 505 | 616 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items |
| 506 | 617 | // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). |
| 507 | @@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 618 | @@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 508 | 619 | |
| 509 | 620 | - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts |
| 510 | 621 | withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems |
| ... | ... | @@ -512,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 512 | 623 | { |
| 513 | 624 | if (dicts.count == 0) { |
| 514 | 625 | return currentItems; |
| 515 | @@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 626 | @@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 516 | 627 | [items addObjectsFromArray:currentItems]; |
| 517 | 628 | for (NSUInteger i = 0; i < dicts.count; i++) { |
| 518 | 629 | NSDictionary *dict = dicts[i]; |
| ... | ... | @@ -700,7 +811,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 700 | 811 | + } |
| 701 | 812 | + } |
| 702 | 813 | +#endif |
| 703 | + if (index != nil && index.integerValue < items.count) { | |
| 814 | + if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { | |
| 704 | 815 | + [items insertObject:item atIndex:index.integerValue]; |
| 705 | 816 | + } else { |
| 706 | 817 | + [items addObject:item]; |
| ... | ... | @@ -711,7 +822,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 711 | 822 | RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict |
| 712 | 823 | action:^(NSString *buttonId) { |
| 713 | 824 | auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( |
| 714 | @@ -809,11 +1306,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 825 | @@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 826 | } | |
| 827 | imageLoader:_imageLoader]; | |
| 828 | NSNumber *index = dict[@"index"]; | |
| 829 | - if (index.integerValue < items.count) { | |
| 830 | + if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { | |
| 831 | [items insertObject:item atIndex:index.integerValue]; | |
| 832 | } else { | |
| 715 | 833 | [items addObject:item]; |
| 716 | 834 | } |
| 717 | 835 | } else if (dict[@"spacing"]) { |
| ... | ... | @@ -729,9 +847,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 729 | 847 | + item.width = [spacingValue doubleValue]; |
| 730 | 848 | + } |
| 731 | 849 | NSNumber *index = dict[@"index"]; |
| 732 | if (index.integerValue < items.count) { | |
| 850 | - if (index.integerValue < items.count) { | |
| 851 | + if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { | |
| 733 | 852 | [items insertObject:item atIndex:index.integerValue]; |
| 734 | @@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 853 | } else { | |
| 854 | [items addObject:item]; | |
| 855 | @@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 735 | 856 | return items; |
| 736 | 857 | } |
| 737 | 858 | |
| ... | ... | @@ -779,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 779 | 900 | RNS_IGNORE_SUPER_CALL_BEGIN |
| 780 | 901 | - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex |
| 781 | 902 | { |
| 782 | @@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | |
| 903 | @@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | |
| 783 | 904 | } |
| 784 | 905 | |
| 785 | 906 | _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); |
| ... | ... | @@ -788,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 788 | 909 | if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { |
| 789 | 910 | _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); |
| 790 | 911 | } |
| 791 | @@ -1038,6 +1582,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | |
| 912 | @@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | |
| 792 | 913 | _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; |
| 793 | 914 | _backButtonDisplayMode = |
| 794 | 915 | [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; |
| ... | ... | @@ -796,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 796 | 917 | |
| 797 | 918 | if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { |
| 798 | 919 | _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; |
| 799 | @@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | |
| 920 | @@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | |
| 800 | 921 | _headerRightBarButtonItems = array; |
| 801 | 922 | } |
| 802 | 923 |
pnpm-lock.yaml+33-29| ... | ... | @@ -12,6 +12,9 @@ catalogs: |
| 12 | 12 | '@effect/tsgo': |
| 13 | 13 | specifier: 0.13.2 |
| 14 | 14 | version: 0.13.2 |
| 15 | '@legendapp/list': | |
| 16 | specifier: 3.3.5 | |
| 17 | version: 3.3.5 | |
| 15 | 18 | '@noble/curves': |
| 16 | 19 | specifier: 1.9.1 |
| 17 | 20 | version: 1.9.1 |
| ... | ... | @@ -69,12 +72,13 @@ overrides: |
| 69 | 72 | packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= |
| 70 | 73 | |
| 71 | 74 | patchedDependencies: |
| 75 | '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 | |
| 72 | 76 | '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b |
| 73 | 77 | '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 |
| 74 | 78 | '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 |
| 75 | '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 | |
| 79 | '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 | |
| 76 | 80 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa |
| 77 | '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae | |
| 81 | '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 | |
| 78 | 82 | '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 |
| 79 | 83 | '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 |
| 80 | 84 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 |
| ... | ... | @@ -82,7 +86,7 @@ patchedDependencies: |
| 82 | 86 | react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 |
| 83 | 87 | react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 |
| 84 | 88 | react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 |
| 85 | react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 | |
| 89 | react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e | |
| 86 | 90 | |
| 87 | 91 | importers: |
| 88 | 92 | |
| ... | ... | @@ -197,7 +201,7 @@ importers: |
| 197 | 201 | version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 198 | 202 | '@clerk/expo': |
| 199 | 203 | specifier: 4.2.0 |
| 200 | version: 4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | |
| 204 | version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | |
| 201 | 205 | '@effect/atom-react': |
| 202 | 206 | specifier: 4.0.0-beta.103 |
| 203 | 207 | version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) |
| ... | ... | @@ -211,8 +215,8 @@ importers: |
| 211 | 215 | specifier: ~56.0.18 |
| 212 | 216 | version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) |
| 213 | 217 | '@legendapp/list': |
| 214 | specifier: 3.3.3 | |
| 215 | version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 218 | specifier: 'catalog:' | |
| 219 | version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 216 | 220 | '@noble/curves': |
| 217 | 221 | specifier: 'catalog:' |
| 218 | 222 | version: 1.9.1 |
| ... | ... | @@ -224,7 +228,7 @@ importers: |
| 224 | 228 | version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 225 | 229 | '@react-native-menu/menu': |
| 226 | 230 | specifier: ^2.0.0 |
| 227 | version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 231 | version: 2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 228 | 232 | '@react-navigation/elements': |
| 229 | 233 | specifier: 2.9.26 |
| 230 | 234 | version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) |
| ... | ... | @@ -233,7 +237,7 @@ importers: |
| 233 | 237 | version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 234 | 238 | '@react-navigation/native-stack': |
| 235 | 239 | specifier: 7.17.6 |
| 236 | version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) | |
| 240 | version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d) | |
| 237 | 241 | '@shikijs/core': |
| 238 | 242 | specifier: 4.2.0 |
| 239 | 243 | version: 4.2.0 |
| ... | ... | @@ -398,7 +402,7 @@ importers: |
| 398 | 402 | version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 399 | 403 | react-native-screens: |
| 400 | 404 | specifier: 4.25.2 |
| 401 | version: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 405 | version: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 402 | 406 | react-native-shiki-engine: |
| 403 | 407 | specifier: ^0.3.12 |
| 404 | 408 | version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | ... | @@ -537,8 +541,8 @@ importers: |
| 537 | 541 | specifier: ^0.9.0 |
| 538 | 542 | version: 0.9.0 |
| 539 | 543 | '@legendapp/list': |
| 540 | specifier: 3.3.3 | |
| 541 | version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 544 | specifier: 'catalog:' | |
| 545 | version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 542 | 546 | '@lexical/react': |
| 543 | 547 | specifier: ^0.41.0 |
| 544 | 548 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) |
| ... | ... | @@ -2950,8 +2954,8 @@ packages: |
| 2950 | 2954 | resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} |
| 2951 | 2955 | engines: {node: '>=12'} |
| 2952 | 2956 | |
| 2953 | '@legendapp/list@3.3.3': | |
| 2954 | resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} | |
| 2957 | '@legendapp/list@3.3.5': | |
| 2958 | resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==} | |
| 2955 | 2959 | peerDependencies: |
| 2956 | 2960 | react: '*' |
| 2957 | 2961 | react-dom: '*' |
| ... | ... | @@ -11584,7 +11588,7 @@ snapshots: |
| 11584 | 11588 | electron-store: 8.2.0 |
| 11585 | 11589 | react-dom: 19.2.6(react@19.2.6) |
| 11586 | 11590 | |
| 11587 | '@clerk/expo@4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': | |
| 11591 | '@clerk/expo@4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': | |
| 11588 | 11592 | dependencies: |
| 11589 | 11593 | '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11590 | 11594 | '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| ... | ... | @@ -12253,7 +12257,7 @@ snapshots: |
| 12253 | 12257 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12254 | 12258 | zod: 3.25.76 |
| 12255 | 12259 | optionalDependencies: |
| 12256 | expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) | |
| 12260 | expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) | |
| 12257 | 12261 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12258 | 12262 | transitivePeerDependencies: |
| 12259 | 12263 | - '@expo/dom-webview' |
| ... | ... | @@ -12329,7 +12333,7 @@ snapshots: |
| 12329 | 12333 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12330 | 12334 | zod: 3.25.76 |
| 12331 | 12335 | optionalDependencies: |
| 12332 | expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) | |
| 12336 | expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) | |
| 12333 | 12337 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) |
| 12334 | 12338 | transitivePeerDependencies: |
| 12335 | 12339 | - '@expo/dom-webview' |
| ... | ... | @@ -12669,7 +12673,7 @@ snapshots: |
| 12669 | 12673 | react: 19.2.3 |
| 12670 | 12674 | optionalDependencies: |
| 12671 | 12675 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12672 | expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) | |
| 12676 | expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) | |
| 12673 | 12677 | react-dom: 19.2.3(react@19.2.3) |
| 12674 | 12678 | transitivePeerDependencies: |
| 12675 | 12679 | - supports-color |
| ... | ... | @@ -12684,7 +12688,7 @@ snapshots: |
| 12684 | 12688 | react: 19.2.6 |
| 12685 | 12689 | optionalDependencies: |
| 12686 | 12690 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 12687 | expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) | |
| 12691 | expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) | |
| 12688 | 12692 | react-dom: 19.2.6(react@19.2.6) |
| 12689 | 12693 | transitivePeerDependencies: |
| 12690 | 12694 | - supports-color |
| ... | ... | @@ -12987,7 +12991,7 @@ snapshots: |
| 12987 | 12991 | dependencies: |
| 12988 | 12992 | jsbi: 4.3.2 |
| 12989 | 12993 | |
| 12990 | '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | |
| 12994 | '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | |
| 12991 | 12995 | dependencies: |
| 12992 | 12996 | react: 19.2.3 |
| 12993 | 12997 | use-sync-external-store: 1.6.0(react@19.2.3) |
| ... | ... | @@ -12995,7 +12999,7 @@ snapshots: |
| 12995 | 12999 | react-dom: 19.2.3(react@19.2.3) |
| 12996 | 13000 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12997 | 13001 | |
| 12998 | '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 13002 | '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 12999 | 13003 | dependencies: |
| 13000 | 13004 | react: 19.2.6 |
| 13001 | 13005 | use-sync-external-store: 1.6.0(react@19.2.6) |
| ... | ... | @@ -14091,7 +14095,7 @@ snapshots: |
| 14091 | 14095 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) |
| 14092 | 14096 | optional: true |
| 14093 | 14097 | |
| 14094 | '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | |
| 14098 | '@react-native-menu/menu@2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | |
| 14095 | 14099 | dependencies: |
| 14096 | 14100 | react: 19.2.3 |
| 14097 | 14101 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| ... | ... | @@ -14269,7 +14273,7 @@ snapshots: |
| 14269 | 14273 | optionalDependencies: |
| 14270 | 14274 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14271 | 14275 | |
| 14272 | '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)': | |
| 14276 | '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)': | |
| 14273 | 14277 | dependencies: |
| 14274 | 14278 | '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) |
| 14275 | 14279 | '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | ... | @@ -14277,7 +14281,7 @@ snapshots: |
| 14277 | 14281 | react: 19.2.3 |
| 14278 | 14282 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14279 | 14283 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14280 | react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 14284 | react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 14281 | 14285 | sf-symbols-typescript: 2.2.0 |
| 14282 | 14286 | warn-once: 0.1.1 |
| 14283 | 14287 | transitivePeerDependencies: |
| ... | ... | @@ -17056,7 +17060,7 @@ snapshots: |
| 17056 | 17060 | - supports-color |
| 17057 | 17061 | - typescript |
| 17058 | 17062 | |
| 17059 | expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7): | |
| 17063 | expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8): | |
| 17060 | 17064 | dependencies: |
| 17061 | 17065 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17062 | 17066 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | ... | @@ -17087,7 +17091,7 @@ snapshots: |
| 17087 | 17091 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 17088 | 17092 | react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) |
| 17089 | 17093 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17090 | react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 17094 | react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 17091 | 17095 | server-only: 0.0.1 |
| 17092 | 17096 | sf-symbols-typescript: 2.2.0 |
| 17093 | 17097 | shallowequal: 1.1.0 |
| ... | ... | @@ -17107,7 +17111,7 @@ snapshots: |
| 17107 | 17111 | - supports-color |
| 17108 | 17112 | optional: true |
| 17109 | 17113 | |
| 17110 | expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f): | |
| 17114 | expo-router@56.2.11(db5c693a26481047569df6781f34db9f): | |
| 17111 | 17115 | dependencies: |
| 17112 | 17116 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17113 | 17117 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| ... | ... | @@ -17138,7 +17142,7 @@ snapshots: |
| 17138 | 17142 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) |
| 17139 | 17143 | react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) |
| 17140 | 17144 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17141 | react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | |
| 17145 | react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | |
| 17142 | 17146 | server-only: 0.0.1 |
| 17143 | 17147 | sf-symbols-typescript: 2.2.0 |
| 17144 | 17148 | shallowequal: 1.1.0 |
| ... | ... | @@ -19953,14 +19957,14 @@ snapshots: |
| 19953 | 19957 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) |
| 19954 | 19958 | optional: true |
| 19955 | 19959 | |
| 19956 | react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | |
| 19960 | react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | |
| 19957 | 19961 | dependencies: |
| 19958 | 19962 | react: 19.2.3 |
| 19959 | 19963 | react-freeze: 1.0.4(react@19.2.3) |
| 19960 | 19964 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 19961 | 19965 | warn-once: 0.1.1 |
| 19962 | 19966 | |
| 19963 | react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): | |
| 19967 | react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): | |
| 19964 | 19968 | dependencies: |
| 19965 | 19969 | react: 19.2.6 |
| 19966 | 19970 | react-freeze: 1.0.4(react@19.2.6) |
pnpm-workspace.yaml+4-1| ... | ... | @@ -39,6 +39,7 @@ catalog: |
| 39 | 39 | "@effect/sql-sqlite-bun": 4.0.0-beta.103 |
| 40 | 40 | "@effect/tsgo": 0.13.2 |
| 41 | 41 | "@effect/vitest": 4.0.0-beta.103 |
| 42 | "@legendapp/list": 3.3.5 | |
| 42 | 43 | "@noble/curves": 1.9.1 |
| 43 | 44 | "@noble/hashes": 1.8.0 |
| 44 | 45 | "@pierre/diffs": 1.3.0-beta.10 |
| ... | ... | @@ -74,6 +75,7 @@ minimumReleaseAgeExclude: |
| 74 | 75 | - "@effect/vitest@4.0.0-beta.103" |
| 75 | 76 | - alchemy@2.0.0-beta.65 |
| 76 | 77 | - effect@4.0.0-beta.103 |
| 78 | - "@legendapp/list@3.3.5" | |
| 77 | 79 | |
| 78 | 80 | overrides: |
| 79 | 81 | "@clerk/backend": "catalog:" |
| ... | ... | @@ -122,10 +124,11 @@ packageExtensions: |
| 122 | 124 | vite: "catalog:" |
| 123 | 125 | |
| 124 | 126 | patchedDependencies: |
| 127 | "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch | |
| 125 | 128 | "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch |
| 126 | 129 | "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch |
| 127 | 130 | "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch |
| 128 | "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch | |
| 131 | "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch | |
| 129 | 132 | "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch |
| 130 | 133 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch |
| 131 | 134 | "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch |