From 79b7925bc4d1b618d23b91fc42c780e7c40be1cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 07:48:00 +0000 Subject: [PATCH 1/6] fix(mobile): a focused feed input stays in view when the keyboard opens On iOS the thread feed rests at its end, so KeyboardChatScrollView lifts all content by the full keyboard height for any input. That is right for the composer, but an input inside a feed row (secret or link request card) rode past the header on short displays such as the folded Duo. Patch react-native-keyboard-controller's iOS chat keyboard hook: when the focused input belongs to the list's own scroll view, scroll only as far as needed to keep it between the header inset and the keyboard/composer. The hook records that signed shift, measured from where the feed actually rested (a composer lift that whenAtEnd suppressed counts as none), and the next keyboard transition undoes exactly it under every lift behavior, including the persistent one used while sending with the keyboard. Whether the feed is at its end is judged from that rest position, so the composer still gets its full lift after a feed input moved the feed. The composer lift, persistent holding after a send, interactive dismissal, inverted lists and Android are unchanged. A test drives the patched hook source with Reanimated and native measurement stubbed. It checks that hiding the keyboard returns the feed to its offset for a covered input away from the end and for a partly hidden input moved down below the header under each lift behavior, after focus moves from an unlifted composer to a feed input, and that moving focus from a feed input to the composer at the end lifts by the full keyboard height from rest, including after the feed input moved the feed down. Co-Authored-By: Claude Opus 5.5 --- .../keyboard/chatKeyboardFeedInput.test.tsx | 159 +++++ apps/mobile/vite.config.ts | 1 + ...ct-native-keyboard-controller@1.22.8.patch | 603 ++++++++++++++++-- pnpm-lock.yaml | 6 +- 4 files changed, 721 insertions(+), 48 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..fcc7c7cfa6f1d --- /dev/null +++ b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx @@ -0,0 +1,159 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { LayoutChangeEvent } from "react-native"; +import type { KeyboardChatScrollViewProps, NativeEvent } from "react-native-keyboard-controller"; +import { expect, it, vi } from "vite-plus/test"; + +const FEED_TAG = 7; +const INPUT_TAG = 42; +const COMPOSER_TAG = 43; + +type Shared = { value: T }; +type FocusedInput = { + target: number; + parentScrollViewTarget: number; + layout: { absoluteY: number; height: number }; +}; +type KeyboardHandler = { + onStart: (event: NativeEvent) => void; + onEnd: (event: NativeEvent) => void; +}; + +const native = vi.hoisted(() => ({ + handler: null as KeyboardHandler | null, + input: { value: null } as Shared, + scroll: { value: 0 } as Shared, +})); + +// Exercises the patched iOS hook with only Reanimated and native measurement replaced. +vi.mock("react-native-reanimated", () => ({ + useSharedValue: (value: T): Shared => ({ value }), + measure: () => ({ pageY: 0 }), + interpolate: () => 0, +})); +vi.mock("react-native-keyboard-controller/src/hooks", () => ({ + useKeyboardHandler: (handler: KeyboardHandler) => { + native.handler = handler; + }, + 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: { value: { width: 466, height: 678 } }, + size: { value: { width: 466, height: 2000 } }, + offset: native.scroll, + onLayout: () => {}, + onContentSizeChange: () => {}, + }), +})); + +// Metro bundles the library source; a literal import would also 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"); + +type LiftBehavior = NonNullable; + +// Header inset 80, composer padding 80, and a 300pt keyboard over a 678pt feed. +// The feed input sits at `inputTop` on screen while the feed rests at `restOffset`. +function renderFeed( + restOffset: number, + inputTop: number, + keyboardLiftBehavior: LiftBehavior = "whenAtEnd", +) { + native.scroll.value = restOffset; + let focused = INPUT_TAG; + let contentOffsetY: Shared | undefined; + function Feed() { + const chat = useChatKeyboard({ current: null } as never, { + inverted: false, + keyboardLiftBehavior, + freeze: { value: false } as never, + offset: 0, + initialContentOffsetY: restOffset, + blankSpace: { value: 0 } as never, + extraContentPadding: { value: 80 } as never, + adjustedInsetCompensation: 0, + adjustedStartInsetCompensation: 80, + }); + chat.onLayout({} as LayoutChangeEvent); + contentOffsetY = chat.contentOffsetY; + return null; + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + act(() => root.render()); + + // Native applies each target offset before the next keyboard event. + const transition = (height: number) => { + native.input.value = + focused === INPUT_TAG + ? { + target: INPUT_TAG, + parentScrollViewTarget: FEED_TAG, + layout: { absoluteY: inputTop + restOffset - native.scroll.value, height: 40 }, + } + : { + target: COMPOSER_TAG, + parentScrollViewTarget: -1, + layout: { absoluteY: 600, height: 40 }, + }; + const event = { height, duration: 250, progress: height > 0 ? 1 : 0, target: focused }; + native.handler?.onStart(event); + native.scroll.value = contentOffsetY?.value ?? Number.NaN; + native.handler?.onEnd(event); + return native.scroll.value; + }; + return { + open: (target = INPUT_TAG) => { + focused = target; + return transition(300); + }, + hide: () => transition(0), + [Symbol.dispose]: () => act(() => root.unmount()), + }; +} + +const liftBehaviors: LiftBehavior[] = ["whenAtEnd", "always", "never", "persistent"]; + +it.each(liftBehaviors)( + "returns the feed to its offset when hiding the keyboard for a covered input away from the end (%s)", + (behavior) => { + using feed = renderFeed(500, 500, behavior); + + expect([feed.open(), feed.hide()]).toEqual([742, 500]); + }, +); + +it.each(liftBehaviors)( + "returns the feed to its offset after moving a partly hidden input down below the header (%s)", + (behavior) => { + using feed = renderFeed(1322, 60, behavior); + + expect([feed.open(), feed.hide()]).toEqual([1302, 1322]); + }, +); + +it("returns the feed to its offset when focus moves from an unlifted composer to a feed input", () => { + using feed = renderFeed(500, 500); + + expect([feed.open(COMPOSER_TAG), feed.open(), feed.hide()]).toEqual([500, 742, 500]); +}); + +it("undoes the feed input shift before lifting for the composer at the end", () => { + using feed = renderFeed(1322, 500); + + expect([feed.open(), feed.open(COMPOSER_TAG), feed.hide()]).toEqual([1564, 1622, 1322]); +}); + +it("lifts for the composer and returns to the end after a feed input moved the feed down", () => { + using feed = renderFeed(1322, 40); + + expect([feed.open(), feed.open(COMPOSER_TAG), feed.hide()]).toEqual([1282, 1622, 1322]); +}); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 5aab6e4a1cd3b..be0eafcc77d12 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -44,6 +44,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.22.8.patch b/patches/react-native-keyboard-controller@1.22.8.patch index 66cd20076e6c4..2fcf955c6b477 100644 --- a/patches/react-native-keyboard-controller@1.22.8.patch +++ b/patches/react-native-keyboard-controller@1.22.8.patch @@ -149,9 +149,18 @@ index 0b78ff3..98b3b6c 100644 onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js -index 4d33b3f..6a787d3 100644 +index 4d33b3f..91c1f1b 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +@@ -3,7 +3,7 @@ + Object.defineProperty(exports, "__esModule", { + value: true + }); +-exports.shouldShiftContent = exports.isScrollAtEnd = exports.getVisibleMinimumPaddingFraction = exports.getScrollEffective = exports.getMinimumPaddingAbsorbed = exports.getEffectiveHeight = exports.computeIOSContentOffset = exports.clampedScrollTarget = void 0; ++exports.shouldShiftContent = exports.isScrollAtEnd = exports.getVisibleMinimumPaddingFraction = exports.getScrollEffective = exports.getMinimumPaddingAbsorbed = exports.getFocusedInputScrollDelta = exports.getEffectiveHeight = exports.computeIOSContentOffset = exports.clampedScrollTarget = void 0; + var _reactNativeReanimated = require("react-native-reanimated"); + const AT_END_THRESHOLD = 20; + @@ -206,7 +206,7 @@ const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentHeight, * ``` */ @@ -161,7 +170,7 @@ index 4d33b3f..6a787d3 100644 "worklet"; const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; -@@ -214,8 +214,9 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, +@@ -214,8 +214,32 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, const maxScroll = Math.max(contentHeight - layoutHeight, 0); return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); } @@ -172,13 +181,47 @@ index 4d33b3f..6a787d3 100644 + return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); }; exports.computeIOSContentOffset = computeIOSContentOffset; ++/** ++ * Compute how far to scroll so a focused input inside the list sits in the ++ * strip left between the top inset and the keyboard: just enough to reveal ++ * its bottom, never so far that its top passes under the top inset. ++ * ++ * @param inputTop - Input top relative to the scroll view top. ++ * @param inputHeight - Height of the input. ++ * @param visibleTop - Top of the uncovered strip (e.g. the header inset). ++ * @param visibleBottom - Bottom of the uncovered strip once the keyboard is open. ++ * @returns Scroll delta: positive scrolls content up, negative scrolls it down. ++ * @example ++ * ```ts ++ * getFocusedInputScrollDelta(500, 40, 80, 400); // 140 ++ * getFocusedInputScrollDelta(200, 40, 80, 400); // 0 ++ * getFocusedInputScrollDelta(50, 40, 80, 400); // -30 ++ * ``` ++ */ ++const getFocusedInputScrollDelta = (inputTop, inputHeight, visibleTop, visibleBottom) => { ++ "worklet"; ++ ++ return Math.min(inputTop - visibleTop, Math.max(0, inputTop + inputHeight - visibleBottom)); ++}; ++exports.getFocusedInputScrollDelta = getFocusedInputScrollDelta; //# 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 6481228..0dc5dd1 100644 +index 6481228..bef6c4d 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,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, @@ -189,17 +232,62 @@ index 6481228..0dc5dd1 100644 } = options; const padding = (0, _reactNativeReanimated.useSharedValue)(0); const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); -@@ -59,6 +61,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -40,13 +44,24 @@ function useChatKeyboard(scrollViewRef, options) { + const targetKeyboardHeight = (0, _reactNativeReanimated.useSharedValue)(0); + const prevAbsorption = (0, _reactNativeReanimated.useSharedValue)(0); + const isInteractiveDismissal = (0, _reactNativeReanimated.useSharedValue)(false); ++ const scrollViewTarget = (0, _reactNativeReanimated.useSharedValue)(null); ++ const inputShift = (0, _reactNativeReanimated.useSharedValue)(null); ++ const lifted = (0, _reactNativeReanimated.useSharedValue)(false); ++ 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 => { ++ // eslint-disable-next-line react-compiler/react-compiler ++ scrollViewTarget.value = (0, _findNodeHandle.findNodeHandle)(scrollViewRef.current) ?? null; ++ onScrollLayout(e); ++ }, [onScrollLayout, scrollViewRef, scrollViewTarget]); + (0, _hooks.useKeyboardHandler)({ + onStart: e => { + "worklet"; +@@ -54,12 +69,27 @@ function useChatKeyboard(scrollViewRef, options) { + if (freeze.value) { + return; + } ++ ++ // Signed shift the focused-input branch applied, and whether the last ++ // transition lifted by the keyboard; the next transition undoes exactly ++ // this instead of inferring a lift from the padding. ++ const prevInputShift = inputShift.value; ++ const prevLifted = lifted.value; ++ ++ // eslint-disable-next-line react-compiler/react-compiler ++ inputShift.value = null; ++ lifted.value = false; + if (e.height > 0) { + // eslint-disable-next-line react-compiler/react-compiler targetKeyboardHeight.value = e.height; } const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); +- const atEnd = (0, _helpers.isScrollAtEnd)(scroll.value, layout.value.height, size.value.height, inverted); + if (e.duration === 0) { + currentHeight.value = effective; + } - const atEnd = (0, _helpers.isScrollAtEnd)(scroll.value, layout.value.height, size.value.height, inverted); ++ // Judge the end from the rest position, without a feed-input shift ++ // that this transition is about to undo. ++ const atEnd = (0, _helpers.isScrollAtEnd)(prevInputShift === null ? scroll.value : scroll.value - prevInputShift, layout.value.height, size.value.height, inverted); // Scale minimum padding absorption by how much of it is visible. -@@ -67,7 +72,7 @@ function useChatKeyboard(scrollViewRef, options) { + // Fully visible → full absorption; fully off-screen → no absorption. +@@ -67,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); @@ -208,7 +296,15 @@ index 6481228..0dc5dd1 100644 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -87,8 +92,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -80,30 +110,30 @@ function useChatKeyboard(scrollViewRef, options) { + } + + // persistent mode: when keyboard shrinks, clamp to valid range +- if (keyboardLiftBehavior === "persistent" && effective < padding.value) { ++ if (keyboardLiftBehavior === "persistent" && effective < padding.value && prevInputShift === null) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + if (inverted) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -219,7 +315,14 @@ index 6481228..0dc5dd1 100644 } return; } -@@ -102,8 +107,8 @@ function useChatKeyboard(scrollViewRef, options) { + + // never mode: when keyboard shrinks, clamp to valid range + // to avoid ghost padding +- if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd) { ++ if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd && prevInputShift === null) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + if (inverted) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -230,11 +333,49 @@ index 6481228..0dc5dd1 100644 } return; } -@@ -129,17 +134,25 @@ function useChatKeyboard(scrollViewRef, options) { - contentOffsetY.value = scroll.value; +@@ -112,34 +142,60 @@ 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 = prevInputShift ?? (0, _helpers.getScrollEffective)(padding.value, prevAbsorption.value); + const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; ++ ++ // An input inside the list scrolls only as far as it needs to stay ++ // in view, whatever the lift behavior, instead of riding the full ++ // keyboard height past the top inset. ++ const focused = input.value; ++ const viewport = !inverted && e.height > 0 && focused !== null && focused.target === e.target && scrollViewTarget.value !== null && focused.parentScrollViewTarget === scrollViewTarget.value ? (0, _reactNativeReanimated.measure)(scrollViewRef) : null; ++ if (focused !== null && viewport !== null) { ++ const delta = (0, _helpers.getFocusedInputScrollDelta)(focused.layout.absoluteY - viewport.pageY, focused.layout.height, adjustedStartInsetCompensation, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); ++ const target = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); ++ inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); ++ contentOffsetY.value = target; ++ return; ++ } ++ ++ // Content that does not lift stays put, except that a focused-input ++ // shift is undone whatever the lift behavior. ++ const heldScroll = prevInputShift === null ? scroll.value : Math.max(-adjustedStartInsetCompensation, Math.min(relativeScroll, size.value.height - layout.value.height + actualTotalPadding)); + if (!(0, _helpers.shouldShiftContent)(keyboardLiftBehavior, atEnd)) { + // Preserve current scroll position so animated props + // don't re-apply a stale contentOffset when padding changes +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; + return; + } + + // When blankSpace fully absorbs the keyboard opening, preserve current scroll position + // (only when keyboard is open — effective > 0 — not when closing) + if (scrollEffective === 0 && minimumPaddingAbsorbed > 0 && effective > 0) { +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; return; } - contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ lifted.value = true; + contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); }, - onInteractive: () => { @@ -261,7 +402,7 @@ index 6481228..0dc5dd1 100644 }, onEnd: e => { "worklet"; -@@ -148,9 +161,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -148,9 +204,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); @@ -321,7 +462,7 @@ index 11e81a9..de39eac 100644 //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/commonjs/hooks/useKeyboardState/index.js b/lib/commonjs/hooks/useKeyboardState/index.js -index a4b287006a47011a58c8a16f19b31a479087f44e..4084025d644c8555b269f9d496266e2cb3234024 100644 +index a4b2870..4084025 100644 --- a/lib/commonjs/hooks/useKeyboardState/index.js +++ b/lib/commonjs/hooks/useKeyboardState/index.js @@ -7,7 +7,7 @@ exports.useKeyboardState = useKeyboardState; @@ -334,7 +475,7 @@ index a4b287006a47011a58c8a16f19b31a479087f44e..4084025d644c8555b269f9d496266e2c ..._module.KeyboardController.state(), isVisible: _module.KeyboardController.isVisible() diff --git a/lib/commonjs/module.js b/lib/commonjs/module.js -index f27c5a095129cf60831088a73ce94d44f3ba52cd..3c2f7569510d0a56fd937b6e91a6adc0d5ff6f59 100644 +index f27c5a0..3c2f756 100644 --- a/lib/commonjs/module.js +++ b/lib/commonjs/module.js @@ -22,6 +22,12 @@ _bindings.KeyboardEvents.addListener("keyboardWillShow", e => { @@ -427,7 +568,7 @@ index e0c8391..8df802a 100644 onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js -index 295e221..2f7b87e 100644 +index 295e221..20db412 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js @@ -193,7 +193,7 @@ export const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentH @@ -439,7 +580,7 @@ index 295e221..2f7b87e 100644 "worklet"; const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; -@@ -201,7 +201,8 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH +@@ -201,7 +201,30 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH const maxScroll = Math.max(contentHeight - layoutHeight, 0); return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); } @@ -448,14 +589,49 @@ index 295e221..2f7b87e 100644 + const minScroll = -startInsetCompensation; + const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll); + return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); ++}; ++/** ++ * Compute how far to scroll so a focused input inside the list sits in the ++ * strip left between the top inset and the keyboard: just enough to reveal ++ * its bottom, never so far that its top passes under the top inset. ++ * ++ * @param inputTop - Input top relative to the scroll view top. ++ * @param inputHeight - Height of the input. ++ * @param visibleTop - Top of the uncovered strip (e.g. the header inset). ++ * @param visibleBottom - Bottom of the uncovered strip once the keyboard is open. ++ * @returns Scroll delta: positive scrolls content up, negative scrolls it down. ++ * @example ++ * ```ts ++ * getFocusedInputScrollDelta(500, 40, 80, 400); // 140 ++ * getFocusedInputScrollDelta(200, 40, 80, 400); // 0 ++ * getFocusedInputScrollDelta(50, 40, 80, 400); // -30 ++ * ``` ++ */ ++export const getFocusedInputScrollDelta = (inputTop, inputHeight, visibleTop, visibleBottom) => { ++ "worklet"; ++ ++ return Math.min(inputTop - visibleTop, Math.max(0, inputTop + inputHeight - visibleBottom)); }; //# 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 043b973..df5a408 100644 +index 043b973..00a039e 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,7 +1,9 @@ +-import { useSharedValue } from "react-native-reanimated"; +-import { useKeyboardHandler } from "../../../hooks"; ++import { useCallback } from "react"; ++import { measure, useSharedValue } from "react-native-reanimated"; ++import { useKeyboardHandler, useReanimatedFocusedInput } from "../../../hooks"; ++import { findNodeHandle } from "../../../utils/findNodeHandle"; + import useScrollState from "../../hooks/useScrollState"; +-import { computeIOSContentOffset, getEffectiveHeight, getScrollEffective, getVisibleMinimumPaddingFraction, isScrollAtEnd, shouldShiftContent } from "./helpers"; ++import { computeIOSContentOffset, getEffectiveHeight, getFocusedInputScrollDelta, getScrollEffective, getVisibleMinimumPaddingFraction, isScrollAtEnd, shouldShiftContent } from "./helpers"; + /** + * Hook that manages keyboard-driven scrolling for chat-style scroll views. + * Calculates padding (extra scrollable space) and content shift values, +@@ -25,7 +27,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, @@ -466,17 +642,62 @@ index 043b973..df5a408 100644 } = options; const padding = useSharedValue(0); const currentHeight = useSharedValue(0); -@@ -52,6 +54,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -33,13 +37,24 @@ function useChatKeyboard(scrollViewRef, options) { + const targetKeyboardHeight = useSharedValue(0); + const prevAbsorption = useSharedValue(0); + const isInteractiveDismissal = useSharedValue(false); ++ const scrollViewTarget = useSharedValue(null); ++ const inputShift = useSharedValue(null); ++ const lifted = useSharedValue(false); ++ const { ++ input ++ } = useReanimatedFocusedInput(); + const { + layout, + size, + offset: scroll, +- onLayout, ++ onLayout: onScrollLayout, + onContentSizeChange + } = useScrollState(scrollViewRef, options.initialContentOffsetY); ++ const onLayout = useCallback(e => { ++ // eslint-disable-next-line react-compiler/react-compiler ++ scrollViewTarget.value = findNodeHandle(scrollViewRef.current) ?? null; ++ onScrollLayout(e); ++ }, [onScrollLayout, scrollViewRef, scrollViewTarget]); + useKeyboardHandler({ + onStart: e => { + "worklet"; +@@ -47,12 +62,27 @@ function useChatKeyboard(scrollViewRef, options) { + if (freeze.value) { + return; + } ++ ++ // Signed shift the focused-input branch applied, and whether the last ++ // transition lifted by the keyboard; the next transition undoes exactly ++ // this instead of inferring a lift from the padding. ++ const prevInputShift = inputShift.value; ++ const prevLifted = lifted.value; ++ ++ // eslint-disable-next-line react-compiler/react-compiler ++ inputShift.value = null; ++ lifted.value = false; + if (e.height > 0) { + // eslint-disable-next-line react-compiler/react-compiler targetKeyboardHeight.value = e.height; } const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); +- const atEnd = isScrollAtEnd(scroll.value, layout.value.height, size.value.height, inverted); + if (e.duration === 0) { + currentHeight.value = effective; + } - const atEnd = isScrollAtEnd(scroll.value, layout.value.height, size.value.height, inverted); ++ // Judge the end from the rest position, without a feed-input shift ++ // that this transition is about to undo. ++ const atEnd = isScrollAtEnd(prevInputShift === null ? scroll.value : scroll.value - prevInputShift, layout.value.height, size.value.height, inverted); // Scale minimum padding absorption by how much of it is visible. -@@ -60,7 +65,7 @@ function useChatKeyboard(scrollViewRef, options) { + // Fully visible → full absorption; fully off-screen → no absorption. +@@ -60,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); @@ -485,7 +706,15 @@ index 043b973..df5a408 100644 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -80,8 +85,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -73,30 +103,30 @@ function useChatKeyboard(scrollViewRef, options) { + } + + // persistent mode: when keyboard shrinks, clamp to valid range +- if (keyboardLiftBehavior === "persistent" && effective < padding.value) { ++ if (keyboardLiftBehavior === "persistent" && effective < padding.value && prevInputShift === null) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + if (inverted) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -496,7 +725,14 @@ index 043b973..df5a408 100644 } return; } -@@ -95,8 +100,8 @@ function useChatKeyboard(scrollViewRef, options) { + + // never mode: when keyboard shrinks, clamp to valid range + // to avoid ghost padding +- if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd) { ++ if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd && prevInputShift === null) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + if (inverted) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -507,11 +743,49 @@ index 043b973..df5a408 100644 } return; } -@@ -122,17 +127,25 @@ function useChatKeyboard(scrollViewRef, options) { - contentOffsetY.value = scroll.value; +@@ -105,34 +135,60 @@ 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 = prevInputShift ?? getScrollEffective(padding.value, prevAbsorption.value); + const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; ++ ++ // An input inside the list scrolls only as far as it needs to stay ++ // in view, whatever the lift behavior, instead of riding the full ++ // keyboard height past the top inset. ++ const focused = input.value; ++ const viewport = !inverted && e.height > 0 && focused !== null && focused.target === e.target && scrollViewTarget.value !== null && focused.parentScrollViewTarget === scrollViewTarget.value ? measure(scrollViewRef) : null; ++ if (focused !== null && viewport !== null) { ++ const delta = getFocusedInputScrollDelta(focused.layout.absoluteY - viewport.pageY, focused.layout.height, adjustedStartInsetCompensation, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); ++ const target = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); ++ inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); ++ contentOffsetY.value = target; ++ return; ++ } ++ ++ // Content that does not lift stays put, except that a focused-input ++ // shift is undone whatever the lift behavior. ++ const heldScroll = prevInputShift === null ? scroll.value : Math.max(-adjustedStartInsetCompensation, Math.min(relativeScroll, size.value.height - layout.value.height + actualTotalPadding)); + if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) { + // Preserve current scroll position so animated props + // don't re-apply a stale contentOffset when padding changes +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; + return; + } + + // When blankSpace fully absorbs the keyboard opening, preserve current scroll position + // (only when keyboard is open — effective > 0 — not when closing) + if (scrollEffective === 0 && minimumPaddingAbsorbed > 0 && effective > 0) { +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; return; } - contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ lifted.value = true; + contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); }, - onInteractive: () => { @@ -538,7 +812,7 @@ index 043b973..df5a408 100644 }, onEnd: e => { "worklet"; -@@ -141,9 +154,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -141,9 +197,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); @@ -599,7 +873,7 @@ index cdca47b..3ea3a7f 100644 //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/module/hooks/useKeyboardState/index.js b/lib/module/hooks/useKeyboardState/index.js -index 8da7c09fbb398253420d7bfcd9a85abdb981065b..31b1ff4370d8328a0569e559e3bbfa4b201f5a9d 100644 +index 8da7c09..31b1ff4 100644 --- a/lib/module/hooks/useKeyboardState/index.js +++ b/lib/module/hooks/useKeyboardState/index.js @@ -1,7 +1,7 @@ @@ -612,7 +886,7 @@ index 8da7c09fbb398253420d7bfcd9a85abdb981065b..31b1ff4370d8328a0569e559e3bbfa4b ...KeyboardController.state(), isVisible: KeyboardController.isVisible() diff --git a/lib/module/module.js b/lib/module/module.js -index 01aad2effee498a8daec55e1d4644a5fbc723765..1140b706265d6c4295581c96b82b27bbf9e6d020 100644 +index 01aad2e..1140b70 100644 --- a/lib/module/module.js +++ b/lib/module/module.js @@ -16,6 +16,12 @@ KeyboardEvents.addListener("keyboardWillShow", e => { @@ -644,15 +918,33 @@ index eba1514..6597d80 100644 * 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/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts -index 2b51cc4..3acec1f 100644 +index 2b51cc4..69307a2 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts -@@ -129,4 +129,4 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH +@@ -129,4 +129,22 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 * ``` */ -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; ++/** ++ * Compute how far to scroll so a focused input inside the list sits in the ++ * strip left between the top inset and the keyboard: just enough to reveal ++ * its bottom, never so far that its top passes under the top inset. ++ * ++ * @param inputTop - Input top relative to the scroll view top. ++ * @param inputHeight - Height of the input. ++ * @param visibleTop - Top of the uncovered strip (e.g. the header inset). ++ * @param visibleBottom - Bottom of the uncovered strip once the keyboard is open. ++ * @returns Scroll delta: positive scrolls content up, negative scrolls it down. ++ * @example ++ * ```ts ++ * getFocusedInputScrollDelta(500, 40, 80, 400); // 140 ++ * getFocusedInputScrollDelta(200, 40, 80, 400); // 0 ++ * getFocusedInputScrollDelta(50, 40, 80, 400); // -30 ++ * ``` ++ */ ++export declare const getFocusedInputScrollDelta: (inputTop: number, inputHeight: number, visibleTop: number, visibleBottom: number) => number; diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts index 8d358bc..911f3d9 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts @@ -804,7 +1096,7 @@ index b7c98c8..e1012a8 100644 * 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/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts -index 37eacd1..3d77ab2 100644 +index 37eacd1..9434ceb 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts @@ -245,6 +245,7 @@ export const computeIOSContentOffset = ( @@ -815,7 +1107,7 @@ index 37eacd1..3d77ab2 100644 ): number => { "worklet"; -@@ -262,7 +263,14 @@ export const computeIOSContentOffset = ( +@@ -262,7 +263,45 @@ export const computeIOSContentOffset = ( ); } @@ -825,18 +1117,74 @@ index 37eacd1..3d77ab2 100644 + contentHeight - layoutHeight + paddingForMax, + minScroll, + ); - -- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ + return Math.min( + Math.max(keyboardHeight + relativeScroll, minScroll), + maxScroll, ++ ); ++}; + +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++/** ++ * Compute how far to scroll so a focused input inside the list sits in the ++ * strip left between the top inset and the keyboard: just enough to reveal ++ * its bottom, never so far that its top passes under the top inset. ++ * ++ * @param inputTop - Input top relative to the scroll view top. ++ * @param inputHeight - Height of the input. ++ * @param visibleTop - Top of the uncovered strip (e.g. the header inset). ++ * @param visibleBottom - Bottom of the uncovered strip once the keyboard is open. ++ * @returns Scroll delta: positive scrolls content up, negative scrolls it down. ++ * @example ++ * ```ts ++ * getFocusedInputScrollDelta(500, 40, 80, 400); // 140 ++ * getFocusedInputScrollDelta(200, 40, 80, 400); // 0 ++ * getFocusedInputScrollDelta(50, 40, 80, 400); // -30 ++ * ``` ++ */ ++export const getFocusedInputScrollDelta = ( ++ inputTop: number, ++ inputHeight: number, ++ visibleTop: number, ++ visibleBottom: number, ++): number => { ++ "worklet"; ++ ++ return Math.min( ++ inputTop - visibleTop, ++ Math.max(0, inputTop + inputHeight - visibleBottom), + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index 1ae9b81..208505b 100644 +index 1ae9b81..0a0a723 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -@@ -43,6 +43,8 @@ function useChatKeyboard( +@@ -1,11 +1,14 @@ +-import { useSharedValue } from "react-native-reanimated"; ++import { useCallback } from "react"; ++import { measure, 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, ++ getFocusedInputScrollDelta, + getScrollEffective, + getVisibleMinimumPaddingFraction, + isScrollAtEnd, +@@ -13,6 +16,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 +47,8 @@ function useChatKeyboard( offset, blankSpace, extraContentPadding, @@ -845,7 +1193,54 @@ index 1ae9b81..208505b 100644 } = options; const padding = useSharedValue(0); -@@ -80,6 +82,10 @@ function useChatKeyboard( +@@ -51,15 +57,28 @@ function useChatKeyboard( + const targetKeyboardHeight = useSharedValue(0); + const prevAbsorption = useSharedValue(0); + const isInteractiveDismissal = useSharedValue(false); ++ const scrollViewTarget = useSharedValue(null); ++ const inputShift = useSharedValue(null); ++ const lifted = useSharedValue(false); ++ const { input } = useReanimatedFocusedInput(); + + const { + layout, + size, + offset: scroll, +- onLayout, ++ onLayout: onScrollLayout, + onContentSizeChange, + } = useScrollState(scrollViewRef, options.initialContentOffsetY); + ++ const onLayout = useCallback( ++ (e: LayoutChangeEvent) => { ++ // eslint-disable-next-line react-compiler/react-compiler ++ scrollViewTarget.value = findNodeHandle(scrollViewRef.current) ?? null; ++ onScrollLayout(e); ++ }, ++ [onScrollLayout, scrollViewRef, scrollViewTarget], ++ ); ++ + useKeyboardHandler( + { + onStart: (e) => { +@@ -69,6 +88,16 @@ function useChatKeyboard( + return; + } + ++ // Signed shift the focused-input branch applied, and whether the last ++ // transition lifted by the keyboard; the next transition undoes exactly ++ // this instead of inferring a lift from the padding. ++ const prevInputShift = inputShift.value; ++ const prevLifted = lifted.value; ++ ++ // eslint-disable-next-line react-compiler/react-compiler ++ inputShift.value = null; ++ lifted.value = false; ++ + if (e.height > 0) { + // eslint-disable-next-line react-compiler/react-compiler + targetKeyboardHeight.value = e.height; +@@ -80,8 +109,14 @@ function useChatKeyboard( offset, ); @@ -853,10 +1248,15 @@ index 1ae9b81..208505b 100644 + currentHeight.value = effective; + } + ++ // Judge the end from the rest position, without a feed-input shift ++ // that this transition is about to undo. const atEnd = isScrollAtEnd( - scroll.value, +- scroll.value, ++ prevInputShift === null ? scroll.value : scroll.value - prevInputShift, layout.value.height, -@@ -105,10 +111,12 @@ function useChatKeyboard( + size.value.height, + inverted, +@@ -105,10 +140,12 @@ function useChatKeyboard( effective, minimumPaddingAbsorbed, ); @@ -873,7 +1273,17 @@ index 1ae9b81..208505b 100644 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -142,11 +150,11 @@ function useChatKeyboard( +@@ -124,7 +161,8 @@ function useChatKeyboard( + // persistent mode: when keyboard shrinks, clamp to valid range + if ( + keyboardLiftBehavior === "persistent" && +- effective < padding.value ++ effective < padding.value && ++ prevInputShift === null + ) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; +@@ -142,11 +180,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -887,7 +1297,17 @@ index 1ae9b81..208505b 100644 Math.min(scroll.value, maxScroll), ); } -@@ -177,11 +185,11 @@ function useChatKeyboard( +@@ -159,7 +197,8 @@ function useChatKeyboard( + if ( + keyboardLiftBehavior === "never" && + effective < padding.value && +- atEnd ++ atEnd && ++ prevInputShift === null + ) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; +@@ -177,11 +216,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -901,7 +1321,100 @@ index 1ae9b81..208505b 100644 Math.min(scroll.value, maxScroll), ); } -@@ -231,17 +239,36 @@ function useChatKeyboard( +@@ -193,10 +232,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 = ++ prevInputShift ?? ++ getScrollEffective(padding.value, prevAbsorption.value); + const relativeScroll = inverted + ? scroll.value + prevScrollEffective + : scroll.value - prevScrollEffective; +@@ -204,10 +242,63 @@ function useChatKeyboard( + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + ++ // An input inside the list scrolls only as far as it needs to stay ++ // in view, whatever the lift behavior, instead of riding the full ++ // keyboard height past the top inset. ++ const focused = input.value; ++ const viewport = ++ !inverted && ++ e.height > 0 && ++ focused !== null && ++ focused.target === e.target && ++ scrollViewTarget.value !== null && ++ focused.parentScrollViewTarget === scrollViewTarget.value ++ ? measure(scrollViewRef) ++ : null; ++ ++ if (focused !== null && viewport !== null) { ++ const delta = getFocusedInputScrollDelta( ++ focused.layout.absoluteY - viewport.pageY, ++ focused.layout.height, ++ adjustedStartInsetCompensation, ++ layout.value.height - ++ (effective + extraContentPadding.value + adjustedInsetCompensation), ++ ); ++ const target = Math.max( ++ -adjustedStartInsetCompensation, ++ Math.min( ++ scroll.value + delta, ++ size.value.height - layout.value.height + actualTotalPadding, ++ ), ++ ); ++ ++ inputShift.value = ++ target - ++ (prevInputShift === null && !prevLifted ++ ? scroll.value ++ : relativeScroll); ++ contentOffsetY.value = target; ++ ++ return; ++ } ++ ++ // Content that does not lift stays put, except that a focused-input ++ // shift is undone whatever the lift behavior. ++ const heldScroll = ++ prevInputShift === null ++ ? scroll.value ++ : Math.max( ++ -adjustedStartInsetCompensation, ++ Math.min( ++ relativeScroll, ++ size.value.height - layout.value.height + actualTotalPadding, ++ ), ++ ); ++ + if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) { + // Preserve current scroll position so animated props + // don't re-apply a stale contentOffset when padding changes +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; + + return; + } +@@ -219,11 +310,12 @@ function useChatKeyboard( + minimumPaddingAbsorbed > 0 && + effective > 0 + ) { +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; + + return; + } + ++ lifted.value = true; + contentOffsetY.value = computeIOSContentOffset( + relativeScroll, + scrollEffective, +@@ -231,17 +323,36 @@ function useChatKeyboard( layout.value.height, inverted, actualTotalPadding, @@ -942,7 +1455,7 @@ index 1ae9b81..208505b 100644 }, onEnd: (e) => { "worklet"; -@@ -256,10 +283,18 @@ function useChatKeyboard( +@@ -256,10 +367,18 @@ function useChatKeyboard( offset, ); @@ -1056,7 +1569,7 @@ index 99cc6c6..d883e76 100644 } diff --git a/src/hooks/useKeyboardState/index.ts b/src/hooks/useKeyboardState/index.ts -index e5fe38dd8e7b8370bfb575296259933094857ca5..d0b98f60a6d7ad7f72ebd70b22e1ce6b7fb56874 100644 +index e5fe38d..d0b98f6 100644 --- a/src/hooks/useKeyboardState/index.ts +++ b/src/hooks/useKeyboardState/index.ts @@ -5,7 +5,7 @@ import { KeyboardController } from "../../module"; @@ -1069,7 +1582,7 @@ index e5fe38dd8e7b8370bfb575296259933094857ca5..d0b98f60a6d7ad7f72ebd70b22e1ce6b const getLatestState = () => ({ ...KeyboardController.state(), diff --git a/src/module.ts b/src/module.ts -index f74941914c9ecebbd0a6c2ba55c5436378de79d4..b7223cff162d18be11f8de248a7bb8f32e694658 100644 +index f749419..b7223cf 100644 --- a/src/module.ts +++ b/src/module.ts @@ -26,6 +26,13 @@ KeyboardEvents.addListener("keyboardWillShow", (e) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56b9fb3f0a7b..3ba8d071c7efe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -385,7 +385,7 @@ patchedDependencies: react-native-enriched-markdown@1.2.0-t3.16: 542e6932066897941f54b902fa7823b2a1861c4a741b5369d5c2d49bd18485a2 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 react-native-image-viewing@0.2.2: 4aa038d541c7ddcc8f1437eb3e7b4f769f61de78e7f91b53f9805dfca608474d - react-native-keyboard-controller@1.22.8: 4e9b5a52aa8c1f9de8082bad281ee979df7860fc77ec49820d4ed00c3c7cc5a2 + react-native-keyboard-controller@1.22.8: ef473533a859a4976338db6b3914178f37c9f46299de7eae083388372f18a88d react-native-pdf-jsi@5.0.0: 6f52854210f61bef499850ebd498229a111bc34ec5b2a4925e644f2f48f42760 react-native-reanimated@4.7.1: 95ee26e5fcab5f50bc4b4c47b3f7e6278a0b20850205efb9f6ee7e70a22d2b59 react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 @@ -746,7 +746,7 @@ importers: version: 0.2.2(patch_hash=4aa038d541c7ddcc8f1437eb3e7b4f769f61de78e7f91b53f9805dfca608474d)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@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.22.8 - version: 1.22.8(patch_hash=4e9b5a52aa8c1f9de8082bad281ee979df7860fc77ec49820d4ed00c3c7cc5a2)(e2a826d844fe10cdcbda558127861778) + version: 1.22.8(patch_hash=ef473533a859a4976338db6b3914178f37c9f46299de7eae083388372f18a88d)(e2a826d844fe10cdcbda558127861778) react-native-nitro-modules: specifier: 0.37.1 version: 0.37.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@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) @@ -21273,7 +21273,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@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.22.8(patch_hash=4e9b5a52aa8c1f9de8082bad281ee979df7860fc77ec49820d4ed00c3c7cc5a2)(e2a826d844fe10cdcbda558127861778): + react-native-keyboard-controller@1.22.8(patch_hash=ef473533a859a4976338db6b3914178f37c9f46299de7eae083388372f18a88d)(e2a826d844fe10cdcbda558127861778): dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@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) From db571ce260c43655551ab7b19109eb264fa88890 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 16:09:52 +0000 Subject: [PATCH 2/6] fix(mobile): port feed-input keyboard lift onto keyboard-controller 1.23.0 The merge with main took main's react-native-keyboard-controller 1.23.0 patch verbatim, which dropped this branch's fix for a focused feed input riding the full keyboard height past the header (#32377). Re-apply it to the iOS useChatKeyboard hook in src, lib/module and lib/commonjs: an input inside the list scrolls only as far as it needs, the hook records the signed shift it applied, and the next keyboard transition undoes exactly that shift under every lift behavior. 1.23.0's frozen-transition path ("nothing to undo") now also drops the recorded shift, so the first open after a frozen close no longer jumps the feed. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../keyboard/chatKeyboardFeedInput.test.tsx | 17 +- ...ct-native-keyboard-controller@1.23.0.patch | 585 ++++++++++++++++-- pnpm-lock.yaml | 6 +- 3 files changed, 565 insertions(+), 43 deletions(-) diff --git a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx index fcc7c7cfa6f1d..4314925749bb6 100644 --- a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx +++ b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx @@ -70,11 +70,12 @@ function renderFeed( native.scroll.value = restOffset; let focused = INPUT_TAG; let contentOffsetY: Shared | undefined; + const freeze: Shared = { value: false }; function Feed() { const chat = useChatKeyboard({ current: null } as never, { inverted: false, keyboardLiftBehavior, - freeze: { value: false } as never, + freeze: freeze as never, offset: 0, initialContentOffsetY: restOffset, blankSpace: { value: 0 } as never, @@ -116,6 +117,9 @@ function renderFeed( return transition(300); }, hide: () => transition(0), + setFrozen: (frozen: boolean) => { + freeze.value = frozen; + }, [Symbol.dispose]: () => act(() => root.unmount()), }; } @@ -157,3 +161,14 @@ it("lifts for the composer and returns to the end after a feed input moved the f expect([feed.open(), feed.open(COMPOSER_TAG), feed.hide()]).toEqual([1282, 1622, 1322]); }); + +it("keeps the feed where it rests after the keyboard closed while frozen", () => { + using feed = renderFeed(500, 500); + + feed.open(); + feed.setFrozen(true); + feed.hide(); + feed.setFrozen(false); + + expect(feed.open(COMPOSER_TAG)).toBe(742); +}); diff --git a/patches/react-native-keyboard-controller@1.23.0.patch b/patches/react-native-keyboard-controller@1.23.0.patch index 5b5b07fed4486..98e8e0308ef95 100644 --- a/patches/react-native-keyboard-controller@1.23.0.patch +++ b/patches/react-native-keyboard-controller@1.23.0.patch @@ -159,9 +159,18 @@ index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3e onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js -index 4d33b3fe426070cf312fc493ce902b2c24d853ea..6a787d3054ba09e90c54b615ab302df51a2888f7 100644 +index 4d33b3fe426070cf312fc493ce902b2c24d853ea..91c1f1b866b23b8bfa3f4ba3ec4a3176e20a9a3e 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +@@ -3,7 +3,7 @@ + Object.defineProperty(exports, "__esModule", { + value: true + }); +-exports.shouldShiftContent = exports.isScrollAtEnd = exports.getVisibleMinimumPaddingFraction = exports.getScrollEffective = exports.getMinimumPaddingAbsorbed = exports.getEffectiveHeight = exports.computeIOSContentOffset = exports.clampedScrollTarget = void 0; ++exports.shouldShiftContent = exports.isScrollAtEnd = exports.getVisibleMinimumPaddingFraction = exports.getScrollEffective = exports.getMinimumPaddingAbsorbed = exports.getFocusedInputScrollDelta = exports.getEffectiveHeight = exports.computeIOSContentOffset = exports.clampedScrollTarget = void 0; + var _reactNativeReanimated = require("react-native-reanimated"); + const AT_END_THRESHOLD = 20; + @@ -206,7 +206,7 @@ const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentHeight, * ``` */ @@ -171,7 +180,7 @@ index 4d33b3fe426070cf312fc493ce902b2c24d853ea..6a787d3054ba09e90c54b615ab302df5 "worklet"; const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; -@@ -214,8 +214,9 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, +@@ -214,8 +214,32 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, const maxScroll = Math.max(contentHeight - layoutHeight, 0); return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); } @@ -182,13 +191,47 @@ index 4d33b3fe426070cf312fc493ce902b2c24d853ea..6a787d3054ba09e90c54b615ab302df5 + return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); }; exports.computeIOSContentOffset = computeIOSContentOffset; ++/** ++ * Compute how far to scroll so a focused input inside the list sits in the ++ * strip left between the top inset and the keyboard: just enough to reveal ++ * its bottom, never so far that its top passes under the top inset. ++ * ++ * @param inputTop - Input top relative to the scroll view top. ++ * @param inputHeight - Height of the input. ++ * @param visibleTop - Top of the uncovered strip (e.g. the header inset). ++ * @param visibleBottom - Bottom of the uncovered strip once the keyboard is open. ++ * @returns Scroll delta: positive scrolls content up, negative scrolls it down. ++ * @example ++ * ```ts ++ * getFocusedInputScrollDelta(500, 40, 80, 400); // 140 ++ * getFocusedInputScrollDelta(200, 40, 80, 400); // 0 ++ * getFocusedInputScrollDelta(50, 40, 80, 400); // -30 ++ * ``` ++ */ ++const getFocusedInputScrollDelta = (inputTop, inputHeight, visibleTop, visibleBottom) => { ++ "worklet"; ++ ++ return Math.min(inputTop - visibleTop, Math.max(0, inputTop + inputHeight - visibleBottom)); ++}; ++exports.getFocusedInputScrollDelta = getFocusedInputScrollDelta; //# 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..867585fbfb53f62d08dc298a4c342fb47865c02b 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,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, @@ -199,17 +242,58 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 } = options; const padding = (0, _reactNativeReanimated.useSharedValue)(0); const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); -@@ -67,6 +69,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -42,13 +46,27 @@ 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); ++ const scrollViewTarget = (0, _reactNativeReanimated.useSharedValue)(null); ++ // Signed shift the focused-input branch applied, and whether the last ++ // transition lifted by the keyboard; the next transition undoes exactly ++ // this instead of inferring a lift from the padding. ++ const inputShift = (0, _reactNativeReanimated.useSharedValue)(null); ++ const lifted = (0, _reactNativeReanimated.useSharedValue)(false); ++ 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 => { ++ // eslint-disable-next-line react-compiler/react-compiler ++ scrollViewTarget.value = (0, _findNodeHandle.findNodeHandle)(scrollViewRef.current) ?? null; ++ onScrollLayout(e); ++ }, [onScrollLayout, scrollViewRef, scrollViewTarget]); + (0, _hooks.useKeyboardHandler)({ + onStart: e => { + "worklet"; +@@ -65,9 +83,20 @@ function useChatKeyboard(scrollViewRef, options) { + // The frozen transition didn't shift the content: nothing to undo. + lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; ++ inputShift.value = null; ++ lifted.value = false; } ++ const prevInputShift = inputShift.value; ++ const prevLifted = lifted.value; ++ inputShift.value = null; ++ lifted.value = false; const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); +- const atEnd = (0, _helpers.isScrollAtEnd)(scroll.value, layout.value.height, size.value.height, inverted); + if (e.duration === 0) { + currentHeight.value = effective; + } - const atEnd = (0, _helpers.isScrollAtEnd)(scroll.value, layout.value.height, size.value.height, inverted); ++ // Judge the end from the rest position, without a feed-input shift ++ // that this transition is about to undo. ++ const atEnd = (0, _helpers.isScrollAtEnd)(prevInputShift === null ? scroll.value : scroll.value - prevInputShift, 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) { + // Fully visible → full absorption; fully off-screen → no absorption. +@@ -75,7 +104,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 +302,15 @@ 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) { +@@ -88,30 +117,30 @@ function useChatKeyboard(scrollViewRef, options) { + } + + // persistent mode: when keyboard shrinks, clamp to valid range +- if (keyboardLiftBehavior === "persistent" && effective < padding.value) { ++ if (keyboardLiftBehavior === "persistent" && effective < padding.value && prevInputShift === null) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + if (inverted) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -229,7 +321,14 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 } return; } -@@ -110,8 +115,8 @@ function useChatKeyboard(scrollViewRef, options) { + + // never mode: when keyboard shrinks, clamp to valid range + // to avoid ghost padding +- if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd) { ++ if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd && prevInputShift === null) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + if (inverted) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -240,11 +339,49 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 } return; } -@@ -137,17 +142,25 @@ function useChatKeyboard(scrollViewRef, options) { - contentOffsetY.value = scroll.value; +@@ -120,34 +149,60 @@ 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 = prevInputShift ?? (0, _helpers.getScrollEffective)(padding.value, prevAbsorption.value); + const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; ++ ++ // An input inside the list scrolls only as far as it needs to stay ++ // in view, whatever the lift behavior, instead of riding the full ++ // keyboard height past the top inset. ++ const focused = input.value; ++ const viewport = !inverted && e.height > 0 && focused !== null && focused.target === e.target && scrollViewTarget.value !== null && focused.parentScrollViewTarget === scrollViewTarget.value ? (0, _reactNativeReanimated.measure)(scrollViewRef) : null; ++ if (focused !== null && viewport !== null) { ++ const delta = (0, _helpers.getFocusedInputScrollDelta)(focused.layout.absoluteY - viewport.pageY, focused.layout.height, adjustedStartInsetCompensation, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); ++ const target = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); ++ inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); ++ contentOffsetY.value = target; ++ return; ++ } ++ ++ // Content that does not lift stays put, except that a focused-input ++ // shift is undone whatever the lift behavior. ++ const heldScroll = prevInputShift === null ? scroll.value : Math.max(-adjustedStartInsetCompensation, Math.min(relativeScroll, size.value.height - layout.value.height + actualTotalPadding)); + if (!(0, _helpers.shouldShiftContent)(keyboardLiftBehavior, atEnd)) { + // Preserve current scroll position so animated props + // don't re-apply a stale contentOffset when padding changes +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; + return; + } + + // When blankSpace fully absorbs the keyboard opening, preserve current scroll position + // (only when keyboard is open — effective > 0 — not when closing) + if (scrollEffective === 0 && minimumPaddingAbsorbed > 0 && effective > 0) { +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; return; } - contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ lifted.value = true; + contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); }, - onInteractive: () => { @@ -271,7 +408,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..480b60f7b55d7a4945bf163e53783116 }, onEnd: e => { "worklet"; -@@ -156,9 +169,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -156,9 +211,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); @@ -472,7 +609,7 @@ index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1 onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js -index 295e2219b560519b8d033968b5105d1da3e77315..2f7b87e6c68956f757db94a8197c1c61d1fafe28 100644 +index 295e2219b560519b8d033968b5105d1da3e77315..20db412fd3eee6cf9c0da5c93c75abeb478088e3 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js @@ -193,7 +193,7 @@ export const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentH @@ -484,7 +621,7 @@ index 295e2219b560519b8d033968b5105d1da3e77315..2f7b87e6c68956f757db94a8197c1c61 "worklet"; const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; -@@ -201,7 +201,8 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH +@@ -201,7 +201,30 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH const maxScroll = Math.max(contentHeight - layoutHeight, 0); return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); } @@ -493,14 +630,49 @@ index 295e2219b560519b8d033968b5105d1da3e77315..2f7b87e6c68956f757db94a8197c1c61 + const minScroll = -startInsetCompensation; + const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll); + return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); ++}; ++/** ++ * Compute how far to scroll so a focused input inside the list sits in the ++ * strip left between the top inset and the keyboard: just enough to reveal ++ * its bottom, never so far that its top passes under the top inset. ++ * ++ * @param inputTop - Input top relative to the scroll view top. ++ * @param inputHeight - Height of the input. ++ * @param visibleTop - Top of the uncovered strip (e.g. the header inset). ++ * @param visibleBottom - Bottom of the uncovered strip once the keyboard is open. ++ * @returns Scroll delta: positive scrolls content up, negative scrolls it down. ++ * @example ++ * ```ts ++ * getFocusedInputScrollDelta(500, 40, 80, 400); // 140 ++ * getFocusedInputScrollDelta(200, 40, 80, 400); // 0 ++ * getFocusedInputScrollDelta(50, 40, 80, 400); // -30 ++ * ``` ++ */ ++export const getFocusedInputScrollDelta = (inputTop, inputHeight, visibleTop, visibleBottom) => { ++ "worklet"; ++ ++ return Math.min(inputTop - visibleTop, Math.max(0, inputTop + inputHeight - visibleBottom)); }; //# 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..dfb84ec97626dc81e8ed78f11c8f23a84b95e362 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,7 +1,9 @@ +-import { useSharedValue } from "react-native-reanimated"; +-import { useKeyboardHandler } from "../../../hooks"; ++import { useCallback } from "react"; ++import { measure, useSharedValue } from "react-native-reanimated"; ++import { useKeyboardHandler, useReanimatedFocusedInput } from "../../../hooks"; ++import { findNodeHandle } from "../../../utils/findNodeHandle"; + import useScrollState from "../../hooks/useScrollState"; +-import { computeIOSContentOffset, getEffectiveHeight, getScrollEffective, getVisibleMinimumPaddingFraction, isScrollAtEnd, shouldShiftContent } from "./helpers"; ++import { computeIOSContentOffset, getEffectiveHeight, getFocusedInputScrollDelta, getScrollEffective, getVisibleMinimumPaddingFraction, isScrollAtEnd, shouldShiftContent } from "./helpers"; + /** + * Hook that manages keyboard-driven scrolling for chat-style scroll views. + * Calculates padding (extra scrollable space) and content shift values, +@@ -25,7 +27,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, @@ -511,17 +683,58 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da } = options; const padding = useSharedValue(0); const currentHeight = useSharedValue(0); -@@ -60,6 +62,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -35,13 +39,27 @@ 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); ++ const scrollViewTarget = useSharedValue(null); ++ // Signed shift the focused-input branch applied, and whether the last ++ // transition lifted by the keyboard; the next transition undoes exactly ++ // this instead of inferring a lift from the padding. ++ const inputShift = useSharedValue(null); ++ const lifted = useSharedValue(false); ++ const { ++ input ++ } = useReanimatedFocusedInput(); + const { + layout, + size, + offset: scroll, +- onLayout, ++ onLayout: onScrollLayout, + onContentSizeChange + } = useScrollState(scrollViewRef, options.initialContentOffsetY); ++ const onLayout = useCallback(e => { ++ // eslint-disable-next-line react-compiler/react-compiler ++ scrollViewTarget.value = findNodeHandle(scrollViewRef.current) ?? null; ++ onScrollLayout(e); ++ }, [onScrollLayout, scrollViewRef, scrollViewTarget]); + useKeyboardHandler({ + onStart: e => { + "worklet"; +@@ -58,9 +76,20 @@ function useChatKeyboard(scrollViewRef, options) { + // The frozen transition didn't shift the content: nothing to undo. + lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; ++ inputShift.value = null; ++ lifted.value = false; } ++ const prevInputShift = inputShift.value; ++ const prevLifted = lifted.value; ++ inputShift.value = null; ++ lifted.value = false; const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); +- const atEnd = isScrollAtEnd(scroll.value, layout.value.height, size.value.height, inverted); + if (e.duration === 0) { + currentHeight.value = effective; + } - const atEnd = isScrollAtEnd(scroll.value, layout.value.height, size.value.height, inverted); ++ // Judge the end from the rest position, without a feed-input shift ++ // that this transition is about to undo. ++ const atEnd = isScrollAtEnd(prevInputShift === null ? scroll.value : scroll.value - prevInputShift, 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) { + // Fully visible → full absorption; fully off-screen → no absorption. +@@ -68,7 +97,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 +743,15 @@ 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) { +@@ -81,30 +110,30 @@ function useChatKeyboard(scrollViewRef, options) { + } + + // persistent mode: when keyboard shrinks, clamp to valid range +- if (keyboardLiftBehavior === "persistent" && effective < padding.value) { ++ if (keyboardLiftBehavior === "persistent" && effective < padding.value && prevInputShift === null) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + if (inverted) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -541,7 +762,14 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da } return; } -@@ -103,8 +108,8 @@ function useChatKeyboard(scrollViewRef, options) { + + // never mode: when keyboard shrinks, clamp to valid range + // to avoid ghost padding +- if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd) { ++ if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd && prevInputShift === null) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + if (inverted) { const maxScroll = Math.max(size.value.height - layout.value.height, 0); contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); } else { @@ -552,11 +780,49 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da } return; } -@@ -130,17 +135,25 @@ function useChatKeyboard(scrollViewRef, options) { - contentOffsetY.value = scroll.value; +@@ -113,34 +142,60 @@ 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 = prevInputShift ?? getScrollEffective(padding.value, prevAbsorption.value); + const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; ++ ++ // An input inside the list scrolls only as far as it needs to stay ++ // in view, whatever the lift behavior, instead of riding the full ++ // keyboard height past the top inset. ++ const focused = input.value; ++ const viewport = !inverted && e.height > 0 && focused !== null && focused.target === e.target && scrollViewTarget.value !== null && focused.parentScrollViewTarget === scrollViewTarget.value ? measure(scrollViewRef) : null; ++ if (focused !== null && viewport !== null) { ++ const delta = getFocusedInputScrollDelta(focused.layout.absoluteY - viewport.pageY, focused.layout.height, adjustedStartInsetCompensation, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); ++ const target = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); ++ inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); ++ contentOffsetY.value = target; ++ return; ++ } ++ ++ // Content that does not lift stays put, except that a focused-input ++ // shift is undone whatever the lift behavior. ++ const heldScroll = prevInputShift === null ? scroll.value : Math.max(-adjustedStartInsetCompensation, Math.min(relativeScroll, size.value.height - layout.value.height + actualTotalPadding)); + if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) { + // Preserve current scroll position so animated props + // don't re-apply a stale contentOffset when padding changes +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; + return; + } + + // When blankSpace fully absorbs the keyboard opening, preserve current scroll position + // (only when keyboard is open — effective > 0 — not when closing) + if (scrollEffective === 0 && minimumPaddingAbsorbed > 0 && effective > 0) { +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; return; } - contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ lifted.value = true; + contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); }, - onInteractive: () => { @@ -583,7 +849,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..753893680fd20f207491f0c2dbca49da }, onEnd: e => { "worklet"; -@@ -149,9 +162,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -149,9 +204,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); @@ -714,15 +980,33 @@ index eba1514c2d94ae42c024da3882475567a8e8260b..6597d80df11f973ceb2f52c30ee0ece8 * 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/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts -index 2b51cc4737b4a05b5396da31e67e54177f43c498..3acec1fbddee6227915590a6c8d449757cb4d7b2 100644 +index 2b51cc4737b4a05b5396da31e67e54177f43c498..69307a2150e3da94bbfb45da0d5e57fed2955d49 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts -@@ -129,4 +129,4 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH +@@ -129,4 +129,22 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 * ``` */ -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; ++/** ++ * Compute how far to scroll so a focused input inside the list sits in the ++ * strip left between the top inset and the keyboard: just enough to reveal ++ * its bottom, never so far that its top passes under the top inset. ++ * ++ * @param inputTop - Input top relative to the scroll view top. ++ * @param inputHeight - Height of the input. ++ * @param visibleTop - Top of the uncovered strip (e.g. the header inset). ++ * @param visibleBottom - Bottom of the uncovered strip once the keyboard is open. ++ * @returns Scroll delta: positive scrolls content up, negative scrolls it down. ++ * @example ++ * ```ts ++ * getFocusedInputScrollDelta(500, 40, 80, 400); // 140 ++ * getFocusedInputScrollDelta(200, 40, 80, 400); // 0 ++ * getFocusedInputScrollDelta(50, 40, 80, 400); // -30 ++ * ``` ++ */ ++export declare const getFocusedInputScrollDelta: (inputTop: number, inputHeight: number, visibleTop: number, visibleBottom: 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 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts @@ -901,7 +1185,7 @@ index b7c98c8ede80f690c8c9ad60263efe3ebd5b73df..e1012a80d59ff172396ff17550cb8402 * 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/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts -index 37eacd10857cd70c51b3468c4b466e4959d0a415..3d77ab27c614f44b64da8270823942cb670b097b 100644 +index 37eacd10857cd70c51b3468c4b466e4959d0a415..9434cebf61cf380e964e35e687a8c9dd777ef6d3 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts @@ -245,6 +245,7 @@ export const computeIOSContentOffset = ( @@ -912,7 +1196,7 @@ index 37eacd10857cd70c51b3468c4b466e4959d0a415..3d77ab27c614f44b64da8270823942cb ): number => { "worklet"; -@@ -262,7 +263,14 @@ export const computeIOSContentOffset = ( +@@ -262,7 +263,45 @@ export const computeIOSContentOffset = ( ); } @@ -922,18 +1206,74 @@ index 37eacd10857cd70c51b3468c4b466e4959d0a415..3d77ab27c614f44b64da8270823942cb + contentHeight - layoutHeight + paddingForMax, + minScroll, + ); - -- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ + return Math.min( + Math.max(keyboardHeight + relativeScroll, minScroll), + maxScroll, ++ ); ++}; + +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++/** ++ * Compute how far to scroll so a focused input inside the list sits in the ++ * strip left between the top inset and the keyboard: just enough to reveal ++ * its bottom, never so far that its top passes under the top inset. ++ * ++ * @param inputTop - Input top relative to the scroll view top. ++ * @param inputHeight - Height of the input. ++ * @param visibleTop - Top of the uncovered strip (e.g. the header inset). ++ * @param visibleBottom - Bottom of the uncovered strip once the keyboard is open. ++ * @returns Scroll delta: positive scrolls content up, negative scrolls it down. ++ * @example ++ * ```ts ++ * getFocusedInputScrollDelta(500, 40, 80, 400); // 140 ++ * getFocusedInputScrollDelta(200, 40, 80, 400); // 0 ++ * getFocusedInputScrollDelta(50, 40, 80, 400); // -30 ++ * ``` ++ */ ++export const getFocusedInputScrollDelta = ( ++ inputTop: number, ++ inputHeight: number, ++ visibleTop: number, ++ visibleBottom: number, ++): number => { ++ "worklet"; ++ ++ return Math.min( ++ inputTop - visibleTop, ++ Math.max(0, inputTop + inputHeight - visibleBottom), + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e3cb63d7d 100644 +index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4bed5d41f93 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -@@ -43,6 +43,8 @@ function useChatKeyboard( +@@ -1,11 +1,14 @@ +-import { useSharedValue } from "react-native-reanimated"; ++import { useCallback } from "react"; ++import { measure, 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, ++ getFocusedInputScrollDelta, + getScrollEffective, + getVisibleMinimumPaddingFraction, + isScrollAtEnd, +@@ -13,6 +16,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 +47,8 @@ function useChatKeyboard( offset, blankSpace, extraContentPadding, @@ -942,7 +1282,56 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e } = options; const padding = useSharedValue(0); -@@ -90,6 +92,10 @@ function useChatKeyboard( +@@ -53,15 +59,31 @@ function useChatKeyboard( + const isInteractiveDismissal = useSharedValue(false); + // Set by a transition that ran while frozen and so applied no content shift. + const lastTransitionFrozen = useSharedValue(false); ++ const scrollViewTarget = useSharedValue(null); ++ // Signed shift the focused-input branch applied, and whether the last ++ // transition lifted by the keyboard; the next transition undoes exactly ++ // this instead of inferring a lift from the padding. ++ const inputShift = useSharedValue(null); ++ const lifted = useSharedValue(false); ++ const { input } = useReanimatedFocusedInput(); + + const { + layout, + size, + offset: scroll, +- onLayout, ++ onLayout: onScrollLayout, + onContentSizeChange, + } = useScrollState(scrollViewRef, options.initialContentOffsetY); + ++ const onLayout = useCallback( ++ (e: LayoutChangeEvent) => { ++ // eslint-disable-next-line react-compiler/react-compiler ++ scrollViewTarget.value = findNodeHandle(scrollViewRef.current) ?? null; ++ onScrollLayout(e); ++ }, ++ [onScrollLayout, scrollViewRef, scrollViewTarget], ++ ); ++ + useKeyboardHandler( + { + onStart: (e) => { +@@ -82,16 +104,30 @@ function useChatKeyboard( + // The frozen transition didn't shift the content: nothing to undo. + lastTransitionFrozen.value = false; + prevAbsorption.value = padding.value; ++ inputShift.value = null; ++ lifted.value = false; + } + ++ const prevInputShift = inputShift.value; ++ const prevLifted = lifted.value; ++ ++ inputShift.value = null; ++ lifted.value = false; ++ + const effective = getEffectiveHeight( + e.height, + targetKeyboardHeight.value, offset, ); @@ -950,10 +1339,15 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e + currentHeight.value = effective; + } + ++ // Judge the end from the rest position, without a feed-input shift ++ // that this transition is about to undo. const atEnd = isScrollAtEnd( - scroll.value, +- scroll.value, ++ prevInputShift === null ? scroll.value : scroll.value - prevInputShift, layout.value.height, -@@ -115,10 +121,12 @@ function useChatKeyboard( + size.value.height, + inverted, +@@ -115,10 +151,12 @@ function useChatKeyboard( effective, minimumPaddingAbsorbed, ); @@ -970,7 +1364,17 @@ 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( +@@ -134,7 +172,8 @@ function useChatKeyboard( + // persistent mode: when keyboard shrinks, clamp to valid range + if ( + keyboardLiftBehavior === "persistent" && +- effective < padding.value ++ effective < padding.value && ++ prevInputShift === null + ) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; +@@ -152,11 +191,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -984,7 +1388,17 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e Math.min(scroll.value, maxScroll), ); } -@@ -187,11 +195,11 @@ function useChatKeyboard( +@@ -169,7 +208,8 @@ function useChatKeyboard( + if ( + keyboardLiftBehavior === "never" && + effective < padding.value && +- atEnd ++ atEnd && ++ prevInputShift === null + ) { + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; +@@ -187,11 +227,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -998,7 +1412,100 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e Math.min(scroll.value, maxScroll), ); } -@@ -241,17 +249,36 @@ function useChatKeyboard( +@@ -203,10 +243,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 = ++ prevInputShift ?? ++ getScrollEffective(padding.value, prevAbsorption.value); + const relativeScroll = inverted + ? scroll.value + prevScrollEffective + : scroll.value - prevScrollEffective; +@@ -214,10 +253,63 @@ function useChatKeyboard( + padding.value = effective; + prevAbsorption.value = minimumPaddingAbsorbed; + ++ // An input inside the list scrolls only as far as it needs to stay ++ // in view, whatever the lift behavior, instead of riding the full ++ // keyboard height past the top inset. ++ const focused = input.value; ++ const viewport = ++ !inverted && ++ e.height > 0 && ++ focused !== null && ++ focused.target === e.target && ++ scrollViewTarget.value !== null && ++ focused.parentScrollViewTarget === scrollViewTarget.value ++ ? measure(scrollViewRef) ++ : null; ++ ++ if (focused !== null && viewport !== null) { ++ const delta = getFocusedInputScrollDelta( ++ focused.layout.absoluteY - viewport.pageY, ++ focused.layout.height, ++ adjustedStartInsetCompensation, ++ layout.value.height - ++ (effective + extraContentPadding.value + adjustedInsetCompensation), ++ ); ++ const target = Math.max( ++ -adjustedStartInsetCompensation, ++ Math.min( ++ scroll.value + delta, ++ size.value.height - layout.value.height + actualTotalPadding, ++ ), ++ ); ++ ++ inputShift.value = ++ target - ++ (prevInputShift === null && !prevLifted ++ ? scroll.value ++ : relativeScroll); ++ contentOffsetY.value = target; ++ ++ return; ++ } ++ ++ // Content that does not lift stays put, except that a focused-input ++ // shift is undone whatever the lift behavior. ++ const heldScroll = ++ prevInputShift === null ++ ? scroll.value ++ : Math.max( ++ -adjustedStartInsetCompensation, ++ Math.min( ++ relativeScroll, ++ size.value.height - layout.value.height + actualTotalPadding, ++ ), ++ ); ++ + if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) { + // Preserve current scroll position so animated props + // don't re-apply a stale contentOffset when padding changes +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; + + return; + } +@@ -229,11 +321,12 @@ function useChatKeyboard( + minimumPaddingAbsorbed > 0 && + effective > 0 + ) { +- contentOffsetY.value = scroll.value; ++ contentOffsetY.value = heldScroll; + + return; + } + ++ lifted.value = true; + contentOffsetY.value = computeIOSContentOffset( + relativeScroll, + scrollEffective, +@@ -241,17 +334,36 @@ function useChatKeyboard( layout.value.height, inverted, actualTotalPadding, @@ -1039,7 +1546,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a599edb77c094574c57adcb217d8404e }, onEnd: (e) => { "worklet"; -@@ -266,10 +293,18 @@ function useChatKeyboard( +@@ -266,10 +378,18 @@ function useChatKeyboard( offset, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efd4f84496318..7ed8030653a91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,7 +382,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: afad7e278af9065091c7b0e24cfd504d20efde6286a078decaa1517399e6abf2 react-native-pdf-jsi@6.1.0: d29e6b69813548de641a09db705dd94823d3f96832821ae9c3f6a196dd15cb68 react-native-reanimated@4.7.1: 95ee26e5fcab5f50bc4b4c47b3f7e6278a0b20850205efb9f6ee7e70a22d2b59 react-native-svg@15.16.1: 110f9243ffad03654ce40dd1cdb09f01af16b8fa2ccb2b2cd3d62e682c180399 @@ -738,7 +738,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=afad7e278af9065091c7b0e24cfd504d20efde6286a078decaa1517399e6abf2)(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) @@ -21015,7 +21015,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=afad7e278af9065091c7b0e24cfd504d20efde6286a078decaa1517399e6abf2)(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) From 703f28f7a31eacc55d8e5b0b9ed2eb7c5ce2c890 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 16:27:35 +0000 Subject: [PATCH 3/6] fix(mobile): keep feed inputs below the real thread header The feed-input keyboard lift measured the header with adjustedStartInsetCompensation, which the app never sets, so an input partly under the header counted as visible. It also clamped to offset 0, so a feed resting at -contentInset.top (iOS 26+) jumped under the header and never came back. KeyboardChatScrollView (patched dependency) takes an obscuredStartInset prop for the header height in viewport coordinates, defaulting to the top content inset. The focused-input branch and its undo clamp to the real minimum offset, -(contentInset.top + adjustedStartInsetCompensation). ThreadFeed passes the header clearance it already uses for scroll indicators, so both the native-inset and header-spacer layouts work. Co-Authored-By: Claude Opus 5.5 --- .../keyboard/chatKeyboardFeedInput.test.tsx | 41 +++- .../src/features/threads/ThreadFeed.tsx | 1 + ...ct-native-keyboard-controller@1.23.0.patch | 195 +++++++++++------- pnpm-lock.yaml | 6 +- 4 files changed, 162 insertions(+), 81 deletions(-) diff --git a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx index 4314925749bb6..508e790f2bf26 100644 --- a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx +++ b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx @@ -59,13 +59,19 @@ const { useChatKeyboard } = (await import( )) as typeof import("react-native-keyboard-controller/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/index.ios"); type LiftBehavior = NonNullable; +type Header = Pick; -// Header inset 80, composer padding 80, and a 300pt keyboard over a 678pt feed. +// The header props ThreadFeed passes: a native top inset on iOS 26+, a list-header spacer before. +const nativeInset = (top: number): Header => ({ contentInset: { top }, obscuredStartInset: top }); +const headerSpacer = (top: number): Header => ({ obscuredStartInset: top }); + +// Composer padding 80 and a 300pt keyboard over a 678pt feed. // The feed input sits at `inputTop` on screen while the feed rests at `restOffset`. function renderFeed( restOffset: number, inputTop: number, keyboardLiftBehavior: LiftBehavior = "whenAtEnd", + header: Header = nativeInset(80), ) { native.scroll.value = restOffset; let focused = INPUT_TAG; @@ -81,7 +87,8 @@ function renderFeed( blankSpace: { value: 0 } as never, extraContentPadding: { value: 80 } as never, adjustedInsetCompensation: 0, - adjustedStartInsetCompensation: 80, + adjustedStartInsetCompensation: 0, + ...header, }); chat.onLayout({} as LayoutChangeEvent); contentOffsetY = chat.contentOffsetY; @@ -135,14 +142,30 @@ it.each(liftBehaviors)( }, ); -it.each(liftBehaviors)( - "returns the feed to its offset after moving a partly hidden input down below the header (%s)", - (behavior) => { - using feed = renderFeed(1322, 60, behavior); - - expect([feed.open(), feed.hide()]).toEqual([1302, 1322]); +it.each([ + ...liftBehaviors.flatMap((behavior) => + [nativeInset(80), headerSpacer(80)].map((header) => ({ + name: `moves a partly hidden input below the header (${behavior}, ${header.contentInset ? "native inset" : "header spacer"})`, + behavior, + header, + restOffset: 1322, + inputTop: 60, + expected: [1302, 1322], + })), + ), + { + name: "leaves a feed resting at its top inset alone for an input in view", + behavior: "whenAtEnd" as const, + header: nativeInset(100), + restOffset: -100, + inputTop: 150, + expected: [-100, -100], }, -); +])("$name, then returns the feed to its offset", (row) => { + using feed = renderFeed(row.restOffset, row.inputTop, row.behavior, row.header); + + expect([feed.open(), feed.hide()]).toEqual(row.expected); +}); it("returns the feed to its offset when focus moves from an unlifted composer to a feed input", () => { using feed = renderFeed(500, 500); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fcc957840d98e..efd9fbad49c16 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3669,6 +3669,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/patches/react-native-keyboard-controller@1.23.0.patch b/patches/react-native-keyboard-controller@1.23.0.patch index 98e8e0308ef95..cf55104c60bc9 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..3444bcf3b51fd5d493eb0cfd606c69a2d51314bf 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, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -102,16 +103,18 @@ index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3e onEndVisible, ...rest }, ref) => { -@@ -54,6 +57,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -54,6 +58,10 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ contentInset: rest.contentInset, ++ 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 +69,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -120,7 +123,7 @@ index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3e scroll, layout, size, -@@ -72,7 +79,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -72,7 +82,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ (0, _useFrozenPadding.useFrozenPadding)({ freeze: freezeSV, offset, @@ -130,7 +133,7 @@ index 0b78ff38d8f0a2360632df3d9988c0c32bd56b99..9600d6c8ac857a3f56753f49b5a52e3e }); (0, _useEndVisible.useEndVisible)({ scroll, -@@ -91,10 +99,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -91,10 +102,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))); @@ -217,7 +220,7 @@ index 4d33b3fe426070cf312fc493ce902b2c24d853ea..91c1f1b866b23b8bfa3f4ba3ec4a3176 //# 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..867585fbfb53f62d08dc298a4c342fb47865c02b 100644 +index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98455f7450 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -4,8 +4,10 @@ Object.defineProperty(exports, "__esModule", { @@ -231,18 +234,21 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 var _useScrollState = _interopRequireDefault(require("../../hooks/useScrollState")); var _helpers = require("./helpers"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } -@@ -32,7 +34,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -32,8 +34,12 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, ++ obscuredStartInset } = options; ++ const contentInsetTop = options.contentInset?.top ?? 0; const padding = (0, _reactNativeReanimated.useSharedValue)(0); const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); -@@ -42,13 +46,27 @@ function useChatKeyboard(scrollViewRef, options) { + const contentOffsetY = (0, _reactNativeReanimated.useSharedValue)(options.initialContentOffsetY ?? 0); +@@ -42,13 +48,27 @@ 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); @@ -271,7 +277,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 (0, _hooks.useKeyboardHandler)({ onStart: e => { "worklet"; -@@ -65,9 +83,20 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -65,9 +85,20 @@ function useChatKeyboard(scrollViewRef, options) { // The frozen transition didn't shift the content: nothing to undo. lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; @@ -293,7 +299,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 // Scale minimum padding absorption by how much of it is visible. // Fully visible → full absorption; fully off-screen → no absorption. -@@ -75,7 +104,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -75,7 +106,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); @@ -302,7 +308,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -88,30 +117,30 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -88,30 +119,30 @@ function useChatKeyboard(scrollViewRef, options) { } // persistent mode: when keyboard shrinks, clamp to valid range @@ -339,7 +345,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 } return; } -@@ -120,34 +149,60 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -120,34 +151,62 @@ 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. @@ -354,9 +360,11 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 + // keyboard height past the top inset. + const focused = input.value; + const viewport = !inverted && e.height > 0 && focused !== null && focused.target === e.target && scrollViewTarget.value !== null && focused.parentScrollViewTarget === scrollViewTarget.value ? (0, _reactNativeReanimated.measure)(scrollViewRef) : null; ++ // The feed rests no higher than its full leading inset. ++ const startInset = contentInsetTop + adjustedStartInsetCompensation; + if (focused !== null && viewport !== null) { -+ const delta = (0, _helpers.getFocusedInputScrollDelta)(focused.layout.absoluteY - viewport.pageY, focused.layout.height, adjustedStartInsetCompensation, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); -+ const target = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); ++ const delta = (0, _helpers.getFocusedInputScrollDelta)(focused.layout.absoluteY - viewport.pageY, focused.layout.height, obscuredStartInset ?? startInset, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); ++ const target = Math.max(-startInset, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); + inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); + contentOffsetY.value = target; + return; @@ -364,7 +372,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 + + // Content that does not lift stays put, except that a focused-input + // shift is undone whatever the lift behavior. -+ const heldScroll = prevInputShift === null ? scroll.value : Math.max(-adjustedStartInsetCompensation, Math.min(relativeScroll, size.value.height - layout.value.height + actualTotalPadding)); ++ const heldScroll = prevInputShift === null ? scroll.value : Math.max(-startInset, Math.min(relativeScroll, size.value.height - layout.value.height + actualTotalPadding)); if (!(0, _helpers.shouldShiftContent)(keyboardLiftBehavior, atEnd)) { // Preserve current scroll position so animated props // don't re-apply a stale contentOffset when padding changes @@ -408,7 +416,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 }, onEnd: e => { "worklet"; -@@ -156,9 +211,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -156,9 +215,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); @@ -416,7 +424,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..867585fbfb53f62d08dc298a4c342fb4 padding.value = effective; } - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); -+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation, contentInsetTop, obscuredStartInset]); return { padding, currentHeight, @@ -523,7 +531,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..832cc0f101bdbfc47663c20b1f1af7d5d8d88b7a 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js +++ b/lib/module/components/KeyboardChatScrollView/index.js @@ -1,9 +1,9 @@ @@ -539,12 +547,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, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -552,16 +561,18 @@ index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1 onEndVisible, ...rest }, ref) => { -@@ -47,6 +50,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -47,6 +51,10 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ contentInset: rest.contentInset, ++ 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 +62,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -570,7 +581,7 @@ index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1 scroll, layout, size, -@@ -65,7 +72,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -65,7 +75,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ useFrozenPadding({ freeze: freezeSV, offset, @@ -580,7 +591,7 @@ index e0c83918d9569e2ff541e45d31460121c73ec225..724c62e2892f75f6bb53844b84c1ade1 }); useEndVisible({ scroll, -@@ -84,10 +92,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -84,10 +95,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))); @@ -656,7 +667,7 @@ index 295e2219b560519b8d033968b5105d1da3e77315..20db412fd3eee6cf9c0da5c93c75abeb //# 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..dfb84ec97626dc81e8ed78f11c8f23a84b95e362 100644 +index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346a32554a6 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -1,7 +1,9 @@ @@ -672,18 +683,21 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 /** * Hook that manages keyboard-driven scrolling for chat-style scroll views. * Calculates padding (extra scrollable space) and content shift values, -@@ -25,7 +27,9 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -25,8 +27,12 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, ++ obscuredStartInset } = options; ++ const contentInsetTop = options.contentInset?.top ?? 0; const padding = useSharedValue(0); const currentHeight = useSharedValue(0); -@@ -35,13 +39,27 @@ function useChatKeyboard(scrollViewRef, options) { + const contentOffsetY = useSharedValue(options.initialContentOffsetY ?? 0); +@@ -35,13 +41,27 @@ 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); @@ -712,7 +726,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 useKeyboardHandler({ onStart: e => { "worklet"; -@@ -58,9 +76,20 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -58,9 +78,20 @@ function useChatKeyboard(scrollViewRef, options) { // The frozen transition didn't shift the content: nothing to undo. lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; @@ -734,7 +748,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 // Scale minimum padding absorption by how much of it is visible. // Fully visible → full absorption; fully off-screen → no absorption. -@@ -68,7 +97,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -68,7 +99,7 @@ function useChatKeyboard(scrollViewRef, options) { const visiblePadding = visibleFraction * blankSpace.value; const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); @@ -743,7 +757,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -81,30 +110,30 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -81,30 +112,30 @@ function useChatKeyboard(scrollViewRef, options) { } // persistent mode: when keyboard shrinks, clamp to valid range @@ -780,7 +794,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 } return; } -@@ -113,34 +142,60 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -113,34 +144,62 @@ 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. @@ -795,9 +809,11 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 + // keyboard height past the top inset. + const focused = input.value; + const viewport = !inverted && e.height > 0 && focused !== null && focused.target === e.target && scrollViewTarget.value !== null && focused.parentScrollViewTarget === scrollViewTarget.value ? measure(scrollViewRef) : null; ++ // The feed rests no higher than its full leading inset. ++ const startInset = contentInsetTop + adjustedStartInsetCompensation; + if (focused !== null && viewport !== null) { -+ const delta = getFocusedInputScrollDelta(focused.layout.absoluteY - viewport.pageY, focused.layout.height, adjustedStartInsetCompensation, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); -+ const target = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); ++ const delta = getFocusedInputScrollDelta(focused.layout.absoluteY - viewport.pageY, focused.layout.height, obscuredStartInset ?? startInset, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); ++ const target = Math.max(-startInset, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); + inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); + contentOffsetY.value = target; + return; @@ -805,7 +821,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 + + // Content that does not lift stays put, except that a focused-input + // shift is undone whatever the lift behavior. -+ const heldScroll = prevInputShift === null ? scroll.value : Math.max(-adjustedStartInsetCompensation, Math.min(relativeScroll, size.value.height - layout.value.height + actualTotalPadding)); ++ const heldScroll = prevInputShift === null ? scroll.value : Math.max(-startInset, Math.min(relativeScroll, size.value.height - layout.value.height + actualTotalPadding)); if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) { // Preserve current scroll position so animated props // don't re-apply a stale contentOffset when padding changes @@ -849,7 +865,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 }, onEnd: e => { "worklet"; -@@ -149,9 +204,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -149,9 +208,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); @@ -857,7 +873,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..dfb84ec97626dc81e8ed78f11c8f23a8 padding.value = effective; } - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); -+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation, contentInsetTop, obscuredStartInset]); return { padding, currentHeight, @@ -965,10 +981,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..d7e5952e20328b261f26342d6a454515e7fdf7e3 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; @@ -976,6 +992,11 @@ index eba1514c2d94ae42c024da3882475567a8e8260b..6597d80df11f973ceb2f52c30ee0ece8 + adjustedInsetCompensation?: number; + /** Leading inset UIKit adds outside raw contentInset. Offset math only. */ + adjustedStartInsetCompensation?: number; ++ /** ++ * Height of the viewport's top edge covered by an overlaid header. A focused ++ * input inside the list is kept below it. Defaults to the top content inset. ++ */ ++ obscuredStartInset?: number; /** * Fires whenever the effective content inset changes — the static `contentInset` * prop combined with the dynamic keyboard-driven padding. @@ -1008,10 +1029,16 @@ index 2b51cc4737b4a05b5396da31e67e54177f43c498..69307a2150e3da94bbfb45da0d5e57fe + */ +export declare const getFocusedInputScrollDelta: (inputTop: number, inputHeight: number, visibleTop: number, visibleBottom: 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..73c0b87689ec028313f3d6c01d94eaa7ca85bd83 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 = { +@@ -1,4 +1,4 @@ +-import type { LayoutChangeEvent } from "react-native"; ++import type { Insets, LayoutChangeEvent } from "react-native"; + import type { SharedValue } from "react-native-reanimated"; + type KeyboardLiftBehavior = "always" | "whenAtEnd" | "persistent" | "never"; + type UseChatKeyboardOptions = { +@@ -11,6 +11,14 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -1019,6 +1046,10 @@ index 8d358bc7bb2c6e0a26e4c04810e371a7693edd84..911f3d90ce56de0f64a39b027d4fa147 + adjustedInsetCompensation: number; + /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */ + adjustedStartInsetCompensation: number; ++ /** Raw `contentInset` prop of the scroll view. */ ++ contentInset?: Insets; ++ /** Height of the viewport's top edge covered by an overlaid header. */ ++ obscuredStartInset?: number; }; type UseChatKeyboardReturn = { /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */ @@ -1057,7 +1088,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..c11e83f8ab5f93d6ef928b2f7e0128400de54a46 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx @@ -1,9 +1,13 @@ @@ -1077,12 +1108,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, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -1090,16 +1122,18 @@ index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974 onEndVisible, ...rest }, -@@ -64,6 +71,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -64,6 +72,10 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ contentInset: rest.contentInset, ++ obscuredStartInset, initialContentOffsetY: rest.contentOffset?.y, }); -@@ -72,6 +81,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -72,6 +84,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< extraContentPadding, keyboardPadding: padding, blankSpace, @@ -1108,7 +1142,7 @@ index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974 scroll, layout, size, -@@ -85,6 +96,7 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -85,6 +99,7 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< freeze: freezeSV, offset, padding, @@ -1116,7 +1150,7 @@ index 935bc71dabad6cd5c82bab77f64b5b6e1e5dc9b6..5306506e791c64e6c64abc91499c8974 }); useEndVisible({ -@@ -109,13 +121,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -109,13 +124,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< ), ); @@ -1163,10 +1197,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..c6c6937ff45ed857a7024a6007871d3a3988a1bc 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; @@ -1181,6 +1215,11 @@ index b7c98c8ede80f690c8c9ad60263efe3ebd5b73df..e1012a80d59ff172396ff17550cb8402 + adjustedInsetCompensation?: number; + /** Leading inset UIKit adds outside raw contentInset. Offset math only. */ + adjustedStartInsetCompensation?: number; ++ /** ++ * Height of the viewport's top edge covered by an overlaid header. A focused ++ * input inside the list is kept below it. Defaults to the top content inset. ++ */ ++ obscuredStartInset?: number; /** * Fires whenever the effective content inset changes — the static `contentInset` * prop combined with the dynamic keyboard-driven padding. @@ -1245,7 +1284,7 @@ index 37eacd10857cd70c51b3468c4b466e4959d0a415..9434cebf61cf380e964e35e687a8c9dd + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4bed5d41f93 100644 +index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed85078562d8d30c 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts @@ -1,11 +1,14 @@ @@ -1273,16 +1312,19 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be import type { AnimatedRef } from "react-native-reanimated"; import type Reanimated from "react-native-reanimated"; -@@ -43,6 +47,8 @@ function useChatKeyboard( +@@ -43,7 +47,11 @@ function useChatKeyboard( offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ obscuredStartInset, } = options; ++ const contentInsetTop = options.contentInset?.top ?? 0; const padding = useSharedValue(0); -@@ -53,15 +59,31 @@ function useChatKeyboard( + const currentHeight = useSharedValue(0); +@@ -53,15 +61,31 @@ function useChatKeyboard( const isInteractiveDismissal = useSharedValue(false); // Set by a transition that ran while frozen and so applied no content shift. const lastTransitionFrozen = useSharedValue(false); @@ -1315,7 +1357,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be useKeyboardHandler( { onStart: (e) => { -@@ -82,16 +104,30 @@ function useChatKeyboard( +@@ -82,16 +106,30 @@ function useChatKeyboard( // The frozen transition didn't shift the content: nothing to undo. lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; @@ -1347,7 +1389,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be layout.value.height, size.value.height, inverted, -@@ -115,10 +151,12 @@ function useChatKeyboard( +@@ -115,10 +153,12 @@ function useChatKeyboard( effective, minimumPaddingAbsorbed, ); @@ -1364,7 +1406,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -134,7 +172,8 @@ function useChatKeyboard( +@@ -134,7 +174,8 @@ function useChatKeyboard( // persistent mode: when keyboard shrinks, clamp to valid range if ( keyboardLiftBehavior === "persistent" && @@ -1374,7 +1416,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be ) { padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; -@@ -152,11 +191,11 @@ function useChatKeyboard( +@@ -152,11 +193,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -1388,7 +1430,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be Math.min(scroll.value, maxScroll), ); } -@@ -169,7 +208,8 @@ function useChatKeyboard( +@@ -169,7 +210,8 @@ function useChatKeyboard( if ( keyboardLiftBehavior === "never" && effective < padding.value && @@ -1398,7 +1440,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be ) { padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; -@@ -187,11 +227,11 @@ function useChatKeyboard( +@@ -187,11 +229,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -1412,7 +1454,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be Math.min(scroll.value, maxScroll), ); } -@@ -203,10 +243,9 @@ function useChatKeyboard( +@@ -203,10 +245,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. @@ -1426,7 +1468,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; -@@ -214,10 +253,63 @@ function useChatKeyboard( +@@ -214,10 +255,66 @@ function useChatKeyboard( padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; @@ -1444,16 +1486,19 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be + ? measure(scrollViewRef) + : null; + ++ // The feed rests no higher than its full leading inset. ++ const startInset = contentInsetTop + adjustedStartInsetCompensation; ++ + if (focused !== null && viewport !== null) { + const delta = getFocusedInputScrollDelta( + focused.layout.absoluteY - viewport.pageY, + focused.layout.height, -+ adjustedStartInsetCompensation, ++ obscuredStartInset ?? startInset, + layout.value.height - + (effective + extraContentPadding.value + adjustedInsetCompensation), + ); + const target = Math.max( -+ -adjustedStartInsetCompensation, ++ -startInset, + Math.min( + scroll.value + delta, + size.value.height - layout.value.height + actualTotalPadding, @@ -1476,7 +1521,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be + prevInputShift === null + ? scroll.value + : Math.max( -+ -adjustedStartInsetCompensation, ++ -startInset, + Math.min( + relativeScroll, + size.value.height - layout.value.height + actualTotalPadding, @@ -1491,7 +1536,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be return; } -@@ -229,11 +321,12 @@ function useChatKeyboard( +@@ -229,11 +326,12 @@ function useChatKeyboard( minimumPaddingAbsorbed > 0 && effective > 0 ) { @@ -1505,7 +1550,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be contentOffsetY.value = computeIOSContentOffset( relativeScroll, scrollEffective, -@@ -241,17 +334,36 @@ function useChatKeyboard( +@@ -241,17 +339,36 @@ function useChatKeyboard( layout.value.height, inverted, actualTotalPadding, @@ -1546,7 +1591,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be }, onEnd: (e) => { "worklet"; -@@ -266,10 +378,18 @@ function useChatKeyboard( +@@ -266,10 +383,20 @@ function useChatKeyboard( offset, ); @@ -1562,15 +1607,23 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..a2178dbab556069f28677a67a1aae4be + extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, ++ contentInsetTop, ++ obscuredStartInset, + ], ); return { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -index f89b7c81f4ac0e85e98b790c75309b7203be1fb2..02e0e196f2f6c3222317429716e50da487ac45e3 100644 +index f89b7c81f4ac0e85e98b790c75309b7203be1fb2..7efa7557c1b61c371e777fbc097053218e248aba 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -@@ -13,6 +13,10 @@ type UseChatKeyboardOptions = { +@@ -1,4 +1,4 @@ +-import type { LayoutChangeEvent } from "react-native"; ++import type { Insets, LayoutChangeEvent } from "react-native"; + import type { SharedValue } from "react-native-reanimated"; + + type KeyboardLiftBehavior = "always" | "whenAtEnd" | "persistent" | "never"; +@@ -13,6 +13,14 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -1578,6 +1631,10 @@ index f89b7c81f4ac0e85e98b790c75309b7203be1fb2..02e0e196f2f6c3222317429716e50da4 + adjustedInsetCompensation: number; + /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */ + adjustedStartInsetCompensation: number; ++ /** Raw `contentInset` prop of the scroll view. */ ++ contentInset?: Insets; ++ /** Height of the viewport's top edge covered by an overlaid header. */ ++ obscuredStartInset?: number; }; type UseChatKeyboardReturn = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ed8030653a91..d9cfb32fdf67c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,7 +382,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: afad7e278af9065091c7b0e24cfd504d20efde6286a078decaa1517399e6abf2 + react-native-keyboard-controller@1.23.0: 25d5620dcb8d96bba16717b7d3e7af284e583989d9fb5930124d7a5ed37cf87c react-native-pdf-jsi@6.1.0: d29e6b69813548de641a09db705dd94823d3f96832821ae9c3f6a196dd15cb68 react-native-reanimated@4.7.1: 95ee26e5fcab5f50bc4b4c47b3f7e6278a0b20850205efb9f6ee7e70a22d2b59 react-native-svg@15.16.1: 110f9243ffad03654ce40dd1cdb09f01af16b8fa2ccb2b2cd3d62e682c180399 @@ -738,7 +738,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=afad7e278af9065091c7b0e24cfd504d20efde6286a078decaa1517399e6abf2)(3ba24c675da40640708746e9087ede39) + version: 1.23.0(patch_hash=25d5620dcb8d96bba16717b7d3e7af284e583989d9fb5930124d7a5ed37cf87c)(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) @@ -21015,7 +21015,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=afad7e278af9065091c7b0e24cfd504d20efde6286a078decaa1517399e6abf2)(3ba24c675da40640708746e9087ede39): + react-native-keyboard-controller@1.23.0(patch_hash=25d5620dcb8d96bba16717b7d3e7af284e583989d9fb5930124d7a5ed37cf87c)(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) From ab0ef3176ea750dfc82012d0fea7aacca0b3a2b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 20:47:23 +0000 Subject: [PATCH 4/6] test(mobile): trim feed-input keyboard header matrix AI-assisted; verified with the tests and tools listed above. --- .../keyboard/chatKeyboardFeedInput.test.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx index 508e790f2bf26..a6bc930c08853 100644 --- a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx +++ b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx @@ -143,16 +143,14 @@ it.each(liftBehaviors)( ); it.each([ - ...liftBehaviors.flatMap((behavior) => - [nativeInset(80), headerSpacer(80)].map((header) => ({ - name: `moves a partly hidden input below the header (${behavior}, ${header.contentInset ? "native inset" : "header spacer"})`, - behavior, - header, - restOffset: 1322, - inputTop: 60, - expected: [1302, 1322], - })), - ), + ...[nativeInset(80), headerSpacer(80)].map((header) => ({ + name: `moves a partly hidden input below the header (${header.contentInset ? "native inset" : "header spacer"})`, + behavior: "whenAtEnd" as const, + header, + restOffset: 1322, + inputTop: 60, + expected: [1302, 1322], + })), { name: "leaves a feed resting at its top inset alone for an input in view", behavior: "whenAtEnd" as const, From 20c000964b6ff3ab64a83a3cc12c30ef3d447ab8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 21:06:15 +0000 Subject: [PATCH 5/6] fix(mobile): a keyboard resize keeps a lifted feed input below the header Native re-reads the focused input's layout on focus and edits, not when the feed scrolls, so a second keyboard transition for the same input measured it at its pre-shift position and lifted it again under the header. Remember the scroll offset each layout was read at and correct a reused one by how far the feed has scrolled since. AI-assisted; verified with the tests and tools listed above. --- .../keyboard/chatKeyboardFeedInput.test.tsx | 35 +++--- ...ct-native-keyboard-controller@1.23.0.patch | 101 ++++++++++++------ pnpm-lock.yaml | 6 +- 3 files changed, 96 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx index a6bc930c08853..ed0a6b213d79d 100644 --- a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx +++ b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx @@ -99,19 +99,20 @@ function renderFeed( act(() => root.render()); // Native applies each target offset before the next keyboard event. - const transition = (height: number) => { - native.input.value = - focused === INPUT_TAG - ? { - target: INPUT_TAG, - parentScrollViewTarget: FEED_TAG, - layout: { absoluteY: inputTop + restOffset - native.scroll.value, height: 40 }, - } - : { - target: COMPOSER_TAG, - parentScrollViewTarget: -1, - layout: { absoluteY: 600, height: 40 }, - }; + const transition = (height: number, refreshInput = true) => { + if (refreshInput) + native.input.value = + focused === INPUT_TAG + ? { + target: INPUT_TAG, + parentScrollViewTarget: FEED_TAG, + layout: { absoluteY: inputTop + restOffset - native.scroll.value, height: 40 }, + } + : { + target: COMPOSER_TAG, + parentScrollViewTarget: -1, + layout: { absoluteY: 600, height: 40 }, + }; const event = { height, duration: 250, progress: height > 0 ? 1 : 0, target: focused }; native.handler?.onStart(event); native.scroll.value = contentOffsetY?.value ?? Number.NaN; @@ -123,6 +124,8 @@ function renderFeed( focused = target; return transition(300); }, + // Native refreshes the focused input's layout on focus and edits, not on scroll. + resize: (height: number) => transition(height, false), hide: () => transition(0), setFrozen: (frozen: boolean) => { freeze.value = frozen; @@ -183,6 +186,12 @@ it("lifts for the composer and returns to the end after a feed input moved the f expect([feed.open(), feed.open(COMPOSER_TAG), feed.hide()]).toEqual([1282, 1622, 1322]); }); +it("keeps a lifted feed input in view when the keyboard resizes before native re-measures it", () => { + using feed = renderFeed(500, 500); + + expect([feed.open(), feed.resize(340), feed.hide()]).toEqual([742, 782, 500]); +}); + it("keeps the feed where it rests after the keyboard closed while frozen", () => { using feed = renderFeed(500, 500); diff --git a/patches/react-native-keyboard-controller@1.23.0.patch b/patches/react-native-keyboard-controller@1.23.0.patch index cf55104c60bc9..09ae8af74b0f6 100644 --- a/patches/react-native-keyboard-controller@1.23.0.patch +++ b/patches/react-native-keyboard-controller@1.23.0.patch @@ -220,7 +220,7 @@ index 4d33b3fe426070cf312fc493ce902b2c24d853ea..91c1f1b866b23b8bfa3f4ba3ec4a3176 //# 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..00645c55df611fb9c667ff4bc2721c98455f7450 100644 +index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..8e40a4563a2ed8d77f1830e18f8d664d4cb2cf6c 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -4,8 +4,10 @@ Object.defineProperty(exports, "__esModule", { @@ -248,7 +248,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98 const padding = (0, _reactNativeReanimated.useSharedValue)(0); const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); const contentOffsetY = (0, _reactNativeReanimated.useSharedValue)(options.initialContentOffsetY ?? 0); -@@ -42,13 +48,27 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -42,13 +48,30 @@ 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); @@ -258,6 +258,9 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98 + // this instead of inferring a lift from the padding. + const inputShift = (0, _reactNativeReanimated.useSharedValue)(null); + const lifted = (0, _reactNativeReanimated.useSharedValue)(false); ++ // Native re-reads the focused input's layout on focus and edits, not when ++ // the list scrolls; the scroll offset a layout was read at corrects it. ++ const measuredInput = (0, _reactNativeReanimated.useSharedValue)(null); + const { + input + } = (0, _hooks.useReanimatedFocusedInput)(); @@ -277,7 +280,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98 (0, _hooks.useKeyboardHandler)({ onStart: e => { "worklet"; -@@ -65,9 +85,20 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -65,9 +88,20 @@ function useChatKeyboard(scrollViewRef, options) { // The frozen transition didn't shift the content: nothing to undo. lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; @@ -299,7 +302,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98 // Scale minimum padding absorption by how much of it is visible. // Fully visible → full absorption; fully off-screen → no absorption. -@@ -75,7 +106,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -75,7 +109,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); @@ -308,7 +311,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -88,30 +119,30 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -88,30 +122,30 @@ function useChatKeyboard(scrollViewRef, options) { } // persistent mode: when keyboard shrinks, clamp to valid range @@ -345,7 +348,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98 } return; } -@@ -120,34 +151,62 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -120,34 +154,69 @@ 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. @@ -363,7 +366,14 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98 + // The feed rests no higher than its full leading inset. + const startInset = contentInsetTop + adjustedStartInsetCompensation; + if (focused !== null && viewport !== null) { -+ const delta = (0, _helpers.getFocusedInputScrollDelta)(focused.layout.absoluteY - viewport.pageY, focused.layout.height, obscuredStartInset ?? startInset, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); ++ const last = measuredInput.value; ++ const scrolledSinceMeasure = last !== null && last.target === focused.target && last.absoluteY === focused.layout.absoluteY ? scroll.value - last.scroll : 0; ++ measuredInput.value = { ++ target: focused.target, ++ absoluteY: focused.layout.absoluteY, ++ scroll: scroll.value - scrolledSinceMeasure ++ }; ++ const delta = (0, _helpers.getFocusedInputScrollDelta)(focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure, focused.layout.height, obscuredStartInset ?? startInset, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); + const target = Math.max(-startInset, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); + inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); + contentOffsetY.value = target; @@ -416,7 +426,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..00645c55df611fb9c667ff4bc2721c98 }, onEnd: e => { "worklet"; -@@ -156,9 +215,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -156,9 +225,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); @@ -667,7 +677,7 @@ index 295e2219b560519b8d033968b5105d1da3e77315..20db412fd3eee6cf9c0da5c93c75abeb //# 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..346beb164264388b908b2a04c95eb346a32554a6 100644 +index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..89adfde1fda0218f4f75e0744edddf2d3f4af05f 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -1,7 +1,9 @@ @@ -697,7 +707,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346 const padding = useSharedValue(0); const currentHeight = useSharedValue(0); const contentOffsetY = useSharedValue(options.initialContentOffsetY ?? 0); -@@ -35,13 +41,27 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -35,13 +41,30 @@ 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); @@ -707,6 +717,9 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346 + // this instead of inferring a lift from the padding. + const inputShift = useSharedValue(null); + const lifted = useSharedValue(false); ++ // Native re-reads the focused input's layout on focus and edits, not when ++ // the list scrolls; the scroll offset a layout was read at corrects it. ++ const measuredInput = useSharedValue(null); + const { + input + } = useReanimatedFocusedInput(); @@ -726,7 +739,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346 useKeyboardHandler({ onStart: e => { "worklet"; -@@ -58,9 +78,20 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -58,9 +81,20 @@ function useChatKeyboard(scrollViewRef, options) { // The frozen transition didn't shift the content: nothing to undo. lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; @@ -748,7 +761,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346 // Scale minimum padding absorption by how much of it is visible. // Fully visible → full absorption; fully off-screen → no absorption. -@@ -68,7 +99,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -68,7 +102,7 @@ function useChatKeyboard(scrollViewRef, options) { const visiblePadding = visibleFraction * blankSpace.value; const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); @@ -757,7 +770,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -81,30 +112,30 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -81,30 +115,30 @@ function useChatKeyboard(scrollViewRef, options) { } // persistent mode: when keyboard shrinks, clamp to valid range @@ -794,7 +807,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346 } return; } -@@ -113,34 +144,62 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -113,34 +147,69 @@ 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. @@ -812,7 +825,14 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346 + // The feed rests no higher than its full leading inset. + const startInset = contentInsetTop + adjustedStartInsetCompensation; + if (focused !== null && viewport !== null) { -+ const delta = getFocusedInputScrollDelta(focused.layout.absoluteY - viewport.pageY, focused.layout.height, obscuredStartInset ?? startInset, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); ++ const last = measuredInput.value; ++ const scrolledSinceMeasure = last !== null && last.target === focused.target && last.absoluteY === focused.layout.absoluteY ? scroll.value - last.scroll : 0; ++ measuredInput.value = { ++ target: focused.target, ++ absoluteY: focused.layout.absoluteY, ++ scroll: scroll.value - scrolledSinceMeasure ++ }; ++ const delta = getFocusedInputScrollDelta(focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure, focused.layout.height, obscuredStartInset ?? startInset, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); + const target = Math.max(-startInset, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); + inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); + contentOffsetY.value = target; @@ -865,7 +885,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..346beb164264388b908b2a04c95eb346 }, onEnd: e => { "worklet"; -@@ -149,9 +208,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -149,9 +218,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); @@ -1284,7 +1304,7 @@ index 37eacd10857cd70c51b3468c4b466e4959d0a415..9434cebf61cf380e964e35e687a8c9dd + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed85078562d8d30c 100644 +index f67fc0d1706bc7ab9169da533514a961799a5551..3307df7b9667168553e62d6e4a10d786cd41092f 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts @@ -1,11 +1,14 @@ @@ -1324,7 +1344,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 const padding = useSharedValue(0); const currentHeight = useSharedValue(0); -@@ -53,15 +61,31 @@ function useChatKeyboard( +@@ -53,15 +61,38 @@ function useChatKeyboard( const isInteractiveDismissal = useSharedValue(false); // Set by a transition that ran while frozen and so applied no content shift. const lastTransitionFrozen = useSharedValue(false); @@ -1334,6 +1354,13 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 + // this instead of inferring a lift from the padding. + const inputShift = useSharedValue(null); + const lifted = useSharedValue(false); ++ // Native re-reads the focused input's layout on focus and edits, not when ++ // the list scrolls; the scroll offset a layout was read at corrects it. ++ const measuredInput = useSharedValue<{ ++ target: number; ++ absoluteY: number; ++ scroll: number; ++ } | null>(null); + const { input } = useReanimatedFocusedInput(); const { @@ -1357,7 +1384,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 useKeyboardHandler( { onStart: (e) => { -@@ -82,16 +106,30 @@ function useChatKeyboard( +@@ -82,16 +113,30 @@ function useChatKeyboard( // The frozen transition didn't shift the content: nothing to undo. lastTransitionFrozen.value = false; prevAbsorption.value = padding.value; @@ -1389,7 +1416,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 layout.value.height, size.value.height, inverted, -@@ -115,10 +153,12 @@ function useChatKeyboard( +@@ -115,10 +160,12 @@ function useChatKeyboard( effective, minimumPaddingAbsorbed, ); @@ -1406,7 +1433,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 // `onInteractive` owns the native scroll offset. When its final // keyboardWillHide event arrives, consume the marker so this -@@ -134,7 +174,8 @@ function useChatKeyboard( +@@ -134,7 +181,8 @@ function useChatKeyboard( // persistent mode: when keyboard shrinks, clamp to valid range if ( keyboardLiftBehavior === "persistent" && @@ -1416,7 +1443,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 ) { padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; -@@ -152,11 +193,11 @@ function useChatKeyboard( +@@ -152,11 +200,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -1430,7 +1457,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 Math.min(scroll.value, maxScroll), ); } -@@ -169,7 +210,8 @@ function useChatKeyboard( +@@ -169,7 +217,8 @@ function useChatKeyboard( if ( keyboardLiftBehavior === "never" && effective < padding.value && @@ -1440,7 +1467,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 ) { padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; -@@ -187,11 +229,11 @@ function useChatKeyboard( +@@ -187,11 +236,11 @@ function useChatKeyboard( } else { const maxScroll = Math.max( size.value.height - layout.value.height + actualTotalPadding, @@ -1454,7 +1481,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 Math.min(scroll.value, maxScroll), ); } -@@ -203,10 +245,9 @@ function useChatKeyboard( +@@ -203,10 +252,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. @@ -1468,7 +1495,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; -@@ -214,10 +255,66 @@ function useChatKeyboard( +@@ -214,10 +262,80 @@ function useChatKeyboard( padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; @@ -1490,8 +1517,22 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 + const startInset = contentInsetTop + adjustedStartInsetCompensation; + + if (focused !== null && viewport !== null) { ++ const last = measuredInput.value; ++ const scrolledSinceMeasure = ++ last !== null && ++ last.target === focused.target && ++ last.absoluteY === focused.layout.absoluteY ++ ? scroll.value - last.scroll ++ : 0; ++ ++ measuredInput.value = { ++ target: focused.target, ++ absoluteY: focused.layout.absoluteY, ++ scroll: scroll.value - scrolledSinceMeasure, ++ }; ++ + const delta = getFocusedInputScrollDelta( -+ focused.layout.absoluteY - viewport.pageY, ++ focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure, + focused.layout.height, + obscuredStartInset ?? startInset, + layout.value.height - @@ -1536,7 +1577,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 return; } -@@ -229,11 +326,12 @@ function useChatKeyboard( +@@ -229,11 +347,12 @@ function useChatKeyboard( minimumPaddingAbsorbed > 0 && effective > 0 ) { @@ -1550,7 +1591,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 contentOffsetY.value = computeIOSContentOffset( relativeScroll, scrollEffective, -@@ -241,17 +339,36 @@ function useChatKeyboard( +@@ -241,17 +360,36 @@ function useChatKeyboard( layout.value.height, inverted, actualTotalPadding, @@ -1591,7 +1632,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..168cb13672ff3fd0973b8ba8ed850785 }, onEnd: (e) => { "worklet"; -@@ -266,10 +383,20 @@ function useChatKeyboard( +@@ -266,10 +404,20 @@ function useChatKeyboard( offset, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7933235c1cd13..7237459abe793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,7 +382,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: 25d5620dcb8d96bba16717b7d3e7af284e583989d9fb5930124d7a5ed37cf87c + react-native-keyboard-controller@1.23.0: a4b9236516302754dc167723f70477f934184fd426d0dc238c56597a1eec1d23 react-native-pdf-jsi@6.1.0: d29e6b69813548de641a09db705dd94823d3f96832821ae9c3f6a196dd15cb68 react-native-reanimated@4.7.1: 95ee26e5fcab5f50bc4b4c47b3f7e6278a0b20850205efb9f6ee7e70a22d2b59 react-native-svg@15.16.1: 110f9243ffad03654ce40dd1cdb09f01af16b8fa2ccb2b2cd3d62e682c180399 @@ -738,7 +738,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=25d5620dcb8d96bba16717b7d3e7af284e583989d9fb5930124d7a5ed37cf87c)(3ba24c675da40640708746e9087ede39) + version: 1.23.0(patch_hash=a4b9236516302754dc167723f70477f934184fd426d0dc238c56597a1eec1d23)(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) @@ -21015,7 +21015,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=25d5620dcb8d96bba16717b7d3e7af284e583989d9fb5930124d7a5ed37cf87c)(3ba24c675da40640708746e9087ede39): + react-native-keyboard-controller@1.23.0(patch_hash=a4b9236516302754dc167723f70477f934184fd426d0dc238c56597a1eec1d23)(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) From 514956e7a761e36d6858c99887a699429721a08e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 21:27:02 +0000 Subject: [PATCH 6/6] fix(mobile): a feed input at the end lifts its card's actions above the keyboard The focused-input branch replaced the normal lift with a minimal reveal, so an input on a pending request card at the end of the feed left the card's actions under the composer. Keep the normal lift where content would lift, capped so the input never passes under the header, and reveal just the input elsewhere. AI-assisted; verified with the tests and tools listed above. --- .../keyboard/chatKeyboardFeedInput.test.tsx | 25 +++-- ...ct-native-keyboard-controller@1.23.0.patch | 95 ++++++++++++------- pnpm-lock.yaml | 6 +- 3 files changed, 81 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx index ed0a6b213d79d..22417609b26ea 100644 --- a/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx +++ b/apps/mobile/src/features/keyboard/chatKeyboardFeedInput.test.tsx @@ -134,14 +134,18 @@ function renderFeed( }; } -const liftBehaviors: LiftBehavior[] = ["whenAtEnd", "always", "never", "persistent"]; - -it.each(liftBehaviors)( - "returns the feed to its offset when hiding the keyboard for a covered input away from the end (%s)", - (behavior) => { +// Lifting behaviors lift by the keyboard; the others reveal just the input. +it.each([ + { behavior: "whenAtEnd" as const, opened: 742 }, + { behavior: "never" as const, opened: 742 }, + { behavior: "always" as const, opened: 800 }, + { behavior: "persistent" as const, opened: 800 }, +])( + "returns the feed to its offset when hiding the keyboard for a covered input away from the end ($behavior)", + ({ behavior, opened }) => { using feed = renderFeed(500, 500, behavior); - expect([feed.open(), feed.hide()]).toEqual([742, 500]); + expect([feed.open(), feed.hide()]).toEqual([opened, 500]); }, ); @@ -168,6 +172,13 @@ it.each([ expect([feed.open(), feed.hide()]).toEqual(row.expected); }); +it("lifts the whole end above the keyboard for an input on a card at the end", () => { + // Card actions sit below the input and must stay above the composer. + using feed = renderFeed(1402, 468); + + expect([feed.open(), feed.hide()]).toEqual([1702, 1402]); +}); + it("returns the feed to its offset when focus moves from an unlifted composer to a feed input", () => { using feed = renderFeed(500, 500); @@ -177,7 +188,7 @@ it("returns the feed to its offset when focus moves from an unlifted composer to it("undoes the feed input shift before lifting for the composer at the end", () => { using feed = renderFeed(1322, 500); - expect([feed.open(), feed.open(COMPOSER_TAG), feed.hide()]).toEqual([1564, 1622, 1322]); + expect([feed.open(), feed.open(COMPOSER_TAG), feed.hide()]).toEqual([1622, 1622, 1322]); }); it("lifts for the composer and returns to the end after a feed input moved the feed down", () => { diff --git a/patches/react-native-keyboard-controller@1.23.0.patch b/patches/react-native-keyboard-controller@1.23.0.patch index 09ae8af74b0f6..e02594d968014 100644 --- a/patches/react-native-keyboard-controller@1.23.0.patch +++ b/patches/react-native-keyboard-controller@1.23.0.patch @@ -220,7 +220,7 @@ index 4d33b3fe426070cf312fc493ce902b2c24d853ea..91c1f1b866b23b8bfa3f4ba3ec4a3176 //# 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..8e40a4563a2ed8d77f1830e18f8d664d4cb2cf6c 100644 +index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..c5f152ca61c721da622db7e415a601a823515457 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -4,8 +4,10 @@ Object.defineProperty(exports, "__esModule", { @@ -348,7 +348,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..8e40a4563a2ed8d77f1830e18f8d664d } return; } -@@ -120,34 +154,69 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -120,34 +154,71 @@ 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. @@ -358,9 +358,9 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..8e40a4563a2ed8d77f1830e18f8d664d padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; + -+ // An input inside the list scrolls only as far as it needs to stay -+ // in view, whatever the lift behavior, instead of riding the full -+ // keyboard height past the top inset. ++ // An input inside the list lifts like the rest of the content, but ++ // never past the top inset; where content would not lift, it scrolls ++ // only as far as it needs to stay in view. + const focused = input.value; + const viewport = !inverted && e.height > 0 && focused !== null && focused.target === e.target && scrollViewTarget.value !== null && focused.parentScrollViewTarget === scrollViewTarget.value ? (0, _reactNativeReanimated.measure)(scrollViewRef) : null; + // The feed rests no higher than its full leading inset. @@ -373,8 +373,10 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..8e40a4563a2ed8d77f1830e18f8d664d + absoluteY: focused.layout.absoluteY, + scroll: scroll.value - scrolledSinceMeasure + }; -+ const delta = (0, _helpers.getFocusedInputScrollDelta)(focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure, focused.layout.height, obscuredStartInset ?? startInset, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); -+ const target = Math.max(-startInset, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); ++ const inputTop = focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure; ++ const visibleTop = obscuredStartInset ?? startInset; ++ const lifts = (0, _helpers.shouldShiftContent)(keyboardLiftBehavior, atEnd) && !(scrollEffective === 0 && minimumPaddingAbsorbed > 0); ++ const target = Math.max(-startInset, lifts ? Math.min((0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation), scroll.value + inputTop - visibleTop) : Math.min(scroll.value + (0, _helpers.getFocusedInputScrollDelta)(inputTop, focused.layout.height, visibleTop, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)), size.value.height - layout.value.height + actualTotalPadding)); + inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); + contentOffsetY.value = target; + return; @@ -426,7 +428,7 @@ index 8ac59042f670460fa822b1a36d971e4c0d5abe2f..8e40a4563a2ed8d77f1830e18f8d664d }, onEnd: e => { "worklet"; -@@ -156,9 +225,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -156,9 +227,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); @@ -677,7 +679,7 @@ index 295e2219b560519b8d033968b5105d1da3e77315..20db412fd3eee6cf9c0da5c93c75abeb //# 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..89adfde1fda0218f4f75e0744edddf2d3f4af05f 100644 +index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..267890b8af7dfb9b0d356d815b6a014024380a75 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -1,7 +1,9 @@ @@ -807,7 +809,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..89adfde1fda0218f4f75e0744edddf2d } return; } -@@ -113,34 +147,69 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -113,34 +147,71 @@ 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. @@ -817,9 +819,9 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..89adfde1fda0218f4f75e0744edddf2d padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; + -+ // An input inside the list scrolls only as far as it needs to stay -+ // in view, whatever the lift behavior, instead of riding the full -+ // keyboard height past the top inset. ++ // An input inside the list lifts like the rest of the content, but ++ // never past the top inset; where content would not lift, it scrolls ++ // only as far as it needs to stay in view. + const focused = input.value; + const viewport = !inverted && e.height > 0 && focused !== null && focused.target === e.target && scrollViewTarget.value !== null && focused.parentScrollViewTarget === scrollViewTarget.value ? measure(scrollViewRef) : null; + // The feed rests no higher than its full leading inset. @@ -832,8 +834,10 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..89adfde1fda0218f4f75e0744edddf2d + absoluteY: focused.layout.absoluteY, + scroll: scroll.value - scrolledSinceMeasure + }; -+ const delta = getFocusedInputScrollDelta(focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure, focused.layout.height, obscuredStartInset ?? startInset, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)); -+ const target = Math.max(-startInset, Math.min(scroll.value + delta, size.value.height - layout.value.height + actualTotalPadding)); ++ const inputTop = focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure; ++ const visibleTop = obscuredStartInset ?? startInset; ++ const lifts = shouldShiftContent(keyboardLiftBehavior, atEnd) && !(scrollEffective === 0 && minimumPaddingAbsorbed > 0); ++ const target = Math.max(-startInset, lifts ? Math.min(computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation), scroll.value + inputTop - visibleTop) : Math.min(scroll.value + getFocusedInputScrollDelta(inputTop, focused.layout.height, visibleTop, layout.value.height - (effective + extraContentPadding.value + adjustedInsetCompensation)), size.value.height - layout.value.height + actualTotalPadding)); + inputShift.value = target - (prevInputShift === null && !prevLifted ? scroll.value : relativeScroll); + contentOffsetY.value = target; + return; @@ -885,7 +889,7 @@ index 6a941e83d1c4a9c5cc57be4881ffe7121cd2b9a6..89adfde1fda0218f4f75e0744edddf2d }, onEnd: e => { "worklet"; -@@ -149,9 +218,10 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -149,9 +220,10 @@ function useChatKeyboard(scrollViewRef, options) { return; } const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); @@ -1304,7 +1308,7 @@ index 37eacd10857cd70c51b3468c4b466e4959d0a415..9434cebf61cf380e964e35e687a8c9dd + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index f67fc0d1706bc7ab9169da533514a961799a5551..3307df7b9667168553e62d6e4a10d786cd41092f 100644 +index f67fc0d1706bc7ab9169da533514a961799a5551..c345e25a691135760fb6b2a4632dc9b9a7863784 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts @@ -1,11 +1,14 @@ @@ -1495,13 +1499,13 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..3307df7b9667168553e62d6e4a10d786 const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective; -@@ -214,10 +262,80 @@ function useChatKeyboard( +@@ -214,10 +262,101 @@ function useChatKeyboard( padding.value = effective; prevAbsorption.value = minimumPaddingAbsorbed; -+ // An input inside the list scrolls only as far as it needs to stay -+ // in view, whatever the lift behavior, instead of riding the full -+ // keyboard height past the top inset. ++ // An input inside the list lifts like the rest of the content, but ++ // never past the top inset; where content would not lift, it scrolls ++ // only as far as it needs to stay in view. + const focused = input.value; + const viewport = + !inverted && @@ -1531,19 +1535,40 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..3307df7b9667168553e62d6e4a10d786 + scroll: scroll.value - scrolledSinceMeasure, + }; + -+ const delta = getFocusedInputScrollDelta( -+ focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure, -+ focused.layout.height, -+ obscuredStartInset ?? startInset, -+ layout.value.height - -+ (effective + extraContentPadding.value + adjustedInsetCompensation), -+ ); ++ const inputTop = ++ focused.layout.absoluteY - viewport.pageY - scrolledSinceMeasure; ++ const visibleTop = obscuredStartInset ?? startInset; ++ const lifts = ++ shouldShiftContent(keyboardLiftBehavior, atEnd) && ++ !(scrollEffective === 0 && minimumPaddingAbsorbed > 0); + const target = Math.max( + -startInset, -+ Math.min( -+ scroll.value + delta, -+ size.value.height - layout.value.height + actualTotalPadding, -+ ), ++ lifts ++ ? Math.min( ++ computeIOSContentOffset( ++ relativeScroll, ++ scrollEffective, ++ size.value.height, ++ layout.value.height, ++ inverted, ++ actualTotalPadding, ++ adjustedStartInsetCompensation, ++ ), ++ scroll.value + inputTop - visibleTop, ++ ) ++ : Math.min( ++ scroll.value + ++ getFocusedInputScrollDelta( ++ inputTop, ++ focused.layout.height, ++ visibleTop, ++ layout.value.height - ++ (effective + ++ extraContentPadding.value + ++ adjustedInsetCompensation), ++ ), ++ size.value.height - layout.value.height + actualTotalPadding, ++ ), + ); + + inputShift.value = @@ -1577,7 +1602,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..3307df7b9667168553e62d6e4a10d786 return; } -@@ -229,11 +347,12 @@ function useChatKeyboard( +@@ -229,11 +368,12 @@ function useChatKeyboard( minimumPaddingAbsorbed > 0 && effective > 0 ) { @@ -1591,7 +1616,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..3307df7b9667168553e62d6e4a10d786 contentOffsetY.value = computeIOSContentOffset( relativeScroll, scrollEffective, -@@ -241,17 +360,36 @@ function useChatKeyboard( +@@ -241,17 +381,36 @@ function useChatKeyboard( layout.value.height, inverted, actualTotalPadding, @@ -1632,7 +1657,7 @@ index f67fc0d1706bc7ab9169da533514a961799a5551..3307df7b9667168553e62d6e4a10d786 }, onEnd: (e) => { "worklet"; -@@ -266,10 +404,20 @@ function useChatKeyboard( +@@ -266,10 +425,20 @@ function useChatKeyboard( offset, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7237459abe793..6eff391e5eb8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,7 +382,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: a4b9236516302754dc167723f70477f934184fd426d0dc238c56597a1eec1d23 + react-native-keyboard-controller@1.23.0: 238db71519fdea4ddb1dc9d0cc037e950b033fd91d3795c8ff56f3839ad2a927 react-native-pdf-jsi@6.1.0: d29e6b69813548de641a09db705dd94823d3f96832821ae9c3f6a196dd15cb68 react-native-reanimated@4.7.1: 95ee26e5fcab5f50bc4b4c47b3f7e6278a0b20850205efb9f6ee7e70a22d2b59 react-native-svg@15.16.1: 110f9243ffad03654ce40dd1cdb09f01af16b8fa2ccb2b2cd3d62e682c180399 @@ -738,7 +738,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=a4b9236516302754dc167723f70477f934184fd426d0dc238c56597a1eec1d23)(3ba24c675da40640708746e9087ede39) + version: 1.23.0(patch_hash=238db71519fdea4ddb1dc9d0cc037e950b033fd91d3795c8ff56f3839ad2a927)(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) @@ -21015,7 +21015,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=a4b9236516302754dc167723f70477f934184fd426d0dc238c56597a1eec1d23)(3ba24c675da40640708746e9087ede39): + react-native-keyboard-controller@1.23.0(patch_hash=238db71519fdea4ddb1dc9d0cc037e950b033fd91d3795c8ff56f3839ad2a927)(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)