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