| 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,7 +49,7 @@ |
| 49 | "@expo-google-fonts/dm-sans": "^0.4.2", | 49 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 50 | "@expo/metro-runtime": "~56.0.15", | 50 | "@expo/metro-runtime": "~56.0.15", |
| 51 | "@expo/ui": "~56.0.18", | 51 | "@expo/ui": "~56.0.18", |
| 52 | "@legendapp/list": "3.3.3", | 52 | "@legendapp/list": "catalog:", |
| 53 | "@noble/curves": "catalog:", | 53 | "@noble/curves": "catalog:", |
| 54 | "@noble/hashes": "catalog:", | 54 | "@noble/hashes": "catalog:", |
| 55 | "@pierre/diffs": "catalog:", | 55 | "@pierre/diffs": "catalog:", |
apps/mobile/src/Stack.tsx+20-13| ... | @@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText"; | ... | @@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText"; |
| 18 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; | 18 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; |
| 19 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; | 19 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; |
| 20 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; | 20 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; |
| 21 | import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; | ||
| 22 | import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; | 21 | import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; |
| 23 | import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; | 22 | import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; |
| 24 | import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; | 23 | import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; |
| ... | @@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { |
| 134 | presentation: "fullScreenModal", | 133 | presentation: "fullScreenModal", |
| 135 | }; | 134 | }; |
| 136 | 135 | ||
| 137 | const SettingsSheetStack = createNativeStackNavigator({ | 136 | const SettingsContentStack = createNativeStackNavigator({ |
| 138 | initialRouteName: "Settings", | 137 | initialRouteName: "Settings", |
| 139 | screenOptions: { | 138 | screenOptions: { |
| 140 | ...GLASS_HEADER_OPTIONS, | 139 | ...GLASS_HEADER_OPTIONS, |
| ... | @@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({ | ... | @@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 198 | title: "Usage", | 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 | SettingsAuth: createNativeStackScreen({ | 216 | SettingsAuth: createNativeStackScreen({ |
| 202 | screen: SettingsAuthRouteScreen, | 217 | screen: SettingsAuthRouteScreen, |
| 203 | linking: "auth", | 218 | linking: "auth", |
| 204 | options: { | ||
| 205 | title: "Sign in", | ||
| 206 | }, | ||
| 207 | }), | 219 | }), |
| 208 | SettingsWaitlist: createNativeStackScreen({ | 220 | SettingsWaitlist: createNativeStackScreen({ |
| 209 | // Keep the old deep link working after the Connect GA launch. | 221 | // Keep the old deep link working after the Connect GA launch. |
| 210 | screen: SettingsAuthRouteScreen, | 222 | screen: SettingsAuthRouteScreen, |
| 211 | linking: "waitlist", | 223 | linking: "waitlist", |
| 212 | options: { | ||
| 213 | title: "Sign in", | ||
| 214 | }, | ||
| 215 | }), | 224 | }), |
| 216 | }, | 225 | }, |
| 217 | }); | 226 | }); |
| ... | @@ -347,11 +356,9 @@ function RootStackLayout(props: { | ... | @@ -347,11 +356,9 @@ function RootStackLayout(props: { |
| 347 | <HardwareKeyboardCommandProvider pathname={pathname}> | 356 | <HardwareKeyboardCommandProvider pathname={pathname}> |
| 348 | <ThreadOutboxDrainWorker /> | 357 | <ThreadOutboxDrainWorker /> |
| 349 | <ShowcaseCaptureCoordinator pathname={pathname} /> | 358 | <ShowcaseCaptureCoordinator pathname={pathname} /> |
| 350 | <ClerkSettingsSheetDetentProvider initiallyExpanded={false}> | 359 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> |
| 351 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> | 360 | {props.children} |
| 352 | {props.children} | 361 | </AdaptiveWorkspaceLayout> |
| 353 | </AdaptiveWorkspaceLayout> | ||
| 354 | </ClerkSettingsSheetDetentProvider> | ||
| 355 | </HardwareKeyboardCommandProvider> | 362 | </HardwareKeyboardCommandProvider> |
| 356 | ); | 363 | ); |
| 357 | } | 364 | } |
apps/mobile/src/components/ControlPill.tsx+26-1| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | type ComponentProps, | 6 | type ComponentProps, |
| 7 | type ReactElement, | 7 | type ReactElement, |
| 8 | type ReactNode, | 8 | type ReactNode, |
| 9 | useRef, | ||
| 9 | } from "react"; | 10 | } from "react"; |
| 10 | import { Platform, Pressable, useColorScheme, View } from "react-native"; | 11 | import { Platform, Pressable, useColorScheme, View } from "react-native"; |
| 11 | import { useThemeColor } from "../lib/useThemeColor"; | 12 | import { useThemeColor } from "../lib/useThemeColor"; |
| ... | @@ -21,10 +22,31 @@ export function ControlPill(props: { | ... | @@ -21,10 +22,31 @@ export function ControlPill(props: { |
| 21 | readonly label?: string; | 22 | readonly label?: string; |
| 22 | readonly accessibilityLabel?: string; | 23 | readonly accessibilityLabel?: string; |
| 23 | readonly onPress?: () => void; | 24 | readonly onPress?: () => void; |
| 25 | readonly activateOnPressIn?: boolean; | ||
| 24 | readonly variant?: "circle" | "pill" | "primary" | "danger"; | 26 | readonly variant?: "circle" | "pill" | "primary" | "danger"; |
| 25 | readonly disabled?: boolean; | 27 | readonly disabled?: boolean; |
| 28 | readonly className?: string; | ||
| 26 | }) { | 29 | }) { |
| 27 | const variant = props.variant ?? "circle"; | 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 | const iconColor = useThemeColor("--color-icon"); | 51 | const iconColor = useThemeColor("--color-icon"); |
| 30 | const iconSubtle = useThemeColor("--color-icon-subtle"); | 52 | const iconSubtle = useThemeColor("--color-icon-subtle"); |
| ... | @@ -54,6 +76,7 @@ export function ControlPill(props: { | ... | @@ -54,6 +76,7 @@ export function ControlPill(props: { |
| 54 | : variant === "danger" | 76 | : variant === "danger" |
| 55 | ? "bg-danger" | 77 | ? "bg-danger" |
| 56 | : "bg-subtle", | 78 | : "bg-subtle", |
| 79 | props.className, | ||
| 57 | ); | 80 | ); |
| 58 | const labelClassName = cn( | 81 | const labelClassName = cn( |
| 59 | "text-center text-xs font-t3-bold", | 82 | "text-center text-xs font-t3-bold", |
| ... | @@ -68,7 +91,9 @@ export function ControlPill(props: { | ... | @@ -68,7 +91,9 @@ export function ControlPill(props: { |
| 68 | <Pressable | 91 | <Pressable |
| 69 | accessibilityLabel={props.accessibilityLabel ?? props.label} | 92 | accessibilityLabel={props.accessibilityLabel ?? props.label} |
| 70 | accessibilityRole="button" | 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 | disabled={props.disabled} | 97 | disabled={props.disabled} |
| 73 | className={containerClassName} | 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,7 +5,6 @@ import { useFocusEffect } from "@react-navigation/native"; |
| 5 | import { useCallback, useMemo, useState } from "react"; | 5 | import { useCallback, useMemo, useState } from "react"; |
| 6 | 6 | ||
| 7 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 7 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 8 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | ||
| 9 | import { useArchivedThreadListActions } from "../home/useThreadListActions"; | 8 | import { useArchivedThreadListActions } from "../home/useThreadListActions"; |
| 10 | import { | 9 | import { |
| 11 | ArchivedThreadsScreen, | 10 | ArchivedThreadsScreen, |
| ... | @@ -18,7 +17,6 @@ import { | ... | @@ -18,7 +17,6 @@ import { |
| 18 | } from "./useArchivedThreadSnapshots"; | 17 | } from "./useArchivedThreadSnapshots"; |
| 19 | 18 | ||
| 20 | export function ArchivedThreadsRouteScreen() { | 19 | export function ArchivedThreadsRouteScreen() { |
| 21 | const { expand } = useClerkSettingsSheetDetent(); | ||
| 22 | const { savedConnectionsById } = useSavedRemoteConnections(); | 20 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 23 | const [searchQuery, setSearchQuery] = useState(""); | 21 | const [searchQuery, setSearchQuery] = useState(""); |
| 24 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); | 22 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); |
| ... | @@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() { | ... | @@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() { |
| 70 | 68 | ||
| 71 | useFocusEffect( | 69 | useFocusEffect( |
| 72 | useCallback(() => { | 70 | useCallback(() => { |
| 73 | expand(); | ||
| 74 | refresh(); | 71 | refresh(); |
| 75 | }, [expand, refresh]), | 72 | }, [refresh]), |
| 76 | ); | 73 | ); |
| 77 | 74 | ||
| 78 | return ( | 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,8 +6,8 @@ import { appAtomRegistry } from "../../state/atom-registry"; |
| 6 | import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; | 6 | import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; |
| 7 | import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; | 7 | import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; |
| 8 | 8 | ||
| 9 | // Sign-in happens inside the Settings sheet; give its detent/session-state | 9 | // Sign-in happens inside the Settings sheet; give its session-state transition |
| 10 | // transitions a beat to settle before presenting another formSheet on top. | 10 | // a beat to settle before presenting another formSheet on top. |
| 11 | const PRESENT_ONBOARDING_DELAY_MS = 600; | 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,7 +134,10 @@ export function HomeRouteScreen() { |
| 134 | <NativeStackScreenOptions | 134 | <NativeStackScreenOptions |
| 135 | options={getConnectionAwareBrandHeaderOptions({ | 135 | options={getConnectionAwareBrandHeaderOptions({ |
| 136 | onOpenEnvironments: () => | 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 | <HomeHeader | 143 | <HomeHeader |
| ... | @@ -148,9 +151,17 @@ export function HomeRouteScreen() { | ... | @@ -148,9 +151,17 @@ export function HomeRouteScreen() { |
| 148 | onEnvironmentChange={setSelectedEnvironmentId} | 151 | onEnvironmentChange={setSelectedEnvironmentId} |
| 149 | onProjectChange={setSelectedProjectKey} | 152 | onProjectChange={setSelectedProjectKey} |
| 150 | onOpenEnvironments={() => | 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 | onProjectSortOrderChange={setProjectSortOrder} | 165 | onProjectSortOrderChange={setProjectSortOrder} |
| 155 | onSearchQueryChange={setSearchQuery} | 166 | onSearchQueryChange={setSearchQuery} |
| 156 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | 167 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| ... | @@ -161,7 +172,10 @@ export function HomeRouteScreen() { | ... | @@ -161,7 +172,10 @@ export function HomeRouteScreen() { |
| 161 | catalogState={catalogState} | 172 | catalogState={catalogState} |
| 162 | environments={environments} | 173 | environments={environments} |
| 163 | onAddConnection={() => | 174 | onAddConnection={() => |
| 164 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) | 175 | navigation.navigate("SettingsSheet", { |
| 176 | screen: "SettingsContent", | ||
| 177 | params: { screen: "SettingsEnvironmentNew" }, | ||
| 178 | }) | ||
| 165 | } | 179 | } |
| 166 | onArchiveThread={archiveThread} | 180 | onArchiveThread={archiveThread} |
| 167 | onDeleteThread={confirmDeleteThread} | 181 | onDeleteThread={confirmDeleteThread} |
| ... | @@ -174,7 +188,12 @@ export function HomeRouteScreen() { | ... | @@ -174,7 +188,12 @@ export function HomeRouteScreen() { |
| 174 | onMovePinnedThread={movePinnedThread} | 188 | onMovePinnedThread={movePinnedThread} |
| 175 | onEnvironmentChange={setSelectedEnvironmentId} | 189 | onEnvironmentChange={setSelectedEnvironmentId} |
| 176 | onProjectChange={setSelectedProjectKey} | 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 | onProjectSortOrderChange={setProjectSortOrder} | 197 | onProjectSortOrderChange={setProjectSortOrder} |
| 179 | onSearchQueryChange={setSearchQuery} | 198 | onSearchQueryChange={setSearchQuery} |
| 180 | onSelectThread={(thread) => { | 199 | onSelectThread={(thread) => { |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+8-2| ... | @@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent( |
| 429 | ); | 429 | ); |
| 430 | 430 | ||
| 431 | const handleOpenSettings = useCallback(() => { | 431 | const handleOpenSettings = useCallback(() => { |
| 432 | navigation.navigate("SettingsSheet", { screen: "Settings" }); | 432 | navigation.navigate("SettingsSheet", { |
| 433 | screen: "SettingsContent", | ||
| 434 | params: { screen: "Settings" }, | ||
| 435 | }); | ||
| 433 | }, [navigation]); | 436 | }, [navigation]); |
| 434 | 437 | ||
| 435 | // Minted here (root stack navigation) so the sidebar pane stays free of | 438 | // Minted here (root stack navigation) so the sidebar pane stays free of |
| 436 | // navigation hooks — on iOS it renders inside an independent nav tree. | 439 | // navigation hooks — on iOS it renders inside an independent nav tree. |
| 437 | const handleOpenEnvironmentSettings = useCallback(() => { | 440 | const handleOpenEnvironmentSettings = useCallback(() => { |
| 438 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); | 441 | navigation.navigate("SettingsSheet", { |
| 442 | screen: "SettingsContent", | ||
| 443 | params: { screen: "SettingsEnvironments" }, | ||
| 444 | }); | ||
| 439 | }, [navigation]); | 445 | }, [navigation]); |
| 440 | 446 | ||
| 441 | const handleNewThreadInProject = useCallback( | 447 | const handleNewThreadInProject = useCallback( |
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx+16-17| ... | @@ -1,8 +1,7 @@ | ... | @@ -1,8 +1,7 @@ |
| 1 | import { useAuth } from "@clerk/expo"; | 1 | import { useAuth } from "@clerk/expo"; |
| 2 | import { AuthView, UserProfileView } from "@clerk/expo/native"; | 2 | import { AuthView, UserProfileView } from "@clerk/expo/native"; |
| 3 | import { StackActions, useNavigation } from "@react-navigation/native"; | 3 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 4 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 4 | import { useCallback, useLayoutEffect } from "react"; |
| 5 | import { useCallback, useEffect } from "react"; | ||
| 6 | import { View } from "react-native"; | 5 | import { View } from "react-native"; |
| 7 | 6 | ||
| 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | 7 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| ... | @@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig"; | ... | @@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 10 | export function SettingsAuthRouteScreen() { | 9 | export function SettingsAuthRouteScreen() { |
| 11 | const navigation = useNavigation(); | 10 | const navigation = useNavigation(); |
| 12 | 11 | ||
| 13 | useEffect(() => { | 12 | useLayoutEffect(() => { |
| 14 | if (!hasCloudPublicConfig()) { | 13 | if (!hasCloudPublicConfig()) { |
| 15 | navigation.dispatch(StackActions.replace("Settings")); | 14 | navigation.dispatch(StackActions.replace("SettingsContent")); |
| 16 | } | 15 | } |
| 17 | }, [navigation]); | 16 | }, [navigation]); |
| 18 | 17 | ||
| ... | @@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() { | ... | @@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() { |
| 22 | function ConfiguredSettingsAuthRouteScreen() { | 21 | function ConfiguredSettingsAuthRouteScreen() { |
| 23 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | 22 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 24 | const navigation = useNavigation(); | 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 | return ( | 29 | return ( |
| 28 | <> | 30 | <View collapsable={false} className="flex-1 overflow-hidden bg-sheet"> |
| 29 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 31 | {isLoaded ? ( |
| 30 | <View collapsable={false} className="flex-1 overflow-hidden bg-sheet"> | 32 | isSignedIn ? ( |
| 31 | {isLoaded ? ( | 33 | <UserProfileView isDismissible={false} onHostBack={handleHostBack} /> |
| 32 | isSignedIn ? ( | 34 | ) : ( |
| 33 | <UserProfileView isDismissible={false} onHostBack={handleHostBack} /> | 35 | <AuthView isDismissible={false} onHostBack={handleHostBack} /> |
| 34 | ) : ( | 36 | ) |
| 35 | <AuthView isDismissible={false} onHostBack={handleHostBack} /> | 37 | ) : null} |
| 36 | ) | 38 | </View> |
| 37 | ) : null} | ||
| 38 | </View> | ||
| 39 | </> | ||
| 40 | ); | 39 | ); |
| 41 | } | 40 | } |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+8-2| ... | @@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() { |
| 98 | accessibilityLabel: "Add environment", | 98 | accessibilityLabel: "Add environment", |
| 99 | icon: "plus", | 99 | icon: "plus", |
| 100 | onPress: () => | 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,7 +111,10 @@ export function SettingsEnvironmentsRouteScreen() { |
| 108 | <NativeHeaderToolbar.Button | 111 | <NativeHeaderToolbar.Button |
| 109 | icon="plus" | 112 | icon="plus" |
| 110 | onPress={() => | 113 | onPress={() => |
| 111 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) | 114 | navigation.navigate("SettingsSheet", { |
| 115 | screen: "SettingsContent", | ||
| 116 | params: { screen: "SettingsEnvironmentNew" }, | ||
| 117 | }) | ||
| 112 | } | 118 | } |
| 113 | separateBackground | 119 | separateBackground |
| 114 | tintColor={headerIconColor} | 120 | tintColor={headerIconColor} |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+6-13| ... | @@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo"; | ... | @@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | import Constants from "expo-constants"; | 3 | import Constants from "expo-constants"; |
| 4 | import * as Notifications from "expo-notifications"; | 4 | import * as Notifications from "expo-notifications"; |
| 5 | import * as Updates from "expo-updates"; | ||
| 6 | import { useNavigation } from "@react-navigation/native"; | 5 | import { useNavigation } from "@react-navigation/native"; |
| 7 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | 7 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | @@ -30,7 +29,6 @@ import { | ... | @@ -30,7 +29,6 @@ import { |
| 30 | subscribeAgentAwarenessRegistrationStatus, | 29 | subscribeAgentAwarenessRegistrationStatus, |
| 31 | } from "../agent-awareness/remoteRegistration"; | 30 | } from "../agent-awareness/remoteRegistration"; |
| 32 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; | 31 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; |
| 33 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | ||
| 34 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; | 32 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; |
| 35 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | 33 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 36 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 34 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| ... | @@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/ | ... | @@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/ |
| 40 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 38 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 41 | import { | 39 | import { |
| 42 | type AppUpdateCheckState, | 40 | type AppUpdateCheckState, |
| 41 | isAppUpdateCheckAvailable, | ||
| 43 | registerHiddenUpdateTap, | 42 | registerHiddenUpdateTap, |
| 44 | runAppUpdateCheck, | 43 | runAppUpdateCheck, |
| 45 | } from "../updates/app-updates"; | 44 | } from "../updates/app-updates"; |
| ... | @@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() { |
| 147 | const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); | 146 | const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); |
| 148 | const insets = useSafeAreaInsets(); | 147 | const insets = useSafeAreaInsets(); |
| 149 | const navigation = useNavigation(); | 148 | const navigation = useNavigation(); |
| 150 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); | ||
| 151 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | 149 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 152 | const { user } = useUser(); | 150 | const { user } = useUser(); |
| 153 | const { savedConnectionsById } = useSavedRemoteConnections(); | 151 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| ... | @@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() { |
| 436 | 434 | ||
| 437 | const openAccount = useCallback(() => { | 435 | const openAccount = useCallback(() => { |
| 438 | if (!isLoaded) return; | 436 | if (!isLoaded) return; |
| 439 | if (!isSignedIn) { | ||
| 440 | expandClerkSheet(); | ||
| 441 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | ||
| 442 | return; | ||
| 443 | } | ||
| 444 | expandClerkSheet(); | ||
| 445 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | 437 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); |
| 446 | }, [expandClerkSheet, isLoaded, isSignedIn, navigation]); | 438 | }, [isLoaded, navigation]); |
| 447 | 439 | ||
| 448 | return ( | 440 | return ( |
| 449 | <View collapsable={false} className="flex-1 bg-sheet"> | 441 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | @@ -577,6 +569,7 @@ function AppSettingsSection() { | ... | @@ -577,6 +569,7 @@ function AppSettingsSection() { |
| 577 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; | 569 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; |
| 578 | const variantLabel = variant === "production" ? "" : capitalize(variant); | 570 | const variantLabel = variant === "production" ? "" : capitalize(variant); |
| 579 | const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; | 571 | const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; |
| 572 | const updateCheckAvailable = isAppUpdateCheckAvailable(); | ||
| 580 | const busy = | 573 | const busy = |
| 581 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; | 574 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; |
| 582 | 575 | ||
| ... | @@ -604,13 +597,13 @@ function AppSettingsSection() { | ... | @@ -604,13 +597,13 @@ function AppSettingsSection() { |
| 604 | }, []); | 597 | }, []); |
| 605 | 598 | ||
| 606 | const handleVersionPress = useCallback(() => { | 599 | const handleVersionPress = useCallback(() => { |
| 607 | if (!Updates.isEnabled || updateInFlight.current) return; | 600 | if (!updateCheckAvailable || updateInFlight.current) return; |
| 608 | const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); | 601 | const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); |
| 609 | hiddenUpdateTapCount.current = tap.nextCount; | 602 | hiddenUpdateTapCount.current = tap.nextCount; |
| 610 | if (tap.shouldCheck) { | 603 | if (tap.shouldCheck) { |
| 611 | void checkForUpdate(); | 604 | void checkForUpdate(); |
| 612 | } | 605 | } |
| 613 | }, [checkForUpdate]); | 606 | }, [checkForUpdate, updateCheckAvailable]); |
| 614 | 607 | ||
| 615 | const statusLabel = | 608 | const statusLabel = |
| 616 | updateState === "checking" | 609 | updateState === "checking" |
| ... | @@ -646,7 +639,7 @@ function AppSettingsSection() { | ... | @@ -646,7 +639,7 @@ function AppSettingsSection() { |
| 646 | <SettingsSection title="App"> | 639 | <SettingsSection title="App"> |
| 647 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> | 640 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> |
| 648 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> | 641 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 649 | {Updates.isEnabled ? ( | 642 | {updateCheckAvailable ? ( |
| 650 | <Pressable | 643 | <Pressable |
| 651 | accessibilityLabel={`Version ${versionLabel}`} | 644 | accessibilityLabel={`Version ${versionLabel}`} |
| 652 | accessibilityRole="text" | 645 | accessibilityRole="text" |
apps/mobile/src/features/settings/components/SettingsRow.tsx+2-1| ... | @@ -64,7 +64,8 @@ export function SettingsRow(props: { | ... | @@ -64,7 +64,8 @@ export function SettingsRow(props: { |
| 64 | disabled={props.disabled} | 64 | disabled={props.disabled} |
| 65 | onPress={() => | 65 | onPress={() => |
| 66 | navigation.navigate("SettingsSheet", { | 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,6 +1,12 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | import { Keyboard, View } from "react-native"; | 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 | import { AsyncResult } from "effect/unstable/reactivity"; | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | 11 | ||
| 6 | import { useConnectionController } from "../connection/useConnectionController"; | 12 | import { useConnectionController } from "../connection/useConnectionController"; |
| ... | @@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry"; | ... | @@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry"; |
| 25 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | 31 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 26 | const SHOWCASE_THREAD_ID = "remote-command-center"; | 32 | const SHOWCASE_THREAD_ID = "remote-command-center"; |
| 27 | 33 | ||
| 34 | type ShowcaseResetRoute = PartialState<NavigationState>["routes"][number]; | ||
| 35 | |||
| 28 | function sceneFromPathname(pathname: string): ShowcaseScene | null { | 36 | function sceneFromPathname(pathname: string): ShowcaseScene | null { |
| 29 | const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; | 37 | const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; |
| 30 | if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { | 38 | if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { |
| ... | @@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) | ... | @@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 166 | navigation.dispatch(StackActions.popToTop()); | 174 | navigation.dispatch(StackActions.popToTop()); |
| 167 | return; | 175 | return; |
| 168 | } | 176 | } |
| 169 | const routes: Array<{ | 177 | const routes: ShowcaseResetRoute[] = [{ name: "Home" }]; |
| 170 | name: string; | ||
| 171 | params?: Record<string, unknown>; | ||
| 172 | state?: { index: number; routes: Array<{ name: string }> }; | ||
| 173 | }> = [{ name: "Home" }]; | ||
| 174 | if (requestedScene === "environments") { | 178 | if (requestedScene === "environments") { |
| 175 | routes.push({ | 179 | routes.push({ |
| 176 | name: "SettingsSheet", | 180 | name: "SettingsSheet", |
| 177 | state: { | 181 | state: { |
| 178 | index: 1, | 182 | index: 0, |
| 179 | routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], | 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 | } else { | 194 | } else { |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+23-4| ... | @@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; | ... | @@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; | 2 | import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | 11 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | import { useFontFamily } from "../../lib/useFontFamily"; | 12 | import { useFontFamily } from "../../lib/useFontFamily"; |
| ... | @@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: { | ... | @@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: { |
| 985 | // The draft is a thread that doesn't exist yet, so it mirrors the thread | 989 | // The draft is a thread that doesn't exist yet, so it mirrors the thread |
| 986 | // page: in-screen header, empty feed canvas above, and the same floating | 990 | // page: in-screen header, empty feed canvas above, and the same floating |
| 987 | // composer chrome as ThreadComposer (collapsed pill → expanded card). | 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 | return ( | 1003 | return ( |
| 989 | <View className="flex-1 bg-screen"> | 1004 | <View className="flex-1 bg-screen"> |
| 990 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 1005 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 991 | <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} /> | 1006 | <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} /> |
| 992 | 1007 | ||
| 993 | <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1"> | 1008 | <View className="flex-1" /> |
| 994 | <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 | <View | 1015 | <View |
| 997 | className="px-4 pt-2" | 1016 | className="px-4 pt-2" |
| 998 | style={{ | 1017 | style={{ |
| ... | @@ -1056,7 +1075,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1056,7 +1075,7 @@ export function NewTaskDraftScreen(props: { |
| 1056 | </ComposerToolbarRow> | 1075 | </ComposerToolbarRow> |
| 1057 | ) : null} | 1076 | ) : null} |
| 1058 | </View> | 1077 | </View> |
| 1059 | </KeyboardAvoidingView> | 1078 | </KeyboardStickyView> |
| 1060 | {settingsSheet} | 1079 | {settingsSheet} |
| 1061 | </View> | 1080 | </View> |
| 1062 | ); | 1081 | ); |
apps/mobile/src/features/threads/PendingUserInputCard.tsx+301-67| ... | @@ -1,18 +1,64 @@ | ... | @@ -1,18 +1,64 @@ |
| 1 | import type { ApprovalRequestId } from "@t3tools/contracts"; | 1 | import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; |
| 2 | import { Pressable, View } from "react-native"; | 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 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 18 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 19 | import { ControlPill } from "../../components/ControlPill"; | ||
| 5 | import { cn } from "../../lib/cn"; | 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 | export interface PendingUserInputCardProps { | 28 | export interface PendingUserInputCardProps { |
| 9 | readonly pendingUserInput: PendingUserInput; | 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 | readonly drafts: Record<string, PendingUserInputDraftAnswer>; | 56 | readonly drafts: Record<string, PendingUserInputDraftAnswer>; |
| 11 | readonly answers: Record<string, string> | null; | 57 | readonly answers: Record<string, string | ReadonlyArray<string>> | null; |
| 12 | readonly respondingUserInputId: ApprovalRequestId | null; | 58 | readonly respondingUserInputId: ApprovalRequestId | null; |
| 13 | readonly onSelectOption: ( | 59 | readonly onSelectOption: ( |
| 14 | requestId: ApprovalRequestId, | 60 | requestId: ApprovalRequestId, |
| 15 | questionId: string, | 61 | question: UserInputQuestion, |
| 16 | label: string, | 62 | label: string, |
| 17 | ) => void; | 63 | ) => void; |
| 18 | readonly onChangeCustomAnswer: ( | 64 | readonly onChangeCustomAnswer: ( |
| ... | @@ -23,74 +69,242 @@ export interface PendingUserInputCardProps { | ... | @@ -23,74 +69,242 @@ export interface PendingUserInputCardProps { |
| 23 | readonly onSubmit: () => Promise<unknown>; | 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 | export function PendingUserInputCard(props: PendingUserInputCardProps) { | 89 | export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 27 | // The surface is opaque on purpose: the card floats over the thread feed | 90 | const iconSubtle = useThemeColor("--color-icon-subtle"); |
| 28 | // with no blur behind it, so a translucent background renders the questions | 91 | const questionCount = props.pendingUserInput.questions.length; |
| 29 | // on top of whatever message happens to sit underneath. | 92 | |
| 30 | return ( | 93 | const cardCoverage = props.cardCoverage; |
| 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"> | 94 | const barHeightRef = useRef(0); |
| 32 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> | 95 | const cardHeightRef = useRef(0); |
| 33 | User input needed | 96 | // Measured card height, written straight from onLayout: the collapse slide |
| 34 | </Text> | 97 | // distance. Not animated — it only changes on discrete relayouts. |
| 35 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> | 98 | const cardHeight = useSharedValue(0); |
| 36 | Fill in the pending answers | 99 | const notifyCoverage = useCallback(() => { |
| 37 | </Text> | 100 | if (!cardCoverage) { |
| 38 | {props.pendingUserInput.questions.map((question) => { | 101 | return; |
| 39 | const draft = props.drafts[question.id]; | 102 | } |
| 40 | return ( | 103 | const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); |
| 41 | <View key={question.id} className="gap-2 pt-1"> | 104 | if (coverage === cardCoverage.value) { |
| 42 | <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500"> | 105 | return; |
| 43 | {question.header} | 106 | } |
| 44 | </Text> | 107 | if (cardCoverage.value === 0) { |
| 45 | <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50"> | 108 | // First measurement lands while the list is doing its initial |
| 46 | {question.question} | 109 | // end-pin (thread opened onto a pending request); animating it from |
| 47 | </Text> | 110 | // zero would move the end anchor out from under that scroll. |
| 48 | <View className="flex-row flex-wrap gap-2.5"> | 111 | cardCoverage.value = coverage; |
| 49 | {question.options.map((option) => { | 112 | return; |
| 50 | const selected = | 113 | } |
| 51 | draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; | 114 | // Animated so a coverage change at rest (discrete max-height |
| 52 | return ( | 115 | // corrections) glides the feed instead of stepping it; toggle timing is |
| 53 | <Pressable | 116 | // owned by the host's progress values. |
| 54 | key={option.label} | 117 | cardCoverage.value = withTiming(coverage, { |
| 55 | className={cn( | 118 | duration: USER_INPUT_TOGGLE_DURATION_MS, |
| 56 | "rounded-full border px-3 py-2.5 ", | 119 | easing: Easing.out(Easing.cubic), |
| 57 | selected | 120 | }); |
| 58 | ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" | 121 | }, [cardCoverage]); |
| 59 | : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70", | 122 | const handleBarLayout = useCallback( |
| 60 | )} | 123 | (event: LayoutChangeEvent) => { |
| 61 | onPress={() => | 124 | barHeightRef.current = event.nativeEvent.layout.height; |
| 62 | props.onSelectOption( | 125 | notifyCoverage(); |
| 63 | props.pendingUserInput.requestId, | 126 | }, |
| 64 | question.id, | 127 | [notifyCoverage], |
| 65 | option.label, | 128 | ); |
| 66 | ) | 129 | const handleCardLayout = useCallback( |
| 67 | } | 130 | (event: LayoutChangeEvent) => { |
| 68 | > | 131 | cardHeightRef.current = event.nativeEvent.layout.height; |
| 69 | <Text | 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 | className={cn( | 266 | className={cn( |
| 71 | "font-t3-bold text-sm", | 267 | "rounded-full border px-3 py-2.5 ", |
| 72 | selected | 268 | selected |
| 73 | ? "text-sky-700 dark:text-sky-300" | 269 | ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" |
| 74 | : "text-neutral-600 dark:text-neutral-300", | 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} | 280 | <Text |
| 78 | </Text> | 281 | className={cn( |
| 79 | </Pressable> | 282 | "font-t3-bold text-sm", |
| 80 | ); | 283 | selected |
| 81 | })} | 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 | </View> | 304 | </View> |
| 83 | <TextInput | 305 | ); |
| 84 | value={draft?.customAnswer ?? ""} | 306 | })} |
| 85 | onChangeText={(value) => | 307 | </ScrollView> |
| 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 | })} | ||
| 94 | <Pressable | 308 | <Pressable |
| 95 | className={cn( | 309 | className={cn( |
| 96 | "items-center justify-center rounded-2xl px-4 py-3.5", | 310 | "items-center justify-center rounded-2xl px-4 py-3.5", |
| ... | @@ -103,6 +317,26 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { | ... | @@ -103,6 +317,26 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 103 | > | 317 | > |
| 104 | <Text className="font-t3-extrabold text-sm text-white">Submit answers</Text> | 318 | <Text className="font-t3-extrabold text-sm text-white">Submit answers</Text> |
| 105 | </Pressable> | 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 | </View> | 340 | </View> |
| 107 | ); | 341 | ); |
| 108 | } | 342 | } |
apps/mobile/src/features/threads/ThreadComposer.tsx+94-13| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | serializeComposerFileLink, | 14 | serializeComposerFileLink, |
| 15 | type ComposerTrigger, | 15 | type ComposerTrigger, |
| 16 | } from "@t3tools/shared/composerTrigger"; | 16 | } from "@t3tools/shared/composerTrigger"; |
| 17 | import * as Haptics from "expo-haptics"; | ||
| 17 | import type { ReactNode } from "react"; | 18 | import type { ReactNode } from "react"; |
| 18 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; | 19 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 19 | import { | 20 | import { |
| ... | @@ -51,7 +52,7 @@ import { | ... | @@ -51,7 +52,7 @@ import { |
| 51 | ComposerToolbarScroller, | 52 | ComposerToolbarScroller, |
| 52 | ComposerToolbarTrigger, | 53 | ComposerToolbarTrigger, |
| 53 | } from "../../components/ComposerToolbarTrigger"; | 54 | } from "../../components/ComposerToolbarTrigger"; |
| 54 | import { ControlPill } from "../../components/ControlPill"; | 55 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; |
| 55 | import { ProviderIcon } from "../../components/ProviderIcon"; | 56 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 56 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 57 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 57 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| ... | @@ -62,9 +63,13 @@ import { | ... | @@ -62,9 +63,13 @@ import { |
| 62 | normalizeSearchQuery, | 63 | normalizeSearchQuery, |
| 63 | scoreQueryMatch, | 64 | scoreQueryMatch, |
| 64 | } from "@t3tools/shared/searchRanking"; | 65 | } from "@t3tools/shared/searchRanking"; |
| 65 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | 66 | import { |
| 67 | applyProviderOptionSelection, | ||
| 68 | resolveProviderOptionDescriptors, | ||
| 69 | } from "../../lib/providerOptions"; | ||
| 66 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; | 70 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 67 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | 71 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 72 | import { buildThreadSettingsMenu } from "./thread-settings-menu"; | ||
| 68 | import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; | 73 | import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; |
| 69 | import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; | 74 | import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; |
| 70 | 75 | ||
| ... | @@ -113,6 +118,8 @@ export interface ThreadComposerProps { | ... | @@ -113,6 +118,8 @@ export interface ThreadComposerProps { |
| 113 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; | 118 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| 114 | readonly onReconnectEnvironment: () => void; | 119 | readonly onReconnectEnvironment: () => void; |
| 115 | readonly onExpandedChange?: (expanded: boolean) => void; | 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,13 +314,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 307 | } | 314 | } |
| 308 | }, [inputRef]); | 315 | }, [inputRef]); |
| 309 | 316 | ||
| 317 | const onEditorFocusChange = props.onEditorFocusChange; | ||
| 310 | const handleFocus = useCallback(() => { | 318 | const handleFocus = useCallback(() => { |
| 311 | setIsFocused(true); | 319 | setIsFocused(true); |
| 312 | }, []); | 320 | onEditorFocusChange?.(true); |
| 321 | }, [onEditorFocusChange]); | ||
| 313 | 322 | ||
| 314 | const handleBlur = useCallback(() => { | 323 | const handleBlur = useCallback(() => { |
| 315 | setIsFocused(false); | 324 | setIsFocused(false); |
| 316 | }, []); | 325 | onEditorFocusChange?.(false); |
| 326 | }, [onEditorFocusChange]); | ||
| 317 | const showStopAction = | 327 | const showStopAction = |
| 318 | props.selectedThread.session?.status === "running" || | 328 | props.selectedThread.session?.status === "running" || |
| 319 | props.selectedThread.session?.status === "starting"; | 329 | props.selectedThread.session?.status === "starting"; |
| ... | @@ -623,6 +633,61 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -623,6 +633,61 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 623 | interactionMode: currentInteractionMode, | 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 | return ( | 691 | return ( |
| 627 | <Animated.View | 692 | <Animated.View |
| 628 | className="px-4" | 693 | className="px-4" |
| ... | @@ -792,15 +857,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -792,15 +857,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 792 | onPress={() => void props.onPickDraftImages()} | 857 | onPress={() => void props.onPickDraftImages()} |
| 793 | showChevron={false} | 858 | showChevron={false} |
| 794 | /> | 859 | /> |
| 795 | <ComposerToolbarTrigger | 860 | {settingsMenu ? ( |
| 796 | accessibilityLabel="Thread settings" | 861 | <ControlPillMenu |
| 797 | iconNode={ | 862 | actions={settingsMenu.actions} |
| 798 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | 863 | onPressAction={({ nativeEvent }) => handleSettingsMenuAction(nativeEvent.event)} |
| 799 | } | 864 | > |
| 800 | label={settingsSummaryLabel} | 865 | <ComposerToolbarTrigger |
| 801 | maxWidth={320} | 866 | accessibilityLabel="Thread settings" |
| 802 | onPress={settingsSheetPresentation.open} | 867 | iconNode={ |
| 803 | /> | 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 | {showStopAction ? ( | 885 | {showStopAction ? ( |
| 805 | <ComposerToolbarButton | 886 | <ComposerToolbarButton |
| 806 | accessibilityLabel="Stop" | 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,6 +2,7 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect |
| 2 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; | 2 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; |
| 3 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; | 3 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 4 | import type { LegendListRef } from "@legendapp/list/react-native"; | 4 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 5 | import { HeaderHeightContext } from "@react-navigation/elements"; | ||
| 5 | import type { | 6 | import type { |
| 6 | ApprovalRequestId, | 7 | ApprovalRequestId, |
| 7 | EnvironmentId, | 8 | EnvironmentId, |
| ... | @@ -13,18 +14,45 @@ import type { | ... | @@ -13,18 +14,45 @@ import type { |
| 13 | RuntimeMode, | 14 | RuntimeMode, |
| 14 | ServerConfig as T3ServerConfig, | 15 | ServerConfig as T3ServerConfig, |
| 15 | ThreadId, | 16 | ThreadId, |
| 17 | UserInputQuestion, | ||
| 16 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 17 | import * as Haptics from "expo-haptics"; | 19 | import * as Haptics from "expo-haptics"; |
| 18 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 20 | import { |
| 19 | import { Platform, View, type GestureResponderEvent } from "react-native"; | 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 | import { | 40 | import { |
| 21 | KeyboardController, | 41 | KeyboardController, |
| 22 | KeyboardStickyView, | 42 | KeyboardStickyView, |
| 23 | useKeyboardState, | 43 | useKeyboardState, |
| 24 | } from "react-native-keyboard-controller"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 53 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 27 | 54 | ||
| 55 | import { ControlPill } from "../../components/ControlPill"; | ||
| 28 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 56 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 29 | import type { StatusTone } from "../../components/StatusPill"; | 57 | import type { StatusTone } from "../../components/StatusPill"; |
| 30 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 58 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| ... | @@ -38,6 +66,11 @@ import type { | ... | @@ -38,6 +66,11 @@ import type { |
| 38 | } from "../../lib/threadActivity"; | 66 | } from "../../lib/threadActivity"; |
| 39 | import { PendingApprovalCard } from "./PendingApprovalCard"; | 67 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 40 | import { PendingUserInputCard } from "./PendingUserInputCard"; | 68 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 69 | import { | ||
| 70 | derivePendingUserInputMaxHeight, | ||
| 71 | ESTIMATED_KEYBOARD_HEIGHT, | ||
| 72 | USER_INPUT_TOGGLE_DURATION_MS, | ||
| 73 | } from "./pendingUserInputLayout"; | ||
| 41 | import { | 74 | import { |
| 42 | COMPOSER_COLLAPSED_CHROME, | 75 | COMPOSER_COLLAPSED_CHROME, |
| 43 | COMPOSER_EXPANDED_CHROME, | 76 | COMPOSER_EXPANDED_CHROME, |
| ... | @@ -58,7 +91,7 @@ export interface ThreadDetailScreenProps { | ... | @@ -58,7 +91,7 @@ export interface ThreadDetailScreenProps { |
| 58 | readonly respondingApprovalId: ApprovalRequestId | null; | 91 | readonly respondingApprovalId: ApprovalRequestId | null; |
| 59 | readonly activePendingUserInput: PendingUserInput | null; | 92 | readonly activePendingUserInput: PendingUserInput | null; |
| 60 | readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; | 93 | readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; |
| 61 | readonly activePendingUserInputAnswers: Record<string, string> | null; | 94 | readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null; |
| 62 | readonly respondingUserInputId: ApprovalRequestId | null; | 95 | readonly respondingUserInputId: ApprovalRequestId | null; |
| 63 | readonly draftMessage: string; | 96 | readonly draftMessage: string; |
| 64 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 97 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| ... | @@ -93,7 +126,7 @@ export interface ThreadDetailScreenProps { | ... | @@ -93,7 +126,7 @@ export interface ThreadDetailScreenProps { |
| 93 | ) => Promise<unknown>; | 126 | ) => Promise<unknown>; |
| 94 | readonly onSelectUserInputOption: ( | 127 | readonly onSelectUserInputOption: ( |
| 95 | requestId: ApprovalRequestId, | 128 | requestId: ApprovalRequestId, |
| 96 | questionId: string, | 129 | question: UserInputQuestion, |
| 97 | label: string, | 130 | label: string, |
| 98 | ) => void; | 131 | ) => void; |
| 99 | readonly onChangeUserInputCustomAnswer: ( | 132 | readonly onChangeUserInputCustomAnswer: ( |
| ... | @@ -175,8 +208,48 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE | ... | @@ -175,8 +208,48 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE |
| 175 | }, [threadId, feed]); | 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 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 216 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 179 | const insets = useSafeAreaInsets(); | 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 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | 253 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 181 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | 254 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 182 | const composerEditorRef = useRef<ComposerEditorHandle>(null); | 255 | const composerEditorRef = useRef<ComposerEditorHandle>(null); |
| ... | @@ -187,11 +260,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -187,11 +260,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 187 | const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null); | 260 | const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null); |
| 188 | const [composerExpanded, setComposerExpanded] = useState(false); | 261 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 189 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); | 262 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); |
| 190 | // Key the safe-area padding on keyboard visibility, not focus: on Android | 263 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); |
| 191 | // the back gesture closes the keyboard while the editor stays focused, and | 264 | // Android keys the safe-area padding on keyboard visibility (#5988): the |
| 192 | // a focus-keyed inset would leave the toolbar under the gesture bar. | 265 | // back gesture closes the keyboard while the editor stays focused, and a |
| 193 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | 266 | // focus-keyed inset would leave the toolbar under the gesture bar. iOS must |
| 194 | const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12); | 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 | const contentPresentationKind = props.contentPresentation.kind; | 274 | const contentPresentationKind = props.contentPresentation.kind; |
| 196 | // The raw sync status enters "synchronizing" on every full fetch, cached or | 275 | // The raw sync status enters "synchronizing" on every full fetch, cached or |
| 197 | // not. Whether messages are already on screen decides the pill label: no | 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,6 +291,41 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 212 | const selectedThreadFeed = props.selectedThreadFeed; | 291 | const selectedThreadFeed = props.selectedThreadFeed; |
| 213 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | 292 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 214 | const composerOverlapHeight = composerChrome + composerBottomInset; | 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 | const estimatedOverlayHeight = composerOverlapHeight; | 329 | const estimatedOverlayHeight = composerOverlapHeight; |
| 216 | // The overlay's measured height includes the home-indicator inset (the | 330 | // The overlay's measured height includes the home-indicator inset (the |
| 217 | // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes | 331 | // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes |
| ... | @@ -228,7 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -228,7 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 228 | Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), | 342 | Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), |
| 229 | -nativeInsetOvercount, | 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 | const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); | 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 | const showContent = props.showContent ?? true; | 442 | const showContent = props.showContent ?? true; |
| 233 | const layoutVariant = props.layoutVariant ?? "compact"; | 443 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 234 | const isSplitLayout = layoutVariant === "split"; | 444 | const isSplitLayout = layoutVariant === "split"; |
| ... | @@ -249,6 +459,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -249,6 +459,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 249 | useEffect(() => { | 459 | useEffect(() => { |
| 250 | setAnchorMessageId(null); | 460 | setAnchorMessageId(null); |
| 251 | lastScrolledAnchorMessageIdRef.current = null; | 461 | lastScrolledAnchorMessageIdRef.current = null; |
| 462 | setEndFollowEnabled(true); | ||
| 252 | freeze.set(false); | 463 | freeze.set(false); |
| 253 | }, [freeze, selectedThreadKey]); | 464 | }, [freeze, selectedThreadKey]); |
| 254 | 465 | ||
| ... | @@ -320,6 +531,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -320,6 +531,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 320 | composerEditorRef.current?.blur(); | 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 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { | 544 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { |
| 324 | feedTouchStartRef.current = { | 545 | feedTouchStartRef.current = { |
| 325 | pageX: event.nativeEvent.pageX, | 546 | pageX: event.nativeEvent.pageX, |
| ... | @@ -373,13 +594,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -373,13 +594,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 373 | listRef={listRef} | 594 | listRef={listRef} |
| 374 | freeze={freeze} | 595 | freeze={freeze} |
| 375 | anchorMessageId={anchorMessageId} | 596 | anchorMessageId={anchorMessageId} |
| 376 | contentInsetEndAdjustment={contentInsetEndAdjustment} | 597 | contentInsetEndAdjustment={combinedContentInsetEndAdjustment} |
| 377 | contentTopInset={0} | 598 | contentTopInset={0} |
| 378 | contentBottomInset={estimatedOverlayHeight} | 599 | contentBottomInset={estimatedOverlayHeight} |
| 379 | contentMaxWidth={contentMaxWidth} | 600 | contentMaxWidth={contentMaxWidth} |
| 380 | layoutVariant={layoutVariant} | 601 | layoutVariant={layoutVariant} |
| 381 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} | 602 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} |
| 382 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | 603 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} |
| 604 | onEndFollowEnabledChange={setEndFollowEnabled} | ||
| 383 | skills={selectedProviderSkills} | 605 | skills={selectedProviderSkills} |
| 384 | loadEarlier={props.loadEarlier ?? null} | 606 | loadEarlier={props.loadEarlier ?? null} |
| 385 | /> | 607 | /> |
| ... | @@ -391,6 +613,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -391,6 +613,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 391 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} | 613 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} |
| 392 | {showContent ? ( | 614 | {showContent ? ( |
| 393 | <KeyboardStickyView | 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 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | 620 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 395 | offset={{ closed: 0, opened: 0 }} | 621 | offset={{ closed: 0, opened: 0 }} |
| 396 | > | 622 | > |
| ... | @@ -398,10 +624,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -398,10 +624,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 398 | list's bottom inset, so any padding above the pill/composer | 624 | list's bottom inset, so any padding above the pill/composer |
| 399 | pushes the resting content floor up by the same amount. */} | 625 | pushes the resting content floor up by the same amount. */} |
| 400 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> | 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 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> | 670 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> |
| 402 | {props.activePendingApproval || props.activePendingUserInput ? ( | 671 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 403 | <Animated.View | 672 | <Animated.View |
| 404 | className="shrink-0 gap-3 px-4 pb-3" | 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 | entering={FadeInDown.duration(220)} | 681 | entering={FadeInDown.duration(220)} |
| 406 | exiting={FadeOut.duration(140)} | 682 | exiting={FadeOut.duration(140)} |
| 407 | > | 683 | > |
| ... | @@ -415,6 +691,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -415,6 +691,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 415 | {props.activePendingUserInput ? ( | 691 | {props.activePendingUserInput ? ( |
| 416 | <PendingUserInputCard | 692 | <PendingUserInputCard |
| 417 | pendingUserInput={props.activePendingUserInput} | 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 | drafts={props.activePendingUserInputDrafts} | 701 | drafts={props.activePendingUserInputDrafts} |
| 419 | answers={props.activePendingUserInputAnswers} | 702 | answers={props.activePendingUserInputAnswers} |
| 420 | respondingUserInputId={props.respondingUserInputId} | 703 | respondingUserInputId={props.respondingUserInputId} |
| ... | @@ -427,35 +710,40 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -427,35 +710,40 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 427 | ) : null} | 710 | ) : null} |
| 428 | </View> | 711 | </View> |
| 429 | 712 | ||
| 430 | <ThreadComposer | 713 | {/* Hidden (not unmounted) while a user-input request owns the |
| 431 | editorRef={composerEditorRef} | 714 | composer slot, so composer drafts and editor state survive. */} |
| 432 | draftMessage={props.draftMessage} | 715 | <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}> |
| 433 | draftAttachments={props.draftAttachments} | 716 | <ThreadComposer |
| 434 | placeholder="Ask the repo agent, or run a command…" | 717 | editorRef={composerEditorRef} |
| 435 | contentMaxWidth={contentMaxWidth} | 718 | draftMessage={props.draftMessage} |
| 436 | connectionState={props.connectionStateLabel} | 719 | draftAttachments={props.draftAttachments} |
| 437 | connectionError={props.connectionError} | 720 | placeholder="Ask the repo agent, or run a command…" |
| 438 | environmentLabel={props.environmentLabel} | 721 | contentMaxWidth={contentMaxWidth} |
| 439 | threadSyncPhase={threadSyncPhase} | 722 | connectionState={props.connectionStateLabel} |
| 440 | selectedThread={props.selectedThread} | 723 | connectionError={props.connectionError} |
| 441 | serverConfig={props.serverConfig} | 724 | environmentLabel={props.environmentLabel} |
| 442 | queueCount={props.selectedThreadQueueCount} | 725 | threadSyncPhase={threadSyncPhase} |
| 443 | activeThreadBusy={props.activeThreadBusy} | 726 | selectedThread={props.selectedThread} |
| 444 | environmentId={props.environmentId} | 727 | serverConfig={props.serverConfig} |
| 445 | projectCwd={props.projectWorkspaceRoot} | 728 | queueCount={props.selectedThreadQueueCount} |
| 446 | bottomInset={composerBottomInset} | 729 | activeThreadBusy={props.activeThreadBusy} |
| 447 | onChangeDraftMessage={props.onChangeDraftMessage} | 730 | environmentId={props.environmentId} |
| 448 | onPickDraftImages={props.onPickDraftImages} | 731 | projectCwd={props.projectWorkspaceRoot} |
| 449 | onNativePasteImages={props.onNativePasteImages} | 732 | bottomInset={composerBottomInset} |
| 450 | onRemoveDraftImage={props.onRemoveDraftImage} | 733 | onChangeDraftMessage={props.onChangeDraftMessage} |
| 451 | onStopThread={props.onStopThread} | 734 | onPickDraftImages={props.onPickDraftImages} |
| 452 | onSendMessage={handleSendMessage} | 735 | onNativePasteImages={props.onNativePasteImages} |
| 453 | onReconnectEnvironment={props.onReconnectEnvironment} | 736 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 454 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | 737 | onStopThread={props.onStopThread} |
| 455 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | 738 | onSendMessage={handleSendMessage} |
| 456 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | 739 | onReconnectEnvironment={props.onReconnectEnvironment} |
| 457 | onExpandedChange={setComposerExpanded} | 740 | onUpdateModelSelection={props.onUpdateThreadModelSelection} |
| 458 | /> | 741 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} |
| 742 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | ||
| 743 | onExpandedChange={setComposerExpanded} | ||
| 744 | onEditorFocusChange={handleOwnedInputFocusChange} | ||
| 745 | /> | ||
| 746 | </View> | ||
| 459 | </View> | 747 | </View> |
| 460 | </KeyboardStickyView> | 748 | </KeyboardStickyView> |
| 461 | ) : null} | 749 | ) : null} |
apps/mobile/src/features/threads/ThreadFeed.tsx+94-37| ... | @@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 48 | import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; | 48 | import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; |
| 49 | import { useThemeColor } from "../../lib/useThemeColor"; | 49 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 50 | import { useFontFamily } from "../../lib/useFontFamily"; | 50 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 51 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 51 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 52 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 52 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; | 53 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 53 | import { | 54 | import { |
| ... | @@ -89,6 +90,10 @@ import { | ... | @@ -89,6 +90,10 @@ import { |
| 89 | type ThreadFeedLatestTurn, | 90 | type ThreadFeedLatestTurn, |
| 90 | } from "../../lib/threadActivity"; | 91 | } from "../../lib/threadActivity"; |
| 91 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | 92 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 93 | import { | ||
| 94 | resolveThreadFeedLiveFollow, | ||
| 95 | type ThreadFeedLiveFollowEvent, | ||
| 96 | } from "./thread-feed-live-follow"; | ||
| 92 | import { | 97 | import { |
| 93 | collapsedWorkLogHeight, | 98 | collapsedWorkLogHeight, |
| 94 | ThreadWorkGroupToggle, | 99 | ThreadWorkGroupToggle, |
| ... | @@ -149,6 +154,7 @@ export interface ThreadFeedProps { | ... | @@ -149,6 +154,7 @@ export interface ThreadFeedProps { |
| 149 | readonly layoutVariant?: LayoutVariant; | 154 | readonly layoutVariant?: LayoutVariant; |
| 150 | readonly usesAutomaticContentInsets?: boolean; | 155 | readonly usesAutomaticContentInsets?: boolean; |
| 151 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; | 156 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; |
| 157 | readonly onEndFollowEnabledChange?: (enabled: boolean) => void; | ||
| 152 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | 158 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 153 | /** Non-null when older turns exist beyond the loaded window. */ | 159 | /** Non-null when older turns exist beyond the loaded window. */ |
| 154 | readonly loadEarlier?: { | 160 | readonly loadEarlier?: { |
| ... | @@ -1324,6 +1330,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1324,6 +1330,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1324 | const disclosureAnchorKeyRef = useRef<string | null>(null); | 1330 | const disclosureAnchorKeyRef = useRef<string | null>(null); |
| 1325 | const headerMaterialVisibleRef = useRef(false); | 1331 | const headerMaterialVisibleRef = useRef(false); |
| 1326 | const previousLatestTurnRef = useRef(props.latestTurn); | 1332 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1333 | const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 1327 | const { width: windowWidth } = useWindowDimensions(); | 1334 | const { width: windowWidth } = useWindowDimensions(); |
| 1328 | const { appearance } = useAppearancePreferences(); | 1335 | const { appearance } = useAppearancePreferences(); |
| 1329 | const [viewportWidth, setViewportWidth] = useState(() => | 1336 | const [viewportWidth, setViewportWidth] = useState(() => |
| ... | @@ -1342,13 +1349,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1342,13 +1349,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1342 | // momentum; only motion inside a session can break follow, so MVCP | 1349 | // momentum; only motion inside a session can break follow, so MVCP |
| 1343 | // compensations and programmatic scrolls never strand a follower. | 1350 | // compensations and programmatic scrolls never strand a follower. |
| 1344 | const userScrollSessionRef = useRef(false); | 1351 | const userScrollSessionRef = useRef(false); |
| 1345 | const setEndFollow = useCallback((enabled: boolean) => { | 1352 | const setEndFollow = useCallback( |
| 1346 | if (endFollowEnabledRef.current === enabled) { | 1353 | (enabled: boolean) => { |
| 1347 | return; | 1354 | if (endFollowEnabledRef.current === enabled) { |
| 1348 | } | 1355 | return; |
| 1349 | endFollowEnabledRef.current = enabled; | 1356 | } |
| 1350 | setEndFollowEnabled(enabled); | 1357 | endFollowEnabledRef.current = enabled; |
| 1351 | }, []); | 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 | const [interactionState, setInteractionState] = useState<{ | 1369 | const [interactionState, setInteractionState] = useState<{ |
| 1353 | readonly copiedRowId: string | null; | 1370 | readonly copiedRowId: string | null; |
| 1354 | readonly expandedWorkGroups: Record<string, boolean>; | 1371 | readonly expandedWorkGroups: Record<string, boolean>; |
| ... | @@ -1460,40 +1477,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1460,40 +1477,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1460 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the | 1477 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the |
| 1461 | // header height back or the material toggles a full header too late. | 1478 | // header height back or the material toggles a full header too late. |
| 1462 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); | 1479 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); |
| 1463 | // Latch bookkeeping. LegendList recomputes its inset-aware end distance | 1480 | // LegendList recomputes its inset-aware end distance before invoking |
| 1464 | // before invoking this handler, so getState() is current. Returning to | 1481 | // this handler, so getState() is current. Only the actual end re-arms |
| 1465 | // the end re-arms follow no matter who scrolled (the user, or our own | 1482 | // follow: its broader maintain-scroll threshold is large enough for a |
| 1466 | // scroll-to-end); moving away breaks it only during a user-initiated | 1483 | // streaming chunk to pull a user back before their upward drag escapes. |
| 1467 | // scroll session, so MVCP compensations and programmatic repositioning | 1484 | // A live user-scroll session still wins even if the first scroll event |
| 1468 | // can never strand a follower. | 1485 | // remains inside LegendList's at-end tolerance. |
| 1469 | const listState = props.listRef.current?.getState(); | 1486 | const listState = props.listRef.current?.getState(); |
| 1470 | if (listState) { | 1487 | if (listState) { |
| 1471 | if (listState.isWithinMaintainScrollAtEndThreshold) { | 1488 | transitionEndFollow({ |
| 1472 | setEndFollow(true); | 1489 | type: "scroll", |
| 1473 | } else if (userScrollSessionRef.current) { | 1490 | isAtEnd: listState.isAtEnd, |
| 1474 | setEndFollow(false); | 1491 | userScrollSessionActive: userScrollSessionRef.current, |
| 1475 | } | 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 | const handleScrollBeginDrag = useCallback(() => { | 1503 | const handleScrollBeginDrag = useCallback(() => { |
| 1504 | clearUserScrollSettle(); | ||
| 1481 | userScrollSessionRef.current = true; | 1505 | userScrollSessionRef.current = true; |
| 1482 | }, []); | 1506 | // Pause before the first scroll event. Otherwise a stream update can run |
| 1483 | // The session must survive past finger-lift so momentum that carries the | 1507 | // maintainScrollAtEnd between touch-down and the drag leaving its threshold. |
| 1484 | // user away from the end still breaks follow; a drag released with no | 1508 | transitionEndFollow({ type: "user-scroll-begin" }); |
| 1485 | // momentum ends its session at the release itself, otherwise at momentum | 1509 | }, [clearUserScrollSettle, transitionEndFollow]); |
| 1486 | // end. Leaving a session open would let a later animated maintain-scroll | 1510 | const finishUserScroll = useCallback( |
| 1487 | // read as user motion and break follow spuriously. | 1511 | (releaseIsAtEnd?: boolean) => { |
| 1488 | const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { | 1512 | clearUserScrollSettle(); |
| 1489 | const velocity = event.nativeEvent.velocity?.y ?? 0; | 1513 | const userScrollSessionActive = userScrollSessionRef.current; |
| 1490 | if (Math.abs(velocity) < 0.05) { | ||
| 1491 | userScrollSessionRef.current = false; | 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 | const handleMomentumScrollEnd = useCallback(() => { | 1541 | const handleMomentumScrollEnd = useCallback(() => { |
| 1495 | userScrollSessionRef.current = false; | 1542 | finishUserScroll(); |
| 1496 | }, []); | 1543 | }, [finishUserScroll]); |
| 1544 | |||
| 1545 | useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); | ||
| 1497 | 1546 | ||
| 1498 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { | 1547 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { |
| 1499 | const nextWidth = Math.round(event.nativeEvent.layout.width); | 1548 | const nextWidth = Math.round(event.nativeEvent.layout.width); |
| ... | @@ -1502,23 +1551,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1502,23 +1551,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1502 | setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); | 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 | useEffect(() => { | 1559 | useEffect(() => { |
| 1506 | reportHeaderMaterialVisibility(false); | 1560 | reportHeaderMaterialVisibility(false); |
| 1507 | }, [props.threadId, reportHeaderMaterialVisibility]); | 1561 | }, [feedThreadKey, reportHeaderMaterialVisibility]); |
| 1508 | 1562 | ||
| 1509 | // A thread switch opens pinned to the end; a send explicitly returns to the | 1563 | // A thread switch opens pinned to the end; a send explicitly returns to the |
| 1510 | // live edge (ThreadDetailScreen scrolls the new message into place). Both | 1564 | // live edge (ThreadDetailScreen scrolls the new message into place). Both |
| 1511 | // re-arm follow regardless of where the user had scrolled before. | 1565 | // re-arm follow regardless of where the user had scrolled before. |
| 1512 | useEffect(() => { | 1566 | useEffect(() => { |
| 1567 | clearUserScrollSettle(); | ||
| 1513 | userScrollSessionRef.current = false; | 1568 | userScrollSessionRef.current = false; |
| 1514 | setEndFollow(true); | 1569 | transitionEndFollow({ type: "reset" }); |
| 1515 | }, [props.threadId, setEndFollow]); | 1570 | }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); |
| 1516 | useEffect(() => { | 1571 | useEffect(() => { |
| 1517 | if (props.anchorMessageId !== null) { | 1572 | if (props.anchorMessageId !== null) { |
| 1573 | clearUserScrollSettle(); | ||
| 1518 | userScrollSessionRef.current = false; | 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 | const expandedWorkGroupIds = useMemo(() => { | 1579 | const expandedWorkGroupIds = useMemo(() => { |
| 1524 | const ids = new Set<string>(); | 1580 | const ids = new Set<string>(); |
| ... | @@ -1554,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1554,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1554 | // initial scroll-to-end computes with a zero end inset and rests one | 1610 | // initial scroll-to-end computes with a zero end inset and rests one |
| 1555 | // composer-height short of the end. Layout effect: it must land before the | 1611 | // composer-height short of the end. Layout effect: it must land before the |
| 1556 | // list's first positioning tick or the one-shot initial scroll misses it. | 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 | useLayoutEffect(() => { | 1614 | useLayoutEffect(() => { |
| 1559 | const bottom = props.contentInsetEndAdjustment.value; | 1615 | const bottom = props.contentInsetEndAdjustment.value; |
| 1560 | if (bottom > 0) { | 1616 | if (bottom > 0) { |
| ... | @@ -1921,6 +1977,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1921,6 +1977,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1921 | onScroll={handleScroll} | 1977 | onScroll={handleScroll} |
| 1922 | onScrollBeginDrag={handleScrollBeginDrag} | 1978 | onScrollBeginDrag={handleScrollBeginDrag} |
| 1923 | onScrollEndDrag={handleScrollEndDrag} | 1979 | onScrollEndDrag={handleScrollEndDrag} |
| 1980 | onMomentumScrollBegin={handleMomentumScrollBegin} | ||
| 1924 | onMomentumScrollEnd={handleMomentumScrollEnd} | 1981 | onMomentumScrollEnd={handleMomentumScrollEnd} |
| 1925 | scrollEventThrottle={16} | 1982 | scrollEventThrottle={16} |
| 1926 | ListHeaderComponent={ | 1983 | ListHeaderComponent={ |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+1-27| ... | @@ -30,6 +30,7 @@ import { cn } from "../../lib/cn"; | ... | @@ -30,6 +30,7 @@ import { cn } from "../../lib/cn"; |
| 30 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | 30 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 31 | import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; | 31 | import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; |
| 32 | import { useThemeColor } from "../../lib/useThemeColor"; | 32 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 33 | import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; | ||
| 33 | import { pendingModelAfterPress } from "./thread-settings-sheet-state"; | 34 | import { pendingModelAfterPress } from "./thread-settings-sheet-state"; |
| 34 | import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; | 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,26 +41,6 @@ import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet |
| 40 | */ | 41 | */ |
| 41 | const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]); | 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 | * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, | 45 | * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, |
| 65 | * covering model, provider options, runtime mode, and plan mode in one label. | 46 | * covering model, provider options, runtime mode, and plan mode in one label. |
| ... | @@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: { | ... | @@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: { |
| 79 | ].join(" · "); | 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 | function ModelRow(props: { | 63 | function ModelRow(props: { |
| 90 | readonly option: ModelOption; | 64 | readonly option: ModelOption; |
| 91 | readonly selected: boolean; | 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,6 +32,19 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | describe("runAppUpdateCheck", () => { | 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 | it("downloads and restarts when a new update is available", async () => { | 48 | it("downloads and restarts when a new update is available", async () => { |
| 36 | const client = makeUpdateClient({ | 49 | const client = makeUpdateClient({ |
| 37 | checkForUpdateAsync: vi.fn(async () => ({ | 50 | checkForUpdateAsync: vi.fn(async () => ({ |
| ... | @@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => { | ... | @@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => { |
| 100 | reportError.mockRestore(); | 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 | it("coalesces overlapping launch and manual checks", async () => { | 142 | it("coalesces overlapping launch and manual checks", async () => { |
| 104 | let resolveCheck!: (result: { | 143 | let resolveCheck!: (result: { |
| 105 | readonly isAvailable: boolean; | 144 | readonly isAvailable: boolean; |
apps/mobile/src/features/updates/app-updates.ts+20-3| ... | @@ -48,8 +48,17 @@ interface Deferred { | ... | @@ -48,8 +48,17 @@ interface Deferred { |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | const HIDDEN_UPDATE_TAP_COUNT = 5; | 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 | let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; | 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 | * Keeps the manual update affordance discoverable only to someone deliberately | 63 | * Keeps the manual update affordance discoverable only to someone deliberately |
| 55 | * tapping the version row five times. | 64 | * tapping the version row five times. |
| ... | @@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): { | ... | @@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): { |
| 73 | 82 | ||
| 74 | export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> { | 83 | export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> { |
| 75 | const client = options.client ?? Updates; | 84 | const client = options.client ?? Updates; |
| 76 | if (!client.isEnabled) return; | 85 | if (!isAppUpdateCheckAvailable(client)) return; |
| 77 | 86 | ||
| 78 | if (appUpdateCheckInFlight) { | 87 | if (appUpdateCheckInFlight) { |
| 79 | await observeAppUpdateCheck(appUpdateCheckInFlight, options); | 88 | await observeAppUpdateCheck(appUpdateCheckInFlight, options); |
| ... | @@ -207,19 +216,27 @@ function reportUpdateFailure( | ... | @@ -207,19 +216,27 @@ function reportUpdateFailure( |
| 207 | fallback: string, | 216 | fallback: string, |
| 208 | onFailure: AppUpdateCheckOptions["onFailure"], | 217 | onFailure: AppUpdateCheckOptions["onFailure"], |
| 209 | ): void { | 218 | ): void { |
| 210 | reportAtomCommandResult(result, { label: "app update check" }); | ||
| 211 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; | 219 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; |
| 212 | const error = squashAtomCommandFailure(result); | 220 | const error = squashAtomCommandFailure(result); |
| 221 | if (isAppUpdateUnavailableError(error)) return; | ||
| 222 | |||
| 223 | reportAtomCommandResult(result, { label: "app update check" }); | ||
| 213 | onFailure?.(error instanceof Error ? error.message : fallback); | 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 | export function createAppUpdateLaunchCheck( | 233 | export function createAppUpdateLaunchCheck( |
| 217 | client: AppUpdateClient = Updates, | 234 | client: AppUpdateClient = Updates, |
| 218 | ): () => Promise<void> | undefined { | 235 | ): () => Promise<void> | undefined { |
| 219 | let started = false; | 236 | let started = false; |
| 220 | 237 | ||
| 221 | return () => { | 238 | return () => { |
| 222 | if (started || !client.isEnabled) return undefined; | 239 | if (started || !isAppUpdateCheckAvailable(client)) return undefined; |
| 223 | started = true; | 240 | started = true; |
| 224 | return runAppUpdateCheck({ client }); | 241 | return runAppUpdateCheck({ client }); |
| 225 | }; | 242 | }; |
apps/mobile/src/lib/threadActivity.test.ts+95| ... | @@ -12,12 +12,107 @@ import { | ... | @@ -12,12 +12,107 @@ import { |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | 13 | ||
| 14 | import { | 14 | import { |
| 15 | buildPendingUserInputAnswers, | ||
| 15 | buildThreadFeed, | 16 | buildThreadFeed, |
| 16 | deriveThreadFeedPresentation, | 17 | deriveThreadFeedPresentation, |
| 18 | isPendingUserInputOptionSelected, | ||
| 19 | setPendingUserInputCustomAnswer, | ||
| 20 | togglePendingUserInputOptionSelection, | ||
| 17 | type ThreadFeedActivity, | 21 | type ThreadFeedActivity, |
| 18 | type ThreadFeedEntry, | 22 | type ThreadFeedEntry, |
| 19 | } from "./threadActivity"; | 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 | function makeActivity( | 116 | function makeActivity( |
| 22 | input: Partial<OrchestrationThreadActivity> & | 117 | input: Partial<OrchestrationThreadActivity> & |
| 23 | Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">, | 118 | Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">, |
apps/mobile/src/lib/threadActivity.ts+67-9| ... | @@ -26,7 +26,7 @@ export interface PendingUserInput { | ... | @@ -26,7 +26,7 @@ export interface PendingUserInput { |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | export interface PendingUserInputDraftAnswer { | 28 | export interface PendingUserInputDraftAnswer { |
| 29 | readonly selectedOptionLabel?: string; | 29 | readonly selectedOptionLabels?: ReadonlyArray<string>; |
| 30 | readonly customAnswer?: string; | 30 | readonly customAnswer?: string; |
| 31 | } | 31 | } |
| 32 | 32 | ||
| ... | @@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null { | ... | @@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null { |
| 227 | return trimmed.length > 0 ? trimmed : null; | 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 | function resolvePendingUserInputAnswer( | 242 | function resolvePendingUserInputAnswer( |
| 243 | question: UserInputQuestion, | ||
| 231 | draft: PendingUserInputDraftAnswer | undefined, | 244 | draft: PendingUserInputDraftAnswer | undefined, |
| 232 | ): string | null { | 245 | ): string | ReadonlyArray<string> | null { |
| 233 | const customAnswer = normalizeDraftAnswer(draft?.customAnswer); | 246 | const customAnswer = normalizeDraftAnswer(draft?.customAnswer); |
| 234 | if (customAnswer) { | 247 | if (customAnswer) { |
| 235 | return customAnswer; | 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 | /** Codex children settle via task.updated (idle/failed/interrupted), never | 258 | /** Codex children settle via task.updated (idle/failed/interrupted), never |
| ... | @@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer( | ... | @@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer( |
| 1428 | draft: PendingUserInputDraftAnswer | undefined, | 1446 | draft: PendingUserInputDraftAnswer | undefined, |
| 1429 | customAnswer: string, | 1447 | customAnswer: string, |
| 1430 | ): PendingUserInputDraftAnswer { | 1448 | ): PendingUserInputDraftAnswer { |
| 1431 | const selectedOptionLabel = | 1449 | const selectedOptionLabels = |
| 1432 | customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; | 1450 | customAnswer.trim().length > 0 |
| 1451 | ? undefined | ||
| 1452 | : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); | ||
| 1433 | return { | 1453 | return { |
| 1434 | customAnswer, | 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 | export function buildPendingUserInputAnswers( | 1497 | export function buildPendingUserInputAnswers( |
| 1440 | questions: ReadonlyArray<UserInputQuestion>, | 1498 | questions: ReadonlyArray<UserInputQuestion>, |
| 1441 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | 1499 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 1442 | ): Record<string, string> | null { | 1500 | ): Record<string, string | ReadonlyArray<string>> | null { |
| 1443 | const answers: Record<string, string> = {}; | 1501 | const answers: Record<string, string | ReadonlyArray<string>> = {}; |
| 1444 | 1502 | ||
| 1445 | for (const question of questions) { | 1503 | for (const question of questions) { |
| 1446 | const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); | 1504 | const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); |
| 1447 | if (!answer) { | 1505 | if (!answer) { |
| 1448 | return null; | 1506 | return null; |
| 1449 | } | 1507 | } |
apps/mobile/src/state/use-selected-thread-requests.ts+18-7| ... | @@ -1,7 +1,11 @@ | ... | @@ -1,7 +1,11 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useMemo, useState } from "react"; | 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 | import { Atom } from "effect/unstable/reactivity"; | 9 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 10 | ||
| 7 | import { threadEnvironment } from "../state/threads"; | 11 | import { threadEnvironment } from "../state/threads"; |
| ... | @@ -12,6 +16,7 @@ import { | ... | @@ -12,6 +16,7 @@ import { |
| 12 | derivePendingUserInputs, | 16 | derivePendingUserInputs, |
| 13 | setPendingUserInputCustomAnswer, | 17 | setPendingUserInputCustomAnswer, |
| 14 | sortThreadActivities, | 18 | sortThreadActivities, |
| 19 | togglePendingUserInputOptionSelection, | ||
| 15 | type PendingUserInputDraftAnswer, | 20 | type PendingUserInputDraftAnswer, |
| 16 | } from "../lib/threadActivity"; | 21 | } from "../lib/threadActivity"; |
| 17 | import { appAtomRegistry } from "./atom-registry"; | 22 | import { appAtomRegistry } from "./atom-registry"; |
| ... | @@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< | ... | @@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< |
| 23 | Record<string, Record<string, PendingUserInputDraftAnswer>> | 28 | Record<string, Record<string, PendingUserInputDraftAnswer>> |
| 24 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); | 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 | const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); | 36 | const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); |
| 28 | appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { | 37 | appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { |
| 29 | ...current, | 38 | ...current, |
| 30 | [requestKey]: { | 39 | [requestKey]: { |
| 31 | ...current[requestKey], | 40 | ...current[requestKey], |
| 32 | [questionId]: { | 41 | [question.id]: togglePendingUserInputOptionSelection( |
| 33 | selectedOptionLabel: label, | 42 | question, |
| 34 | }, | 43 | current[requestKey]?.[question.id], |
| 44 | label, | ||
| 45 | ), | ||
| 35 | }, | 46 | }, |
| 36 | }); | 47 | }); |
| 37 | } | 48 | } |
| ... | @@ -97,13 +108,13 @@ export function useSelectedThreadRequests() { | ... | @@ -97,13 +108,13 @@ export function useSelectedThreadRequests() { |
| 97 | : null; | 108 | : null; |
| 98 | 109 | ||
| 99 | const onSelectUserInputOption = useCallback( | 110 | const onSelectUserInputOption = useCallback( |
| 100 | (requestId: ApprovalRequestId, questionId: string, label: string) => { | 111 | (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => { |
| 101 | if (!selectedThreadShell) { | 112 | if (!selectedThreadShell) { |
| 102 | return; | 113 | return; |
| 103 | } | 114 | } |
| 104 | 115 | ||
| 105 | const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); | 116 | const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); |
| 106 | setUserInputDraftOption(requestKey, questionId, label); | 117 | setUserInputDraftOption(requestKey, question, label); |
| 107 | }, | 118 | }, |
| 108 | [selectedThreadShell], | 119 | [selectedThreadShell], |
| 109 | ); | 120 | ); |
apps/web/package.json+1-1| ... | @@ -21,7 +21,7 @@ | ... | @@ -21,7 +21,7 @@ |
| 21 | "@dnd-kit/utilities": "^3.2.2", | 21 | "@dnd-kit/utilities": "^3.2.2", |
| 22 | "@effect/atom-react": "catalog:", | 22 | "@effect/atom-react": "catalog:", |
| 23 | "@formkit/auto-animate": "^0.9.0", | 23 | "@formkit/auto-animate": "^0.9.0", |
| 24 | "@legendapp/list": "3.3.3", | 24 | "@legendapp/list": "catalog:", |
| 25 | "@lexical/react": "^0.41.0", | 25 | "@lexical/react": "^0.41.0", |
| 26 | "@pierre/diffs": "catalog:", | 26 | "@pierre/diffs": "catalog:", |
| 27 | "@pierre/trees": "1.0.0-beta.4", | 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,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 | diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift | 51 | diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift |
| 2 | index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644 | 52 | index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644 |
| 3 | --- a/ios/Shared/MenuViewImplementation.swift | 53 | --- a/ios/Shared/MenuViewImplementation.swift |
| 4 | +++ b/ios/Shared/MenuViewImplementation.swift | 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 | self.menu = menu | 115 | self.menu = menu |
| 8 | self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress | 116 | self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress |
| 9 | + // In long-press mode the button must not intercept touches: as a | 117 | + // In long-press mode the button must not intercept touches: as a |
| ... | @@ -17,6 +125,50 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 | ... | @@ -17,6 +125,50 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 |
| 17 | + self.updateLongPressInteraction() | 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 | + private var longPressInteraction: UIContextMenuInteraction? | 172 | + private var longPressInteraction: UIContextMenuInteraction? |
| 21 | + | 173 | + |
| 22 | + public override func didMoveToSuperview() { | 174 | + public override func didMoveToSuperview() { |
| ... | @@ -44,3 +196,74 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 | ... | @@ -44,3 +196,74 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 |
| 44 | } | 196 | } |
| 45 | 197 | ||
| 46 | public override func reactSetFrame(_ frame: CGRect) { | 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,11 +140,24 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 |
| 140 | 140 | ||
| 141 | NS_ASSUME_NONNULL_END | 141 | NS_ASSUME_NONNULL_END |
| 142 | diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm | 142 | diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm |
| 143 | index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644 | 143 | index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb2199dcbd58 100644 |
| 144 | --- a/ios/RNSScreenStackHeaderConfig.mm | 144 | --- a/ios/RNSScreenStackHeaderConfig.mm |
| 145 | +++ b/ios/RNSScreenStackHeaderConfig.mm | 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 | static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; | 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 | static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; | 161 | static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; |
| 149 | 162 | ||
| 150 | +static NSInteger navigationItemStyleFromCppEquivalent( | 163 | +static NSInteger navigationItemStyleFromCppEquivalent( |
| ... | @@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 164 | @interface RCTImageLoader (Private) | 177 | @interface RCTImageLoader (Private) |
| 165 | - (id<RCTImageCache>)imageCache; | 178 | - (id<RCTImageCache>)imageCache; |
| 166 | @end | 179 | @end |
| 167 | @@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string | 180 | @@ -47,6 +69,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string |
| 168 | @end | 181 | @end |
| 169 | 182 | ||
| 170 | @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving> | 183 | @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving> |
| ... | @@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 174 | @end | 187 | @end |
| 175 | 188 | ||
| 176 | @implementation RNSScreenStackHeaderConfig { | 189 | @implementation RNSScreenStackHeaderConfig { |
| 177 | @@ -81,6 +98,7 @@ - (void)initProps | 190 | @@ -81,6 +106,7 @@ - (void)initProps |
| 178 | self.hidden = YES; | 191 | self.hidden = YES; |
| 179 | _reactSubviews = [NSMutableArray new]; | 192 | _reactSubviews = [NSMutableArray new]; |
| 180 | _backTitleVisible = YES; | 193 | _backTitleVisible = YES; |
| ... | @@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 182 | _blurEffect = RNSBlurEffectStyleNone; | 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 | if (shouldHide) { | 200 | if (shouldHide) { |
| 188 | navitem.title = config.title; | 201 | navitem.title = config.title; |
| ... | @@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 193 | 206 | ||
| 194 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. | 207 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. |
| 195 | [navctr setNavigationBarHidden:YES animated:animated]; | 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 | navitem.largeTitleDisplayMode = | 211 | navitem.largeTitleDisplayMode = |
| 199 | config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; | 212 | config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; |
| ... | @@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 213 | 226 | ||
| 214 | // appearance does not apply to the tvOS so we need to use lagacy customization | 227 | // appearance does not apply to the tvOS so we need to use lagacy customization |
| 215 | #if TARGET_OS_TV | 228 | #if TARGET_OS_TV |
| 216 | @@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc | 229 | @@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc |
| 217 | // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). | 230 | // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). |
| 218 | // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) | 231 | // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) |
| 219 | navitem.title = config.title; | 232 | navitem.title = config.title; |
| ... | @@ -221,32 +234,62 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -221,32 +234,62 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 221 | - withCurrentItems:navitem.leftBarButtonItems]; | 234 | - withCurrentItems:navitem.leftBarButtonItems]; |
| 222 | - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | 235 | - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems |
| 223 | - withCurrentItems:navitem.rightBarButtonItems]; | 236 | - withCurrentItems:navitem.rightBarButtonItems]; |
| 224 | + NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems | 237 | + NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; |
| 225 | + withCurrentItems:navitem.leftBarButtonItems | 238 | + NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; |
| 226 | + navigationItem:navitem]; | 239 | + NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; |
| 227 | + NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems | 240 | + NSArray<UIBarButtonItem *> *subviewLeftItems = navitem.leftBarButtonItems ?: @[]; |
| 228 | + withCurrentItems:navitem.rightBarButtonItems | 241 | + NSArray<UIBarButtonItem *> *subviewRightItems = navitem.rightBarButtonItems ?: @[]; |
| 229 | + navigationItem:navitem]; | 242 | + // The key includes the config instance's identity: cached items capture |
| 230 | + NSArray<UIBarButtonItem *> *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems | 243 | + // this config's event emitter in their press handlers, so a remounted |
| 231 | + withCurrentItems:@[] | 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 | + navigationItem:navitem]; | 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 | +#if !TARGET_OS_TV | 267 | +#if !TARGET_OS_TV |
| 234 | + if (@available(iOS 16.0, *)) { | 268 | + if (@available(iOS 16.0, *)) { |
| 235 | + navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; | 269 | + navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; |
| 236 | + navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; | 270 | + navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; |
| 237 | + if (@available(iOS 26.0, *)) { | 271 | + if (@available(iOS 26.0, *)) { |
| 238 | + navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; | 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; | 280 | +#else |
| 241 | + navitem.rightBarButtonItems = nil; | ||
| 242 | + } else { | ||
| 243 | + navitem.leftBarButtonItems = leftBarButtonItems; | 281 | + navitem.leftBarButtonItems = leftBarButtonItems; |
| 244 | + navitem.rightBarButtonItems = rightBarButtonItems; | 282 | + navitem.rightBarButtonItems = rightBarButtonItems; |
| 245 | + } | ||
| 246 | +#else | ||
| 247 | + navitem.leftBarButtonItems = leftBarButtonItems; | ||
| 248 | + navitem.rightBarButtonItems = rightBarButtonItems; | ||
| 249 | +#endif | 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 | + NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil; | 293 | + NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil; |
| 251 | + for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) { | 294 | + for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) { |
| 252 | + if (toolbarConfig[@"mailSearchToolbar"]) { | 295 | + if (toolbarConfig[@"mailSearchToolbar"]) { |
| ... | @@ -269,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -269,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 269 | + if (existingMailSearchToolbar == nil) { | 312 | + if (existingMailSearchToolbar == nil) { |
| 270 | + existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; | 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 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) | 333 | +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) |
| 276 | + if (@available(iOS 26.0, *)) { | 334 | + if (@available(iOS 26.0, *)) { |
| 277 | + CGFloat horizontalInset = 18.0; | 335 | + CGFloat horizontalInset = 18.0; |
| ... | @@ -304,21 +362,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -304,21 +362,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 304 | + | 362 | + |
| 305 | + UIView *toolbarHost = [[UIView alloc] init]; | 363 | + UIView *toolbarHost = [[UIView alloc] init]; |
| 306 | + toolbarHost.tag = RNSMailSearchToolbarViewTag; | 364 | + toolbarHost.tag = RNSMailSearchToolbarViewTag; |
| 365 | + objc_setAssociatedObject( | ||
| 366 | + toolbarHost, | ||
| 367 | + &RNSAppliedMailSearchToolbarConfigKey, | ||
| 368 | + mailSearchToolbarKey, | ||
| 369 | + OBJC_ASSOCIATION_RETAIN_NONATOMIC); | ||
| 307 | + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; | 370 | + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; |
| 308 | + [chromeHostView addSubview:toolbarHost]; | 371 | + [chromeHostView addSubview:toolbarHost]; |
| 309 | + // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard | 372 | + // The screen stays mounted beneath pushed routes, so its keyboard layout |
| 310 | + // layout guide no longer rests at the bottom safe-area edge while the | 373 | + // guide can track a keyboard owned by another screen. Keep the toolbar at |
| 311 | + // keyboard is hidden, which pushed the toolbar offscreen. The required | 374 | + // rest unless its own search field is editing. |
| 312 | + // resting position is the safe area; the keyboard guide only pulls the | ||
| 313 | + // toolbar up when it actually tracks a visible keyboard. | ||
| 314 | + NSLayoutConstraint *keyboardAvoidConstraint = | 375 | + NSLayoutConstraint *keyboardAvoidConstraint = |
| 315 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor | 376 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor |
| 316 | + constant:-toolbarBottomSpacing]; | 377 | + constant:-toolbarBottomSpacing]; |
| 317 | + keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; | 378 | + keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; |
| 318 | + NSLayoutConstraint *restingBottomConstraint = | 379 | + NSLayoutConstraint *restingBottomConstraint = |
| 319 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor | 380 | + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor |
| 320 | + constant:-toolbarBottomSpacing]; | 381 | + constant:-toolbarBottomSpacing]; |
| 321 | + restingBottomConstraint.priority = UILayoutPriorityDefaultLow; | 382 | + restingBottomConstraint.priority = UILayoutPriorityDefaultHigh; |
| 322 | + [NSLayoutConstraint activateConstraints:@[ | 383 | + [NSLayoutConstraint activateConstraints:@[ |
| 323 | + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], | 384 | + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], |
| 324 | + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor | 385 | + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor |
| ... | @@ -330,6 +391,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -330,6 +391,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 330 | + ]]; | 391 | + ]]; |
| 331 | + [chromeHostView bringSubviewToFront:toolbarHost]; | 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 | + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; | 428 | + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; |
| 334 | + glassEffect.interactive = YES; | 429 | + glassEffect.interactive = YES; |
| 335 | + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; | 430 | + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; |
| ... | @@ -411,6 +506,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -411,6 +506,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 411 | + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; | 506 | + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; |
| 412 | + searchBar.searchTextField.textColor = UIColor.labelColor; | 507 | + searchBar.searchTextField.textColor = UIColor.labelColor; |
| 413 | + searchBar.searchTextField.tintColor = UIColor.labelColor; | 508 | + searchBar.searchTextField.tintColor = UIColor.labelColor; |
| 509 | + configureKeyboardTracking(searchBar.searchTextField); | ||
| 414 | + if (placeholder != nil) { | 510 | + if (placeholder != nil) { |
| 415 | + searchBar.searchTextField.attributedPlaceholder = | 511 | + searchBar.searchTextField.attributedPlaceholder = |
| 416 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; | 512 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; |
| ... | @@ -443,6 +539,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -443,6 +539,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 443 | + searchField.adjustsFontForContentSizeCategory = YES; | 539 | + searchField.adjustsFontForContentSizeCategory = YES; |
| 444 | + searchField.textColor = UIColor.labelColor; | 540 | + searchField.textColor = UIColor.labelColor; |
| 445 | + searchField.tintColor = UIColor.labelColor; | 541 | + searchField.tintColor = UIColor.labelColor; |
| 542 | + configureKeyboardTracking(searchField); | ||
| 446 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; | 543 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; |
| 447 | + [glassView.contentView addSubview:searchField]; | 544 | + [glassView.contentView addSubview:searchField]; |
| 448 | + [NSLayoutConstraint activateConstraints:@[ | 545 | + [NSLayoutConstraint activateConstraints:@[ |
| ... | @@ -491,20 +588,34 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -491,20 +588,34 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 491 | + navigationToolbarConfigs = @[]; | 588 | + navigationToolbarConfigs = @[]; |
| 492 | + } | 589 | + } |
| 493 | + | 590 | + |
| 494 | + NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs | 591 | + NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[]; |
| 495 | + withCurrentItems:@[] | 592 | + // Same reuse rule as the header item groups above (including the config |
| 496 | + navigationItem:navitem]; | 593 | + // identity — cached toolbar items capture this config's event emitter). |
| 497 | + if (toolbarItems.count > 0) { | 594 | + // The top-view-controller and hidden-state checks scope the skip to |
| 498 | + vc.toolbarItems = toolbarItems; | 595 | + // same-screen refreshes, so transitions between screens with different |
| 499 | + [navctr setToolbarHidden:NO animated:animated]; | 596 | + // toolbars still reapply. |
| 500 | + } else { | 597 | + NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ]; |
| 501 | + vc.toolbarItems = nil; | 598 | + BOOL reuseToolbarItems = navctr.topViewController == vc && |
| 502 | + [navctr setToolbarHidden:YES animated:animated]; | 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 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items | 616 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items |
| 506 | // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). | 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 | - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts | 620 | - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts |
| 510 | withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems | 621 | withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems |
| ... | @@ -512,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -512,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 512 | { | 623 | { |
| 513 | if (dicts.count == 0) { | 624 | if (dicts.count == 0) { |
| 514 | return currentItems; | 625 | return currentItems; |
| 515 | @@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | 626 | @@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * |
| 516 | [items addObjectsFromArray:currentItems]; | 627 | [items addObjectsFromArray:currentItems]; |
| 517 | for (NSUInteger i = 0; i < dicts.count; i++) { | 628 | for (NSUInteger i = 0; i < dicts.count; i++) { |
| 518 | NSDictionary *dict = dicts[i]; | 629 | NSDictionary *dict = dicts[i]; |
| ... | @@ -700,7 +811,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -700,7 +811,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 700 | + } | 811 | + } |
| 701 | + } | 812 | + } |
| 702 | +#endif | 813 | +#endif |
| 703 | + if (index != nil && index.integerValue < items.count) { | 814 | + if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { |
| 704 | + [items insertObject:item atIndex:index.integerValue]; | 815 | + [items insertObject:item atIndex:index.integerValue]; |
| 705 | + } else { | 816 | + } else { |
| 706 | + [items addObject:item]; | 817 | + [items addObject:item]; |
| ... | @@ -711,7 +822,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -711,7 +822,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 711 | RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict | 822 | RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict |
| 712 | action:^(NSString *buttonId) { | 823 | action:^(NSString *buttonId) { |
| 713 | auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | 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 | [items addObject:item]; | 833 | [items addObject:item]; |
| 716 | } | 834 | } |
| 717 | } else if (dict[@"spacing"]) { | 835 | } else if (dict[@"spacing"]) { |
| ... | @@ -729,9 +847,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -729,9 +847,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 729 | + item.width = [spacingValue doubleValue]; | 847 | + item.width = [spacingValue doubleValue]; |
| 730 | + } | 848 | + } |
| 731 | NSNumber *index = dict[@"index"]; | 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 | [items insertObject:item atIndex:index.integerValue]; | 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 | return items; | 856 | return items; |
| 736 | } | 857 | } |
| 737 | 858 | ||
| ... | @@ -779,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -779,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 779 | RNS_IGNORE_SUPER_CALL_BEGIN | 900 | RNS_IGNORE_SUPER_CALL_BEGIN |
| 780 | - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex | 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 | _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); | 906 | _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); |
| ... | @@ -788,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -788,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 788 | if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { | 909 | if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { |
| 789 | _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); | 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 | _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; | 913 | _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; |
| 793 | _backButtonDisplayMode = | 914 | _backButtonDisplayMode = |
| 794 | [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; | 915 | [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; |
| ... | @@ -796,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d | ... | @@ -796,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d |
| 796 | 917 | ||
| 797 | if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { | 918 | if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { |
| 798 | _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; | 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 | _headerRightBarButtonItems = array; | 921 | _headerRightBarButtonItems = array; |
| 801 | } | 922 | } |
| 802 | 923 |
pnpm-lock.yaml+33-29| ... | @@ -12,6 +12,9 @@ catalogs: | ... | @@ -12,6 +12,9 @@ catalogs: |
| 12 | '@effect/tsgo': | 12 | '@effect/tsgo': |
| 13 | specifier: 0.13.2 | 13 | specifier: 0.13.2 |
| 14 | version: 0.13.2 | 14 | version: 0.13.2 |
| 15 | '@legendapp/list': | ||
| 16 | specifier: 3.3.5 | ||
| 17 | version: 3.3.5 | ||
| 15 | '@noble/curves': | 18 | '@noble/curves': |
| 16 | specifier: 1.9.1 | 19 | specifier: 1.9.1 |
| 17 | version: 1.9.1 | 20 | version: 1.9.1 |
| ... | @@ -69,12 +72,13 @@ overrides: | ... | @@ -69,12 +72,13 @@ overrides: |
| 69 | packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= | 72 | packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= |
| 70 | 73 | ||
| 71 | patchedDependencies: | 74 | patchedDependencies: |
| 75 | '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 | ||
| 72 | '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b | 76 | '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b |
| 73 | '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 | 77 | '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 |
| 74 | '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 | 78 | '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 |
| 75 | '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 | 79 | '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 |
| 76 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa | 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 | '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 | 82 | '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 |
| 79 | '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 | 83 | '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 |
| 80 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 | 84 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 |
| ... | @@ -82,7 +86,7 @@ patchedDependencies: | ... | @@ -82,7 +86,7 @@ patchedDependencies: |
| 82 | react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 | 86 | react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 |
| 83 | react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 | 87 | react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 |
| 84 | react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 | 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 | importers: | 91 | importers: |
| 88 | 92 | ||
| ... | @@ -197,7 +201,7 @@ importers: | ... | @@ -197,7 +201,7 @@ importers: |
| 197 | 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) | 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 | '@clerk/expo': | 202 | '@clerk/expo': |
| 199 | specifier: 4.2.0 | 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 | '@effect/atom-react': | 205 | '@effect/atom-react': |
| 202 | specifier: 4.0.0-beta.103 | 206 | specifier: 4.0.0-beta.103 |
| 203 | version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) | 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,8 +215,8 @@ importers: |
| 211 | specifier: ~56.0.18 | 215 | specifier: ~56.0.18 |
| 212 | version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) | 216 | version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) |
| 213 | '@legendapp/list': | 217 | '@legendapp/list': |
| 214 | specifier: 3.3.3 | 218 | specifier: 'catalog:' |
| 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) | 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 | '@noble/curves': | 220 | '@noble/curves': |
| 217 | specifier: 'catalog:' | 221 | specifier: 'catalog:' |
| 218 | version: 1.9.1 | 222 | version: 1.9.1 |
| ... | @@ -224,7 +228,7 @@ importers: | ... | @@ -224,7 +228,7 @@ importers: |
| 224 | 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) | 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 | '@react-native-menu/menu': | 229 | '@react-native-menu/menu': |
| 226 | specifier: ^2.0.0 | 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 | '@react-navigation/elements': | 232 | '@react-navigation/elements': |
| 229 | specifier: 2.9.26 | 233 | specifier: 2.9.26 |
| 230 | version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) | 234 | version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) |
| ... | @@ -233,7 +237,7 @@ importers: | ... | @@ -233,7 +237,7 @@ importers: |
| 233 | 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) | 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 | '@react-navigation/native-stack': | 238 | '@react-navigation/native-stack': |
| 235 | specifier: 7.17.6 | 239 | specifier: 7.17.6 |
| 236 | version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) | 240 | version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d) |
| 237 | '@shikijs/core': | 241 | '@shikijs/core': |
| 238 | specifier: 4.2.0 | 242 | specifier: 4.2.0 |
| 239 | version: 4.2.0 | 243 | version: 4.2.0 |
| ... | @@ -398,7 +402,7 @@ importers: | ... | @@ -398,7 +402,7 @@ importers: |
| 398 | 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) | 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 | react-native-screens: | 403 | react-native-screens: |
| 400 | specifier: 4.25.2 | 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 | react-native-shiki-engine: | 406 | react-native-shiki-engine: |
| 403 | specifier: ^0.3.12 | 407 | specifier: ^0.3.12 |
| 404 | 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) | 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,8 +541,8 @@ importers: |
| 537 | specifier: ^0.9.0 | 541 | specifier: ^0.9.0 |
| 538 | version: 0.9.0 | 542 | version: 0.9.0 |
| 539 | '@legendapp/list': | 543 | '@legendapp/list': |
| 540 | specifier: 3.3.3 | 544 | specifier: 'catalog:' |
| 541 | version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 545 | version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 542 | '@lexical/react': | 546 | '@lexical/react': |
| 543 | specifier: ^0.41.0 | 547 | specifier: ^0.41.0 |
| 544 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) | 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,8 +2954,8 @@ packages: |
| 2950 | resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} | 2954 | resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} |
| 2951 | engines: {node: '>=12'} | 2955 | engines: {node: '>=12'} |
| 2952 | 2956 | ||
| 2953 | '@legendapp/list@3.3.3': | 2957 | '@legendapp/list@3.3.5': |
| 2954 | resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} | 2958 | resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==} |
| 2955 | peerDependencies: | 2959 | peerDependencies: |
| 2956 | react: '*' | 2960 | react: '*' |
| 2957 | react-dom: '*' | 2961 | react-dom: '*' |
| ... | @@ -11584,7 +11588,7 @@ snapshots: | ... | @@ -11584,7 +11588,7 @@ snapshots: |
| 11584 | electron-store: 8.2.0 | 11588 | electron-store: 8.2.0 |
| 11585 | react-dom: 19.2.6(react@19.2.6) | 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 | dependencies: | 11592 | dependencies: |
| 11589 | '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 11593 | '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 11590 | '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 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,7 +12257,7 @@ snapshots: |
| 12253 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 12257 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12254 | zod: 3.25.76 | 12258 | zod: 3.25.76 |
| 12255 | optionalDependencies: | 12259 | optionalDependencies: |
| 12256 | expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) | 12260 | expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) |
| 12257 | 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) | 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 | transitivePeerDependencies: | 12262 | transitivePeerDependencies: |
| 12259 | - '@expo/dom-webview' | 12263 | - '@expo/dom-webview' |
| ... | @@ -12329,7 +12333,7 @@ snapshots: | ... | @@ -12329,7 +12333,7 @@ snapshots: |
| 12329 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 12333 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12330 | zod: 3.25.76 | 12334 | zod: 3.25.76 |
| 12331 | optionalDependencies: | 12335 | optionalDependencies: |
| 12332 | expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) | 12336 | expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) |
| 12333 | 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) | 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 | transitivePeerDependencies: | 12338 | transitivePeerDependencies: |
| 12335 | - '@expo/dom-webview' | 12339 | - '@expo/dom-webview' |
| ... | @@ -12669,7 +12673,7 @@ snapshots: | ... | @@ -12669,7 +12673,7 @@ snapshots: |
| 12669 | react: 19.2.3 | 12673 | react: 19.2.3 |
| 12670 | optionalDependencies: | 12674 | optionalDependencies: |
| 12671 | '@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) | 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 | react-dom: 19.2.3(react@19.2.3) | 12677 | react-dom: 19.2.3(react@19.2.3) |
| 12674 | transitivePeerDependencies: | 12678 | transitivePeerDependencies: |
| 12675 | - supports-color | 12679 | - supports-color |
| ... | @@ -12684,7 +12688,7 @@ snapshots: | ... | @@ -12684,7 +12688,7 @@ snapshots: |
| 12684 | react: 19.2.6 | 12688 | react: 19.2.6 |
| 12685 | optionalDependencies: | 12689 | optionalDependencies: |
| 12686 | '@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) | 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 | react-dom: 19.2.6(react@19.2.6) | 12692 | react-dom: 19.2.6(react@19.2.6) |
| 12689 | transitivePeerDependencies: | 12693 | transitivePeerDependencies: |
| 12690 | - supports-color | 12694 | - supports-color |
| ... | @@ -12987,7 +12991,7 @@ snapshots: | ... | @@ -12987,7 +12991,7 @@ snapshots: |
| 12987 | dependencies: | 12991 | dependencies: |
| 12988 | jsbi: 4.3.2 | 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 | dependencies: | 12995 | dependencies: |
| 12992 | react: 19.2.3 | 12996 | react: 19.2.3 |
| 12993 | use-sync-external-store: 1.6.0(react@19.2.3) | 12997 | use-sync-external-store: 1.6.0(react@19.2.3) |
| ... | @@ -12995,7 +12999,7 @@ snapshots: | ... | @@ -12995,7 +12999,7 @@ snapshots: |
| 12995 | react-dom: 19.2.3(react@19.2.3) | 12999 | react-dom: 19.2.3(react@19.2.3) |
| 12996 | 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) | 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 | dependencies: | 13003 | dependencies: |
| 13000 | react: 19.2.6 | 13004 | react: 19.2.6 |
| 13001 | use-sync-external-store: 1.6.0(react@19.2.6) | 13005 | use-sync-external-store: 1.6.0(react@19.2.6) |
| ... | @@ -14091,7 +14095,7 @@ snapshots: | ... | @@ -14091,7 +14095,7 @@ snapshots: |
| 14091 | 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) | 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 | optional: true | 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 | dependencies: | 14099 | dependencies: |
| 14096 | react: 19.2.3 | 14100 | react: 19.2.3 |
| 14097 | 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) | 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,7 +14273,7 @@ snapshots: |
| 14269 | optionalDependencies: | 14273 | optionalDependencies: |
| 14270 | '@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) | 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 | dependencies: | 14277 | dependencies: |
| 14274 | '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) | 14278 | '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) |
| 14275 | '@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) | 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,7 +14281,7 @@ snapshots: |
| 14277 | react: 19.2.3 | 14281 | react: 19.2.3 |
| 14278 | 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) | 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 | 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) | 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 | sf-symbols-typescript: 2.2.0 | 14285 | sf-symbols-typescript: 2.2.0 |
| 14282 | warn-once: 0.1.1 | 14286 | warn-once: 0.1.1 |
| 14283 | transitivePeerDependencies: | 14287 | transitivePeerDependencies: |
| ... | @@ -17056,7 +17060,7 @@ snapshots: | ... | @@ -17056,7 +17060,7 @@ snapshots: |
| 17056 | - supports-color | 17060 | - supports-color |
| 17057 | - typescript | 17061 | - typescript |
| 17058 | 17062 | ||
| 17059 | expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7): | 17063 | expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8): |
| 17060 | dependencies: | 17064 | dependencies: |
| 17061 | '@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) | 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 | '@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) | 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,7 +17091,7 @@ snapshots: |
| 17087 | 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) | 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 | react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) | 17092 | react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) |
| 17089 | 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) | 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 | server-only: 0.0.1 | 17095 | server-only: 0.0.1 |
| 17092 | sf-symbols-typescript: 2.2.0 | 17096 | sf-symbols-typescript: 2.2.0 |
| 17093 | shallowequal: 1.1.0 | 17097 | shallowequal: 1.1.0 |
| ... | @@ -17107,7 +17111,7 @@ snapshots: | ... | @@ -17107,7 +17111,7 @@ snapshots: |
| 17107 | - supports-color | 17111 | - supports-color |
| 17108 | optional: true | 17112 | optional: true |
| 17109 | 17113 | ||
| 17110 | expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f): | 17114 | expo-router@56.2.11(db5c693a26481047569df6781f34db9f): |
| 17111 | dependencies: | 17115 | dependencies: |
| 17112 | '@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) | 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 | '@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) | 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,7 +17142,7 @@ snapshots: |
| 17138 | 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) | 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 | react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) | 17143 | react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) |
| 17140 | 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) | 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 | server-only: 0.0.1 | 17146 | server-only: 0.0.1 |
| 17143 | sf-symbols-typescript: 2.2.0 | 17147 | sf-symbols-typescript: 2.2.0 |
| 17144 | shallowequal: 1.1.0 | 17148 | shallowequal: 1.1.0 |
| ... | @@ -19953,14 +19957,14 @@ snapshots: | ... | @@ -19953,14 +19957,14 @@ snapshots: |
| 19953 | 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) | 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 | optional: true | 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 | dependencies: | 19961 | dependencies: |
| 19958 | react: 19.2.3 | 19962 | react: 19.2.3 |
| 19959 | react-freeze: 1.0.4(react@19.2.3) | 19963 | react-freeze: 1.0.4(react@19.2.3) |
| 19960 | 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) | 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 | warn-once: 0.1.1 | 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 | dependencies: | 19968 | dependencies: |
| 19965 | react: 19.2.6 | 19969 | react: 19.2.6 |
| 19966 | react-freeze: 1.0.4(react@19.2.6) | 19970 | react-freeze: 1.0.4(react@19.2.6) |
pnpm-workspace.yaml+4-1| ... | @@ -39,6 +39,7 @@ catalog: | ... | @@ -39,6 +39,7 @@ catalog: |
| 39 | "@effect/sql-sqlite-bun": 4.0.0-beta.103 | 39 | "@effect/sql-sqlite-bun": 4.0.0-beta.103 |
| 40 | "@effect/tsgo": 0.13.2 | 40 | "@effect/tsgo": 0.13.2 |
| 41 | "@effect/vitest": 4.0.0-beta.103 | 41 | "@effect/vitest": 4.0.0-beta.103 |
| 42 | "@legendapp/list": 3.3.5 | ||
| 42 | "@noble/curves": 1.9.1 | 43 | "@noble/curves": 1.9.1 |
| 43 | "@noble/hashes": 1.8.0 | 44 | "@noble/hashes": 1.8.0 |
| 44 | "@pierre/diffs": 1.3.0-beta.10 | 45 | "@pierre/diffs": 1.3.0-beta.10 |
| ... | @@ -74,6 +75,7 @@ minimumReleaseAgeExclude: | ... | @@ -74,6 +75,7 @@ minimumReleaseAgeExclude: |
| 74 | - "@effect/vitest@4.0.0-beta.103" | 75 | - "@effect/vitest@4.0.0-beta.103" |
| 75 | - alchemy@2.0.0-beta.65 | 76 | - alchemy@2.0.0-beta.65 |
| 76 | - effect@4.0.0-beta.103 | 77 | - effect@4.0.0-beta.103 |
| 78 | - "@legendapp/list@3.3.5" | ||
| 77 | 79 | ||
| 78 | overrides: | 80 | overrides: |
| 79 | "@clerk/backend": "catalog:" | 81 | "@clerk/backend": "catalog:" |
| ... | @@ -122,10 +124,11 @@ packageExtensions: | ... | @@ -122,10 +124,11 @@ packageExtensions: |
| 122 | vite: "catalog:" | 124 | vite: "catalog:" |
| 123 | 125 | ||
| 124 | patchedDependencies: | 126 | patchedDependencies: |
| 127 | "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch | ||
| 125 | "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch | 128 | "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch |
| 126 | "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch | 129 | "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch |
| 127 | "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch | 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 | "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch | 132 | "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch |
| 130 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch | 133 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch |
| 131 | "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch | 134 | "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch |