diff --git a/shared/chat/conversation/command-markdown.tsx b/shared/chat/conversation/command-markdown.tsx
index c0acfda8390c..2a893cf277fa 100644
--- a/shared/chat/conversation/command-markdown.tsx
+++ b/shared/chat/conversation/command-markdown.tsx
@@ -1,11 +1,7 @@
import * as Kb from '@/common-adapters'
import * as React from 'react'
import * as InputState from './input-area/input-state'
-import {MaxInputAreaContext} from './input-area/normal/max-input-area-context'
-
-// used until the conversation reports its height; the markdown mounts long after layout, so this
-// is only a backstop against an unbounded body
-const fallbackMaxHeight = 250
+import {ComposerBoxContext} from './composer-viewport-context'
const CommandMarkdown = () => {
const styles = useStyles()
@@ -15,10 +11,8 @@ const CommandMarkdown = () => {
// a percentage maxHeight has no definite-height ancestor here, so yoga re-resolves it at
// every nesting level and each box ends up taller than its content: the leftover slack shows
// as a gap between the input's buttons and the keyboard. clamp in points instead.
- const maxInputArea = React.useContext(MaxInputAreaContext)
- const maxHeightStyle = isMobile
- ? {maxHeight: maxInputArea ? Math.floor(maxInputArea * 0.35) : fallbackMaxHeight}
- : undefined
+ const {commandMarkdownMaxHeight} = React.useContext(ComposerBoxContext)
+ const maxHeightStyle = isMobile ? {maxHeight: commandMarkdownMaxHeight} : undefined
return (
+import {
+ composerStickyOffset,
+ computeComposerBox,
+ expandedInputMaxHeight,
+ restingScrollOffset,
+ stickyTranslateY,
+} from './composer-geometry'
+
+// The numbers below are literal pixel results, not re-derived from the
+// constants: every one of them encodes a shipped fix (the suggestion popup
+// clipping its last row, the list jumping on keyboard dismiss, the giphy popup
+// resizing), so a change to the model has to be spelled out here.
+
+describe('composerStickyOffset', () => {
+ test('lifts by the bottom inset only while the keyboard is closed', () => {
+ expect(composerStickyOffset(34)).toEqual({closed: -34, opened: 0})
+ expect(composerStickyOffset(0)).toEqual({closed: -0, opened: 0})
+ })
+})
+
+describe('computeComposerBox', () => {
+ test('publishes the measured box, 0 until it has been laid out', () => {
+ expect(computeComposerBox(700).visibleHeight).toBe(700)
+ expect(computeComposerBox(0).visibleHeight).toBe(0)
+ })
+
+ describe('expandedSuggestionListHeight', () => {
+ test('takes 35% of the box, clamped to [120, 240]', () => {
+ // 753 * 0.35 = 263.55 -> floored to 263 -> clamped to 240
+ expect(computeComposerBox(753).expandedSuggestionListHeight).toBe(240)
+ // 600 * 0.35 = 210, and 600 leaves 416 of reserve, so 210 stands
+ expect(computeComposerBox(600).expandedSuggestionListHeight).toBe(210)
+ })
+
+ test('never eats the three lines the expanded input keeps for itself', () => {
+ // 400 - 91 (bar) - 15 (gap) - 78 (three lines) = 216 of reserve, more than
+ // the 400*0.35=140 preference, so the preference still stands
+ expect(computeComposerBox(400).expandedSuggestionListHeight).toBe(140)
+ // 200 leaves only 16 of reserve; the 120 floor must not push past it
+ expect(computeComposerBox(200).expandedSuggestionListHeight).toBe(16)
+ // and a box smaller than the input itself reserves nothing
+ expect(computeComposerBox(100).expandedSuggestionListHeight).toBe(0)
+ })
+
+ test('is 0 before the box has been laid out', () => {
+ expect(computeComposerBox(0).expandedSuggestionListHeight).toBe(0)
+ })
+ })
+
+ describe('commandMarkdownMaxHeight', () => {
+ test('takes the same 35% of the box, but unclamped', () => {
+ expect(computeComposerBox(753).commandMarkdownMaxHeight).toBe(263)
+ // deliberately below the suggestion list's 120 floor: this panel scrolls
+ expect(computeComposerBox(200).commandMarkdownMaxHeight).toBe(70)
+ })
+
+ test('falls back to a fixed backstop before layout', () => {
+ // it mounts long after layout, so 0 here means "no measurement yet"
+ expect(computeComposerBox(0).commandMarkdownMaxHeight).toBe(250)
+ })
+ })
+})
+
+describe('expandedInputMaxHeight', () => {
+ test('fills the box minus the bar, the gap and anything reserved above it', () => {
+ // 753 - 91 - 15 = 647
+ expect(expandedInputMaxHeight(753, 0, 0)).toBe(647)
+ // keyboard up: 753 - 336 - 91 - 15 = 311
+ expect(expandedInputMaxHeight(753, -336, 0)).toBe(311)
+ // with a 200pt suggestion list reserved above it
+ expect(expandedInputMaxHeight(753, -336, 200)).toBe(111)
+ })
+
+ test('never drops below three lines', () => {
+ expect(expandedInputMaxHeight(753, -336, 600)).toBe(78)
+ expect(expandedInputMaxHeight(0, 0, 0)).toBe(78)
+ })
+})
+
+describe('stickyTranslateY', () => {
+ test('matches the sticky offset at both ends of the keyboard transition', () => {
+ const stickyOffset = composerStickyOffset(34)
+ expect(stickyTranslateY(34, 0, 0)).toBe(stickyOffset.closed)
+ expect(stickyTranslateY(34, -336, 1)).toBe(-336 + stickyOffset.opened)
+ })
+
+ test('interpolates the inset away as the keyboard opens', () => {
+ expect(stickyTranslateY(34, -168, 0.5)).toBe(-185)
+ })
+
+ test('extrapolates past both ends rather than clamping', () => {
+ // reanimated's interpolate defaults to EXTEND, and the keyboard's progress
+ // overshoots on a spring; clamping here would desync the jump button from
+ // the bar it is supposed to rest on
+ expect(stickyTranslateY(34, 0, -0.5)).toBe(-51)
+ expect(stickyTranslateY(34, 0, 1.5)).toBe(17)
+ })
+})
+
+describe('restingScrollOffset', () => {
+ test('lands the newest message above the keyboard', () => {
+ expect(restingScrollOffset(34, -336)).toBe(-302)
+ })
+
+ test('clamps to 0 so a closed keyboard cannot push content down', () => {
+ expect(restingScrollOffset(34, 0)).toBe(0)
+ expect(restingScrollOffset(34, -20)).toBe(0)
+ })
+})
diff --git a/shared/chat/conversation/composer-geometry.ts b/shared/chat/conversation/composer-geometry.ts
new file mode 100644
index 000000000000..7237251454d4
--- /dev/null
+++ b/shared/chat/conversation/composer-geometry.ts
@@ -0,0 +1,121 @@
+// Geometry of the mobile composer: the box the conversation occupies under the
+// navigation header, and everything sized from it — the expandable text input,
+// the suggestion popup, the command-markdown panel, and the offsets that keep
+// anything pinned to the input bar lined up with it.
+//
+// Split deliberately in two. computeComposerBox depends on the measured layout,
+// so its result changes identity on mount and rotation; the bottom inset and
+// the keyboard values do not. They are published as separate contexts (see
+// ComposerProvider) so the message list, which reads only the latter, does not
+// re-render every time the conversation box is measured. Keep that seam:
+// anything layout-derived belongs in the box, anything stable in the anchor,
+// and fixed sizes are plain exports.
+//
+// Dependency-free on purpose: the keyboard-driven helpers below run as
+// reanimated worklets on the UI thread, so they may only touch their arguments
+// and the constants in this file.
+
+/** Collapsed height of the text input. */
+export const singleLineHeight = 36
+/** Height of the text input when it is not expanded but has grown. */
+export const threeLineHeight = 78
+/** Height of the button row under the text input, plus its padding. */
+const composerBarHeight = 91
+/** Slack left between an expanded input and the top of the conversation box. */
+const expandedInputTopGap = 15
+/** Share of the conversation box a panel stacked above the input may cover. */
+const composerPanelHeightRatio = 0.35
+const minExpandedSuggestionListHeight = 120
+const maxExpandedSuggestionListHeight = 240
+/**
+ * Used until the conversation reports its height; the markdown mounts long after
+ * layout, so this is only a backstop against an unbounded body.
+ */
+const commandMarkdownFallbackMaxHeight = 250
+
+export type ComposerBox = {
+ /**
+ * The conversation box as actually laid out; 0 before the first layout, which
+ * is why every consumer has a fallback.
+ */
+ visibleHeight: number
+ /** maxHeight of the suggestion list rendered inside an expanded input. */
+ expandedSuggestionListHeight: number
+ /** maxHeight of the command-markdown panel above the input. */
+ commandMarkdownMaxHeight: number
+}
+
+/**
+ * KeyboardStickyView offset for the input bar and anything that has to sit on
+ * top of it: the bar rides `bottomInset` above the window bottom while the
+ * keyboard is closed, and flush against the keyboard while it is open.
+ */
+export const composerStickyOffset = (bottomInset: number) => ({closed: -bottomInset, opened: 0})
+
+/** `measuredHeight` is the conversation box's onLayout height, 0 until laid out. */
+export const computeComposerBox = (measuredHeight: number): ComposerBox => {
+ const visibleHeight = measuredHeight
+ const panelHeight = Math.floor(visibleHeight * composerPanelHeightRatio)
+ // an expanded input keeps at least three lines for itself, so the suggestion
+ // list can never claim more than what is left over above it
+ const suggestionReserve = Math.max(
+ 0,
+ visibleHeight - composerBarHeight - expandedInputTopGap - threeLineHeight
+ )
+ const preferredSuggestionListHeight = visibleHeight
+ ? Math.max(
+ minExpandedSuggestionListHeight,
+ Math.min(maxExpandedSuggestionListHeight, panelHeight)
+ )
+ : 0
+
+ return {
+ // deliberately unclamped, unlike the suggestion list: this panel scrolls, so
+ // a short conversation box should shrink it rather than hold a 120pt floor
+ commandMarkdownMaxHeight: visibleHeight ? panelHeight : commandMarkdownFallbackMaxHeight,
+ expandedSuggestionListHeight: Math.min(preferredSuggestionListHeight, suggestionReserve),
+ visibleHeight,
+ }
+}
+
+/**
+ * maxHeight of the expanded text input. The input is pinned above the keyboard,
+ * so the room it can grow into shrinks by the keyboard height, and by whatever
+ * the suggestion list has reserved above it.
+ */
+export const expandedInputMaxHeight = (
+ visibleHeight: number,
+ keyboardHeight: number,
+ reservedHeight: number
+) => {
+ 'worklet'
+ return Math.max(
+ threeLineHeight,
+ visibleHeight + keyboardHeight - composerBarHeight - expandedInputTopGap - reservedHeight
+ )
+}
+
+/**
+ * The translation `stickyOffset` produces, for views that have to mirror the
+ * input bar's position by hand instead of living in a KeyboardStickyView.
+ * `keyboardProgress` runs 0 (closed) to 1 (open).
+ */
+export const stickyTranslateY = (
+ bottomInset: number,
+ keyboardHeight: number,
+ keyboardProgress: number
+) => {
+ 'worklet'
+ return keyboardHeight - bottomInset * (1 - keyboardProgress)
+}
+
+/**
+ * Scroll offset the inverted message list rests at. KeyboardChatScrollView sets
+ * contentInset.top = K - bottomInset and contentOffset.y = -(K - bottomInset)
+ * while the keyboard is open, so scrolling to 0 would drop the newest message
+ * behind the keyboard.
+ */
+export const restingScrollOffset = (bottomInset: number, keyboardHeight: number) => {
+ 'worklet'
+ return Math.min(keyboardHeight + bottomInset, 0)
+}
diff --git a/shared/chat/conversation/composer-viewport-context.test.tsx b/shared/chat/conversation/composer-viewport-context.test.tsx
new file mode 100644
index 000000000000..a92af15773d0
--- /dev/null
+++ b/shared/chat/conversation/composer-viewport-context.test.tsx
@@ -0,0 +1,159 @@
+/** @jest-environment jsdom */
+///
+import * as React from 'react'
+import {render} from '@testing-library/react'
+import {
+ ComposerBoxContext,
+ ComposerProvider,
+ useComposerAnchor,
+ type ComposerAnchor,
+} from './composer-viewport-context'
+import {computeComposerBox} from './composer-geometry'
+
+// The composer's geometry is published as two contexts so that consumers only
+// re-render for what they read. The message list reads the anchor alone, and the
+// anchor must survive every measurement of the conversation box: merging these
+// back into one context costs an extra list render on every mount and rotation.
+
+type Probe = {renders: number}
+
+const makeProbe = (read: () => void) => {
+ const probe: Probe = {renders: 0}
+ const Component = React.memo(function Component() {
+ probe.renders++
+ read()
+ return null
+ })
+ return {Component, probe}
+}
+
+// stands in for the reanimated shared values, which are created once and keep
+// their identity for the life of the conversation
+const stableShared = {value: 0} as ComposerAnchor['keyboardHeight']
+
+type ConversationProps = {
+ measuredHeight: number
+ bottomInset: number
+ children: React.ReactNode
+}
+
+const Conversation = ({measuredHeight, bottomInset, children}: ConversationProps) => (
+
+ {children}
+
+)
+
+test('measuring the conversation box does not re-render anchor-only consumers', () => {
+ let seenBottomInset = -1
+ let seenVisibleHeight = -1
+ const anchorOnly = makeProbe(() => {
+ seenBottomInset = useComposerAnchor().bottomInset
+ })
+ const boxOnly = makeProbe(() => {
+ seenVisibleHeight = React.useContext(ComposerBoxContext).visibleHeight
+ })
+ const probes = (
+ <>
+
+
+ >
+ )
+
+ const {rerender} = render(
+
+ {probes}
+
+ )
+ expect(anchorOnly.probe.renders).toBe(1)
+ expect(boxOnly.probe.renders).toBe(1)
+ expect(seenVisibleHeight).toBe(0)
+
+ // first layout: the box is measured, the anchor is untouched
+ rerender(
+
+ {probes}
+
+ )
+ expect(boxOnly.probe.renders).toBe(2)
+ expect(seenVisibleHeight).toBe(753)
+ expect(anchorOnly.probe.renders).toBe(1)
+
+ // a re-measure to the same height must not churn either
+ rerender(
+
+ {probes}
+
+ )
+ expect(boxOnly.probe.renders).toBe(2)
+ expect(anchorOnly.probe.renders).toBe(1)
+
+ // but a real inset change does reach the anchor, and only the anchor
+ rerender(
+
+ {probes}
+
+ )
+ expect(anchorOnly.probe.renders).toBe(2)
+ expect(seenBottomInset).toBe(0)
+ expect(boxOnly.probe.renders).toBe(2)
+})
+
+test('the composer panels see their sizes on first layout and after rotation', () => {
+ let box = computeComposerBox(1)
+ const panels = makeProbe(() => {
+ box = React.useContext(ComposerBoxContext)
+ })
+
+ const {rerender} = render(
+
+
+
+ )
+ expect(box.visibleHeight).toBe(0)
+ expect(box.commandMarkdownMaxHeight).toBe(250)
+ expect(box.expandedSuggestionListHeight).toBe(0)
+
+ rerender(
+
+
+
+ )
+ expect(box.visibleHeight).toBe(753)
+ expect(box.commandMarkdownMaxHeight).toBe(263)
+ expect(box.expandedSuggestionListHeight).toBe(240)
+
+ // rotation: a shorter box shrinks both panels
+ rerender(
+
+
+
+ )
+ expect(box.commandMarkdownMaxHeight).toBe(105)
+ expect(box.expandedSuggestionListHeight).toBe(116)
+})
+
+test('the box default outside a conversation is the pre-layout box', () => {
+ // the desktop command-markdown panel reads the box with no provider above it
+ let box = computeComposerBox(1)
+ const outside = makeProbe(() => {
+ box = React.useContext(ComposerBoxContext)
+ })
+ render()
+
+ expect(box).toEqual(computeComposerBox(0))
+ expect(box.commandMarkdownMaxHeight).toBe(250)
+})
+
+test('reading the anchor outside a conversation throws instead of faking a keyboard', () => {
+ const outside = makeProbe(() => {
+ useComposerAnchor()
+ })
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
+ expect(() => render()).toThrow(/inside a ComposerProvider/)
+ spy.mockRestore()
+})
diff --git a/shared/chat/conversation/composer-viewport-context.tsx b/shared/chat/conversation/composer-viewport-context.tsx
new file mode 100644
index 000000000000..c9995b6fc5c3
--- /dev/null
+++ b/shared/chat/conversation/composer-viewport-context.tsx
@@ -0,0 +1,70 @@
+import * as React from 'react'
+import type {SharedValue} from 'react-native-reanimated'
+import {computeComposerBox, type ComposerBox} from './composer-geometry'
+
+/**
+ * Where the composer's bottom edge sits and how the keyboard moves it. Nothing
+ * here is derived from layout, so this object's identity survives every
+ * measurement of the conversation box — which is what keeps the message list
+ * from re-rendering when the box is measured. Do not add layout-derived fields.
+ */
+export type ComposerAnchor = {
+ /** Bottom safe-area inset. */
+ bottomInset: number
+ /** reanimated's keyboard offset: 0 while closed, negative while open. */
+ keyboardHeight: SharedValue
+ /** 0 (keyboard closed) to 1 (keyboard fully open). */
+ keyboardProgress: SharedValue
+}
+
+// no default: the keyboard values are real shared values that only a
+// conversation owns, and a stand-in would silently pin the list and the input
+// to a closed keyboard and a zero inset
+const ComposerAnchorContext = React.createContext(null)
+ComposerAnchorContext.displayName = 'ComposerAnchorContext'
+
+export const useComposerAnchor = () => {
+ const anchor = React.useContext(ComposerAnchorContext)
+ if (!anchor) {
+ throw new Error('useComposerAnchor must be used inside a ComposerProvider')
+ }
+ return anchor
+}
+
+/**
+ * The measured conversation box and every size derived from it. Changes identity
+ * on first layout and on rotation, so only the composer's own panels should read
+ * it; anything that just needs the bottom edge reads the anchor.
+ *
+ * The default is the pre-layout box, so a consumer rendered outside a
+ * conversation (the desktop command-markdown panel) gets the same fallbacks it
+ * would get before the box is measured.
+ */
+export const ComposerBoxContext = React.createContext(computeComposerBox(0))
+ComposerBoxContext.displayName = 'ComposerBoxContext'
+
+type ComposerProviderProps = ComposerAnchor & {
+ /** onLayout height of the conversation box. 0 until it has been laid out. */
+ measuredHeight: number
+ children: React.ReactNode
+}
+
+/**
+ * Publishes the anchor and the box as two separately memoized values, split on
+ * whether they are layout-derived: the message list reads only the anchor, so
+ * measuring the box must not change the anchor's identity. Merging them costs a
+ * list render on every mount and rotation.
+ */
+export const ComposerProvider = (p: ComposerProviderProps) => {
+ const {bottomInset, keyboardHeight, keyboardProgress, measuredHeight, children} = p
+ const anchor = React.useMemo(
+ () => ({bottomInset, keyboardHeight, keyboardProgress}),
+ [bottomInset, keyboardHeight, keyboardProgress]
+ )
+ const box = React.useMemo(() => computeComposerBox(measuredHeight), [measuredHeight])
+ return (
+
+ {children}
+
+ )
+}
diff --git a/shared/chat/conversation/input-area/normal/input.tsx b/shared/chat/conversation/input-area/normal/input.tsx
index 285eda1e0f6f..face862f9162 100644
--- a/shared/chat/conversation/input-area/normal/input.tsx
+++ b/shared/chat/conversation/input-area/normal/input.tsx
@@ -29,7 +29,6 @@ import {
withTiming,
default as Reanimated,
} from '@/common-adapters/reanimated'
-import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
import FilePickerPopup from '../filepicker-popup'
import {launchCameraAsync, launchImageLibraryAsync} from '@/util/expo-image-picker'
import {pickDocumentsAsync} from '@/util/expo-document-picker.native'
@@ -37,7 +36,12 @@ import {filePickerError} from '@/util/storeless-actions'
import {AudioSendWrapper} from '@/chat/audio/audio-send.native'
import {standardTransformer} from '../suggestors/common'
import logger from '@/logger'
-import {MaxInputAreaContext} from './max-input-area-context'
+import {ComposerBoxContext, useComposerAnchor} from '@/chat/conversation/composer-viewport-context'
+import {
+ expandedInputMaxHeight,
+ singleLineHeight,
+ threeLineHeight,
+} from '@/chat/conversation/composer-geometry'
import MoreMenuPopup from './moremenu-popup.native'
// ==================== DESKTOP LOW-LEVEL INPUT ====================
@@ -955,12 +959,6 @@ const useDesktopStyles = Kb.Styles.createStyleHook(
// ==================== NATIVE PLATFORM INPUT ====================
-const singleLineHeight = 36
-const threeLineHeight = 78
-const inputAreaHeight = 91
-const maxExpandedSuggestionListHeight = 240
-const minExpandedSuggestionListHeight = 120
-
type MenuType = 'exploding' | 'filepickerpopup' | 'moremenu'
type NativeButtonsProps = Pick<
@@ -1216,18 +1214,13 @@ const NativeAnimatedInput = (() => {
return function NativeAnimatedInput(p: NativeAnimatedInputProps) {
'use no memo'
const nativeStyles = useNativeStyles()
- const maxInputArea = React.useContext(MaxInputAreaContext)
+ const {visibleHeight} = React.useContext(ComposerBoxContext)
+ const {keyboardHeight} = useComposerAnchor()
const {expanded, inputRef, reservedHeight = 0, ...rest} = p
const lastExpandedRef = React.useRef(expanded)
const offset = useSharedValue(expanded ? 1 : 0)
- // 0 (closed) down to -keyboardHeight (open). When the keyboard is up the
- // input is pinned above it, so the room to expand into shrinks by the
- // keyboard height — otherwise the expanded input grows past the top of the
- // screen.
- const {height: keyboardAnimHeight} = useReanimatedKeyboardAnimation()
const as = useAnimatedStyle(() => {
- const available = maxInputArea + keyboardAnimHeight.value
- const maxHeight = Math.max(threeLineHeight, available - inputAreaHeight - 15 - reservedHeight)
+ const maxHeight = expandedInputMaxHeight(visibleHeight, keyboardHeight.value, reservedHeight)
return {
maxHeight: withTiming(offset.value ? maxHeight : threeLineHeight),
minHeight: withTiming(offset.value ? maxHeight : singleLineHeight),
@@ -1256,18 +1249,7 @@ const NativePlatformInput = (p: Props) => {
const [height, setHeight] = React.useState(0)
const [expanded, setExpanded] = React.useState(false) // updates immediately, used for the icon etc
const inputRef = React.useRef(null)
- const maxInputArea = React.useContext(MaxInputAreaContext)
- const preferredExpandedSuggestionListHeight = maxInputArea
- ? Math.max(
- minExpandedSuggestionListHeight,
- Math.min(maxExpandedSuggestionListHeight, Math.floor(maxInputArea * 0.35))
- )
- : 0
- const maxSuggestionReserveHeight = Math.max(0, maxInputArea - inputAreaHeight - 15 - threeLineHeight)
- const expandedSuggestionListHeight = Math.min(
- preferredExpandedSuggestionListHeight,
- maxSuggestionReserveHeight
- )
+ const {expandedSuggestionListHeight} = React.useContext(ComposerBoxContext)
const suggestionListStyle = Kb.Styles.collapseStyles([
nativeStyles.suggestionList,
!!height && {marginBottom: height},
diff --git a/shared/chat/conversation/input-area/normal/max-input-area-context.tsx b/shared/chat/conversation/input-area/normal/max-input-area-context.tsx
deleted file mode 100644
index 488e599e5aec..000000000000
--- a/shared/chat/conversation/input-area/normal/max-input-area-context.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import * as React from 'react'
-export const MaxInputAreaContext = React.createContext(0)
-MaxInputAreaContext.displayName = 'MaxInputAreaContext'
diff --git a/shared/chat/conversation/input-area/suggestors/index.tsx b/shared/chat/conversation/input-area/suggestors/index.tsx
index 670e6e72ceb7..57893765f722 100644
--- a/shared/chat/conversation/input-area/suggestors/index.tsx
+++ b/shared/chat/conversation/input-area/suggestors/index.tsx
@@ -8,10 +8,10 @@ import * as InputState from '../input-state'
import type * as Common from './common'
import type {PlatformInputProps as Props, RefType as InputRef} from '../normal/input.shared'
import {useConversationThreadID} from '../../thread-context'
-import {KeyboardStickyView, useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
+import {KeyboardStickyView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {MaxInputAreaContext} from '../normal/max-input-area-context'
-import {useAnimatedStyle, default as Reanimated} from '@/common-adapters/reanimated'
+import {composerStickyOffset} from '@/chat/conversation/composer-geometry'
+import {default as Reanimated} from '@/common-adapters/reanimated'
const positionFallbacks = ['bottom center'] as const
@@ -447,26 +447,17 @@ type PopupProps = {
}
const MobileSuggestionArea = (p: {children: React.ReactNode}) => {
const styles = useStyles()
+ // @gorhom/portal renders this at the popup host, a sibling of the router, so
+ // the conversation's contexts never reach it and the inset has to come from
+ // a hook here
const insets = useSafeAreaInsets()
- const maxInputArea = React.useContext(MaxInputAreaContext)
- const {height: keyboardHeight} = useReanimatedKeyboardAnimation()
- // this overlay is portaled to the window root, but the input bar sits
- // insets.bottom above the window bottom while the keyboard is closed (the
- // KeyboardStickyView in conversation/normal), so mirror its offsets or the
- // list covers the input when no keyboard is up
- const stickyOffset = React.useMemo(() => ({closed: -insets.bottom, opened: 0}), [insets.bottom])
- // the sticky view only translates, it keeps the full window height, so give
- // the list the same box the conversation has (below the header, above the
- // keyboard). without it the list's percentage maxHeight resolves against the
- // whole screen and the bottom-anchored list runs up over the header.
- // keyboardHeight is negative while the keyboard is up
- const areaStyle = useAnimatedStyle(() => ({
- height: maxInputArea ? Math.max(0, maxInputArea + keyboardHeight.value) : undefined,
- }))
+ // the input bar sits insets.bottom above the window bottom while the keyboard
+ // is closed, so mirror its offset or this list covers the input
+ const stickyOffset = React.useMemo(() => composerStickyOffset(insets.bottom), [insets.bottom])
return (
-
+
{p.children}
diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx
index cd67d88f843b..62d54b2bc8aa 100644
--- a/shared/chat/conversation/list-area/index.tsx
+++ b/shared/chat/conversation/list-area/index.tsx
@@ -34,14 +34,11 @@ import type {LegendListRef} from '@/common-adapters'
import {FlatList} from 'react-native'
import type {ScrollViewProps} from 'react-native'
import {mobileTypingContainerHeight} from '../input-area/normal/typing'
-import {
- KeyboardChatScrollView,
- useKeyboardState,
- useReanimatedKeyboardAnimation,
-} from 'react-native-keyboard-controller'
-import Animated, {interpolate, useAnimatedStyle} from 'react-native-reanimated'
+import {KeyboardChatScrollView, useKeyboardState} from 'react-native-keyboard-controller'
+import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {ThreadSearchOverlayContext} from '../thread-search-overlay-context'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {useComposerAnchor} from '../composer-viewport-context'
+import {restingScrollOffset, stickyTranslateY} from '../composer-geometry'
type ItemType = T.Chat.Ordinal
const noOrdinals: ReadonlyArray = []
@@ -732,17 +729,13 @@ const useNativeScrolling = (p: {
const loadOlderMessages = useConversationThreadLoadOlderMessagesDueToScroll()
const getThreadLoadStatusOptions = useThreadLoadStatusOptionsGetter()
- // KeyboardChatScrollView sets contentInset.top = K - insets.bottom and
- // contentOffset.y = -(K - insets.bottom) when keyboard is open. Scrolling to
- // offset=0 would place content K-insets.bottom pixels lower (behind the keyboard).
- // We compute the correct resting offset: keyboardHeight.value (negative) + insets.bottom.
- // When keyboard is closed keyboardHeight.value = 0 so the result is clamped to 0.
- const {height: keyboardAnimHeight} = useReanimatedKeyboardAnimation()
- const {bottom: insetsBottom} = useSafeAreaInsets()
+ const {bottomInset, keyboardHeight} = useComposerAnchor()
const scrollToBottom = React.useCallback(() => {
- const offset = Math.min(keyboardAnimHeight.value + insetsBottom, 0)
- listRef.current?.scrollToOffset({animated: false, offset})
- }, [insetsBottom, keyboardAnimHeight, listRef])
+ listRef.current?.scrollToOffset({
+ animated: false,
+ offset: restingScrollOffset(bottomInset, keyboardHeight.value),
+ })
+ }, [bottomInset, keyboardHeight, listRef])
const {setScrollRef} = React.useContext(ThreadRefsContext)
React.useEffect(() => {
@@ -874,25 +867,17 @@ const NativeConversationList = function NativeConversationList() {
const getItemType = useGetItemType()
- const insets = useSafeAreaInsets()
+ const {bottomInset, keyboardHeight, keyboardProgress} = useComposerAnchor()
const isKeyboardVisible = useKeyboardState((s: {isVisible: boolean}) => s.isVisible)
// While the thread-search bar is open it overlays the bottom of the list. Reserve
// that height as extra content padding so centered/newest messages clear it.
const searchOverlayHeight = React.useContext(ThreadSearchOverlayContext)
- const {height: keyboardAnimHeight, progress: keyboardProgress} = useReanimatedKeyboardAnimation()
- const insetsBottom = insets.bottom
- // The input/search bar lives in a KeyboardStickyView with offset
- // {closed: -insets.bottom, opened: 0}, so it's translated above the list's layout
- // bottom even when the keyboard is closed. Mirror that exact translation here so the
- // jump button always rests on the bar's visual top edge instead of being clipped by it.
+ // The input/search bar is translated above the list's layout bottom even when the
+ // keyboard is closed. Mirror that exact translation here so the jump button always
+ // rests on the bar's visual top edge instead of being clipped by it.
const jumpLiftStyle = useAnimatedStyle(() => ({
- transform: [
- {
- translateY:
- keyboardAnimHeight.value + interpolate(keyboardProgress.value, [0, 1], [-insetsBottom, 0]),
- },
- ],
+ transform: [{translateY: stickyTranslateY(bottomInset, keyboardHeight.value, keyboardProgress.value)}],
}))
const {scrollToCentered, scrollToBottom, onEndReached, onScrollToIndexFailed} = useNativeScrolling({
@@ -1072,13 +1057,13 @@ const NativeConversationList = function NativeConversationList() {
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never"
inverted={true}
- offset={insets.bottom}
+ offset={bottomInset}
extraContentPadding={searchOverlayHeight}
{...props}
- scrollIndicatorInsets={{top: insets.bottom}}
+ scrollIndicatorInsets={{top: bottomInset}}
/>
),
- [insets.bottom, searchOverlayHeight]
+ [bottomInset, searchOverlayHeight]
)
const mvpAutoscroll = !(centeredOrdinalOrNone > 0 || !numOrdinals || isKeyboardVisible)
@@ -1086,9 +1071,9 @@ const NativeConversationList = function NativeConversationList() {
const nativeContentContainerStyle = React.useMemo(
() => ({
paddingBottom: 0,
- paddingTop: mobileTypingContainerHeight + insets.bottom,
+ paddingTop: mobileTypingContainerHeight + bottomInset,
}),
- [insets.bottom]
+ [bottomInset]
)
return (
diff --git a/shared/chat/conversation/normal/index.tsx b/shared/chat/conversation/normal/index.tsx
index 4a16c1b89849..293fc16ea3fa 100644
--- a/shared/chat/conversation/normal/index.tsx
+++ b/shared/chat/conversation/normal/index.tsx
@@ -22,9 +22,10 @@ import ThreadSearch from '../search'
import '../conversation.css'
import {PortalHost} from '@/common-adapters/portal.native'
import {useSafeAreaInsets, useSafeAreaFrame} from 'react-native-safe-area-context'
-import {MaxInputAreaContext} from '../input-area/normal/max-input-area-context'
+import {ComposerProvider} from '../composer-viewport-context'
+import {composerStickyOffset} from '../composer-geometry'
import {ThreadSearchOverlayContext} from '../thread-search-overlay-context'
-import {KeyboardStickyView} from 'react-native-keyboard-controller'
+import {KeyboardStickyView, useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
import {useSharedValue} from 'react-native-reanimated'
import {HeaderHeightContext} from '@react-navigation/elements'
import logger from '@/logger'
@@ -121,12 +122,12 @@ const NativeConversation = function NativeConversation() {
const styles = useStyles()
type LayoutEvent = {nativeEvent: {layout: {height: number}}}
- const [maxInputArea, setMaxInputArea] = React.useState(0)
+ const [measuredHeight, setMeasuredHeight] = React.useState(0)
// measure the fixed-height outer container, not the flex list area: the list
// shrinks as the input expands, so measuring it makes the expand animation
// chase a moving target
const onContentLayout = (e: LayoutEvent) => {
- setMaxInputArea(e.nativeEvent.layout.height)
+ setMeasuredHeight(e.nativeEvent.layout.height)
}
const conversationIDKey = useConversationThreadID()
@@ -138,55 +139,61 @@ const NativeConversation = function NativeConversation() {
// a gap under the suggestion popup (that popup anchors to the window, not to this box)
const headerHeight = React.useContext(HeaderHeightContext) ?? insets.top + (Kb.Styles.isTablet ? 115 : 44)
const windowHeight = useSafeAreaFrame().height
+ const bottomInset = insets.bottom
+ const {height: keyboardHeight, progress: keyboardProgress} = useReanimatedKeyboardAnimation()
+ const stickyOffset = React.useMemo(() => composerStickyOffset(bottomInset), [bottomInset])
const height = windowHeight - headerHeight
const safeStyle = {height, maxHeight: height, minHeight: height}
const threadLoadedOffline = useThreadMeta(m => m.offline)
- const stickyOffset = React.useMemo(() => ({closed: -insets.bottom, opened: 0}), [insets.bottom])
-
// Height of the search bar that overlays the list bottom while searching.
// Shared with ListArea (extra content padding + jump-button lift).
const searchOverlayHeight = useSharedValue(0)
return (
-
-
- {threadLoadedOffline && }
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+ {threadLoadedOffline && }
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
)
}