authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-05 22:50:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-05 22:50:33-07:00
log32e7844837b35ca0a0ff038997a45ea75cb7103f
tree63dccadbf768bed7bcfebb45bc77dc5846eb6c9b
parent482d56233dc3c27f614c843e0f6cc02b3f648a5f
signature Signed by PGP key B5690EEEBB952194

Fix mobile legend anchor under automatic iOS insets (#3684)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

12 files changed, 1830 insertions(+), 123 deletions(-)

.github/workflows/mobile-eas-production.yml created+110
......@@ -0,0 +1,110 @@
1name: Mobile EAS Production
2
3# Production builds and OTA updates run from CI (Linux) — never from a laptop.
4# Under the fingerprint runtime-version policy the fingerprint must be computed
5# in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different
6# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux
7# runner, with corepack pinning pnpm 10.24 in eas.json, local == build.
8on:
9 workflow_dispatch:
10 inputs:
11 mode:
12 description: "build (+ auto-submit to TestFlight) or update (OTA)"
13 required: true
14 type: choice
15 default: build
16 options:
17 - build
18 - update
19 platform:
20 description: "Target platform"
21 required: true
22 type: choice
23 default: ios
24 options:
25 - ios
26 - android
27 - all
28 message:
29 description: "OTA update message (mode=update only)"
30 required: false
31 type: string
32
33jobs:
34 production:
35 name: EAS Production ${{ inputs.mode }}
36 runs-on: blacksmith-8vcpu-ubuntu-2404
37 permissions:
38 contents: read
39 env:
40 APP_VARIANT: production
41 NODE_OPTIONS: --max-old-space-size=8192
42 steps:
43 - id: expo-token
44 name: Check for EXPO_TOKEN
45 env:
46 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
47 run: |
48 if [ -n "$EXPO_TOKEN" ]; then
49 echo "present=true" >> "$GITHUB_OUTPUT"
50 else
51 echo "present=false" >> "$GITHUB_OUTPUT"
52 echo "EXPO_TOKEN is not available; skipping EAS production job."
53 fi
54
55 - name: Checkout
56 if: steps.expo-token.outputs.present == 'true'
57 uses: actions/checkout@v6
58 with:
59 fetch-depth: 0
60
61 - name: Setup Vite+
62 if: steps.expo-token.outputs.present == 'true'
63 uses: voidzero-dev/setup-vp@v1
64 with:
65 node-version-file: package.json
66 cache: true
67 run-install: true
68
69 - name: Expose pnpm
70 if: steps.expo-token.outputs.present == 'true'
71 run: |
72 pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
73 vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
74 echo "$vp_pnpm_bin" >> "$GITHUB_PATH"
75 "$vp_pnpm_bin/pnpm" --version
76
77 - name: Setup EAS
78 if: steps.expo-token.outputs.present == 'true'
79 uses: expo/expo-github-action@v8
80 with:
81 eas-version: latest
82 token: ${{ secrets.EXPO_TOKEN }}
83 packager: pnpm
84
85 - name: Pull production environment variables
86 if: steps.expo-token.outputs.present == 'true'
87 working-directory: apps/mobile
88 env:
89 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
90 run: eas env:pull production --non-interactive
91
92 - name: Build and submit
93 if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build'
94 working-directory: apps/mobile
95 env:
96 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
97 run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait
98
99 - name: Publish OTA update
100 if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update'
101 working-directory: apps/mobile
102 env:
103 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
104 run: |
105 eas update \
106 --channel production \
107 --environment production \
108 --platform ${{ inputs.platform }} \
109 --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \
110 --non-interactive
apps/mobile/app.config.ts+5-1
......@@ -66,7 +66,11 @@ const config: ExpoConfig = {
6666 scheme: variant.scheme,
6767 version: "0.1.0",
6868 runtimeVersion: {
69 policy: process.env.MOBILE_VERSION_POLICY ?? "appVersion",
69 // Fingerprint (not appVersion) so an OTA only reaches binaries whose native
70 // project — native deps, config plugins, AND patches/ — matches the update.
71 // With appVersion, every 0.1.0 build shares a runtime version, so a JS update
72 // could land on a binary missing the native changes it needs and crash.
73 policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint",
7074 },
7175 orientation: "portrait",
7276 icon: "./assets/icon.png",
apps/mobile/eas.json+8-1
......@@ -6,6 +6,7 @@
66 },
77 "build": {
88 "development": {
9 "corepack": true,
910 "env": {
1011 "APP_VARIANT": "development"
1112 },
......@@ -14,6 +15,7 @@
1415 "distribution": "internal"
1516 },
1617 "preview": {
18 "corepack": true,
1719 "env": {
1820 "APP_VARIANT": "preview"
1921 },
......@@ -22,6 +24,7 @@
2224 "distribution": "internal"
2325 },
2426 "preview:dev": {
27 "corepack": true,
2528 "env": {
2629 "APP_VARIANT": "preview",
2730 "MOBILE_VERSION_POLICY": "fingerprint"
......@@ -35,6 +38,7 @@
3538 }
3639 },
3740 "production": {
41 "corepack": true,
3842 "env": {
3943 "APP_VARIANT": "production"
4044 },
......@@ -46,7 +50,10 @@
4650 "submit": {
4751 "production": {
4852 "ios": {
49 "ascAppId": "6761315631"
53 "ascAppId": "6787819824"
54 },
55 "android": {
56 "track": "internal"
5057 }
5158 }
5259 }
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+29-1
......@@ -1,5 +1,7 @@
11import { useAuth, useUser } from "@clerk/expo";
2import Constants from "expo-constants";
23import * as Notifications from "expo-notifications";
4import * as Updates from "expo-updates";
35import { useNavigation } from "@react-navigation/native";
46import { NativeStackScreenOptions } from "../../native/StackHeader";
57import { SymbolView } from "expo-symbols";
......@@ -422,6 +424,23 @@ function ConfiguredSettingsRouteScreen() {
422424function AppSettingsSection() {
423425 const icon = useThemeColor("--color-icon");
424426
427 const version = Constants.expoConfig?.version ?? "0.0.0";
428 // Fall back to "production" to match resolveAppVariant in app.config.ts, so a
429 // missing variant never mislabels a production build as development.
430 const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production";
431 const variantLabel = variant === "production" ? "" : capitalize(variant);
432 const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version;
433 // Which JS is actually running: the bundle shipped in the binary, or an OTA
434 // update downloaded on top of it. Surfacing this makes "am I even on the
435 // right build?" answerable at a glance.
436 const bundleLabel = Updates.isEnabled
437 ? Updates.isEmbeddedLaunch
438 ? "Embedded"
439 : Updates.updateId
440 ? `OTA ${Updates.updateId.slice(0, 7)}`
441 : null
442 : null;
443
425444 return (
426445 <SettingsSection title="App">
427446 <View className="flex-row items-center gap-4 p-4">
......@@ -433,12 +452,21 @@ function AppSettingsSection() {
433452 weight="regular"
434453 />
435454 <Text className="flex-1 text-lg text-foreground">Version</Text>
436 <Text className="text-lg text-foreground-muted">Alpha</Text>
455 <View className="items-end">
456 <Text className="text-lg text-foreground-muted">{versionLabel}</Text>
457 {bundleLabel ? (
458 <Text className="text-xs text-foreground-muted/70">{bundleLabel}</Text>
459 ) : null}
460 </View>
437461 </View>
438462 </SettingsSection>
439463 );
440464}
441465
466function capitalize(value: string): string {
467 return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
468}
469
442470function ArchivedThreadsSettingsSection() {
443471 return (
444472 <SettingsSection title="Threads">
apps/mobile/src/features/threads/ThreadComposer.tsx+87-70
......@@ -25,6 +25,7 @@ import {
2525 type ViewStyle,
2626} from "react-native";
2727import ImageViewing from "react-native-image-viewing";
28import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
2829import { useThemeColor } from "../../lib/useThemeColor";
2930import { scopedThreadKey } from "../../lib/scopedEntities";
3031
......@@ -112,6 +113,10 @@ export interface ThreadComposerProps {
112113 * The pill / card container — renders as LiquidGlassView on supported
113114 * iOS 26+ devices (progressive blur, native morph), opaque View otherwise.
114115 */
116// One timing for every piece of the expanded↔compact morph so the surface,
117// toolbar, and siblings move together instead of popping between layouts.
118const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220);
119
115120function ComposerSurface(props: {
116121 readonly children: ReactNode;
117122 readonly style: ViewStyle;
......@@ -130,7 +135,7 @@ function ComposerSurface(props: {
130135
131136 if (isLiquidGlassSupported) {
132137 return (
133 <View style={shadowStyle}>
138 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>
134139 <LiquidGlassView
135140 effect="regular"
136141 interactive
......@@ -139,12 +144,12 @@ function ComposerSurface(props: {
139144 >
140145 {props.children}
141146 </LiquidGlassView>
142 </View>
147 </Animated.View>
143148 );
144149 }
145150
146151 return (
147 <View style={shadowStyle}>
152 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>
148153 <View
149154 style={[
150155 props.style,
......@@ -157,7 +162,7 @@ function ComposerSurface(props: {
157162 >
158163 {props.children}
159164 </View>
160 </View>
165 </Animated.View>
161166 );
162167}
163168
......@@ -669,7 +674,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
669674 }
670675
671676 return (
672 <View
677 <Animated.View
678 layout={COMPOSER_LAYOUT_TRANSITION}
673679 style={{
674680 paddingHorizontal: 16,
675681 paddingTop: isExpanded ? 8 : 6,
......@@ -679,8 +685,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
679685 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)",
680686 }}
681687 >
682 <View
688 <Animated.View
683689 className="w-full"
690 layout={COMPOSER_LAYOUT_TRANSITION}
684691 style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }}
685692 >
686693 {composerTrigger && composerMenuItems.length > 0 ? (
......@@ -733,13 +740,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
733740 >
734741 {/* Attachment strip — inside the card, above the text input */}
735742 {isExpanded ? (
736 <View style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }}>
743 <Animated.View
744 entering={FadeIn.duration(160)}
745 exiting={FadeOut.duration(120)}
746 style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }}
747 >
737748 <ComposerAttachmentStrip
738749 attachments={props.draftAttachments}
739750 onRemove={props.onRemoveDraftImage}
740751 onPressImage={onPressImage}
741752 />
742 </View>
753 </Animated.View>
743754 ) : null}
744755
745756 <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}>
......@@ -812,81 +823,87 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
812823 </View>
813824 ) : null}
814825 {!isExpanded ? (
815 showStopAction ? (
816 <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
817 ) : (
818 <ControlPill
819 icon="arrow.up"
820 variant="primary"
821 disabled={!canSend}
822 onPress={handleSend}
823 />
824 )
826 <Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}>
827 {showStopAction ? (
828 <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
829 ) : (
830 <ControlPill
831 icon="arrow.up"
832 variant="primary"
833 disabled={!canSend}
834 onPress={handleSend}
835 />
836 )}
837 </Animated.View>
825838 ) : null}
826839 </ComposerSurface>
827840
828841 {/* Toolbar row — matches draft page layout (expanded only) */}
829842 {isExpanded ? (
830 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
831 <ComposerToolbarScroller
832 fadeOpaque={toolbarFadeOpaque}
833 fadeTransparent={toolbarFadeTransparent}
834 >
835 <ComposerToolbarButton
836 icon="plus"
837 onPress={() => void props.onPickDraftImages()}
838 showChevron={false}
839 />
840 <ControlPillMenu
841 actions={modelMenuActions}
842 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
843 >
844 <ComposerToolbarTrigger
845 accessibilityLabel="Model"
846 iconNode={
847 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
848 }
849 label={currentModelOption?.label ?? currentModelSelection.model}
850 />
851 </ControlPillMenu>
852 <ControlPillMenu
853 actions={optionsMenuActions}
854 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
843 <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}>
844 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
845 <ComposerToolbarScroller
846 fadeOpaque={toolbarFadeOpaque}
847 fadeTransparent={toolbarFadeTransparent}
855848 >
856 <ComposerToolbarTrigger
857 accessibilityLabel="Configuration"
858 icon="slider.horizontal.3"
859 label={configurationLabel}
860 />
861 </ControlPillMenu>
862 {showStopAction ? (
863849 <ComposerToolbarButton
864 icon="stop.fill"
865 variant="danger"
866 onPress={props.onStopThread}
850 icon="plus"
851 onPress={() => void props.onPickDraftImages()}
867852 showChevron={false}
868853 />
869 ) : null}
870 </ComposerToolbarScroller>
871 <ComposerToolbarButton
872 accessibilityLabel={sendLabel}
873 icon="arrow.up"
874 variant="primary"
875 disabled={!canSend}
876 onPress={handleSend}
877 showChevron={false}
878 />
879 </ComposerToolbarRow>
854 <ControlPillMenu
855 actions={modelMenuActions}
856 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
857 >
858 <ComposerToolbarTrigger
859 accessibilityLabel="Model"
860 iconNode={
861 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
862 }
863 label={currentModelOption?.label ?? currentModelSelection.model}
864 />
865 </ControlPillMenu>
866 <ControlPillMenu
867 actions={optionsMenuActions}
868 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
869 >
870 <ComposerToolbarTrigger
871 accessibilityLabel="Configuration"
872 icon="slider.horizontal.3"
873 label={configurationLabel}
874 />
875 </ControlPillMenu>
876 {showStopAction ? (
877 <ComposerToolbarButton
878 icon="stop.fill"
879 variant="danger"
880 onPress={props.onStopThread}
881 showChevron={false}
882 />
883 ) : null}
884 </ComposerToolbarScroller>
885 <ComposerToolbarButton
886 accessibilityLabel={sendLabel}
887 icon="arrow.up"
888 variant="primary"
889 disabled={!canSend}
890 onPress={handleSend}
891 showChevron={false}
892 />
893 </ComposerToolbarRow>
894 </Animated.View>
880895 ) : null}
881896
882897 {/* Queue count */}
883898 {props.queueCount > 0 ? (
884 <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}>
885 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
886 automatically.
887 </Text>
899 <Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(120)}>
900 <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}>
901 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
902 automatically.
903 </Text>
904 </Animated.View>
888905 ) : null}
889 </View>
906 </Animated.View>
890907
891908 <ImageViewing
892909 images={previewImageUri ? [{ uri: previewImageUri }] : []}
......@@ -896,6 +913,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
896913 swipeToCloseEnabled
897914 doubleTapToZoomEnabled
898915 />
899 </View>
916 </Animated.View>
900917 );
901918});
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+42-15
......@@ -17,8 +17,9 @@ import type {
1717import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
1818import * as Haptics from "expo-haptics";
1919import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20import { View, type GestureResponderEvent } from "react-native";
20import { Platform, View, type GestureResponderEvent } from "react-native";
2121import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
22import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
2223import { useSafeAreaInsets } from "react-native-safe-area-context";
2324
2425import { AppText as Text } from "../../components/AppText";
......@@ -170,7 +171,11 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE
170171 }, [threadId, feed]);
171172}
172173
173const WORKING_INDICATOR_HEIGHT = 44;
174// Pre-measurement estimate for the working pill's slot (the real height is
175// measured via onComposerLayout since the pill lives inside the composer
176// overlay). Matches the rendered pill: pt-2 + pb-2 (16) wrapping a bordered
177// px-3/py-2 row (~36), so ~52 — keep it in sync with WorkingDurationPill.
178const WORKING_INDICATOR_HEIGHT = 52;
174179
175180const WorkingDurationPill = memo(function WorkingDurationPill(props: {
176181 readonly startedAt: string;
......@@ -187,7 +192,12 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
187192 const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
188193
189194 return (
190 <View className="px-4 pb-2" style={{ flexShrink: 0 }}>
195 <Animated.View
196 className="px-4 pb-2 pt-2"
197 entering={FadeInDown.duration(200)}
198 exiting={FadeOut.duration(140)}
199 style={{ flexShrink: 0 }}
200 >
191201 <View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]">
192202 <View className="flex-row items-center gap-2">
193203 <View className="flex-row items-center gap-1">
......@@ -200,7 +210,7 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
200210 </Text>
201211 </View>
202212 </View>
203 </View>
213 </Animated.View>
204214 );
205215});
206216
......@@ -238,11 +248,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
238248 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
239249 const composerOverlapHeight = composerChrome + composerBottomInset;
240250 const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
241 const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8;
251 const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
252 // The overlay's measured height includes the home-indicator inset (the
253 // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
254 // UIKit add the safe-area bottom to the content inset AGAIN — leaving a
255 // dead strip between the resting content and the composer. Report the
256 // overlay height minus the safe area; UIKit adds it back, and ThreadFeed
257 // hands LegendList the same delta via contentInsetEndStaticAdjustment so
258 // its end-scroll math matches the real resting position.
259 const nativeInsetOvercount =
260 props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0;
242261 const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(
243262 listRef,
244263 composerOverlayRef,
245 estimatedOverlayHeight,
264 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
265 -nativeInsetOvercount,
246266 );
247267 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
248268 const showContent = props.showContent ?? true;
......@@ -408,18 +428,25 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
408428 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
409429 offset={{ closed: 0, opened: 0 }}
410430 >
411 <View
412 ref={composerOverlayRef}
413 onLayout={onComposerLayout}
414 style={{ width: "100%", paddingTop: 8 }}
415 >
416 <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}>
431 {/* No paddingTop here: the overlay's measured height becomes the
432 list's bottom inset, so any padding above the pill/composer
433 pushes the resting content floor up by the same amount. */}
434 <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ width: "100%" }}>
435 <Animated.View
436 layout={LinearTransition.duration(220)}
437 style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}
438 >
417439 {props.activeWorkStartedAt ? (
418440 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
419441 ) : null}
420442
421443 {props.activePendingApproval || props.activePendingUserInput ? (
422 <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
444 <Animated.View
445 className="gap-3 px-4 pb-3"
446 entering={FadeInDown.duration(220)}
447 exiting={FadeOut.duration(140)}
448 style={{ flexShrink: 0 }}
449 >
423450 {props.activePendingApproval ? (
424451 <PendingApprovalCard
425452 approval={props.activePendingApproval}
......@@ -438,9 +465,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
438465 onSubmit={props.onSubmitUserInput}
439466 />
440467 ) : null}
441 </View>
468 </Animated.View>
442469 ) : null}
443 </View>
470 </Animated.View>
444471
445472 <ThreadComposer
446473 editorRef={composerEditorRef}
apps/mobile/src/features/threads/ThreadFeed.tsx+111-11
......@@ -4,11 +4,14 @@ import { type LegendListRef } from "@legendapp/list/react-native";
44import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
55import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
66import { SymbolView } from "expo-symbols";
7import { HeaderHeightContext } from "@react-navigation/elements";
78import { useNavigation } from "@react-navigation/native";
89import {
910 memo,
1011 useCallback,
12 useContext,
1113 useEffect,
14 useLayoutEffect,
1215 useMemo,
1316 useRef,
1417 useState,
......@@ -41,7 +44,13 @@ import {
4144import { TouchableOpacity } from "react-native-gesture-handler";
4245import ImageViewing from "react-native-image-viewing";
4346import { useSafeAreaInsets } from "react-native-safe-area-context";
44import { type SharedValue } from "react-native-reanimated";
47import Animated, {
48 FadeIn,
49 FadeInUp,
50 FadeOut,
51 LinearTransition,
52 type SharedValue,
53} from "react-native-reanimated";
4554import { useThemeColor } from "../../lib/useThemeColor";
4655import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
4756import {
......@@ -96,6 +105,19 @@ function formatMessageTime(input: string): string {
96105 return MESSAGE_TIME_FORMATTER.format(timestamp);
97106}
98107
108// Rows shift when content above them grows (streaming text, work-log folds);
109// animating the container position turns those jumps into slides.
110const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180);
111
112// Entering animations must only play for rows born just now — LegendList
113// remounts rows when they scroll back into view, and replaying an entrance for
114// old content would be its own kind of jank.
115const FRESH_ENTRY_WINDOW_MS = 3_000;
116function isFreshTimestamp(input: string): boolean {
117 const timestamp = Date.parse(input);
118 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;
119}
120
99121export interface ThreadFeedProps {
100122 readonly environmentId: EnvironmentId;
101123 readonly threadId: ThreadId;
......@@ -749,8 +771,12 @@ function renderFeedEntry(
749771 !message.streaming;
750772
751773 if (isUser) {
774 const enterAnimated = isFreshTimestamp(message.createdAt);
752775 return (
753 <View className="mb-5 items-end">
776 <Animated.View
777 className="mb-5 items-end"
778 {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
779 >
754780 <View
755781 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
756782 style={{
......@@ -794,7 +820,7 @@ function renderFeedEntry(
794820 />
795821 ) : null}
796822 </View>
797 </View>
823 </Animated.View>
798824 );
799825 }
800826
......@@ -804,8 +830,12 @@ function renderFeedEntry(
804830 return null;
805831 }
806832
833 const enterAnimated = isFreshTimestamp(message.createdAt);
807834 return (
808 <View className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}>
835 <Animated.View
836 className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
837 {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
838 >
809839 {message.text.trim().length > 0 ? (
810840 hasNativeSelectableMarkdownText() ? (
811841 <SelectableMarkdownText
......@@ -850,7 +880,7 @@ function renderFeedEntry(
850880 </Text>
851881 </View>
852882 ) : null}
853 </View>
883 </Animated.View>
854884 );
855885 }
856886
......@@ -1146,6 +1176,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11461176 const [viewportWidth, setViewportWidth] = useState(() =>
11471177 props.layoutVariant === "split" ? 0 : windowWidth,
11481178 );
1179 const [viewportHeight, setViewportHeight] = useState(0);
11491180 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
11501181 const [interactionState, setInteractionState] = useState<{
11511182 readonly copiedRowId: string | null;
......@@ -1177,6 +1208,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11771208 const bottomContentInset = props.contentBottomInset ?? 18;
11781209 const usesNativeAutomaticInsets =
11791210 props.usesAutomaticContentInsets === true && Platform.OS === "ios";
1211 // With automatic insets the header inset lives in UIKit's adjustedContentInset,
1212 // which LegendList's JS anchoring math cannot see — it measures the anchored
1213 // end space from the scroll view's frame top. Fold the header height back into
1214 // the anchor offset or a just-sent message anchors underneath the header and
1215 // the oversized end space keeps maintainScrollAtEnd snapping away from earlier
1216 // messages. Read the context directly (useHeaderHeight throws outside a
1217 // header-providing screen) and fall back to the standard iOS bar height.
1218 const navigationHeaderHeight = useContext(HeaderHeightContext);
1219 const anchorTopInset = usesNativeAutomaticInsets
1220 ? navigationHeaderHeight || insets.top + 44
1221 : topContentInset;
11801222
11811223 const iconSubtleColor = useThemeColor("--color-icon-subtle");
11821224 const userBubbleColor = useThemeColor("--color-user-bubble");
......@@ -1242,13 +1284,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
12421284 );
12431285 const handleScroll = useCallback(
12441286 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
1245 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6);
1287 // anchorTopInset, not topContentInset: under automatic insets the list
1288 // rests at contentOffset.y = -headerHeight (the inset lives only in
1289 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
1290 // header height back or the material toggles a full header too late.
1291 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
12461292 },
1247 [reportHeaderMaterialVisibility, topContentInset],
1293 [reportHeaderMaterialVisibility, anchorTopInset],
12481294 );
12491295 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
12501296 const nextWidth = Math.round(event.nativeEvent.layout.width);
1297 const nextHeight = Math.round(event.nativeEvent.layout.height);
12511298 setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
1299 setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
12521300 }, []);
12531301
12541302 useEffect(() => {
......@@ -1275,15 +1323,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
12751323 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
12761324 );
12771325
1326 // The empty↔filled key below remounts the list, which resets its imperative
1327 // content-inset override — and useKeyboardChatComposerInset (mounted above
1328 // the remount boundary) deduplicates by height, so it never re-reports the
1329 // composer inset to the fresh instance. Without this, the remounted list's
1330 // initial scroll-to-end computes with a zero end inset and rests one
1331 // composer-height short of the end. Layout effect: it must land before the
1332 // list's first positioning tick or the one-shot initial scroll misses it.
1333 const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`;
1334 useLayoutEffect(() => {
1335 const bottom = props.contentInsetEndAdjustment.value;
1336 if (bottom > 0) {
1337 props.listRef.current?.reportContentInset({ bottom });
1338 }
1339 }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]);
1340
12781341 const anchoredEndSpace = useMemo(
12791342 () =>
12801343 resolveChatListAnchoredEndSpace(
12811344 presentedFeed,
12821345 props.anchorMessageId,
12831346 (entry) => (entry.type === "message" ? entry.id : null),
1284 { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET },
1347 { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
12851348 ),
1286 [presentedFeed, props.anchorMessageId, topContentInset],
1349 [presentedFeed, props.anchorMessageId, anchorTopInset],
12871350 );
12881351 const terminalAssistantMessageIds = useMemo(() => {
12891352 const terminalIdsByTurn = new Map<TurnId, string>();
......@@ -1506,7 +1569,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
15061569 // an already-attached list under a transparent header can pin
15071570 // short content at offset 0 (one header-height too high). A fresh
15081571 // mount positions during attach, where UIKit applies the inset.
1509 key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}
1572 key={listMountKey}
15101573 style={{ flex: 1 }}
15111574 // RN 0.81+ drops touches inside the contentInset area
15121575 // (facebook/react-native#54123); the anchored end space after a send
......@@ -1527,13 +1590,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
15271590 }
15281591 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
15291592 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1593 itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION}
1594 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
1595 // lets its scroll math clamp programmatic scrolls to -headerInset
1596 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
1597 // content rest below the transparent header rather than at frame top.
1598 contentInsetStartAdjustment={usesNativeAutomaticInsets ? anchorTopInset : 0}
15301599 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1600 // UIKit's automatic behavior adds the safe-area bottom on top of the
1601 // raw contentInset the keyboard integration writes. The detail screen
1602 // under-reports the composer inset by this amount (see
1603 // ThreadDetailScreen); this tells LegendList's scroll math about the
1604 // extra so programmatic end scrolls land at the true resting offset.
1605 contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}
1606 // The keyboard integration's offset math (end pinning, max scroll)
1607 // must add the same UIKit-added extra, or its keyboard-open end
1608 // targets land one safe-area short of the true resting offset.
1609 adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0}
15311610 freeze={props.freeze}
1611 // Animated: on send, the optimistic message's dataChange fires
1612 // maintainScrollAtEnd before any render-cycle suppression could
1613 // engage — an instant snap there teleports the feed to the anchor
1614 // instead of scrolling to it. Keeping it enabled (animated) during
1615 // anchor scrolls also lets it correct a scroll that landed on a
1616 // stale end target once the anchor row finishes measuring.
15321617 maintainScrollAtEnd={
15331618 disclosureToggleSettling
15341619 ? false
15351620 : {
1536 animated: false,
1621 animated: true,
15371622 on: {
15381623 dataChange: true,
15391624 itemLayout: true,
......@@ -1552,6 +1637,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
15521637 keyboardShouldPersistTaps="always"
15531638 keyboardDismissMode="none"
15541639 keyboardLiftBehavior="whenAtEnd"
1640 // Seed the list's scroll math with the real viewport before its own
1641 // onLayout: the empty→filled remount can then tell at mount that
1642 // short content underflows the viewport and skip programmatic
1643 // positioning entirely (any offset write during screen attach races
1644 // UIKit's adjustedContentInset application and lands high or low).
1645 {...(viewportHeight > 0 && viewportWidth > 0
1646 ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } }
1647 : {})}
1648 // RN's native scrollTo command clamps targets to a floor of
1649 // -contentInset.top using the RAW inset — under automatic insets the
1650 // header inset only exists in adjustedContentInset, so scrolls to
1651 // negative offsets (content top below the transparent header) get
1652 // clamped to 0. This prop disables that clamp; UIKit still bounces
1653 // user overscroll back to the adjusted rest position.
1654 scrollToOverflowEnabled
15551655 estimatedItemSize={180}
15561656 initialScrollAtEnd
15571657 onScroll={handleScroll}
apps/mobile/src/features/threads/thread-work-log.tsx+14-2
......@@ -5,6 +5,7 @@ import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "re
55import { AppText as Text } from "../../components/AppText";
66import { cn } from "../../lib/cn";
77import type { ThreadFeedActivity } from "../../lib/threadActivity";
8import Animated, { FadeIn } from "react-native-reanimated";
89
910const WORK_LOG_LAYOUT_ANIMATION = {
1011 duration: 180,
......@@ -68,6 +69,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol {
6869 }
6970}
7071
72// Entering fades only for rows created moments ago: rows remount whenever the
73// list scrolls them back into view, and old rows must not replay an entrance.
74const FRESH_ROW_WINDOW_MS = 3_000;
75function isFreshRow(createdAt: string): boolean {
76 const timestamp = Date.parse(createdAt);
77 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
78}
79
7180export function ThreadWorkLog(props: {
7281 readonly activities: ReadonlyArray<ThreadFeedActivity>;
7382 readonly copiedRowId: string | null;
......@@ -104,7 +113,10 @@ export function ThreadWorkLog(props: {
104113 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
105114
106115 return (
107 <View key={row.id}>
116 <Animated.View
117 key={row.id}
118 {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})}
119 >
108120 <Pressable
109121 accessibilityRole={canExpand ? "button" : undefined}
110122 accessibilityLabel={displayText}
......@@ -207,7 +219,7 @@ export function ThreadWorkLog(props: {
207219 </ScrollView>
208220 </View>
209221 ) : null}
210 </View>
222 </Animated.View>
211223 );
212224 })}
213225 </View>
patches/@legendapp__list@3.2.0.patch created+922
......@@ -0,0 +1,922 @@
1diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 5a115ea..2c65d31 100644
3--- a/keyboard.d.ts
4+++ b/keyboard.d.ts
5@@ -269,7 +269,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@@ -278,8 +278,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
25diff --git a/keyboard.js b/keyboard.js
26index 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
107diff --git a/keyboard.mjs b/keyboard.mjs
108index 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
189diff --git a/react-native.d.ts b/react-native.d.ts
190index 72d3f59..435a5fc 100644
191--- a/react-native.d.ts
192+++ b/react-native.d.ts
193@@ -284,6 +284,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
206diff --git a/react-native.js b/react-native.js
207index 8d4ff89..18f0d62 100644
208--- a/react-native.js
209+++ b/react-native.js
210@@ -1195,7 +1195,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");
219@@ -1480,18 +1480,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@@ -1626,10 +1631,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@@ -1676,7 +1681,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@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) {
271 }
272 state.pendingMaintainScrollAtEnd = false;
273 if (shouldMaintainScrollAtEnd) {
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@@ -1759,9 +1776,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@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
313 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
314 return 0;
315 }
316- const maxScroll = Math.max(0, totalSize - state.scrollLength);
317+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
318+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
319+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
320 const clampDelta = maxScroll - state.scroll;
321 if (unresolvedAmount < 0) {
322 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
323@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
324 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
325 return true;
326 }
327- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
328+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
329 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
330 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
331 if (isAtExpectedNativeClamp) {
332@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) {
333 if (diff > 0) {
334 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
335 } else {
336- const maxScroll = Math.max(0, totalSize - state.scrollLength);
337+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
338 state.scroll = maxScroll;
339 state.scrollPending = maxScroll;
340 diff = 0;
341@@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, {
342 }
343
344 // src/core/initialScroll.ts
345+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
346+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
347+var INSET_END_REVEAL_STABLE_FRAMES = 3;
348+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
349+function startInsetEndSettleWatchdog(ctx) {
350+ const state = ctx.state;
351+ if (state.insetEndSettleWatchdogActive) {
352+ return;
353+ }
354+ state.insetEndSettleWatchdogActive = true;
355+ state.didUserDrag = false;
356+ // Hold the readyToRender opacity gate until the end landing is stable, so
357+ // the estimated-to-measured settle chase happens before first VISIBLE
358+ // paint instead of in front of the user. Capped so slow measurement can
359+ // never hide content for long.
360+ state.insetEndRevealHold = true;
361+ let frames = 0;
362+ let settledFrames = 0;
363+ let revealStableFrames = 0;
364+ const releaseRevealHold = () => {
365+ if (state.insetEndRevealHold) {
366+ state.insetEndRevealHold = false;
367+ setInitialRenderState(ctx, {});
368+ }
369+ };
370+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
371+ // can move the end target one more time — do that upgrade while still
372+ // hidden (phase 1), then require stability again before revealing (phase 2)
373+ // so the first visible frame is the settled one.
374+ let revealPhase = 1;
375+ const onRevealStability = () => {
376+ var _a3;
377+ if (!state.insetEndRevealHold) {
378+ return;
379+ }
380+ if (revealPhase === 1) {
381+ revealPhase = 2;
382+ revealStableFrames = 0;
383+ setAdaptiveRender(ctx, "normal");
384+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
385+ return;
386+ }
387+ releaseRevealHold();
388+ };
389+ const tick = () => {
390+ // The user owns the scroll from their first drag — a re-pin here would
391+ // pin the list under their finger.
392+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
393+ state.insetEndSettleWatchdogActive = false;
394+ releaseRevealHold();
395+ return;
396+ }
397+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
398+ releaseRevealHold();
399+ }
400+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
401+ const contentSize = getContentSize(ctx);
402+ const scrollLength = state.scrollLength;
403+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
404+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
405+ const distance = endOffset - state.scroll;
406+ // Estimated row sizes converging to measured ones can strand the initial
407+ // end landing when the library's own end-anchor bookkeeping gives up.
408+ // While still near the end (never fighting a user who scrolled away),
409+ // re-pin to the current true end until sizes stop changing.
410+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
411+ settledFrames = 0;
412+ revealStableFrames = 0;
413+ const scroller = state.refScroller.current;
414+ if (scroller) {
415+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
416+ }
417+ } else {
418+ settledFrames++;
419+ revealStableFrames++;
420+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
421+ onRevealStability();
422+ }
423+ }
424+ } else {
425+ // Conditions that make re-pinning unnecessary (underflow, in-flight
426+ // programmatic scroll) count toward stability for the reveal.
427+ revealStableFrames++;
428+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
429+ onRevealStability();
430+ }
431+ }
432+ requestAnimationFrame(tick);
433+ };
434+ requestAnimationFrame(tick);
435+}
436 function dispatchInitialScroll(ctx, params) {
437 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
438+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
439+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
440+ if (ctx.state.scrollLength <= 0) {
441+ // The scroll length is unknown, so the resolved target is meaningless;
442+ // the initial-scroll machinery re-advances after layout.
443+ return;
444+ }
445+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
446+ // Content underflows the viewport and nothing has scrolled yet: the
447+ // untouched native rest position (UIKit's adjustedContentInset) IS the
448+ // end position. Dispatching a negative scroll here would race UIKit's
449+ // inset application during screen attach, so just finish the session.
450+ finishInitialScroll(ctx, {
451+ resolvedOffset
452+ });
453+ return;
454+ }
455+ ctx.state.didDispatchInsetInitialScroll = true;
456+ if (target.viewPosition === 1) {
457+ startInsetEndSettleWatchdog(ctx);
458+ }
459+ }
460 const requestedIndex = target.index;
461 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
462 const itemSize = getItemSizeAtIndex(ctx, index);
463@@ -2804,7 +2945,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
464 return;
465 }
466 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
467- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
468+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
469+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
470+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
471 if (!shouldKeepEndTargetAlive) {
472 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
473 clearPendingInitialScrollFooterLayout(ctx, {
474@@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
475 }
476 contentBelowAnchor += footerSize + stylePaddingBottom;
477 isReady = !hasUnknownTailSize;
478- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
479+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
480+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
481 } else if (anchorIndex >= 0) {
482 isReady = false;
483 }
484@@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
485 updateScroll(ctx, state.scroll, true);
486 }
487 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
488+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
489+ set$(ctx, "anchoredEndSpaceSize", nextSize);
490+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
491+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
492+ updateScroll(ctx, state.scroll, true);
493+ }
494 }
495 return nextSize;
496 }
497@@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
498 dataVersion,
499 drawDistance = 250,
500 contentInsetEndAdjustment,
501+ contentInsetStartAdjustment,
502 estimatedItemSize = 100,
503 estimatedListSize,
504 extraData,
505@@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
506 onLayout: onLayoutProp,
507 onLoad,
508 onMomentumScrollEnd,
509+ onScrollBeginDrag,
510 onRefresh,
511 onScroll: onScrollProp,
512 onStartReached,
513@@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
514 contentContainerAlignItems: contentContainerStyle.alignItems,
515 contentInset,
516 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
517+ contentInsetStartAdjustment,
518 data: dataProp,
519 dataVersion,
520 drawDistance,
521@@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
522 return void 0;
523 }
524 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
525+ if (getContentInsetStartAdjustment(ctx) > 0) {
526+ // With a native leading inset (transparent header + automatic insets),
527+ // any mount-time contentOffset races UIKit's adjustedContentInset
528+ // application. The gated initial-scroll dispatch positions the list once
529+ // real sizes are known instead.
530+ return void 0;
531+ }
532 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
533 }, [usesBootstrapInitialScroll]);
534 React2.useLayoutEffect(() => {
535@@ -6995,6 +7155,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
536 onMomentumScrollEnd(event);
537 }
538 },
539+ onScrollBeginDrag: (event) => {
540+ ctx.state.didUserDrag = true;
541+ if (onScrollBeginDrag) {
542+ onScrollBeginDrag(event);
543+ }
544+ },
545 onScroll: (event) => onScroll(ctx, event)
546 }),
547 []
548@@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
549 onLayout,
550 onLayoutFooter,
551 onMomentumScrollEnd: fns.onMomentumScrollEnd,
552+ onScrollBeginDrag: fns.onScrollBeginDrag,
553 onScroll: onScrollHandler,
554 recycleItems,
555 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
556diff --git a/react-native.mjs b/react-native.mjs
557index 2e96ca7..6e8913e 100644
558--- a/react-native.mjs
559+++ b/react-native.mjs
560@@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, {
561 if (didInitialScroll) {
562 state.didFinishInitialScroll = true;
563 }
564- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
565+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
566 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
567 set$(ctx, "readyToRender", true);
568 setAdaptiveRender(ctx, "normal");
569@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
570 }
571
572 // src/core/clampScrollOffset.ts
573+function getContentInsetStartAdjustment(ctx) {
574+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
575+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
576+}
577 function clampScrollOffset(ctx, offset, scrollTarget) {
578 const state = ctx.state;
579 const contentSize = getContentSize(ctx);
580+ const minOffset = -getContentInsetStartAdjustment(ctx);
581 let clampedOffset = offset;
582 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
583- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
584+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
585 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
586 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
587 const maxOffset = baseMaxOffset + extraEndOffset;
588 clampedOffset = Math.min(offset, maxOffset);
589 }
590- clampedOffset = Math.max(0, clampedOffset);
591+ clampedOffset = Math.max(minOffset, clampedOffset);
592 return clampedOffset;
593 }
594
595@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) {
596 finishScrollTo(ctx);
597 }
598 }
599-function scrollToFallbackOffset(ctx, offset) {
600+function scrollToFallbackOffset(ctx, offset, animated) {
601 var _a3;
602 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
603- animated: false,
604+ animated: !!animated,
605 x: ctx.state.props.horizontal ? offset : 0,
606 y: ctx.state.props.horizontal ? 0 : offset
607 });
608@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) {
609 });
610 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
611 } else if (shouldRetryUnalignedEndScroll) {
612- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
613+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
614+ if (!isActivelyAnimatingToEnd) {
615+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
616+ }
617 scheduleFallbackCheck(100);
618 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
619 finishScrollTo(ctx);
620@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) {
621 }
622 state.pendingMaintainScrollAtEnd = false;
623 if (shouldMaintainScrollAtEnd) {
624+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
625+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
626+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
627+ return false;
628+ }
629 const contentSize = getContentSize(ctx);
630+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
631 if (contentSize < state.scrollLength) {
632- state.scroll = 0;
633+ state.scroll = -maintainInsetStartAdjustment;
634+ if (maintainInsetStartAdjustment > 0) {
635+ return true;
636+ }
637 }
638 if (!state.maintainingScrollAtEnd) {
639 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
640@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) {
641 y: 0
642 });
643 } else {
644- scroller == null ? void 0 : scroller.scrollToEnd({
645- animated: maintainScrollAtEnd.animated
646- });
647+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
648+ if (insetStartAdjustment > 0) {
649+ scroller == null ? void 0 : scroller.scrollTo({
650+ animated: maintainScrollAtEnd.animated,
651+ x: 0,
652+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
653+ });
654+ } else {
655+ scroller == null ? void 0 : scroller.scrollToEnd({
656+ animated: maintainScrollAtEnd.animated
657+ });
658+ }
659 }
660 setTimeout(
661 () => {
662@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
663 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
664 return 0;
665 }
666- const maxScroll = Math.max(0, totalSize - state.scrollLength);
667+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
668+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
669+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
670 const clampDelta = maxScroll - state.scroll;
671 if (unresolvedAmount < 0) {
672 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
673@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
674 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
675 return true;
676 }
677- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
678+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
679 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
680 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
681 if (isAtExpectedNativeClamp) {
682@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) {
683 if (diff > 0) {
684 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
685 } else {
686- const maxScroll = Math.max(0, totalSize - state.scrollLength);
687+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
688 state.scroll = maxScroll;
689 state.scrollPending = maxScroll;
690 diff = 0;
691@@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, {
692 }
693
694 // src/core/initialScroll.ts
695+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
696+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
697+var INSET_END_REVEAL_STABLE_FRAMES = 3;
698+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
699+function startInsetEndSettleWatchdog(ctx) {
700+ const state = ctx.state;
701+ if (state.insetEndSettleWatchdogActive) {
702+ return;
703+ }
704+ state.insetEndSettleWatchdogActive = true;
705+ state.didUserDrag = false;
706+ // Hold the readyToRender opacity gate until the end landing is stable, so
707+ // the estimated-to-measured settle chase happens before first VISIBLE
708+ // paint instead of in front of the user. Capped so slow measurement can
709+ // never hide content for long.
710+ state.insetEndRevealHold = true;
711+ let frames = 0;
712+ let settledFrames = 0;
713+ let revealStableFrames = 0;
714+ const releaseRevealHold = () => {
715+ if (state.insetEndRevealHold) {
716+ state.insetEndRevealHold = false;
717+ setInitialRenderState(ctx, {});
718+ }
719+ };
720+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
721+ // can move the end target one more time — do that upgrade while still
722+ // hidden (phase 1), then require stability again before revealing (phase 2)
723+ // so the first visible frame is the settled one.
724+ let revealPhase = 1;
725+ const onRevealStability = () => {
726+ var _a3;
727+ if (!state.insetEndRevealHold) {
728+ return;
729+ }
730+ if (revealPhase === 1) {
731+ revealPhase = 2;
732+ revealStableFrames = 0;
733+ setAdaptiveRender(ctx, "normal");
734+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
735+ return;
736+ }
737+ releaseRevealHold();
738+ };
739+ const tick = () => {
740+ // The user owns the scroll from their first drag — a re-pin here would
741+ // pin the list under their finger.
742+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
743+ state.insetEndSettleWatchdogActive = false;
744+ releaseRevealHold();
745+ return;
746+ }
747+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
748+ releaseRevealHold();
749+ }
750+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
751+ const contentSize = getContentSize(ctx);
752+ const scrollLength = state.scrollLength;
753+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
754+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
755+ const distance = endOffset - state.scroll;
756+ // Estimated row sizes converging to measured ones can strand the initial
757+ // end landing when the library's own end-anchor bookkeeping gives up.
758+ // While still near the end (never fighting a user who scrolled away),
759+ // re-pin to the current true end until sizes stop changing.
760+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
761+ settledFrames = 0;
762+ revealStableFrames = 0;
763+ const scroller = state.refScroller.current;
764+ if (scroller) {
765+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
766+ }
767+ } else {
768+ settledFrames++;
769+ revealStableFrames++;
770+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
771+ onRevealStability();
772+ }
773+ }
774+ } else {
775+ // Conditions that make re-pinning unnecessary (underflow, in-flight
776+ // programmatic scroll) count toward stability for the reveal.
777+ revealStableFrames++;
778+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
779+ onRevealStability();
780+ }
781+ }
782+ requestAnimationFrame(tick);
783+ };
784+ requestAnimationFrame(tick);
785+}
786 function dispatchInitialScroll(ctx, params) {
787 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
788+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
789+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
790+ if (ctx.state.scrollLength <= 0) {
791+ // The scroll length is unknown, so the resolved target is meaningless;
792+ // the initial-scroll machinery re-advances after layout.
793+ return;
794+ }
795+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
796+ // Content underflows the viewport and nothing has scrolled yet: the
797+ // untouched native rest position (UIKit's adjustedContentInset) IS the
798+ // end position. Dispatching a negative scroll here would race UIKit's
799+ // inset application during screen attach, so just finish the session.
800+ finishInitialScroll(ctx, {
801+ resolvedOffset
802+ });
803+ return;
804+ }
805+ ctx.state.didDispatchInsetInitialScroll = true;
806+ if (target.viewPosition === 1) {
807+ startInsetEndSettleWatchdog(ctx);
808+ }
809+ }
810 const requestedIndex = target.index;
811 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
812 const itemSize = getItemSizeAtIndex(ctx, index);
813@@ -2783,7 +2924,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
814 return;
815 }
816 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
817- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
818+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
819+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
820+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
821 if (!shouldKeepEndTargetAlive) {
822 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
823 clearPendingInitialScrollFooterLayout(ctx, {
824@@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
825 }
826 contentBelowAnchor += footerSize + stylePaddingBottom;
827 isReady = !hasUnknownTailSize;
828- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
829+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
830+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
831 } else if (anchorIndex >= 0) {
832 isReady = false;
833 }
834@@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
835 updateScroll(ctx, state.scroll, true);
836 }
837 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
838+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
839+ set$(ctx, "anchoredEndSpaceSize", nextSize);
840+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
841+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
842+ updateScroll(ctx, state.scroll, true);
843+ }
844 }
845 return nextSize;
846 }
847@@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
848 dataVersion,
849 drawDistance = 250,
850 contentInsetEndAdjustment,
851+ contentInsetStartAdjustment,
852 estimatedItemSize = 100,
853 estimatedListSize,
854 extraData,
855@@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
856 onLayout: onLayoutProp,
857 onLoad,
858 onMomentumScrollEnd,
859+ onScrollBeginDrag,
860 onRefresh,
861 onScroll: onScrollProp,
862 onStartReached,
863@@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
864 contentContainerAlignItems: contentContainerStyle.alignItems,
865 contentInset,
866 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
867+ contentInsetStartAdjustment,
868 data: dataProp,
869 dataVersion,
870 drawDistance,
871@@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
872 return void 0;
873 }
874 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
875+ if (getContentInsetStartAdjustment(ctx) > 0) {
876+ // With a native leading inset (transparent header + automatic insets),
877+ // any mount-time contentOffset races UIKit's adjustedContentInset
878+ // application. The gated initial-scroll dispatch positions the list once
879+ // real sizes are known instead.
880+ return void 0;
881+ }
882 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
883 }, [usesBootstrapInitialScroll]);
884 useLayoutEffect(() => {
885@@ -6974,6 +7134,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
886 onMomentumScrollEnd(event);
887 }
888 },
889+ onScrollBeginDrag: (event) => {
890+ ctx.state.didUserDrag = true;
891+ if (onScrollBeginDrag) {
892+ onScrollBeginDrag(event);
893+ }
894+ },
895 onScroll: (event) => onScroll(ctx, event)
896 }),
897 []
898@@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
899 onLayout,
900 onLayoutFooter,
901 onMomentumScrollEnd: fns.onMomentumScrollEnd,
902+ onScrollBeginDrag: fns.onScrollBeginDrag,
903 onScroll: onScrollHandler,
904 recycleItems,
905 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
906diff --git a/reanimated.d.ts b/reanimated.d.ts
907index 7e2d11f..d5b0d66 100644
908--- a/reanimated.d.ts
909+++ b/reanimated.d.ts
910@@ -285,6 +285,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
911 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
912 */
913 contentInsetEndAdjustment?: number;
914+ /**
915+ * Width/height of a leading content inset applied natively outside the list's knowledge
916+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
917+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
918+ */
919+ contentInsetStartAdjustment?: number;
920 /**
921 * Number of columns to render items in.
922 * @default 1
patches/react-native-keyboard-controller@1.21.13.patch created+483
......@@ -0,0 +1,483 @@
1diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js
2index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644
3--- a/lib/commonjs/components/KeyboardChatScrollView/index.js
4+++ b/lib/commonjs/components/KeyboardChatScrollView/index.js
5@@ -26,9 +26,11 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
6 offset = 0,
7 extraContentPadding = ZERO_CONTENT_PADDING,
8 blankSpace = ZERO_BLANK_SPACE,
9+ adjustedInsetCompensation = 0,
10 applyWorkaroundForContentInsetHitTestBug = false,
11 onLayout: onLayoutProp,
12 onContentSizeChange: onContentSizeChangeProp,
13+ onContentInsetChange,
14 onEndVisible,
15 ...rest
16 }, ref) => {
17@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
18 freeze: freezeSV,
19 offset,
20 blankSpace,
21- extraContentPadding
22+ extraContentPadding,
23+ adjustedInsetCompensation
24 });
25 (0, _useExtraContentPadding.useExtraContentPadding)({
26 scrollViewRef,
27 extraContentPadding,
28 keyboardPadding: padding,
29 blankSpace,
30+ adjustedInsetCompensation,
31 scroll,
32 layout,
33 size,
34@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
35 // a bug for you, please open an issue.
36 const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
37
38+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
39+ // to the consumer - a virtualized list needs it in its own scroll math or
40+ // its end/maintain targets point at the under-the-keyboard resting offset.
41+ (0, _reactNativeReanimated.useAnimatedReaction)(() => totalPadding.value, (current, previous) => {
42+ if (onContentInsetChange && current !== previous) {
43+ (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({
44+ bottom: current
45+ });
46+ }
47+ }, [onContentInsetChange]);
48+
49 // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
50 // Apps that render into the unsafe area can supply a negative
51 // scrollIndicatorInsets adjustment at the application layer.
52- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value);
53+ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value);
54 const onLayout = (0, _react.useCallback)(e => {
55 onLayoutInternal(e);
56 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
57diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
58index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644
59--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
60+++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
61@@ -32,7 +32,8 @@ function useChatKeyboard(scrollViewRef, options) {
62 freeze,
63 offset,
64 blankSpace,
65- extraContentPadding
66+ extraContentPadding,
67+ adjustedInsetCompensation
68 } = options;
69 const padding = (0, _reactNativeReanimated.useSharedValue)(0);
70 const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0);
71@@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) {
72 const visiblePadding = visibleFraction * blankSpace.value;
73 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
74 const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed);
75- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value);
76+ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation;
77
78 // persistent mode: when keyboard shrinks, clamp to valid range
79 if (keyboardLiftBehavior === "persistent" && effective < padding.value) {
80@@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) {
81 const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
82 padding.value = effective;
83 }
84- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
85+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]);
86 return {
87 padding,
88 currentHeight,
89diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
90index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644
91--- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
92+++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
93@@ -29,6 +29,7 @@ function useExtraContentPadding(options) {
94 extraContentPadding,
95 keyboardPadding,
96 blankSpace,
97+ adjustedInsetCompensation,
98 scroll,
99 layout,
100 size,
101@@ -68,8 +69,8 @@ function useExtraContentPadding(options) {
102 }
103
104 // Compute effective delta considering blankSpace floor
105- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous);
106- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current);
107+ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation;
108+ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation;
109 const effectiveDelta = currentTotal - previousTotal;
110 if (effectiveDelta === 0) {
111 // blankSpace absorbed the change
112@@ -92,6 +93,6 @@ function useExtraContentPadding(options) {
113 const target = Math.min(scroll.value + effectiveDelta, maxScroll);
114 scrollToTarget(target);
115 }
116- }, [inverted, keyboardLiftBehavior]);
117+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]);
118 }
119 //# sourceMappingURL=index.js.map
120\ No newline at end of file
121diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js
122index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644
123--- a/lib/module/components/KeyboardChatScrollView/index.js
124+++ b/lib/module/components/KeyboardChatScrollView/index.js
125@@ -1,7 +1,7 @@
126 function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
127 import React, { forwardRef, useCallback, useMemo } from "react";
128 import { StyleSheet } from "react-native";
129-import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
130+import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
131 import Reanimated from "react-native-reanimated";
132 import useCombinedRef from "../hooks/useCombinedRef";
133 import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";
134@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
135 offset = 0,
136 extraContentPadding = ZERO_CONTENT_PADDING,
137 blankSpace = ZERO_BLANK_SPACE,
138+ adjustedInsetCompensation = 0,
139 applyWorkaroundForContentInsetHitTestBug = false,
140 onLayout: onLayoutProp,
141 onContentSizeChange: onContentSizeChangeProp,
142+ onContentInsetChange,
143 onEndVisible,
144 ...rest
145 }, ref) => {
146@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
147 freeze: freezeSV,
148 offset,
149 blankSpace,
150- extraContentPadding
151+ extraContentPadding,
152+ adjustedInsetCompensation
153 });
154 useExtraContentPadding({
155 scrollViewRef,
156 extraContentPadding,
157 keyboardPadding: padding,
158 blankSpace,
159+ adjustedInsetCompensation,
160 scroll,
161 layout,
162 size,
163@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
164 // a bug for you, please open an issue.
165 const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
166
167+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
168+ // to the consumer - a virtualized list needs it in its own scroll math or
169+ // its end/maintain targets point at the under-the-keyboard resting offset.
170+ useAnimatedReaction(() => totalPadding.value, (current, previous) => {
171+ if (onContentInsetChange && current !== previous) {
172+ runOnJS(onContentInsetChange)({
173+ bottom: current
174+ });
175+ }
176+ }, [onContentInsetChange]);
177+
178 // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
179 // Apps that render into the unsafe area can supply a negative
180 // scrollIndicatorInsets adjustment at the application layer.
181- const indicatorPadding = useDerivedValue(() => padding.value + extraContentPadding.value);
182+ const indicatorPadding = useDerivedValue(() => padding.value);
183 const onLayout = useCallback(e => {
184 onLayoutInternal(e);
185 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
186diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
187index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644
188--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
189+++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
190@@ -25,7 +25,8 @@ function useChatKeyboard(scrollViewRef, options) {
191 freeze,
192 offset,
193 blankSpace,
194- extraContentPadding
195+ extraContentPadding,
196+ adjustedInsetCompensation
197 } = options;
198 const padding = useSharedValue(0);
199 const currentHeight = useSharedValue(0);
200@@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) {
201 const visiblePadding = visibleFraction * blankSpace.value;
202 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
203 const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed);
204- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value);
205+ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation;
206
207 // persistent mode: when keyboard shrinks, clamp to valid range
208 if (keyboardLiftBehavior === "persistent" && effective < padding.value) {
209@@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) {
210 const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
211 padding.value = effective;
212 }
213- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
214+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]);
215 return {
216 padding,
217 currentHeight,
218diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
219index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644
220--- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
221+++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
222@@ -23,6 +23,7 @@ function useExtraContentPadding(options) {
223 extraContentPadding,
224 keyboardPadding,
225 blankSpace,
226+ adjustedInsetCompensation,
227 scroll,
228 layout,
229 size,
230@@ -62,8 +63,8 @@ function useExtraContentPadding(options) {
231 }
232
233 // Compute effective delta considering blankSpace floor
234- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous);
235- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current);
236+ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation;
237+ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation;
238 const effectiveDelta = currentTotal - previousTotal;
239 if (effectiveDelta === 0) {
240 // blankSpace absorbed the change
241@@ -86,7 +87,7 @@ function useExtraContentPadding(options) {
242 const target = Math.min(scroll.value + effectiveDelta, maxScroll);
243 scrollToTarget(target);
244 }
245- }, [inverted, keyboardLiftBehavior]);
246+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]);
247 }
248 export { useExtraContentPadding };
249 //# sourceMappingURL=index.js.map
250\ No newline at end of file
251diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
252index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644
253--- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts
254+++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
255@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = {
256 * Default is `undefined` (equivalent to `0` — no minimum floor).
257 */
258 blankSpace?: SharedValue<number>;
259+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
260+ adjustedInsetCompensation?: number;
261 /**
262 * Fires whenever the effective content inset changes — the static `contentInset`
263 * prop combined with the dynamic keyboard-driven padding.
264diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
265index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644
266--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
267+++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
268@@ -9,6 +9,8 @@ type UseChatKeyboardOptions = {
269 blankSpace: SharedValue<number>;
270 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
271 extraContentPadding: SharedValue<number>;
272+ /** Safe-area extra beyond raw contentInset. Offset math only. */
273+ adjustedInsetCompensation: number;
274 };
275 type UseChatKeyboardReturn = {
276 /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */
277diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
278index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644
279--- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
280+++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
281@@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = {
282 keyboardPadding: SharedValue<number>;
283 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
284 blankSpace: SharedValue<number>;
285+ /** Safe-area extra beyond raw contentInset. Offset math only. */
286+ adjustedInsetCompensation: number;
287 /** Current vertical scroll offset. */
288 scroll: SharedValue<number>;
289 /** Visible viewport dimensions. */
290diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx
291index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644
292--- a/src/components/KeyboardChatScrollView/index.tsx
293+++ b/src/components/KeyboardChatScrollView/index.tsx
294@@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react";
295 import { StyleSheet } from "react-native";
296 import {
297 makeMutable,
298+ runOnJS,
299+ useAnimatedReaction,
300 useAnimatedRef,
301 useAnimatedStyle,
302 useDerivedValue,
303@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef<
304 offset = 0,
305 extraContentPadding = ZERO_CONTENT_PADDING,
306 blankSpace = ZERO_BLANK_SPACE,
307+ adjustedInsetCompensation = 0,
308 applyWorkaroundForContentInsetHitTestBug = false,
309 onLayout: onLayoutProp,
310 onContentSizeChange: onContentSizeChangeProp,
311+ onContentInsetChange,
312 onEndVisible,
313 ...rest
314 },
315@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef<
316 offset,
317 blankSpace,
318 extraContentPadding,
319+ adjustedInsetCompensation,
320 });
321
322 useExtraContentPadding({
323@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef<
324 extraContentPadding,
325 keyboardPadding: padding,
326 blankSpace,
327+ adjustedInsetCompensation,
328 scroll,
329 layout,
330 size,
331@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef<
332 ),
333 );
334
335- // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
336- // Apps that render into the unsafe area can supply a negative
337- // scrollIndicatorInsets adjustment at the application layer.
338- const indicatorPadding = useDerivedValue(
339- () => padding.value + extraContentPadding.value,
340+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
341+ // to the consumer — a virtualized list needs it in its own scroll math or
342+ // its end/maintain targets point at the under-the-keyboard resting offset.
343+ useAnimatedReaction(
344+ () => totalPadding.value,
345+ (current, previous) => {
346+ if (onContentInsetChange && current !== previous) {
347+ runOnJS(onContentInsetChange)({ bottom: current });
348+ }
349+ },
350+ [onContentInsetChange],
351 );
352
353+ // Scroll indicator inset = keyboard only (excludes extraContentPadding and
354+ // blankSpace): with a floating composer the indicator track should run the
355+ // full height of the scroll view, behind the composer, like iOS Messages.
356+ // The keyboard still lifts it so it never disappears under the keyboard.
357+ const indicatorPadding = useDerivedValue(() => padding.value);
358+
359 const onLayout = useCallback(
360 (e: LayoutChangeEvent) => {
361 onLayoutInternal(e);
362diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts
363index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644
364--- a/src/components/KeyboardChatScrollView/types.ts
365+++ b/src/components/KeyboardChatScrollView/types.ts
366@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = {
367 * Default is `undefined` (equivalent to `0` — no minimum floor).
368 */
369 blankSpace?: SharedValue<number>;
370+ /**
371+ * Extra bottom inset UIKit adds on top of the raw `contentInset` this
372+ * component writes (e.g. the home-indicator safe area under
373+ * `contentInsetAdjustmentBehavior="automatic"`). Used ONLY in scroll-offset
374+ * math (max scroll / end pinning) — never written into `contentInset`.
375+ *
376+ * Default is `0`.
377+ */
378+ adjustedInsetCompensation?: number;
379 /**
380 * Fires whenever the effective content inset changes — the static `contentInset`
381 * prop combined with the dynamic keyboard-driven padding.
382diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
383index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644
384--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
385+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
386@@ -43,6 +43,7 @@ function useChatKeyboard(
387 offset,
388 blankSpace,
389 extraContentPadding,
390+ adjustedInsetCompensation,
391 } = options;
392
393 const padding = useSharedValue(0);
394@@ -104,10 +105,12 @@ function useChatKeyboard(
395 effective,
396 minimumPaddingAbsorbed,
397 );
398- const actualTotalPadding = Math.max(
399- blankSpace.value,
400- effective + extraContentPadding.value,
401- );
402+ // UIKit adds adjustedInsetCompensation (safe area) on top of the raw
403+ // inset; include it here so end/max-scroll targets match the real
404+ // resting offsets.
405+ const actualTotalPadding =
406+ Math.max(blankSpace.value, effective + extraContentPadding.value) +
407+ adjustedInsetCompensation;
408
409 // persistent mode: when keyboard shrinks, clamp to valid range
410 if (
411@@ -242,7 +245,7 @@ function useChatKeyboard(
412 padding.value = effective;
413 },
414 },
415- [inverted, keyboardLiftBehavior, offset, extraContentPadding],
416+ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation],
417 );
418
419 return {
420diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
421index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644
422--- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
423+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
424@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = {
425 blankSpace: SharedValue<number>;
426 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
427 extraContentPadding: SharedValue<number>;
428+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
429+ adjustedInsetCompensation: number;
430 };
431
432 type UseChatKeyboardReturn = {
433diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
434index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644
435--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
436+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
437@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = {
438 keyboardPadding: SharedValue<number>;
439 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
440 blankSpace: SharedValue<number>;
441+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
442+ adjustedInsetCompensation: number;
443 /** Current vertical scroll offset. */
444 scroll: SharedValue<number>;
445 /** Visible viewport dimensions. */
446@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
447 extraContentPadding,
448 keyboardPadding,
449 blankSpace,
450+ adjustedInsetCompensation,
451 scroll,
452 layout,
453 size,
454@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
455 }
456
457 // Compute effective delta considering blankSpace floor
458- const previousTotal = Math.max(
459- blankSpace.value,
460- keyboardPadding.value + previous,
461- );
462- const currentTotal = Math.max(
463- blankSpace.value,
464- keyboardPadding.value + current,
465- );
466+ const previousTotal =
467+ Math.max(blankSpace.value, keyboardPadding.value + previous) +
468+ adjustedInsetCompensation;
469+ const currentTotal =
470+ Math.max(blankSpace.value, keyboardPadding.value + current) +
471+ adjustedInsetCompensation;
472 const effectiveDelta = currentTotal - previousTotal;
473
474 if (effectiveDelta === 0) {
475@@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
476 scrollToTarget(target);
477 }
478 },
479- [inverted, keyboardLiftBehavior],
480+ [inverted, keyboardLiftBehavior, adjustedInsetCompensation],
481 );
482 }
483
pnpm-lock.yaml+12-6
......@@ -76,6 +76,9 @@ patchedDependencies:
7676 '@ff-labs/fff-node@0.9.4':
7777 hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
7878 path: patches/@ff-labs__fff-node@0.9.4.patch
79 '@legendapp/list@3.2.0':
80 hash: 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3
81 path: patches/@legendapp__list@3.2.0.patch
7982 '@pierre/diffs@1.3.0-beta.5':
8083 hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a
8184 path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch
......@@ -94,6 +97,9 @@ patchedDependencies:
9497 react-native-gesture-handler@2.31.2:
9598 hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
9699 path: patches/react-native-gesture-handler@2.31.2.patch
100 react-native-keyboard-controller@1.21.13:
101 hash: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008
102 path: patches/react-native-keyboard-controller@1.21.13.patch
97103 react-native-nitro-modules@0.35.9:
98104 hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
99105 path: patches/react-native-nitro-modules@0.35.9.patch
......@@ -226,7 +232,7 @@ importers:
226232 version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3)
227233 '@legendapp/list':
228234 specifier: 3.2.0
229 version: 3.2.0(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
235 version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
230236 '@noble/curves':
231237 specifier: 'catalog:'
232238 version: 1.9.1
......@@ -379,7 +385,7 @@ importers:
379385 version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
380386 react-native-keyboard-controller:
381387 specifier: 1.21.13
382 version: 1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
388 version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
383389 react-native-nitro-markdown:
384390 specifier: ^0.5.0
385391 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
......@@ -537,7 +543,7 @@ importers:
537543 version: 0.9.0
538544 '@legendapp/list':
539545 specifier: 3.2.0
540 version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
546 version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
541547 '@lexical/react':
542548 specifier: ^0.41.0
543549 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
......@@ -12654,7 +12660,7 @@ snapshots:
1265412660 dependencies:
1265512661 jsbi: 4.3.2
1265612662
12657 '@legendapp/list@3.2.0(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12663 '@legendapp/list@3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
1265812664 dependencies:
1265912665 react: 19.2.3
1266012666 use-sync-external-store: 1.6.0(react@19.2.3)
......@@ -12662,7 +12668,7 @@ snapshots:
1266212668 react-dom: 19.2.3(react@19.2.3)
1266312669 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1266412670
12665 '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12671 '@legendapp/list@3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
1266612672 dependencies:
1266712673 react: 19.2.6
1266812674 use-sync-external-store: 1.6.0(react@19.2.6)
......@@ -18950,7 +18956,7 @@ snapshots:
1895018956 react: 19.2.3
1895118957 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1895218958
18953 react-native-keyboard-controller@1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18959 react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
1895418960 dependencies:
1895518961 react: 19.2.3
1895618962 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
pnpm-workspace.yaml+7-16
......@@ -5,20 +5,6 @@ packages:
55 - packages/*
66 - scripts
77
8# Install both Windows and Linux native binaries so the WSL (Linux) backend
9# can load platform-gated optional deps (e.g. @yuuang/ffi-rs-linux-x64-gnu)
10# out of the same node_modules the Windows desktop uses.
11supportedArchitectures:
12 os:
13 - current
14 - linux
15 cpu:
16 - current
17 - x64
18 libc:
19 - current
20 - glibc
21
228catalog:
239 "@clerk/backend": 3.8.4
2410 "@clerk/clerk-js": 6.22.0
......@@ -84,8 +70,6 @@ overrides:
8470 "@pierre/diffs>@shikijs/transformers": ^4.2.0
8571 "@types/node": "catalog:"
8672 effect: "catalog:"
87 # Pin to the version our patch targets; expo-modules-core's ~56.0.10 range
88 # otherwise floats to newer releases on fresh resolves (ERR_PNPM_UNUSED_PATCH).
8973 expo-modules-jsi: 56.0.10
9074 vite: "catalog:"
9175 yaml: "catalog:"
......@@ -105,12 +89,14 @@ patchedDependencies:
10589 "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch
10690 "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch
10791 "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch
92 "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch
10893 "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch
10994 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
11095 "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch
11196 effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch
11297 expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch
11398 react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch
99 react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch
114100 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch
115101 react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch
116102
......@@ -119,3 +105,8 @@ peerDependencyRules:
119105 - vite
120106 allowedVersions:
121107 vite: "*"
108
109supportedArchitectures:
110 cpu: [current, x64]
111 libc: [current, glibc]
112 os: [current, linux]