From aadff31c6beb9945d42dc37d9af37a323d0a2b3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Jul 2026 16:46:14 -0700 Subject: [PATCH 1/3] Fix mobile legend anchor with automatic insets - Fold header inset into the anchor offset when iOS automatic content insets are enabled - Prevent new messages from anchoring under the header and snapping past earlier messages --- apps/mobile/src/features/threads/ThreadFeed.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fc9cebe93c8..78fc7179a3c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1177,6 +1177,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const bottomContentInset = props.contentBottomInset ?? 18; const usesNativeAutomaticInsets = props.usesAutomaticContentInsets === true && Platform.OS === "ios"; + // With automatic insets the header inset lives in UIKit's adjustedContentInset, + // which LegendList's JS anchoring math cannot see — it measures the anchored + // end space from the scroll view's frame top. Fold the header height back into + // the anchor offset or a just-sent message anchors underneath the header and + // the oversized end space keeps maintainScrollAtEnd snapping away from earlier + // messages. + const anchorTopInset = usesNativeAutomaticInsets ? insets.top + 44 : topContentInset; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); @@ -1281,9 +1288,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { presentedFeed, props.anchorMessageId, (entry) => (entry.type === "message" ? entry.id : null), - { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET }, + { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), - [presentedFeed, props.anchorMessageId, topContentInset], + [presentedFeed, props.anchorMessageId, anchorTopInset], ); const terminalAssistantMessageIds = useMemo(() => { const terminalIdsByTurn = new Map(); From 1c4b21c37a0d6d6140281b896ff7c779fcfac722 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 4 Jul 2026 11:13:37 -0700 Subject: [PATCH 2/3] Fix mobile thread anchoring and layout motion - Patch LegendList and keyboard inset handling for transparent headers - Animate composer, feed, and work-log transitions more smoothly - Add tests and dependency patches for the mobile scroll fixes --- .../src/features/threads/ThreadComposer.tsx | 157 +++-- .../features/threads/ThreadDetailScreen.tsx | 40 +- .../src/features/threads/ThreadFeed.tsx | 108 ++- .../src/features/threads/thread-work-log.tsx | 16 +- patches/@legendapp__list@3.2.0.patch | 653 ++++++++++++++++++ ...ct-native-keyboard-controller@1.21.6.patch | 105 +++ pnpm-lock.yaml | 24 +- pnpm-workspace.yaml | 2 + 8 files changed, 1007 insertions(+), 98 deletions(-) create mode 100644 patches/@legendapp__list@3.2.0.patch create mode 100644 patches/react-native-keyboard-controller@1.21.6.patch diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 409deab2ebc..bc107b0c3d2 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -25,6 +25,7 @@ import { type ViewStyle, } from "react-native"; import ImageViewing from "react-native-image-viewing"; +import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -112,6 +113,10 @@ export interface ThreadComposerProps { * The pill / card container — renders as LiquidGlassView on supported * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. */ +// One timing for every piece of the expanded↔compact morph so the surface, +// toolbar, and siblings move together instead of popping between layouts. +const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220); + function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; @@ -130,7 +135,7 @@ function ComposerSurface(props: { if (isLiquidGlassSupported) { return ( - + {props.children} - + ); } return ( - + {props.children} - + ); } @@ -669,7 +674,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return ( - - {composerTrigger && composerMenuItems.length > 0 ? ( @@ -733,13 +740,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer > {/* Attachment strip — inside the card, above the text input */} {isExpanded ? ( - 0 ? 10 : 0 }}> + 0 ? 10 : 0 }} + > - + ) : null} @@ -812,81 +823,87 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} {!isExpanded ? ( - showStopAction ? ( - - ) : ( - - ) + + {showStopAction ? ( + + ) : ( + + )} + ) : null} {/* Toolbar row — matches draft page layout (expanded only) */} {isExpanded ? ( - - - void props.onPickDraftImages()} - showChevron={false} - /> - handleModelMenuAction(nativeEvent.event)} - > - - } - label={currentModelOption?.label ?? currentModelSelection.model} - /> - - handleOptionsMenuAction(nativeEvent.event)} + + + - - - {showStopAction ? ( void props.onPickDraftImages()} showChevron={false} /> - ) : null} - - - + handleModelMenuAction(nativeEvent.event)} + > + + } + label={currentModelOption?.label ?? currentModelSelection.model} + /> + + handleOptionsMenuAction(nativeEvent.event)} + > + + + {showStopAction ? ( + + ) : null} + + + + ) : null} {/* Queue count */} {props.queueCount > 0 ? ( - - {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send - automatically. - + + + {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send + automatically. + + ) : null} - + - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e0634102cb1..f7566d857a3 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -17,8 +17,9 @@ import type { import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { View, type GestureResponderEvent } from "react-native"; +import { Platform, View, type GestureResponderEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; +import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; @@ -187,7 +188,12 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; return ( - + @@ -200,7 +206,7 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { - + ); }); @@ -239,10 +245,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; + // The overlay's measured height includes the home-indicator inset (the + // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes + // UIKit add the safe-area bottom to the content inset AGAIN — leaving a + // dead strip between the resting content and the composer. Report the + // overlay height minus the safe area; UIKit adds it back, and ThreadFeed + // hands LegendList the same delta via contentInsetEndStaticAdjustment so + // its end-scroll math matches the real resting position. + const nativeInsetOvercount = + props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0; const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( listRef, composerOverlayRef, - estimatedOverlayHeight, + Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), + -nativeInsetOvercount, ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; @@ -413,13 +429,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onLayout={onComposerLayout} style={{ width: "100%", paddingTop: 8 }} > - + {props.activeWorkStartedAt ? ( ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( - + {props.activePendingApproval ? ( ) : null} - + ) : null} - + + ) : null} - + ); } @@ -804,8 +830,12 @@ function renderFeedEntry( return null; } + const enterAnimated = isFreshTimestamp(message.createdAt); return ( - + {message.text.trim().length > 0 ? ( hasNativeSelectableMarkdownText() ? ( ) : null} - + ); } @@ -1146,6 +1176,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); + const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; @@ -1182,8 +1213,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // end space from the scroll view's frame top. Fold the header height back into // the anchor offset or a just-sent message anchors underneath the header and // the oversized end space keeps maintainScrollAtEnd snapping away from earlier - // messages. - const anchorTopInset = usesNativeAutomaticInsets ? insets.top + 44 : topContentInset; + // messages. Read the context directly (useHeaderHeight throws outside a + // header-providing screen) and fall back to the standard iOS bar height. + const navigationHeaderHeight = useContext(HeaderHeightContext); + const anchorTopInset = usesNativeAutomaticInsets + ? navigationHeaderHeight || insets.top + 44 + : topContentInset; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); @@ -1255,7 +1290,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); + const nextHeight = Math.round(event.nativeEvent.layout.height); setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); useEffect(() => { @@ -1282,6 +1319,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], ); + // The empty↔filled key below remounts the list, which resets its imperative + // content-inset override — and useKeyboardChatComposerInset (mounted above + // the remount boundary) deduplicates by height, so it never re-reports the + // composer inset to the fresh instance. Without this, the remounted list's + // initial scroll-to-end computes with a zero end inset and rests one + // composer-height short of the end. Layout effect: it must land before the + // list's first positioning tick or the one-shot initial scroll misses it. + const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + useLayoutEffect(() => { + const bottom = props.contentInsetEndAdjustment.value; + if (bottom > 0) { + props.listRef.current?.reportContentInset({ bottom }); + } + }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); + const anchoredEndSpace = useMemo( () => resolveChatListAnchoredEndSpace( @@ -1513,7 +1565,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // an already-attached list under a transparent header can pin // short content at offset 0 (one header-height too high). A fresh // mount positions during attach, where UIKit applies the inset. - key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`} + key={listMountKey} style={{ flex: 1 }} // RN 0.81+ drops touches inside the contentInset area // (facebook/react-native#54123); the anchored end space after a send @@ -1534,13 +1586,36 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION} + // The anchored end space is realized as a bottom contentInset, and + // RN's scroll content view doesn't extend into the inset region — + // without this, touches on the blank area below the last message + // fall through and don't scroll the list. + applyWorkaroundForContentInsetHitTestBug + // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): + // lets its scroll math clamp programmatic scrolls to -headerInset + // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short + // content rest below the transparent header rather than at frame top. + contentInsetStartAdjustment={usesNativeAutomaticInsets ? anchorTopInset : 0} contentInsetEndAdjustment={props.contentInsetEndAdjustment} + // UIKit's automatic behavior adds the safe-area bottom on top of the + // raw contentInset the keyboard integration writes. The detail screen + // under-reports the composer inset by this amount (see + // ThreadDetailScreen); this tells LegendList's scroll math about the + // extra so programmatic end scrolls land at the true resting offset. + contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0} freeze={props.freeze} + // Animated: on send, the optimistic message's dataChange fires + // maintainScrollAtEnd before any render-cycle suppression could + // engage — an instant snap there teleports the feed to the anchor + // instead of scrolling to it. Keeping it enabled (animated) during + // anchor scrolls also lets it correct a scroll that landed on a + // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ disclosureToggleSettling ? false : { - animated: false, + animated: true, on: { dataChange: true, itemLayout: true, @@ -1559,6 +1634,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { keyboardShouldPersistTaps="always" keyboardDismissMode="none" keyboardLiftBehavior="whenAtEnd" + // Seed the list's scroll math with the real viewport before its own + // onLayout: the empty→filled remount can then tell at mount that + // short content underflows the viewport and skip programmatic + // positioning entirely (any offset write during screen attach races + // UIKit's adjustedContentInset application and lands high or low). + {...(viewportHeight > 0 && viewportWidth > 0 + ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } } + : {})} + // RN's native scrollTo command clamps targets to a floor of + // -contentInset.top using the RAW inset — under automatic insets the + // header inset only exists in adjustedContentInset, so scrolls to + // negative offsets (content top below the transparent header) get + // clamped to 0. This prop disables that clamp; UIKit still bounces + // user overscroll back to the adjusted rest position. + scrollToOverflowEnabled estimatedItemSize={180} initialScrollAtEnd onScroll={handleScroll} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 2bf3b5c9a15..24005a898ae 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -5,6 +5,7 @@ import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "re import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; +import Animated, { FadeIn } from "react-native-reanimated"; const WORK_LOG_LAYOUT_ANIMATION = { duration: 180, @@ -68,6 +69,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { } } +// Entering fades only for rows created moments ago: rows remount whenever the +// list scrolls them back into view, and old rows must not replay an entrance. +const FRESH_ROW_WINDOW_MS = 3_000; +function isFreshRow(createdAt: string): boolean { + const timestamp = Date.parse(createdAt); + return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; +} + export function ThreadWorkLog(props: { readonly activities: ReadonlyArray; readonly copiedRowId: string | null; @@ -104,7 +113,10 @@ export function ThreadWorkLog(props: { const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; return ( - + ) : null} - + ); })} diff --git a/patches/@legendapp__list@3.2.0.patch b/patches/@legendapp__list@3.2.0.patch new file mode 100644 index 00000000000..e3a6d174e7f --- /dev/null +++ b/patches/@legendapp__list@3.2.0.patch @@ -0,0 +1,653 @@ +diff --git a/keyboard.d.ts b/keyboard.d.ts +index 5a115ea..bdfd5c5 100644 +--- a/keyboard.d.ts ++++ b/keyboard.d.ts +@@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = { + type KeyboardChatComposerRef = { + current: Pick | null; + }; +-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { ++declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { + contentInsetEndAdjustment: SharedValue; + onComposerLayout: (event: LayoutChangeEvent) => void; + }; +@@ -280,6 +280,7 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb + declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { + anchoredEndSpace?: AnchoredEndSpaceConfig; + contentInsetEndAdjustment?: SharedValue; ++ contentInsetEndStaticAdjustment?: number; + keyboardOffset?: number; + } & React.RefAttributes) => React.ReactElement | null; + +diff --git a/keyboard.js b/keyboard.js +index 736286a..063458a 100644 +--- a/keyboard.js ++++ b/keyboard.js +@@ -33,11 +33,12 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. + "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." + ); + } +-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { ++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { + const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); + const lastHeightRef = React.useRef(void 0); + const reportHeight = React.useCallback( +- (height) => { ++ (rawHeight) => { ++ const height = Math.max(0, rawHeight + heightAdjustment); + var _a; + if (Number.isFinite(height) && height !== lastHeightRef.current) { + lastHeightRef.current = height; +@@ -45,7 +46,7 @@ function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { + (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); + } + }, +- [contentInsetEndAdjustment, listRef] ++ [contentInsetEndAdjustment, heightAdjustment, listRef] + ); + React.useLayoutEffect(() => { + var _a; +@@ -87,6 +88,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + anchoredEndSpace, + applyWorkaroundForContentInsetHitTestBug, + contentInsetEndAdjustment, ++ contentInsetEndStaticAdjustment, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -149,6 +151,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + AnimatedLegendListInternal, + { + anchoredEndSpace: anchoredEndSpaceWithBlankSpace, ++ contentInsetEndAdjustment: contentInsetEndStaticAdjustment, + ref: combinedRef, + renderScrollComponent: memoList, + ...rest +diff --git a/keyboard.mjs b/keyboard.mjs +index c1dd270..325b0e9 100644 +--- a/keyboard.mjs ++++ b/keyboard.mjs +@@ -12,11 +12,12 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { + "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." + ); + } +-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { ++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { + const contentInsetEndAdjustment = useSharedValue(initialHeight); + const lastHeightRef = useRef(void 0); + const reportHeight = useCallback( +- (height) => { ++ (rawHeight) => { ++ const height = Math.max(0, rawHeight + heightAdjustment); + var _a; + if (Number.isFinite(height) && height !== lastHeightRef.current) { + lastHeightRef.current = height; +@@ -24,7 +25,7 @@ function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { + (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); + } + }, +- [contentInsetEndAdjustment, listRef] ++ [contentInsetEndAdjustment, heightAdjustment, listRef] + ); + useLayoutEffect(() => { + var _a; +@@ -66,6 +67,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + anchoredEndSpace, + applyWorkaroundForContentInsetHitTestBug, + contentInsetEndAdjustment, ++ contentInsetEndStaticAdjustment, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -128,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + AnimatedLegendListInternal, + { + anchoredEndSpace: anchoredEndSpaceWithBlankSpace, ++ contentInsetEndAdjustment: contentInsetEndStaticAdjustment, + ref: combinedRef, + renderScrollComponent: memoList, + ...rest +diff --git a/react-native.d.ts b/react-native.d.ts +index 72d3f59..435a5fc 100644 +--- a/react-native.d.ts ++++ b/react-native.d.ts +@@ -284,6 +284,12 @@ interface LegendListSpecificProps { + * The adjustment is also rendered as real content padding so the browser scroll range includes it. + */ + contentInsetEndAdjustment?: number; ++ /** ++ * Width/height of a leading content inset applied natively outside the list's knowledge ++ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). ++ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. ++ */ ++ contentInsetStartAdjustment?: number; + /** + * Number of columns to render items in. + * @default 1 +diff --git a/react-native.js b/react-native.js +index 8d4ff89..cc6c956 100644 +--- a/react-native.js ++++ b/react-native.js +@@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { + } + + // src/core/clampScrollOffset.ts ++function getContentInsetStartAdjustment(ctx) { ++ const adjustment = ctx.state.props.contentInsetStartAdjustment; ++ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + const state = ctx.state; + const contentSize = getContentSize(ctx); ++ const minOffset = -getContentInsetStartAdjustment(ctx); + let clampedOffset = offset; + if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { +- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); ++ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); + const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; + const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; + const maxOffset = baseMaxOffset + extraEndOffset; + clampedOffset = Math.min(offset, maxOffset); + } +- clampedOffset = Math.max(0, clampedOffset); ++ clampedOffset = Math.max(minOffset, clampedOffset); + return clampedOffset; + } + +@@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) { + finishScrollTo(ctx); + } + } +-function scrollToFallbackOffset(ctx, offset) { ++function scrollToFallbackOffset(ctx, offset, animated) { + var _a3; + (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ +- animated: false, ++ animated: !!animated, + x: ctx.state.props.horizontal ? offset : 0, + y: ctx.state.props.horizontal ? 0 : offset + }); +@@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) { + }); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) { + } + state.pendingMaintainScrollAtEnd = false; + if (shouldMaintainScrollAtEnd) { ++ const maintainAnchoredEndSpace = state.props.anchoredEndSpace; ++ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; ++ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { ++ return false; ++ } + const contentSize = getContentSize(ctx); ++ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); + if (contentSize < state.scrollLength) { +- state.scroll = 0; ++ state.scroll = -maintainInsetStartAdjustment; ++ if (maintainInsetStartAdjustment > 0) { ++ return true; ++ } + } + if (!state.maintainingScrollAtEnd) { + const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; +@@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) { + y: 0 + }); + } else { +- scroller == null ? void 0 : scroller.scrollToEnd({ +- animated: maintainScrollAtEnd.animated +- }); ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0) { ++ scroller == null ? void 0 : scroller.scrollTo({ ++ animated: maintainScrollAtEnd.animated, ++ x: 0, ++ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) ++ }); ++ } else { ++ scroller == null ? void 0 : scroller.scrollToEnd({ ++ animated: maintainScrollAtEnd.animated ++ }); ++ } + } + setTimeout( + () => { +@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { + return 0; + } +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const insetStartAdjustment = state.props.contentInsetStartAdjustment; ++ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; ++ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); + const clampDelta = maxScroll - state.scroll; + if (unresolvedAmount < 0) { + return Math.max(unresolvedAmount, Math.min(0, clampDelta)); +@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { + settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); + return true; + } +- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); + const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); + const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; + if (isAtExpectedNativeClamp) { +@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) { + if (diff > 0) { + diff = Math.max(0, totalSize - state.scroll - state.scrollLength); + } else { +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); + state.scroll = maxScroll; + state.scrollPending = maxScroll; + diff = 0; +@@ -2374,8 +2402,64 @@ function scrollToIndex(ctx, { + } + + // src/core/initialScroll.ts ++var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; ++function startInsetEndSettleWatchdog(ctx) { ++ const state = ctx.state; ++ if (state.insetEndSettleWatchdogActive) { ++ return; ++ } ++ state.insetEndSettleWatchdogActive = true; ++ let frames = 0; ++ const tick = () => { ++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || !ctx.state || ctx.state !== state) { ++ state.insetEndSettleWatchdogActive = false; ++ return; ++ } ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ const contentSize = getContentSize(ctx); ++ const scrollLength = state.scrollLength; ++ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { ++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); ++ const distance = endOffset - state.scroll; ++ // Estimated row sizes converging to measured ones can strand the initial ++ // end landing when the library's own end-anchor bookkeeping gives up. ++ // While still near the end (never fighting a user who scrolled away), ++ // re-pin to the current true end until sizes stop changing. ++ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { ++ const scroller = state.refScroller.current; ++ if (scroller) { ++ scroller.scrollTo({ animated: false, x: 0, y: endOffset }); ++ } ++ } ++ } ++ requestAnimationFrame(tick); ++ }; ++ requestAnimationFrame(tick); ++} + function dispatchInitialScroll(ctx, params) { + const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { ++ if (ctx.state.scrollLength <= 0) { ++ // The scroll length is unknown, so the resolved target is meaningless; ++ // the initial-scroll machinery re-advances after layout. ++ return; ++ } ++ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { ++ // Content underflows the viewport and nothing has scrolled yet: the ++ // untouched native rest position (UIKit's adjustedContentInset) IS the ++ // end position. Dispatching a negative scroll here would race UIKit's ++ // inset application during screen attach, so just finish the session. ++ finishInitialScroll(ctx, { ++ resolvedOffset ++ }); ++ return; ++ } ++ ctx.state.didDispatchInsetInitialScroll = true; ++ if (target.viewPosition === 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const requestedIndex = target.index; + const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; + const itemSize = getItemSizeAtIndex(ctx, index); +@@ -2804,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { + return; + } + if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { +- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); ++ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; ++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); + if (!shouldKeepEndTargetAlive) { + if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { + clearPendingInitialScrollFooterLayout(ctx, { +@@ -4646,7 +4732,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + } + contentBelowAnchor += footerSize + stylePaddingBottom; + isReady = !hasUnknownTailSize; +- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; + } else if (anchorIndex >= 0) { + isReady = false; + } +@@ -4664,6 +4751,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true); + } + (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); ++ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { ++ set$(ctx, "anchoredEndSpaceSize", nextSize); ++ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); ++ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { ++ updateScroll(ctx, state.scroll, true); ++ } + } + return nextSize; + } +@@ -6462,6 +6555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance = 250, + contentInsetEndAdjustment, ++ contentInsetStartAdjustment, + estimatedItemSize = 100, + estimatedListSize, + extraData, +@@ -6710,6 +6804,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + contentContainerAlignItems: contentContainerStyle.alignItems, + contentInset, + contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, ++ contentInsetStartAdjustment, + data: dataProp, + dataVersion, + drawDistance, +@@ -6789,6 +6884,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + return void 0; + } + const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); ++ if (getContentInsetStartAdjustment(ctx) > 0) { ++ // With a native leading inset (transparent header + automatic insets), ++ // any mount-time contentOffset races UIKit's adjustedContentInset ++ // application. The gated initial-scroll dispatch positions the list once ++ // real sizes are known instead. ++ return void 0; ++ } + return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; + }, [usesBootstrapInitialScroll]); + React2.useLayoutEffect(() => { +diff --git a/react-native.mjs b/react-native.mjs +index 2e96ca7..f8a7765 100644 +--- a/react-native.mjs ++++ b/react-native.mjs +@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { + } + + // src/core/clampScrollOffset.ts ++function getContentInsetStartAdjustment(ctx) { ++ const adjustment = ctx.state.props.contentInsetStartAdjustment; ++ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + const state = ctx.state; + const contentSize = getContentSize(ctx); ++ const minOffset = -getContentInsetStartAdjustment(ctx); + let clampedOffset = offset; + if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { +- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); ++ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); + const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; + const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; + const maxOffset = baseMaxOffset + extraEndOffset; + clampedOffset = Math.min(offset, maxOffset); + } +- clampedOffset = Math.max(0, clampedOffset); ++ clampedOffset = Math.max(minOffset, clampedOffset); + return clampedOffset; + } + +@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) { + finishScrollTo(ctx); + } + } +-function scrollToFallbackOffset(ctx, offset) { ++function scrollToFallbackOffset(ctx, offset, animated) { + var _a3; + (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ +- animated: false, ++ animated: !!animated, + x: ctx.state.props.horizontal ? offset : 0, + y: ctx.state.props.horizontal ? 0 : offset + }); +@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) { + }); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) { + } + state.pendingMaintainScrollAtEnd = false; + if (shouldMaintainScrollAtEnd) { ++ const maintainAnchoredEndSpace = state.props.anchoredEndSpace; ++ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; ++ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { ++ return false; ++ } + const contentSize = getContentSize(ctx); ++ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); + if (contentSize < state.scrollLength) { +- state.scroll = 0; ++ state.scroll = -maintainInsetStartAdjustment; ++ if (maintainInsetStartAdjustment > 0) { ++ return true; ++ } + } + if (!state.maintainingScrollAtEnd) { + const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; +@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) { + y: 0 + }); + } else { +- scroller == null ? void 0 : scroller.scrollToEnd({ +- animated: maintainScrollAtEnd.animated +- }); ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0) { ++ scroller == null ? void 0 : scroller.scrollTo({ ++ animated: maintainScrollAtEnd.animated, ++ x: 0, ++ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) ++ }); ++ } else { ++ scroller == null ? void 0 : scroller.scrollToEnd({ ++ animated: maintainScrollAtEnd.animated ++ }); ++ } + } + setTimeout( + () => { +@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { + return 0; + } +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const insetStartAdjustment = state.props.contentInsetStartAdjustment; ++ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; ++ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); + const clampDelta = maxScroll - state.scroll; + if (unresolvedAmount < 0) { + return Math.max(unresolvedAmount, Math.min(0, clampDelta)); +@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { + settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); + return true; + } +- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); + const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); + const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; + if (isAtExpectedNativeClamp) { +@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) { + if (diff > 0) { + diff = Math.max(0, totalSize - state.scroll - state.scrollLength); + } else { +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); + state.scroll = maxScroll; + state.scrollPending = maxScroll; + diff = 0; +@@ -2353,8 +2381,64 @@ function scrollToIndex(ctx, { + } + + // src/core/initialScroll.ts ++var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; ++function startInsetEndSettleWatchdog(ctx) { ++ const state = ctx.state; ++ if (state.insetEndSettleWatchdogActive) { ++ return; ++ } ++ state.insetEndSettleWatchdogActive = true; ++ let frames = 0; ++ const tick = () => { ++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || !ctx.state || ctx.state !== state) { ++ state.insetEndSettleWatchdogActive = false; ++ return; ++ } ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ const contentSize = getContentSize(ctx); ++ const scrollLength = state.scrollLength; ++ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { ++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); ++ const distance = endOffset - state.scroll; ++ // Estimated row sizes converging to measured ones can strand the initial ++ // end landing when the library's own end-anchor bookkeeping gives up. ++ // While still near the end (never fighting a user who scrolled away), ++ // re-pin to the current true end until sizes stop changing. ++ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { ++ const scroller = state.refScroller.current; ++ if (scroller) { ++ scroller.scrollTo({ animated: false, x: 0, y: endOffset }); ++ } ++ } ++ } ++ requestAnimationFrame(tick); ++ }; ++ requestAnimationFrame(tick); ++} + function dispatchInitialScroll(ctx, params) { + const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { ++ if (ctx.state.scrollLength <= 0) { ++ // The scroll length is unknown, so the resolved target is meaningless; ++ // the initial-scroll machinery re-advances after layout. ++ return; ++ } ++ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { ++ // Content underflows the viewport and nothing has scrolled yet: the ++ // untouched native rest position (UIKit's adjustedContentInset) IS the ++ // end position. Dispatching a negative scroll here would race UIKit's ++ // inset application during screen attach, so just finish the session. ++ finishInitialScroll(ctx, { ++ resolvedOffset ++ }); ++ return; ++ } ++ ctx.state.didDispatchInsetInitialScroll = true; ++ if (target.viewPosition === 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const requestedIndex = target.index; + const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; + const itemSize = getItemSizeAtIndex(ctx, index); +@@ -2783,7 +2867,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { + return; + } + if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { +- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); ++ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; ++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); + if (!shouldKeepEndTargetAlive) { + if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { + clearPendingInitialScrollFooterLayout(ctx, { +@@ -4625,7 +4711,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + } + contentBelowAnchor += footerSize + stylePaddingBottom; + isReady = !hasUnknownTailSize; +- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; + } else if (anchorIndex >= 0) { + isReady = false; + } +@@ -4643,6 +4730,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true); + } + (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); ++ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { ++ set$(ctx, "anchoredEndSpaceSize", nextSize); ++ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); ++ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { ++ updateScroll(ctx, state.scroll, true); ++ } + } + return nextSize; + } +@@ -6441,6 +6534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance = 250, + contentInsetEndAdjustment, ++ contentInsetStartAdjustment, + estimatedItemSize = 100, + estimatedListSize, + extraData, +@@ -6689,6 +6783,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + contentContainerAlignItems: contentContainerStyle.alignItems, + contentInset, + contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, ++ contentInsetStartAdjustment, + data: dataProp, + dataVersion, + drawDistance, +@@ -6768,6 +6863,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + return void 0; + } + const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); ++ if (getContentInsetStartAdjustment(ctx) > 0) { ++ // With a native leading inset (transparent header + automatic insets), ++ // any mount-time contentOffset races UIKit's adjustedContentInset ++ // application. The gated initial-scroll dispatch positions the list once ++ // real sizes are known instead. ++ return void 0; ++ } + return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; + }, [usesBootstrapInitialScroll]); + useLayoutEffect(() => { +diff --git a/reanimated.d.ts b/reanimated.d.ts +index 7e2d11f..d5b0d66 100644 +--- a/reanimated.d.ts ++++ b/reanimated.d.ts +@@ -285,6 +285,12 @@ interface LegendListSpecificProps { + * The adjustment is also rendered as real content padding so the browser scroll range includes it. + */ + contentInsetEndAdjustment?: number; ++ /** ++ * Width/height of a leading content inset applied natively outside the list's knowledge ++ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). ++ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. ++ */ ++ contentInsetStartAdjustment?: number; + /** + * Number of columns to render items in. + * @default 1 diff --git a/patches/react-native-keyboard-controller@1.21.6.patch b/patches/react-native-keyboard-controller@1.21.6.patch new file mode 100644 index 00000000000..d29c9834d25 --- /dev/null +++ b/patches/react-native-keyboard-controller@1.21.6.patch @@ -0,0 +1,105 @@ +diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js +index 8640bb81c748ae284f1047ab0e4cad52c76c6b69..579f85d1f3882891a8e84d779bb687a48dfc2b67 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/index.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/index.js +@@ -68,7 +68,7 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ + // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). + // Apps that render into the unsafe area can supply a negative + // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value); + const onLayout = (0, _react.useCallback)(e => { + onLayoutInternal(e); + onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/commonjs/components/ScrollViewWithBottomPadding/index.js b/lib/commonjs/components/ScrollViewWithBottomPadding/index.js +index dfd94c8f16a255eff8521ba425614a90fb6e684c..678fed771096b7fac9928741334435492a70e9f7 100644 +--- a/lib/commonjs/components/ScrollViewWithBottomPadding/index.js ++++ b/lib/commonjs/components/ScrollViewWithBottomPadding/index.js +@@ -55,7 +55,13 @@ const ScrollViewWithBottomPadding = /*#__PURE__*/(0, _react.forwardRef)(({ + }; + if (contentOffsetY) { + const curr = contentOffsetY.value; +- if (curr !== prevContentOffsetY.value) { ++ if (prevContentOffsetY.value === null) { ++ // First evaluation: record the baseline without emitting. contentOffsetY ++ // only carries keyboard-driven offsets; applying the initial value here ++ // races with (and clobbers) the list's own mount positioning. ++ // eslint-disable-next-line react-compiler/react-compiler ++ prevContentOffsetY.value = curr; ++ } else if (curr !== prevContentOffsetY.value) { + // eslint-disable-next-line react-compiler/react-compiler + prevContentOffsetY.value = curr; + result.contentOffset = { +diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js +index 5e38d3e5f1c7033169a7cb50d862d160264d0943..8820f2061cfd253047e44fdd5b931275d99d79d0 100644 +--- a/lib/module/components/KeyboardChatScrollView/index.js ++++ b/lib/module/components/KeyboardChatScrollView/index.js +@@ -61,7 +61,7 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). + // Apps that render into the unsafe area can supply a negative + // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = useDerivedValue(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = useDerivedValue(() => padding.value); + const onLayout = useCallback(e => { + onLayoutInternal(e); + onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/module/components/ScrollViewWithBottomPadding/index.js b/lib/module/components/ScrollViewWithBottomPadding/index.js +index 8f41afa9eb3f19a44c9df2779268e73b3dbec785..c274d83f4b131e8925b459987e54d9c1040d925c 100644 +--- a/lib/module/components/ScrollViewWithBottomPadding/index.js ++++ b/lib/module/components/ScrollViewWithBottomPadding/index.js +@@ -47,7 +47,13 @@ const ScrollViewWithBottomPadding = /*#__PURE__*/forwardRef(({ + }; + if (contentOffsetY) { + const curr = contentOffsetY.value; +- if (curr !== prevContentOffsetY.value) { ++ if (prevContentOffsetY.value === null) { ++ // First evaluation: record the baseline without emitting. contentOffsetY ++ // only carries keyboard-driven offsets; applying the initial value here ++ // races with (and clobbers) the list's own mount positioning. ++ // eslint-disable-next-line react-compiler/react-compiler ++ prevContentOffsetY.value = curr; ++ } else if (curr !== prevContentOffsetY.value) { + // eslint-disable-next-line react-compiler/react-compiler + prevContentOffsetY.value = curr; + result.contentOffset = { +diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx +index 27353b57dcaa819dad36241f12baebad2ca7ca1b..60b4fb1f06a1c5efff9ac04a4dbd170b61ae883e 100644 +--- a/src/components/KeyboardChatScrollView/index.tsx ++++ b/src/components/KeyboardChatScrollView/index.tsx +@@ -82,12 +82,11 @@ const KeyboardChatScrollView = forwardRef< + Math.max(blankSpace.value, padding.value + extraContentPadding.value), + ); + +- // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). +- // Apps that render into the unsafe area can supply a negative +- // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = useDerivedValue( +- () => padding.value + extraContentPadding.value, +- ); ++ // Scroll indicator inset = keyboard only (excludes extraContentPadding and ++ // blankSpace): with a floating composer the indicator track should run the ++ // full height of the scroll view, behind the composer, like iOS Messages. ++ // The keyboard still lifts it so it never disappears under the keyboard. ++ const indicatorPadding = useDerivedValue(() => padding.value); + + const onLayout = useCallback( + (e: LayoutChangeEvent) => { +diff --git a/src/components/ScrollViewWithBottomPadding/index.tsx b/src/components/ScrollViewWithBottomPadding/index.tsx +index d2a8c9b5919989bbfc54299fe79cc57e4b7dec3a..567277399f55b33f531bcb6104bcfceb794ecda9 100644 +--- a/src/components/ScrollViewWithBottomPadding/index.tsx ++++ b/src/components/ScrollViewWithBottomPadding/index.tsx +@@ -95,7 +95,13 @@ const ScrollViewWithBottomPadding = forwardRef< + if (contentOffsetY) { + const curr = contentOffsetY.value; + +- if (curr !== prevContentOffsetY.value) { ++ if (prevContentOffsetY.value === null) { ++ // First evaluation: record the baseline without emitting. contentOffsetY ++ // only carries keyboard-driven offsets; applying the initial value here ++ // races with (and clobbers) the list's own mount positioning. ++ // eslint-disable-next-line react-compiler/react-compiler ++ prevContentOffsetY.value = curr; ++ } else if (curr !== prevContentOffsetY.value) { + // eslint-disable-next-line react-compiler/react-compiler + prevContentOffsetY.value = curr; + result.contentOffset = { x: 0, y: curr }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef7146e194a..25bea072251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ patchedDependencies: '@ff-labs/fff-node@0.9.4': hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 path: patches/@ff-labs__fff-node@0.9.4.patch + '@legendapp/list@3.2.0': + hash: 95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292 + path: patches/@legendapp__list@3.2.0.patch '@pierre/diffs@1.3.0-beta.5': hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch @@ -94,6 +97,9 @@ patchedDependencies: react-native-gesture-handler@2.31.2: hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 path: patches/react-native-gesture-handler@2.31.2.patch + react-native-keyboard-controller@1.21.6: + hash: 033c459a051f5eea73f403ac03f1722c366d0861f27174ea92d6091f6f1862f4 + path: patches/react-native-keyboard-controller@1.21.6.patch react-native-nitro-modules@0.35.9: hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 path: patches/react-native-nitro-modules@0.35.9.patch @@ -226,7 +232,7 @@ importers: version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3) '@legendapp/list': specifier: 3.2.0 - 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) + version: 3.2.0(patch_hash=95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292)(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) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -378,8 +384,8 @@ importers: specifier: ^0.2.2 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) react-native-keyboard-controller: - specifier: 1.21.13 - 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) + specifier: 1.21.6 + version: 1.21.6(patch_hash=033c459a051f5eea73f403ac03f1722c366d0861f27174ea92d6091f6f1862f4)(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) react-native-nitro-markdown: specifier: ^0.5.0 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: version: 0.9.0 '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.2.0(patch_hash=95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -8814,8 +8820,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.21.13: - resolution: {integrity: sha512-FLr0MucraPyCGykRAcPM8Bv0JT5TcG1juQGMI+GLDuuaoOUKUY3SMUnRhHn7IgSM8KlxpcNQmMPNDmGpOw1OcA==} + react-native-keyboard-controller@1.21.6: + resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==} peerDependencies: react: '*' react-native: '*' @@ -12654,7 +12660,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@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)': + '@legendapp/list@3.2.0(patch_hash=95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292)(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)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12662,7 +12668,7 @@ snapshots: 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) - '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.2.0(patch_hash=95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) @@ -18950,7 +18956,7 @@ snapshots: 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-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): + react-native-keyboard-controller@1.21.6(patch_hash=033c459a051f5eea73f403ac03f1722c366d0861f27174ea92d6091f6f1862f4)(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): dependencies: 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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 25e6fd2889e..0f0b8b4a2ca 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -105,12 +105,14 @@ patchedDependencies: "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch + "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch + react-native-keyboard-controller@1.21.6: patches/react-native-keyboard-controller@1.21.6.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch From 60867bd2ab5a1e7231c3c3b7a56bb6829588b58d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 4 Jul 2026 14:29:13 -0700 Subject: [PATCH 3/3] Fix mobile legend anchor compensation - Pass safe-area inset compensation through the mobile thread list - Adjust keyboard controller and legend list math so end anchors stay aligned - Fix overlay spacing and drag handling to prevent end-position drift --- .../features/threads/ThreadDetailScreen.tsx | 13 +- .../src/features/threads/ThreadFeed.tsx | 9 +- patches/@legendapp__list@3.2.0.patch | 217 ++++++-- ...t-native-keyboard-controller@1.21.13.patch | 483 ++++++++++++++++++ ...ct-native-keyboard-controller@1.21.6.patch | 105 ---- pnpm-lock.yaml | 26 +- pnpm-workspace.yaml | 23 +- 7 files changed, 697 insertions(+), 179 deletions(-) create mode 100644 patches/react-native-keyboard-controller@1.21.13.patch delete mode 100644 patches/react-native-keyboard-controller@1.21.6.patch diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index f7566d857a3..d110c010425 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -189,7 +189,7 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { return ( - + {/* No paddingTop here: the overlay's measured height becomes the + list's bottom inset, so any padding above the pill/composer + pushes the resting content floor up by the same amount. */} + ; onComposerLayout: (event: LayoutChangeEvent) => void; }; -@@ -280,6 +280,7 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb +@@ -278,8 +278,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb + scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise; + }; declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { ++ adjustedInsetCompensation?: number; anchoredEndSpace?: AnchoredEndSpaceConfig; contentInsetEndAdjustment?: SharedValue; + contentInsetEndStaticAdjustment?: number; @@ -20,10 +23,10 @@ index 5a115ea..bdfd5c5 100644 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 736286a..063458a 100644 +index 736286a..8218172 100644 --- a/keyboard.js +++ b/keyboard.js -@@ -33,11 +33,12 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. +@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." ); } @@ -38,8 +41,8 @@ index 736286a..063458a 100644 var _a; if (Number.isFinite(height) && height !== lastHeightRef.current) { lastHeightRef.current = height; -@@ -45,7 +46,7 @@ function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); + contentInsetEndAdjustment.value = height; +- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); } }, - [contentInsetEndAdjustment, listRef] @@ -47,7 +50,11 @@ index 736286a..063458a 100644 ); React.useLayoutEffect(() => { var _a; -@@ -87,6 +88,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { + } + var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { + const { ++ adjustedInsetCompensation, anchoredEndSpace, applyWorkaroundForContentInsetHitTestBug, contentInsetEndAdjustment, @@ -55,7 +62,41 @@ index 736286a..063458a 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -149,6 +151,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + includeInEndInset: true, + onSizeChanged: (size) => { + var _a; +- blankSpace.value = size; ++ // The anchored blank is sized against the ADJUSTED viewport, but the ++ // scroll component writes it as a raw contentInset that UIKit tops up ++ // with the safe-area bottom — write it net of that or the end rest ++ // (and the anchored message) sinks one safe-area under the header. ++ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; + (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); + } + }; +- }, [anchoredEndSpace, blankSpace]); ++ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); + const onContentInsetChange = React.useCallback((insets) => { + var _a; + (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); +@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + reactNativeKeyboardController.KeyboardChatScrollView, + { + ...scrollProps, ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + extraContentPadding: contentInsetEndAdjustment, +@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ); + }, + [ ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + contentInsetEndAdjustment, +@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, @@ -64,10 +105,10 @@ index 736286a..063458a 100644 renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index c1dd270..325b0e9 100644 +index c1dd270..cb0d142 100644 --- a/keyboard.mjs +++ b/keyboard.mjs -@@ -12,11 +12,12 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { +@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." ); } @@ -82,8 +123,8 @@ index c1dd270..325b0e9 100644 var _a; if (Number.isFinite(height) && height !== lastHeightRef.current) { lastHeightRef.current = height; -@@ -24,7 +25,7 @@ function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); + contentInsetEndAdjustment.value = height; +- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); } }, - [contentInsetEndAdjustment, listRef] @@ -91,7 +132,11 @@ index c1dd270..325b0e9 100644 ); useLayoutEffect(() => { var _a; -@@ -66,6 +67,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { + } + var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { + const { ++ adjustedInsetCompensation, anchoredEndSpace, applyWorkaroundForContentInsetHitTestBug, contentInsetEndAdjustment, @@ -99,7 +144,41 @@ index c1dd270..325b0e9 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -128,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + includeInEndInset: true, + onSizeChanged: (size) => { + var _a; +- blankSpace.value = size; ++ // The anchored blank is sized against the ADJUSTED viewport, but the ++ // scroll component writes it as a raw contentInset that UIKit tops up ++ // with the safe-area bottom — write it net of that or the end rest ++ // (and the anchored message) sinks one safe-area under the header. ++ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; + (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); + } + }; +- }, [anchoredEndSpace, blankSpace]); ++ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); + const onContentInsetChange = useCallback((insets) => { + var _a; + (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); +@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + KeyboardChatScrollView, + { + ...scrollProps, ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + extraContentPadding: contentInsetEndAdjustment, +@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ); + }, + [ ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + contentInsetEndAdjustment, +@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, @@ -125,7 +204,7 @@ index 72d3f59..435a5fc 100644 * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index 8d4ff89..cc6c956 100644 +index 8d4ff89..618e7e3 100644 --- a/react-native.js +++ b/react-native.js @@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { @@ -250,20 +329,25 @@ index 8d4ff89..cc6c956 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2374,8 +2402,64 @@ function scrollToIndex(ctx, { +@@ -2374,8 +2402,72 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; ++var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; +function startInsetEndSettleWatchdog(ctx) { + const state = ctx.state; + if (state.insetEndSettleWatchdogActive) { + return; + } + state.insetEndSettleWatchdogActive = true; ++ state.didUserDrag = false; + let frames = 0; ++ let settledFrames = 0; + const tick = () => { -+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || !ctx.state || ctx.state !== state) { ++ // The user owns the scroll from their first drag — a re-pin here would ++ // pin the list under their finger. ++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { + state.insetEndSettleWatchdogActive = false; + return; + } @@ -278,10 +362,13 @@ index 8d4ff89..cc6c956 100644 + // While still near the end (never fighting a user who scrolled away), + // re-pin to the current true end until sizes stop changing. + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { ++ settledFrames = 0; + const scroller = state.refScroller.current; + if (scroller) { + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); + } ++ } else { ++ settledFrames++; + } + } + requestAnimationFrame(tick); @@ -315,7 +402,7 @@ index 8d4ff89..cc6c956 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2804,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2804,7 +2896,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -326,7 +413,7 @@ index 8d4ff89..cc6c956 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4646,7 +4732,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4646,7 +4740,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { } contentBelowAnchor += footerSize + stylePaddingBottom; isReady = !hasUnknownTailSize; @@ -336,7 +423,7 @@ index 8d4ff89..cc6c956 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4664,6 +4751,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4664,6 +4759,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -349,7 +436,7 @@ index 8d4ff89..cc6c956 100644 } return nextSize; } -@@ -6462,6 +6555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6462,6 +6563,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -357,7 +444,15 @@ index 8d4ff89..cc6c956 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6710,6 +6804,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6492,6 +6594,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout: onLayoutProp, + onLoad, + onMomentumScrollEnd, ++ onScrollBeginDrag, + onRefresh, + onScroll: onScrollProp, + onStartReached, +@@ -6710,6 +6813,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -365,7 +460,7 @@ index 8d4ff89..cc6c956 100644 data: dataProp, dataVersion, drawDistance, -@@ -6789,6 +6884,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6789,6 +6893,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -379,8 +474,29 @@ index 8d4ff89..cc6c956 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { +@@ -6995,6 +7106,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onMomentumScrollEnd(event); + } + }, ++ onScrollBeginDrag: (event) => { ++ ctx.state.didUserDrag = true; ++ if (onScrollBeginDrag) { ++ onScrollBeginDrag(event); ++ } ++ }, + onScroll: (event) => onScroll(ctx, event) + }), + [] +@@ -7019,6 +7136,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout, + onLayoutFooter, + onMomentumScrollEnd: fns.onMomentumScrollEnd, ++ onScrollBeginDrag: fns.onScrollBeginDrag, + onScroll: onScrollHandler, + recycleItems, + refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 2e96ca7..f8a7765 100644 +index 2e96ca7..84e167b 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { @@ -505,20 +621,25 @@ index 2e96ca7..f8a7765 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2353,8 +2381,64 @@ function scrollToIndex(ctx, { +@@ -2353,8 +2381,72 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; ++var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; +function startInsetEndSettleWatchdog(ctx) { + const state = ctx.state; + if (state.insetEndSettleWatchdogActive) { + return; + } + state.insetEndSettleWatchdogActive = true; ++ state.didUserDrag = false; + let frames = 0; ++ let settledFrames = 0; + const tick = () => { -+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || !ctx.state || ctx.state !== state) { ++ // The user owns the scroll from their first drag — a re-pin here would ++ // pin the list under their finger. ++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { + state.insetEndSettleWatchdogActive = false; + return; + } @@ -533,10 +654,13 @@ index 2e96ca7..f8a7765 100644 + // While still near the end (never fighting a user who scrolled away), + // re-pin to the current true end until sizes stop changing. + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { ++ settledFrames = 0; + const scroller = state.refScroller.current; + if (scroller) { + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); + } ++ } else { ++ settledFrames++; + } + } + requestAnimationFrame(tick); @@ -570,7 +694,7 @@ index 2e96ca7..f8a7765 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2783,7 +2867,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2783,7 +2875,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -581,7 +705,7 @@ index 2e96ca7..f8a7765 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4625,7 +4711,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4625,7 +4719,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { } contentBelowAnchor += footerSize + stylePaddingBottom; isReady = !hasUnknownTailSize; @@ -591,7 +715,7 @@ index 2e96ca7..f8a7765 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4643,6 +4730,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4643,6 +4738,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -604,7 +728,7 @@ index 2e96ca7..f8a7765 100644 } return nextSize; } -@@ -6441,6 +6534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6441,6 +6542,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -612,7 +736,15 @@ index 2e96ca7..f8a7765 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6689,6 +6783,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6471,6 +6573,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout: onLayoutProp, + onLoad, + onMomentumScrollEnd, ++ onScrollBeginDrag, + onRefresh, + onScroll: onScrollProp, + onStartReached, +@@ -6689,6 +6792,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -620,7 +752,7 @@ index 2e96ca7..f8a7765 100644 data: dataProp, dataVersion, drawDistance, -@@ -6768,6 +6863,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6768,6 +6872,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -634,6 +766,27 @@ index 2e96ca7..f8a7765 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { +@@ -6974,6 +7085,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onMomentumScrollEnd(event); + } + }, ++ onScrollBeginDrag: (event) => { ++ ctx.state.didUserDrag = true; ++ if (onScrollBeginDrag) { ++ onScrollBeginDrag(event); ++ } ++ }, + onScroll: (event) => onScroll(ctx, event) + }), + [] +@@ -6998,6 +7115,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout, + onLayoutFooter, + onMomentumScrollEnd: fns.onMomentumScrollEnd, ++ onScrollBeginDrag: fns.onScrollBeginDrag, + onScroll: onScrollHandler, + recycleItems, + refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/reanimated.d.ts b/reanimated.d.ts index 7e2d11f..d5b0d66 100644 --- a/reanimated.d.ts diff --git a/patches/react-native-keyboard-controller@1.21.13.patch b/patches/react-native-keyboard-controller@1.21.13.patch new file mode 100644 index 00000000000..3dee935cda3 --- /dev/null +++ b/patches/react-native-keyboard-controller@1.21.13.patch @@ -0,0 +1,483 @@ +diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js +index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/index.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/index.js +@@ -26,9 +26,11 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ + offset = 0, + extraContentPadding = ZERO_CONTENT_PADDING, + blankSpace = ZERO_BLANK_SPACE, ++ adjustedInsetCompensation = 0, + applyWorkaroundForContentInsetHitTestBug = false, + onLayout: onLayoutProp, + onContentSizeChange: onContentSizeChangeProp, ++ onContentInsetChange, + onEndVisible, + ...rest + }, ref) => { +@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ + freeze: freezeSV, + offset, + blankSpace, +- extraContentPadding ++ extraContentPadding, ++ adjustedInsetCompensation + }); + (0, _useExtraContentPadding.useExtraContentPadding)({ + scrollViewRef, + extraContentPadding, + keyboardPadding: padding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ + // a bug for you, please open an issue. + const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + ++ // Mirror the effective bottom padding (keyboard + composer + blank floor) ++ // to the consumer - a virtualized list needs it in its own scroll math or ++ // its end/maintain targets point at the under-the-keyboard resting offset. ++ (0, _reactNativeReanimated.useAnimatedReaction)(() => totalPadding.value, (current, previous) => { ++ if (onContentInsetChange && current !== previous) { ++ (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({ ++ bottom: current ++ }); ++ } ++ }, [onContentInsetChange]); ++ + // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). + // Apps that render into the unsafe area can supply a negative + // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value); + const onLayout = (0, _react.useCallback)(e => { + onLayoutInternal(e); + onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +@@ -32,7 +32,8 @@ function useChatKeyboard(scrollViewRef, options) { + freeze, + offset, + blankSpace, +- extraContentPadding ++ extraContentPadding, ++ adjustedInsetCompensation + } = options; + const padding = (0, _reactNativeReanimated.useSharedValue)(0); + const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); +@@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) { + const visiblePadding = visibleFraction * blankSpace.value; + const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); + const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed); +- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value); ++ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation; + + // persistent mode: when keyboard shrinks, clamp to valid range + if (keyboardLiftBehavior === "persistent" && effective < padding.value) { +@@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) { + const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); + padding.value = effective; + } +- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); + return { + padding, + currentHeight, +diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +@@ -29,6 +29,7 @@ function useExtraContentPadding(options) { + extraContentPadding, + keyboardPadding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -68,8 +69,8 @@ function useExtraContentPadding(options) { + } + + // Compute effective delta considering blankSpace floor +- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous); +- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current); ++ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation; ++ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation; + const effectiveDelta = currentTotal - previousTotal; + if (effectiveDelta === 0) { + // blankSpace absorbed the change +@@ -92,6 +93,6 @@ function useExtraContentPadding(options) { + const target = Math.min(scroll.value + effectiveDelta, maxScroll); + scrollToTarget(target); + } +- }, [inverted, keyboardLiftBehavior]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); + } + //# sourceMappingURL=index.js.map +\ No newline at end of file +diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js +index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644 +--- a/lib/module/components/KeyboardChatScrollView/index.js ++++ b/lib/module/components/KeyboardChatScrollView/index.js +@@ -1,7 +1,7 @@ + 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); } + import React, { forwardRef, useCallback, useMemo } from "react"; + import { StyleSheet } from "react-native"; +-import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; ++import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; + import Reanimated from "react-native-reanimated"; + import useCombinedRef from "../hooks/useCombinedRef"; + import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; +@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + offset = 0, + extraContentPadding = ZERO_CONTENT_PADDING, + blankSpace = ZERO_BLANK_SPACE, ++ adjustedInsetCompensation = 0, + applyWorkaroundForContentInsetHitTestBug = false, + onLayout: onLayoutProp, + onContentSizeChange: onContentSizeChangeProp, ++ onContentInsetChange, + onEndVisible, + ...rest + }, ref) => { +@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + freeze: freezeSV, + offset, + blankSpace, +- extraContentPadding ++ extraContentPadding, ++ adjustedInsetCompensation + }); + useExtraContentPadding({ + scrollViewRef, + extraContentPadding, + keyboardPadding: padding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + // a bug for you, please open an issue. + const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + ++ // Mirror the effective bottom padding (keyboard + composer + blank floor) ++ // to the consumer - a virtualized list needs it in its own scroll math or ++ // its end/maintain targets point at the under-the-keyboard resting offset. ++ useAnimatedReaction(() => totalPadding.value, (current, previous) => { ++ if (onContentInsetChange && current !== previous) { ++ runOnJS(onContentInsetChange)({ ++ bottom: current ++ }); ++ } ++ }, [onContentInsetChange]); ++ + // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). + // Apps that render into the unsafe area can supply a negative + // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = useDerivedValue(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = useDerivedValue(() => padding.value); + const onLayout = useCallback(e => { + onLayoutInternal(e); + onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644 +--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js ++++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +@@ -25,7 +25,8 @@ function useChatKeyboard(scrollViewRef, options) { + freeze, + offset, + blankSpace, +- extraContentPadding ++ extraContentPadding, ++ adjustedInsetCompensation + } = options; + const padding = useSharedValue(0); + const currentHeight = useSharedValue(0); +@@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) { + const visiblePadding = visibleFraction * blankSpace.value; + const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); + const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); +- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value); ++ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation; + + // persistent mode: when keyboard shrinks, clamp to valid range + if (keyboardLiftBehavior === "persistent" && effective < padding.value) { +@@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) { + const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); + padding.value = effective; + } +- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); + return { + padding, + currentHeight, +diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644 +--- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js ++++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +@@ -23,6 +23,7 @@ function useExtraContentPadding(options) { + extraContentPadding, + keyboardPadding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -62,8 +63,8 @@ function useExtraContentPadding(options) { + } + + // Compute effective delta considering blankSpace floor +- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous); +- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current); ++ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation; ++ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation; + const effectiveDelta = currentTotal - previousTotal; + if (effectiveDelta === 0) { + // blankSpace absorbed the change +@@ -86,7 +87,7 @@ function useExtraContentPadding(options) { + const target = Math.min(scroll.value + effectiveDelta, maxScroll); + scrollToTarget(target); + } +- }, [inverted, keyboardLiftBehavior]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); + } + export { useExtraContentPadding }; + //# sourceMappingURL=index.js.map +\ No newline at end of file +diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts +index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts +@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = { + * Default is `undefined` (equivalent to `0` — no minimum floor). + */ + blankSpace?: SharedValue; ++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ ++ adjustedInsetCompensation?: number; + /** + * Fires whenever the effective content inset changes — the static `contentInset` + * prop combined with the dynamic keyboard-driven padding. +diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +@@ -9,6 +9,8 @@ type UseChatKeyboardOptions = { + blankSpace: SharedValue; + /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ + extraContentPadding: SharedValue; ++ /** Safe-area extra beyond raw contentInset. Offset math only. */ ++ adjustedInsetCompensation: number; + }; + type UseChatKeyboardReturn = { + /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */ +diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts +index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts +@@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = { + keyboardPadding: SharedValue; + /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ + blankSpace: SharedValue; ++ /** Safe-area extra beyond raw contentInset. Offset math only. */ ++ adjustedInsetCompensation: number; + /** Current vertical scroll offset. */ + scroll: SharedValue; + /** Visible viewport dimensions. */ +diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx +index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644 +--- a/src/components/KeyboardChatScrollView/index.tsx ++++ b/src/components/KeyboardChatScrollView/index.tsx +@@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react"; + import { StyleSheet } from "react-native"; + import { + makeMutable, ++ runOnJS, ++ useAnimatedReaction, + useAnimatedRef, + useAnimatedStyle, + useDerivedValue, +@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef< + offset = 0, + extraContentPadding = ZERO_CONTENT_PADDING, + blankSpace = ZERO_BLANK_SPACE, ++ adjustedInsetCompensation = 0, + applyWorkaroundForContentInsetHitTestBug = false, + onLayout: onLayoutProp, + onContentSizeChange: onContentSizeChangeProp, ++ onContentInsetChange, + onEndVisible, + ...rest + }, +@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef< + offset, + blankSpace, + extraContentPadding, ++ adjustedInsetCompensation, + }); + + useExtraContentPadding({ +@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef< + extraContentPadding, + keyboardPadding: padding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef< + ), + ); + +- // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). +- // Apps that render into the unsafe area can supply a negative +- // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = useDerivedValue( +- () => padding.value + extraContentPadding.value, ++ // Mirror the effective bottom padding (keyboard + composer + blank floor) ++ // to the consumer — a virtualized list needs it in its own scroll math or ++ // its end/maintain targets point at the under-the-keyboard resting offset. ++ useAnimatedReaction( ++ () => totalPadding.value, ++ (current, previous) => { ++ if (onContentInsetChange && current !== previous) { ++ runOnJS(onContentInsetChange)({ bottom: current }); ++ } ++ }, ++ [onContentInsetChange], + ); + ++ // Scroll indicator inset = keyboard only (excludes extraContentPadding and ++ // blankSpace): with a floating composer the indicator track should run the ++ // full height of the scroll view, behind the composer, like iOS Messages. ++ // The keyboard still lifts it so it never disappears under the keyboard. ++ const indicatorPadding = useDerivedValue(() => padding.value); ++ + const onLayout = useCallback( + (e: LayoutChangeEvent) => { + onLayoutInternal(e); +diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts +index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644 +--- a/src/components/KeyboardChatScrollView/types.ts ++++ b/src/components/KeyboardChatScrollView/types.ts +@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = { + * Default is `undefined` (equivalent to `0` — no minimum floor). + */ + blankSpace?: SharedValue; ++ /** ++ * Extra bottom inset UIKit adds on top of the raw `contentInset` this ++ * component writes (e.g. the home-indicator safe area under ++ * `contentInsetAdjustmentBehavior="automatic"`). Used ONLY in scroll-offset ++ * math (max scroll / end pinning) — never written into `contentInset`. ++ * ++ * Default is `0`. ++ */ ++ adjustedInsetCompensation?: number; + /** + * Fires whenever the effective content inset changes — the static `contentInset` + * prop combined with the dynamic keyboard-driven padding. +diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644 +--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts ++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +@@ -43,6 +43,7 @@ function useChatKeyboard( + offset, + blankSpace, + extraContentPadding, ++ adjustedInsetCompensation, + } = options; + + const padding = useSharedValue(0); +@@ -104,10 +105,12 @@ function useChatKeyboard( + effective, + minimumPaddingAbsorbed, + ); +- const actualTotalPadding = Math.max( +- blankSpace.value, +- effective + extraContentPadding.value, +- ); ++ // UIKit adds adjustedInsetCompensation (safe area) on top of the raw ++ // inset; include it here so end/max-scroll targets match the real ++ // resting offsets. ++ const actualTotalPadding = ++ Math.max(blankSpace.value, effective + extraContentPadding.value) + ++ adjustedInsetCompensation; + + // persistent mode: when keyboard shrinks, clamp to valid range + if ( +@@ -242,7 +245,7 @@ function useChatKeyboard( + padding.value = effective; + }, + }, +- [inverted, keyboardLiftBehavior, offset, extraContentPadding], ++ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation], + ); + + return { +diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644 +--- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts ++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = { + blankSpace: SharedValue; + /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ + extraContentPadding: SharedValue; ++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ ++ adjustedInsetCompensation: number; + }; + + type UseChatKeyboardReturn = { +diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644 +--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts ++++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = { + keyboardPadding: SharedValue; + /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ + blankSpace: SharedValue; ++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ ++ adjustedInsetCompensation: number; + /** Current vertical scroll offset. */ + scroll: SharedValue; + /** Visible viewport dimensions. */ +@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + extraContentPadding, + keyboardPadding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + } + + // Compute effective delta considering blankSpace floor +- const previousTotal = Math.max( +- blankSpace.value, +- keyboardPadding.value + previous, +- ); +- const currentTotal = Math.max( +- blankSpace.value, +- keyboardPadding.value + current, +- ); ++ const previousTotal = ++ Math.max(blankSpace.value, keyboardPadding.value + previous) + ++ adjustedInsetCompensation; ++ const currentTotal = ++ Math.max(blankSpace.value, keyboardPadding.value + current) + ++ adjustedInsetCompensation; + const effectiveDelta = currentTotal - previousTotal; + + if (effectiveDelta === 0) { +@@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + scrollToTarget(target); + } + }, +- [inverted, keyboardLiftBehavior], ++ [inverted, keyboardLiftBehavior, adjustedInsetCompensation], + ); + } + diff --git a/patches/react-native-keyboard-controller@1.21.6.patch b/patches/react-native-keyboard-controller@1.21.6.patch deleted file mode 100644 index d29c9834d25..00000000000 --- a/patches/react-native-keyboard-controller@1.21.6.patch +++ /dev/null @@ -1,105 +0,0 @@ -diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js -index 8640bb81c748ae284f1047ab0e4cad52c76c6b69..579f85d1f3882891a8e84d779bb687a48dfc2b67 100644 ---- a/lib/commonjs/components/KeyboardChatScrollView/index.js -+++ b/lib/commonjs/components/KeyboardChatScrollView/index.js -@@ -68,7 +68,7 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ - // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). - // Apps that render into the unsafe area can supply a negative - // scrollIndicatorInsets adjustment at the application layer. -- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); -+ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value); - const onLayout = (0, _react.useCallback)(e => { - onLayoutInternal(e); - onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); -diff --git a/lib/commonjs/components/ScrollViewWithBottomPadding/index.js b/lib/commonjs/components/ScrollViewWithBottomPadding/index.js -index dfd94c8f16a255eff8521ba425614a90fb6e684c..678fed771096b7fac9928741334435492a70e9f7 100644 ---- a/lib/commonjs/components/ScrollViewWithBottomPadding/index.js -+++ b/lib/commonjs/components/ScrollViewWithBottomPadding/index.js -@@ -55,7 +55,13 @@ const ScrollViewWithBottomPadding = /*#__PURE__*/(0, _react.forwardRef)(({ - }; - if (contentOffsetY) { - const curr = contentOffsetY.value; -- if (curr !== prevContentOffsetY.value) { -+ if (prevContentOffsetY.value === null) { -+ // First evaluation: record the baseline without emitting. contentOffsetY -+ // only carries keyboard-driven offsets; applying the initial value here -+ // races with (and clobbers) the list's own mount positioning. -+ // eslint-disable-next-line react-compiler/react-compiler -+ prevContentOffsetY.value = curr; -+ } else if (curr !== prevContentOffsetY.value) { - // eslint-disable-next-line react-compiler/react-compiler - prevContentOffsetY.value = curr; - result.contentOffset = { -diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js -index 5e38d3e5f1c7033169a7cb50d862d160264d0943..8820f2061cfd253047e44fdd5b931275d99d79d0 100644 ---- a/lib/module/components/KeyboardChatScrollView/index.js -+++ b/lib/module/components/KeyboardChatScrollView/index.js -@@ -61,7 +61,7 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ - // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). - // Apps that render into the unsafe area can supply a negative - // scrollIndicatorInsets adjustment at the application layer. -- const indicatorPadding = useDerivedValue(() => padding.value + extraContentPadding.value); -+ const indicatorPadding = useDerivedValue(() => padding.value); - const onLayout = useCallback(e => { - onLayoutInternal(e); - onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); -diff --git a/lib/module/components/ScrollViewWithBottomPadding/index.js b/lib/module/components/ScrollViewWithBottomPadding/index.js -index 8f41afa9eb3f19a44c9df2779268e73b3dbec785..c274d83f4b131e8925b459987e54d9c1040d925c 100644 ---- a/lib/module/components/ScrollViewWithBottomPadding/index.js -+++ b/lib/module/components/ScrollViewWithBottomPadding/index.js -@@ -47,7 +47,13 @@ const ScrollViewWithBottomPadding = /*#__PURE__*/forwardRef(({ - }; - if (contentOffsetY) { - const curr = contentOffsetY.value; -- if (curr !== prevContentOffsetY.value) { -+ if (prevContentOffsetY.value === null) { -+ // First evaluation: record the baseline without emitting. contentOffsetY -+ // only carries keyboard-driven offsets; applying the initial value here -+ // races with (and clobbers) the list's own mount positioning. -+ // eslint-disable-next-line react-compiler/react-compiler -+ prevContentOffsetY.value = curr; -+ } else if (curr !== prevContentOffsetY.value) { - // eslint-disable-next-line react-compiler/react-compiler - prevContentOffsetY.value = curr; - result.contentOffset = { -diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx -index 27353b57dcaa819dad36241f12baebad2ca7ca1b..60b4fb1f06a1c5efff9ac04a4dbd170b61ae883e 100644 ---- a/src/components/KeyboardChatScrollView/index.tsx -+++ b/src/components/KeyboardChatScrollView/index.tsx -@@ -82,12 +82,11 @@ const KeyboardChatScrollView = forwardRef< - Math.max(blankSpace.value, padding.value + extraContentPadding.value), - ); - -- // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). -- // Apps that render into the unsafe area can supply a negative -- // scrollIndicatorInsets adjustment at the application layer. -- const indicatorPadding = useDerivedValue( -- () => padding.value + extraContentPadding.value, -- ); -+ // Scroll indicator inset = keyboard only (excludes extraContentPadding and -+ // blankSpace): with a floating composer the indicator track should run the -+ // full height of the scroll view, behind the composer, like iOS Messages. -+ // The keyboard still lifts it so it never disappears under the keyboard. -+ const indicatorPadding = useDerivedValue(() => padding.value); - - const onLayout = useCallback( - (e: LayoutChangeEvent) => { -diff --git a/src/components/ScrollViewWithBottomPadding/index.tsx b/src/components/ScrollViewWithBottomPadding/index.tsx -index d2a8c9b5919989bbfc54299fe79cc57e4b7dec3a..567277399f55b33f531bcb6104bcfceb794ecda9 100644 ---- a/src/components/ScrollViewWithBottomPadding/index.tsx -+++ b/src/components/ScrollViewWithBottomPadding/index.tsx -@@ -95,7 +95,13 @@ const ScrollViewWithBottomPadding = forwardRef< - if (contentOffsetY) { - const curr = contentOffsetY.value; - -- if (curr !== prevContentOffsetY.value) { -+ if (prevContentOffsetY.value === null) { -+ // First evaluation: record the baseline without emitting. contentOffsetY -+ // only carries keyboard-driven offsets; applying the initial value here -+ // races with (and clobbers) the list's own mount positioning. -+ // eslint-disable-next-line react-compiler/react-compiler -+ prevContentOffsetY.value = curr; -+ } else if (curr !== prevContentOffsetY.value) { - // eslint-disable-next-line react-compiler/react-compiler - prevContentOffsetY.value = curr; - result.contentOffset = { x: 0, y: curr }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25bea072251..855074099c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,7 +77,7 @@ patchedDependencies: hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 path: patches/@ff-labs__fff-node@0.9.4.patch '@legendapp/list@3.2.0': - hash: 95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292 + hash: c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347 path: patches/@legendapp__list@3.2.0.patch '@pierre/diffs@1.3.0-beta.5': hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a @@ -97,9 +97,9 @@ patchedDependencies: react-native-gesture-handler@2.31.2: hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 path: patches/react-native-gesture-handler@2.31.2.patch - react-native-keyboard-controller@1.21.6: - hash: 033c459a051f5eea73f403ac03f1722c366d0861f27174ea92d6091f6f1862f4 - path: patches/react-native-keyboard-controller@1.21.6.patch + react-native-keyboard-controller@1.21.13: + hash: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 + path: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 path: patches/react-native-nitro-modules@0.35.9.patch @@ -232,7 +232,7 @@ importers: version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3) '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(patch_hash=95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292)(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) + version: 3.2.0(patch_hash=c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347)(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) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -384,8 +384,8 @@ importers: specifier: ^0.2.2 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) react-native-keyboard-controller: - specifier: 1.21.6 - version: 1.21.6(patch_hash=033c459a051f5eea73f403ac03f1722c366d0861f27174ea92d6091f6f1862f4)(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) + specifier: 1.21.13 + 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) react-native-nitro-markdown: specifier: ^0.5.0 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) @@ -543,7 +543,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(patch_hash=95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.2.0(patch_hash=c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -8820,8 +8820,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.21.6: - resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==} + react-native-keyboard-controller@1.21.13: + resolution: {integrity: sha512-FLr0MucraPyCGykRAcPM8Bv0JT5TcG1juQGMI+GLDuuaoOUKUY3SMUnRhHn7IgSM8KlxpcNQmMPNDmGpOw1OcA==} peerDependencies: react: '*' react-native: '*' @@ -12660,7 +12660,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(patch_hash=95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292)(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)': + '@legendapp/list@3.2.0(patch_hash=c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347)(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)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12668,7 +12668,7 @@ snapshots: 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) - '@legendapp/list@3.2.0(patch_hash=95cd157e98cafcd952c4ab8fd2839cecce7019d2c2b99be39789d1501e796292)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.2.0(patch_hash=c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) @@ -18956,7 +18956,7 @@ snapshots: 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-native-keyboard-controller@1.21.6(patch_hash=033c459a051f5eea73f403ac03f1722c366d0861f27174ea92d6091f6f1862f4)(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): + 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): dependencies: 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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0f0b8b4a2ca..cc61d023d78 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,20 +5,6 @@ packages: - packages/* - scripts -# Install both Windows and Linux native binaries so the WSL (Linux) backend -# can load platform-gated optional deps (e.g. @yuuang/ffi-rs-linux-x64-gnu) -# out of the same node_modules the Windows desktop uses. -supportedArchitectures: - os: - - current - - linux - cpu: - - current - - x64 - libc: - - current - - glibc - catalog: "@clerk/backend": 3.8.4 "@clerk/clerk-js": 6.22.0 @@ -84,8 +70,6 @@ overrides: "@pierre/diffs>@shikijs/transformers": ^4.2.0 "@types/node": "catalog:" effect: "catalog:" - # Pin to the version our patch targets; expo-modules-core's ~56.0.10 range - # otherwise floats to newer releases on fresh resolves (ERR_PNPM_UNUSED_PATCH). expo-modules-jsi: 56.0.10 vite: "catalog:" yaml: "catalog:" @@ -112,7 +96,7 @@ patchedDependencies: effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch - react-native-keyboard-controller@1.21.6: patches/react-native-keyboard-controller@1.21.6.patch + react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch @@ -121,3 +105,8 @@ peerDependencyRules: - vite allowedVersions: vite: "*" + +supportedArchitectures: + cpu: [current, x64] + libc: [current, glibc] + os: [current, linux]