From def9aaaaab9581decd2dc85469bd9df498f2d8fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:38:09 +0000 Subject: [PATCH] fix(mobile): a focused feed input stays below the header when the keyboard opens On iOS the thread feed rests at its end, so the chat scroll view lifted it by the full keyboard height for any focused input. That is right for the composer. For an input inside the feed, such as the secure-entry card, on a short display it carried the focused input under the thread header. The keyboard-controller patch now caps the lift for an input whose parent scroll view is the feed: the feed lifts only until the input's top reaches the header, whose height ThreadFeed passes as obscuredStartInset. The cap is computed on each keyboard open from the input's position in the content, which does not depend on the scroll offset, and the hook records only how much of the lift it withheld so the next transition undoes what was applied. Co-Authored-By: Claude Fable 5.2 --- .../keyboard/chatKeyboardFeedInput.test.tsx | 163 ++++++++ .../src/features/threads/ThreadFeed.tsx | 1 + apps/mobile/vite.config.ts | 1 + ...ct-native-keyboard-controller@1.23.0.patch | 373 +++++++++++++++--- pnpm-lock.yaml | 6 +- 5 files changed, 485 insertions(+), 59 deletions(-) create mode 100644 apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx diff --git a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx new file mode 100644 index 0000000000000..e0eb238e21ae7 --- /dev/null +++ b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx @@ -0,0 +1,163 @@ +import { act, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import type { LayoutChangeEvent } from "react-native"; +import { expect, it, vi } from "vite-plus/test"; + +interface KeyboardStart { + readonly height: number; + readonly duration: number; + readonly progress: number; + readonly target: number; +} + +const FEED_TAG = 7; +const FEED_INPUT_TAG = 21; +const COMPOSER_TAG = 22; +const VIEWPORT = 678; +const KEYBOARD = 300; +const COMPOSER = 80; +const HEADER = 80; + +const native = vi.hoisted(() => ({ + onStart: (_event: KeyboardStart) => {}, + input: { + value: null as { + target: number; + parentScrollViewTarget: number; + layout: { contentY: number }; + } | null, + }, + scroll: { value: 0 }, + layout: { value: { width: 466, height: 0 } }, + size: { value: { width: 466, height: 0 } }, +})); + +// Exercises the patched hook source with Reanimated and its native inputs replaced. +vi.mock("react-native-reanimated", () => ({ + useSharedValue: (initial: T) => useRef({ value: initial }).current, + interpolate: (value: number) => value, +})); +vi.mock("react-native-keyboard-controller/src/hooks", () => ({ + useKeyboardHandler: (handlers: { onStart: (event: KeyboardStart) => void }) => { + native.onStart = handlers.onStart; + }, + useReanimatedFocusedInput: () => ({ input: native.input }), +})); +vi.mock("react-native-keyboard-controller/src/utils/findNodeHandle", () => ({ + findNodeHandle: () => FEED_TAG, +})); +vi.mock("react-native-keyboard-controller/src/components/hooks/useScrollState", () => ({ + default: () => ({ + layout: native.layout, + size: native.size, + offset: native.scroll, + onLayout: () => {}, + onContentSizeChange: () => {}, + }), +})); + +// Metro bundles the library source; a literal import would make tsc check that +// source against this app's React Native types, which it does not satisfy. +const chatKeyboardSource = + "react-native-keyboard-controller/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios"; +const { useChatKeyboard } = (await import( + chatKeyboardSource +)) as typeof import("react-native-keyboard-controller/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/index.ios"); + +function renderFeed(contentHeight: number) { + native.layout.value = { width: 466, height: VIEWPORT }; + native.size.value = { width: 466, height: contentHeight }; + native.scroll.value = contentHeight - VIEWPORT + COMPOSER; + native.input.value = null; + + let contentOffsetY: { value: number } | undefined; + function Feed() { + const chat = useChatKeyboard({ current: null } as never, { + inverted: false, + keyboardLiftBehavior: "whenAtEnd", + freeze: { value: false } as never, + offset: 0, + blankSpace: { value: 0 } as never, + extraContentPadding: { value: COMPOSER } as never, + adjustedInsetCompensation: 0, + adjustedStartInsetCompensation: 0, + obscuredStartInset: HEADER, + }); + chat.onLayout({ nativeEvent: { layout: { height: VIEWPORT } } } as LayoutChangeEvent); + contentOffsetY = chat.contentOffsetY; + return null; + } + const root = createRoot(document.createElement("div")); + act(() => root.render()); + + // Runs a keyboard transition and lets the scroll view take the offset the hook asked for. + const keyboard = (height: number, target: number) => { + native.onStart({ height, duration: 250, progress: height > 0 ? 1 : 0, target }); + native.scroll.value = contentOffsetY?.value ?? Number.NaN; + return native.scroll.value; + }; + const focusFeedInput = (screenY: number) => { + native.input.value = { + target: FEED_INPUT_TAG, + parentScrollViewTarget: FEED_TAG, + layout: { contentY: native.scroll.value + screenY }, + }; + return keyboard(KEYBOARD, FEED_INPUT_TAG); + }; + const focusComposer = () => { + native.input.value = { + target: COMPOSER_TAG, + parentScrollViewTarget: -1, + layout: { contentY: 0 }, + }; + return keyboard(KEYBOARD, COMPOSER_TAG); + }; + const dismiss = () => { + native.input.value = null; + return keyboard(0, -1); + }; + const grow = (by: number) => { + native.size.value = { width: 466, height: native.size.value.height + by }; + native.scroll.value += by; + }; + + return { + focusFeedInput, + focusComposer, + dismiss, + grow, + [Symbol.dispose]: () => act(() => root.unmount()), + }; +} + +it("lifts a feed at its end only until the focused feed input reaches the header", () => { + using feed = renderFeed(2000); + const rest = 2000 - VIEWPORT + COMPOSER; + + // The full keyboard lift would carry an input 200 pt down the viewport 100 pt above it. + expect(feed.focusFeedInput(200)).toBe(rest + 200 - HEADER); + expect(feed.dismiss()).toBe(rest); + + // An input that stays clear of the header rides the full lift, as the composer does. + expect(feed.focusFeedInput(468)).toBe(rest + KEYBOARD); + expect(feed.dismiss()).toBe(rest); + expect(feed.focusComposer()).toBe(rest + KEYBOARD); + expect(feed.dismiss()).toBe(rest); +}); + +it("measures the feed input again on every keyboard open", () => { + using feed = renderFeed(2000); + const rest = 2000 - VIEWPORT + COMPOSER; + + expect(feed.focusFeedInput(200)).toBe(rest + 200 - HEADER); + // Focus moving to the composer with the keyboard up takes back the withheld lift. + expect(feed.focusComposer()).toBe(rest + KEYBOARD); + expect(feed.dismiss()).toBe(rest); + + // Content grows while the feed follows its end, so the same input keeps its screen position. + feed.grow(400); + expect(feed.focusFeedInput(468)).toBe(rest + 400 + KEYBOARD); + expect(feed.dismiss()).toBe(rest + 400); + expect(feed.focusFeedInput(200)).toBe(rest + 400 + 200 - HEADER); + expect(feed.dismiss()).toBe(rest + 400); +}); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 8e8c1c4955b29..082c582b52af3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3758,6 +3758,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { contentInsetEndAdjustment={props.contentInsetEndAdjustment} keyboardOffset={props.keyboardOffset} {...(usesNativeAutomaticInsets ? { contentInset: { top: anchorTopInset } } : {})} + obscuredStartInset={anchorTopInset} freeze={props.freeze} maintainScrollAtEnd={jumpReady ? false : THREAD_FEED_MAINTAIN_SCROLL_AT_END} maintainVisibleContentPosition diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 2b688fee30ad2..dc7b7fedc0560 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -48,6 +48,7 @@ const reactTests = [ "src/features/connection/ConnectionsNewRouteScreen.test.tsx", "src/features/devices/device-stream.browser.test.ts", "src/features/keyboard/keyboardControllerState.test.tsx", + "src/features/keyboard/chatKeyboardFeedInput.test.tsx", ]; const sourceRoot = `${normalizePath(NodeURL.fileURLToPath(new URL("./src", import.meta.url)))}/`; diff --git a/patches/react-native-keyboard-controller@1.23.0.patch b/patches/react-native-keyboard-controller@1.23.0.patch index 5b5b07fed4486..9895c557daecd 100644 --- a/patches/react-native-keyboard-controller@1.23.0.patch +++ b/patches/react-native-keyboard-controller@1.23.0.patch @@ -86,15 +86,16 @@ index ae97d611a7f727ebbb0e17eabbb3294e1cc76a2f..2ea1d2ddd3fac6ad418d97c700ab885f @objc public init( handler: @escaping (NSString, NSNumber, NSNumber, NSNumber, NSNumber) -> Void, diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js -index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3eb830eaba 100644 +index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..e9f516be241d839f40aa65a11754090e724161e0 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/index.js -@@ -28,9 +28,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -28,9 +28,13 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, + adjustedStartInsetCompensation = 0, ++ obscuredStartInset = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -102,16 +103,17 @@ index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3e onEndVisible, ...rest }, ref) => { -@@ -54,6 +57,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -54,6 +58,9 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ obscuredStartInset, initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y }); (0, _useExtraContentPadding.useExtraContentPadding)({ -@@ -61,6 +66,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -61,6 +68,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -120,7 +122,7 @@ index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3e scroll, layout, size, -@@ -72,7 +79,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -72,7 +81,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ (0, _useFrozenPadding.useFrozenPadding)({ freeze: freezeSV, offset, @@ -130,7 +132,7 @@ index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3e }); (0, _useEndVisible.useEndVisible)({ scroll, -@@ -91,10 +99,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -91,10 +101,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ // a bug for you, please open an issue. const totalPadding = (0, _reanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); @@ -185,23 +187,66 @@ index 4d33b3fe426070cf312fc493ce902b2c24d853ea..6a787d3054ba09e90c54b615ab302df5 //# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e5378311638c437fc 100644 +index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..c7ed90f6e081d878a2abfa1d701cfa8d1d69ee5e 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -@@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -4,8 +4,10 @@ Object.defineProperty(exports, "__esModule", { + value: true + }); + exports.useChatKeyboard = useChatKeyboard; ++var _react = require("react"); + var _reactNativeReanimated = require("react-native-reanimated"); + var _hooks = require("../../../hooks"); ++var _findNodeHandle = require("../../../utils/findNodeHandle"); + var _useScrollState = _interopRequireDefault(require("../../hooks/useScrollState")); + var _helpers = require("./helpers"); + function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } +@@ -32,7 +34,10 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, ++ obscuredStartInset } = options; const padding = (0, _reactNativeReanimated.useSharedValue)(0); const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); -@@ -67,6 +69,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -42,13 +47,24 @@ function useChatKeyboard(scrollViewRef, options) { + const isInteractiveDismissal = (0, _reactNativeReanimated.useSharedValue)(false); + // Set by a transition that ran while frozen and so applied no content shift. + const lastTransitionFrozen = (0, _reactNativeReanimated.useSharedValue)(false); ++ // Part of the applied lift withheld to keep a focused input below the ++ // obscured start; the next transition undoes only what was applied. ++ const liftShortfall = (0, _reactNativeReanimated.useSharedValue)(0); ++ const scrollViewTarget = (0, _reactNativeReanimated.useSharedValue)(null); ++ const { ++ input ++ } = (0, _hooks.useReanimatedFocusedInput)(); + const { + layout, + size, + offset: scroll, +- onLayout, ++ onLayout: onScrollLayout, + onContentSizeChange + } = (0, _useScrollState.default)(scrollViewRef, options.initialContentOffsetY); ++ const onLayout = (0, _react.useCallback)(e => { ++ scrollViewTarget.value = (0, _findNodeHandle.findNodeHandle)(scrollViewRef.current); ++ onScrollLayout(e); ++ }, [onScrollLayout, scrollViewRef, scrollViewTarget]); + (0, _hooks.useKeyboardHandler)({ + onStart: e => { + "worklet"; +@@ -65,8 +81,14 @@ function useChatKeyboard(scrollViewRef, options) { + // The frozen transition didn't shift the content: nothing to undo. + lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; ++ liftShortfall.value = 0; } ++ const prevShortfall = liftShortfall.value; ++ liftShortfall.value = 0; const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); + if (e.duration === 0) { + currentHeight.value = effective; @@ -209,7 +254,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 const atEnd = (0, _helpers.isScrollAtEnd)(scroll.value, layout.value.height, size.value.height, inverted); // Scale minimum padding absorption by how much of it is visible. -@@ -75,7 +80,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -75,7 +97,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); @@ -218,7 +263,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -95,8 +100,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -95,8 +117,8 @@ function useChatKeyboard(scrollViewRef, options) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -229,7 +274,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 } return; } -@@ -110,8 +115,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -110,8 +132,8 @@ function useChatKeyboard(scrollViewRef, options) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -240,12 +285,35 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 } return; } -@@ -137,17 +142,25 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -120,7 +142,7 @@ function useChatKeyboard(scrollViewRef, options) { + // (not the full padding, which includes the absorbed portion). + // Use the stored absorption from the previous event so that + // the unwind matches the shift that was originally applied. +- const prevScrollEffective = (0, _helpers.getScrollEffective)(padding.value, prevAbsorption.value); ++ const prevScrollEffective = (0, _helpers.getScrollEffective)(padding.value, prevAbsorption.value) - prevShortfall; + const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; +@@ -137,17 +159,39 @@ function useChatKeyboard(scrollViewRef, options) { contentOffsetY.value = scroll.value; return; } - contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); -+ contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); ++ const target = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); ++ const focused = input.value; ++ ++ // A focused input of this list stays below the obscured start: lift ++ // only until its top gets there. `contentY` does not depend on the ++ // scroll offset, so nothing measured before this transition is reused. ++ if (!inverted && e.height > 0 && focused && focused.target === e.target && focused.parentScrollViewTarget === scrollViewTarget.value) { ++ const capped = Math.max(relativeScroll, focused.layout.contentY - obscuredStartInset); ++ if (capped < target) { ++ liftShortfall.value = scrollEffective - (capped - relativeScroll); ++ contentOffsetY.value = capped; ++ return; ++ } ++ } ++ contentOffsetY.value = target; }, - onInteractive: () => { + onMove: e => { @@ -271,7 +339,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 }, onEnd: e => { "worklet"; -@@ -156,9 +169,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -156,9 +200,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); @@ -279,7 +347,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 padding.value = effective; } - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); -+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation, obscuredStartInset]); return { padding, currentHeight, @@ -386,7 +454,7 @@ index f27c5a095129cf60831088a73ce94d44f3ba52cd..3c2f7569510d0a56fd937b6e91a6adc0 const keepFocus = (options === null || options === void 0 ? void 0 : options.keepFocus) ?? false; const animated = (options === null || options === void 0 ? void 0 : options.animated) ?? true; diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js -index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1bf87c7ec 100644 +index e0c83918d9569e2ff541e45d31460121c73ec225..d691247776dcfffd07985e810942ef6732ee8092 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js +++ b/lib/module/components/KeyboardChatScrollView/index.js @@ -1,9 +1,9 @@ @@ -402,12 +470,13 @@ index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1 import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; import { useChatKeyboard } from "./useChatKeyboard"; -@@ -21,9 +21,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -21,9 +21,13 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, + adjustedStartInsetCompensation = 0, ++ obscuredStartInset = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -415,16 +484,17 @@ index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1 onEndVisible, ...rest }, ref) => { -@@ -47,6 +50,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -47,6 +51,9 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ obscuredStartInset, initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y }); useExtraContentPadding({ -@@ -54,6 +59,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -54,6 +61,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -433,7 +503,7 @@ index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1 scroll, layout, size, -@@ -65,7 +72,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -65,7 +74,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ useFrozenPadding({ freeze: freezeSV, offset, @@ -443,7 +513,7 @@ index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1 }); useEndVisible({ scroll, -@@ -84,10 +92,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -84,10 +94,26 @@ 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))); @@ -497,23 +567,64 @@ index 295e2219b560519b8d033968b5105d1da3e77315..2f7b87e6c68956f757db94a8197c1c61 //# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da185e4373 100644 +index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..0776f4623fc3d56d5308440682c8bfb12886121a 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -@@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -1,5 +1,7 @@ ++import { useCallback } from "react"; + import { useSharedValue } from "react-native-reanimated"; +-import { useKeyboardHandler } from "../../../hooks"; ++import { useKeyboardHandler, useReanimatedFocusedInput } from "../../../hooks"; ++import { findNodeHandle } from "../../../utils/findNodeHandle"; + import useScrollState from "../../hooks/useScrollState"; + import { computeIOSContentOffset, getEffectiveHeight, getScrollEffective, getVisibleMinimumPaddingFraction, isScrollAtEnd, shouldShiftContent } from "./helpers"; + /** +@@ -25,7 +27,10 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, ++ obscuredStartInset } = options; const padding = useSharedValue(0); const currentHeight = useSharedValue(0); -@@ -60,6 +62,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -35,13 +40,24 @@ function useChatKeyboard(scrollViewRef, options) { + const isInteractiveDismissal = useSharedValue(false); + // Set by a transition that ran while frozen and so applied no content shift. + const lastTransitionFrozen = useSharedValue(false); ++ // Part of the applied lift withheld to keep a focused input below the ++ // obscured start; the next transition undoes only what was applied. ++ const liftShortfall = useSharedValue(0); ++ const scrollViewTarget = useSharedValue(null); ++ const { ++ input ++ } = useReanimatedFocusedInput(); + const { + layout, + size, + offset: scroll, +- onLayout, ++ onLayout: onScrollLayout, + onContentSizeChange + } = useScrollState(scrollViewRef, options.initialContentOffsetY); ++ const onLayout = useCallback(e => { ++ scrollViewTarget.value = findNodeHandle(scrollViewRef.current); ++ onScrollLayout(e); ++ }, [onScrollLayout, scrollViewRef, scrollViewTarget]); + useKeyboardHandler({ + onStart: e => { + "worklet"; +@@ -58,8 +74,14 @@ function useChatKeyboard(scrollViewRef, options) { + // The frozen transition didn't shift the content: nothing to undo. + lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; ++ liftShortfall.value = 0; } ++ const prevShortfall = liftShortfall.value; ++ liftShortfall.value = 0; const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); + if (e.duration === 0) { + currentHeight.value = effective; @@ -521,7 +632,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da const atEnd = isScrollAtEnd(scroll.value, layout.value.height, size.value.height, inverted); // Scale minimum padding absorption by how much of it is visible. -@@ -68,7 +73,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -68,7 +90,7 @@ function useChatKeyboard(scrollViewRef, options) { const visiblePadding = visibleFraction * blankSpace.value; const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); @@ -530,7 +641,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -88,8 +93,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -88,8 +110,8 @@ function useChatKeyboard(scrollViewRef, options) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -541,7 +652,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da } return; } -@@ -103,8 +108,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -103,8 +125,8 @@ function useChatKeyboard(scrollViewRef, options) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -552,12 +663,35 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da } return; } -@@ -130,17 +135,25 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -113,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) { + // (not the full padding, which includes the absorbed portion). + // Use the stored absorption from the previous event so that + // the unwind matches the shift that was originally applied. +- const prevScrollEffective = getScrollEffective(padding.value, prevAbsorption.value); ++ const prevScrollEffective = getScrollEffective(padding.value, prevAbsorption.value) - prevShortfall; + const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; +@@ -130,17 +152,39 @@ function useChatKeyboard(scrollViewRef, options) { contentOffsetY.value = scroll.value; return; } - contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); -+ contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); ++ const target = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); ++ const focused = input.value; ++ ++ // A focused input of this list stays below the obscured start: lift ++ // only until its top gets there. `contentY` does not depend on the ++ // scroll offset, so nothing measured before this transition is reused. ++ if (!inverted && e.height > 0 && focused && focused.target === e.target && focused.parentScrollViewTarget === scrollViewTarget.value) { ++ const capped = Math.max(relativeScroll, focused.layout.contentY - obscuredStartInset); ++ if (capped < target) { ++ liftShortfall.value = scrollEffective - (capped - relativeScroll); ++ contentOffsetY.value = capped; ++ return; ++ } ++ } ++ contentOffsetY.value = target; }, - onInteractive: () => { + onMove: e => { @@ -583,7 +717,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da }, onEnd: e => { "worklet"; -@@ -149,9 +162,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -149,9 +193,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); @@ -591,7 +725,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da padding.value = effective; } - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); -+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation, obscuredStartInset]); return { padding, currentHeight, @@ -699,10 +833,10 @@ index 01aad2effee498a8daec55e1d4644a5fbc723765..1140b706265d6c4295581c96b82b27bb const keepFocus = (options === null || options === void 0 ? void 0 : options.keepFocus) ?? false; const animated = (options === null || options === void 0 ? void 0 : options.animated) ?? true; diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts -index eba1514c2d94ae42c024da3882475567a8e8260b..6597d80df11f973ceb2f52c30ee0ece8bd812402 100644 +index eba1514c2d94ae42c024da3882475567a8e8260b..e9d2a27ba31359dbc756f9a38cf0cf5ec6894f80 100644 --- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts -@@ -88,6 +88,10 @@ export type KeyboardChatScrollViewProps = { +@@ -88,6 +88,15 @@ export type KeyboardChatScrollViewProps = { * Default is `undefined` (equivalent to `0` — no minimum floor). */ blankSpace?: SharedValue; @@ -710,6 +844,11 @@ index eba1514c2d94ae42c024da3882475567a8e8260b..6597d80df11f973ceb2f52c30ee0ece8 + adjustedInsetCompensation?: number; + /** Leading inset UIKit adds outside raw contentInset. Offset math only. */ + adjustedStartInsetCompensation?: number; ++ /** ++ * Height at the start of the viewport that a header covers. A focused input ++ * inside this scroll view is not lifted past it. iOS only. Default is `0`. ++ */ ++ obscuredStartInset?: number; /** * Fires whenever the effective content inset changes — the static `contentInset` * prop combined with the dynamic keyboard-driven padding. @@ -724,10 +863,10 @@ index 2b51cc4737b4a05b5396da31e67e54177f43c498..3acec1fbddee6227915590a6c8d44975 -export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number; +export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInsetCompensation?: number) => number; diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -index 8d358bc7bb2c6e0a26e4c04810e371a7693edd84..911f3d90ce56de0f64a39b027d4fa147ba93d0cd 100644 +index 8d358bc7bb2c6e0a26e4c04810e371a7693edd84..892d3b1a63ce96d427a054a41cba056dbec6ee28 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { +@@ -11,6 +11,12 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -735,6 +874,8 @@ index 8d358bc7bb2c6e0a26e4c04810e371a7693edd84..911f3d90ce56de0f64a39b027d4fa147 + adjustedInsetCompensation: number; + /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */ + adjustedStartInsetCompensation: number; ++ /** Height at the start of the viewport that a header covers. */ ++ obscuredStartInset: number; }; type UseChatKeyboardReturn = { /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */ @@ -773,7 +914,7 @@ index 5c92b3850e21039eb7f11eb2944e2478c0ebab88..01d310135f5328f687087db09d9efb6a +declare function useFrozenPadding({ freeze, offset, padding, currentHeight, }: UseFrozenPaddingOptions): void; export { useFrozenPadding }; diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx -index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974ac318301 100644 +index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..7d27d209dbcbe283e51c9b8fc27ad9ca8285bb7e 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx @@ -1,9 +1,13 @@ @@ -793,12 +934,13 @@ index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974 import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; -@@ -35,9 +39,12 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -35,9 +39,13 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, + adjustedStartInsetCompensation = 0, ++ obscuredStartInset = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -806,16 +948,17 @@ index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974 onEndVisible, ...rest }, -@@ -64,6 +71,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -64,6 +72,9 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ obscuredStartInset, initialContentOffsetY: rest.contentOffset?.y, }); -@@ -72,6 +81,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -72,6 +83,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< extraContentPadding, keyboardPadding: padding, blankSpace, @@ -824,7 +967,7 @@ index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974 scroll, layout, size, -@@ -85,6 +96,7 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -85,6 +98,7 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< freeze: freezeSV, offset, padding, @@ -832,7 +975,7 @@ index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974 }); useEndVisible({ -@@ -109,13 +121,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -109,13 +123,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< ), ); @@ -879,10 +1022,10 @@ index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974 (e: LayoutChangeEvent) => { onLayoutInternal(e); diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts -index b7c98c8ede80f690c8c9ad60263efe3ebd5b73df..e1012a80d59ff172396ff17550cb8402d5ba5673 100644 +index b7c98c8ede80f690c8c9ad60263efe3ebd5b73df..1db4405768d2e98f58a1fafc93d5d0a44e73c020 100644 --- a/src/components/KeyboardChatScrollView/types.ts +++ b/src/components/KeyboardChatScrollView/types.ts -@@ -93,6 +93,17 @@ export type KeyboardChatScrollViewProps = { +@@ -93,6 +93,22 @@ export type KeyboardChatScrollViewProps = { * Default is `undefined` (equivalent to `0` — no minimum floor). */ blankSpace?: SharedValue; @@ -897,6 +1040,11 @@ index b7c98c8ede80f690c8c9ad60263efe3ebd5b73df..e1012a80d59ff172396ff17550cb8402 + adjustedInsetCompensation?: number; + /** Leading inset UIKit adds outside raw contentInset. Offset math only. */ + adjustedStartInsetCompensation?: number; ++ /** ++ * Height at the start of the viewport that a header covers. A focused input ++ * inside this scroll view is not lifted past it. iOS only. Default is `0`. ++ */ ++ obscuredStartInset?: number; /** * Fires whenever the effective content inset changes — the static `contentInset` * prop combined with the dynamic keyboard-driven padding. @@ -930,19 +1078,81 @@ index 37eacd10857cd70c51b3468c4b466e4959d0a415..3d77ab27c614f44b64da8270823942cb + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e3cb63d7d 100644 +index f67fc0d1706bc7ab9169da533514a961799a5551..333f39417a48a34e5a6ce472694fde2ec696c1e7 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -@@ -43,6 +43,8 @@ function useChatKeyboard( +@@ -1,6 +1,8 @@ ++import { useCallback } from "react"; + import { useSharedValue } from "react-native-reanimated"; + +-import { useKeyboardHandler } from "../../../hooks"; ++import { useKeyboardHandler, useReanimatedFocusedInput } from "../../../hooks"; ++import { findNodeHandle } from "../../../utils/findNodeHandle"; + import useScrollState from "../../hooks/useScrollState"; + + import { +@@ -13,6 +15,7 @@ import { + } from "./helpers"; + + import type { UseChatKeyboardOptions, UseChatKeyboardReturn } from "./types"; ++import type { LayoutChangeEvent } from "react-native"; + import type { AnimatedRef } from "react-native-reanimated"; + import type Reanimated from "react-native-reanimated"; + +@@ -43,6 +46,9 @@ function useChatKeyboard( offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ obscuredStartInset, } = options; const padding = useSharedValue(0); -@@ -90,6 +92,10 @@ function useChatKeyboard( +@@ -53,15 +59,28 @@ function useChatKeyboard( + const isInteractiveDismissal = useSharedValue(false); + // Set by a transition that ran while frozen and so applied no content shift. + const lastTransitionFrozen = useSharedValue(false); ++ // Part of the applied lift withheld to keep a focused input below the ++ // obscured start; the next transition undoes only what was applied. ++ const liftShortfall = useSharedValue(0); ++ const scrollViewTarget = useSharedValue(null); ++ const { input } = useReanimatedFocusedInput(); + + const { + layout, + size, + offset: scroll, +- onLayout, ++ onLayout: onScrollLayout, + onContentSizeChange, + } = useScrollState(scrollViewRef, options.initialContentOffsetY); + ++ const onLayout = useCallback( ++ (e: LayoutChangeEvent) => { ++ scrollViewTarget.value = findNodeHandle(scrollViewRef.current); ++ onScrollLayout(e); ++ }, ++ [onScrollLayout, scrollViewRef, scrollViewTarget], ++ ); ++ + useKeyboardHandler( + { + onStart: (e) => { +@@ -82,14 +101,23 @@ function useChatKeyboard( + // The frozen transition didn't shift the content: nothing to undo. + lastTransitionFrozen.value = false; + prevAbsorption.value = padding.value; ++ liftShortfall.value = 0; + } + ++ const prevShortfall = liftShortfall.value; ++ ++ liftShortfall.value = 0; ++ + const effective = getEffectiveHeight( + e.height, + targetKeyboardHeight.value, offset, ); @@ -953,7 +1163,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e const atEnd = isScrollAtEnd( scroll.value, layout.value.height, -@@ -115,10 +121,12 @@ function useChatKeyboard( +@@ -115,10 +143,12 @@ function useChatKeyboard( effective, minimumPaddingAbsorbed, ); @@ -970,7 +1180,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -152,11 +160,11 @@ function useChatKeyboard( +@@ -152,11 +182,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -984,7 +1194,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e Math.min(scroll.value, maxScroll), ); } -@@ -187,11 +195,11 @@ function useChatKeyboard( +@@ -187,11 +217,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -998,12 +1208,60 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e Math.min(scroll.value, maxScroll), ); } -@@ -241,17 +249,36 @@ function useChatKeyboard( +@@ -203,10 +233,9 @@ function useChatKeyboard( + // (not the full padding, which includes the absorbed portion). + // Use the stored absorption from the previous event so that + // the unwind matches the shift that was originally applied. +- const prevScrollEffective = getScrollEffective( +- padding.value, +- prevAbsorption.value, +- ); ++ const prevScrollEffective = ++ getScrollEffective(padding.value, prevAbsorption.value) - ++ prevShortfall; + const relativeScroll = inverted + ? scroll.value + prevScrollEffective + : scroll.value - prevScrollEffective; +@@ -234,24 +263,69 @@ function useChatKeyboard( + return; + } + +- contentOffsetY.value = computeIOSContentOffset( ++ const target = computeIOSContentOffset( + relativeScroll, + scrollEffective, + size.value.height, layout.value.height, inverted, actualTotalPadding, + adjustedStartInsetCompensation, ); ++ const focused = input.value; ++ ++ // A focused input of this list stays below the obscured start: lift ++ // only until its top gets there. `contentY` does not depend on the ++ // scroll offset, so nothing measured before this transition is reused. ++ if ( ++ !inverted && ++ e.height > 0 && ++ focused && ++ focused.target === e.target && ++ focused.parentScrollViewTarget === scrollViewTarget.value ++ ) { ++ const capped = Math.max( ++ relativeScroll, ++ focused.layout.contentY - obscuredStartInset, ++ ); ++ ++ if (capped < target) { ++ liftShortfall.value = scrollEffective - (capped - relativeScroll); ++ contentOffsetY.value = capped; ++ ++ return; ++ } ++ } ++ ++ contentOffsetY.value = target; }, - onInteractive: () => { + onMove: (e) => { @@ -1039,7 +1297,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e }, onEnd: (e) => { "worklet"; -@@ -266,10 +293,18 @@ function useChatKeyboard( +@@ -266,10 +340,19 @@ function useChatKeyboard( offset, ); @@ -1055,15 +1313,16 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e + extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ obscuredStartInset, + ], ); return { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -index f89b7c81f4ac0e85e98b790c75309b7203be1fb2..02e0e196f2f6c3222317429716e50da487ac45e3 100644 +index f89b7c81f4ac0e85e98b790c75309b7203be1fb2..d9d641310e313496a595bc23425b10d5a0d75ce1 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -@@ -13,6 +13,10 @@ type UseChatKeyboardOptions = { +@@ -13,6 +13,12 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -1071,6 +1330,8 @@ index f89b7c81f4ac0e85e98b790c75309b7203be1fb2..02e0e196f2f6c3222317429716e50da4 + adjustedInsetCompensation: number; + /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */ + adjustedStartInsetCompensation: number; ++ /** Height at the start of the viewport that a header covers. */ ++ obscuredStartInset: number; }; type UseChatKeyboardReturn = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbca8110a7837..880df731e8480 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,7 +389,7 @@ patchedDependencies: node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.5.0: 171379dd378f8f11e9187876dec2116e5cbf5f21cef6576cdde6956518324f98 react-native-image-viewing@0.2.2: 4aa038d541c7ddcc8f1437eb3e7b4f769f61de78e7f91b53f9805dfca608474d - react-native-keyboard-controller@1.23.0: 1c7c80317ef050e08d4aabe034a0813d03b497dff2dc8b37fb5f4a6e3b20fddb + react-native-keyboard-controller@1.23.0: d7c07acb423564ae7db61b3089157776f62f4ee0c5185648bc258665721a6d32 react-native-pdf-jsi@6.1.0: d29e6b69813548de641a09db705dd94823d3f96832821ae9c3f6a196dd15cb68 react-native-reanimated@4.7.1: 95ee26e5fcab5f50bc4b4c47b3f7e6278a0b20850205efb9f6ee7e70a22d2b59 react-native-svg@15.16.1: 110f9243ffad03654ce40dd1cdb09f01af16b8fa2ccb2b2cd3d62e682c180399 @@ -760,7 +760,7 @@ importers: version: 0.2.2(patch_hash=4aa038d541c7ddcc8f1437eb3e7b4f769f61de78e7f91b53f9805dfca608474d)(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-keyboard-controller: specifier: 1.23.0 - version: 1.23.0(patch_hash=1c7c80317ef050e08d4aabe034a0813d03b497dff2dc8b37fb5f4a6e3b20fddb)(3ba24c675da40640708746e9087ede39) + version: 1.23.0(patch_hash=d7c07acb423564ae7db61b3089157776f62f4ee0c5185648bc258665721a6d32)(3ba24c675da40640708746e9087ede39) react-native-nitro-modules: specifier: 0.37.2 version: 0.37.2(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -22475,7 +22475,7 @@ snapshots: react: 19.3.0 react-native: 0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.23.0(patch_hash=1c7c80317ef050e08d4aabe034a0813d03b497dff2dc8b37fb5f4a6e3b20fddb)(3ba24c675da40640708746e9087ede39): + react-native-keyboard-controller@1.23.0(patch_hash=d7c07acb423564ae7db61b3089157776f62f4ee0c5185648bc258665721a6d32)(3ba24c675da40640708746e9087ede39): dependencies: react: 19.3.0 react-native: 0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6)