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,
+ suggestionAreaHeight,
+} 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.
+
+const iphoneish = {headerHeight: 91, measuredHeight: 753, windowHeight: 844}
+
+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('sizes the conversation box as the window minus the header', () => {
+ expect(computeComposerBox(iphoneish).containerHeight).toBe(753)
+ expect(computeComposerBox({...iphoneish, headerHeight: 0}).containerHeight).toBe(844)
+ })
+
+ test('publishes the measured box, not the computed one', () => {
+ // the two agree in practice, but only the measured value tells consumers
+ // that a layout has actually happened
+ expect(computeComposerBox({...iphoneish, measuredHeight: 700}).visibleHeight).toBe(700)
+ expect(computeComposerBox({...iphoneish, measuredHeight: 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(iphoneish).expandedSuggestionListHeight).toBe(240)
+ // 600 * 0.35 = 210, and 600 leaves 416 of reserve, so 210 stands
+ expect(computeComposerBox({...iphoneish, measuredHeight: 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({...iphoneish, measuredHeight: 400})
+ .expandedSuggestionListHeight).toBe(140)
+ // 200 leaves only 16 of reserve; the 120 floor must not push past it
+ expect(computeComposerBox({...iphoneish, measuredHeight: 200})
+ .expandedSuggestionListHeight).toBe(16)
+ // and a box smaller than the input itself reserves nothing
+ expect(computeComposerBox({...iphoneish, measuredHeight: 100})
+ .expandedSuggestionListHeight).toBe(0)
+ })
+
+ test('is 0 before the box has been laid out', () => {
+ expect(computeComposerBox({...iphoneish, measuredHeight: 0})
+ .expandedSuggestionListHeight).toBe(0)
+ })
+ })
+
+ describe('commandMarkdownMaxHeight', () => {
+ test('takes the same 35% of the box, but unclamped', () => {
+ expect(computeComposerBox(iphoneish).commandMarkdownMaxHeight).toBe(263)
+ // deliberately below the suggestion list's 120 floor: this panel scrolls
+ expect(computeComposerBox({...iphoneish, measuredHeight: 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({...iphoneish, measuredHeight: 0})
+ .commandMarkdownMaxHeight).toBe(250)
+ })
+ })
+})
+
+describe('suggestionAreaHeight', () => {
+ test('shrinks the popup area by whatever the keyboard covers', () => {
+ expect(suggestionAreaHeight(753, 0)).toBe(753)
+ expect(suggestionAreaHeight(753, -336)).toBe(417)
+ })
+
+ test('never goes negative when the keyboard is taller than the box', () => {
+ expect(suggestionAreaHeight(300, -400)).toBe(0)
+ })
+
+ test('is undefined before layout so the popup stays unconstrained', () => {
+ expect(suggestionAreaHeight(0, 0)).toBeUndefined()
+ expect(suggestionAreaHeight(0, -336)).toBeUndefined()
+ })
+})
+
+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..d9efc020c0bd
--- /dev/null
+++ b/shared/chat/conversation/composer-geometry.ts
@@ -0,0 +1,155 @@
+// 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 sticky offset and
+// the keyboard values do not. They are published as separate contexts 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 belongs beside the offset.
+//
+// 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. */
+const singleLineHeight = 36
+/** Height of the text input when it is not expanded but has grown. */
+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 ComposerBoxInput = {
+ /** Height of the window inside the safe area. */
+ windowHeight: number
+ /** The navigator's measured header height (top inset included). */
+ headerHeight: number
+ /** onLayout height of the conversation box. 0 until it has been laid out. */
+ measuredHeight: number
+}
+
+export type ComposerBox = {
+ /** Height to give the conversation box: the window minus the header. */
+ containerHeight: number
+ /**
+ * The conversation box as actually laid out; 0 before the first layout, which
+ * is why every consumer has a fallback. Panels stacked over the input are
+ * sized from this rather than from `containerHeight` so they track the box
+ * that really got rendered.
+ */
+ visibleHeight: number
+ /** Collapsed height of the text input. */
+ singleLineHeight: number
+ /** Height of the text input when it is not expanded but has grown. */
+ threeLineHeight: 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})
+
+export const computeComposerBox = ({
+ windowHeight,
+ headerHeight,
+ measuredHeight,
+}: ComposerBoxInput): 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,
+ containerHeight: windowHeight - headerHeight,
+ expandedSuggestionListHeight: Math.min(preferredSuggestionListHeight, suggestionReserve),
+ singleLineHeight,
+ threeLineHeight,
+ visibleHeight,
+ }
+}
+
+/**
+ * Height of the area a popup anchored to the input bar may fill: the
+ * conversation box, less whatever the keyboard covers. `keyboardHeight` is
+ * reanimated's keyboard offset, which is 0 closed and negative while open.
+ * undefined until the box has been laid out, so the popup stays unconstrained
+ * rather than collapsing to 0.
+ */
+export const suggestionAreaHeight = (visibleHeight: number, keyboardHeight: number) => {
+ 'worklet'
+ return visibleHeight ? Math.max(0, visibleHeight + keyboardHeight) : undefined
+}
+
+/**
+ * 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..51ef47784108
--- /dev/null
+++ b/shared/chat/conversation/composer-viewport-context.test.tsx
@@ -0,0 +1,171 @@
+/** @jest-environment jsdom */
+///
+import * as React from 'react'
+import {render} from '@testing-library/react'
+import {
+ ComposerAnchorContext,
+ ComposerBoxContext,
+ type ComposerAnchor,
+} from './composer-viewport-context'
+import {composerStickyOffset, 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
+}
+
+// mirrors NativeConversation: two memos, split on whether the value is
+// layout-derived
+const Conversation = ({measuredHeight, bottomInset, children}: ConversationProps) => {
+ const anchor = React.useMemo(
+ () => ({
+ bottomInset,
+ keyboardHeight: stableShared,
+ keyboardProgress: stableShared,
+ stickyOffset: composerStickyOffset(bottomInset),
+ }),
+ [bottomInset]
+ )
+ const box = React.useMemo(
+ () => computeComposerBox({headerHeight: 91, measuredHeight, windowHeight: 844}),
+ [measuredHeight]
+ )
+ return (
+
+ {children}
+
+ )
+}
+
+test('measuring the conversation box does not re-render anchor-only consumers', () => {
+ let seenBottomInset = -1
+ let seenVisibleHeight = -1
+ const anchorOnly = makeProbe(() => {
+ seenBottomInset = React.useContext(ComposerAnchorContext).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
+ rerender(
+
+ {probes}
+
+ )
+ expect(anchorOnly.probe.renders).toBe(2)
+ expect(seenBottomInset).toBe(0)
+})
+
+test('the composer panels see their sizes on first layout and after rotation', () => {
+ let box = computeComposerBox({headerHeight: 0, measuredHeight: 0, windowHeight: 0})
+ const panels = makeProbe(() => {
+ box = React.useContext(ComposerBoxContext)
+ })
+
+ const {rerender} = render(
+
+
+
+ )
+ // pre-layout the panels get the fallbacks, even though the container is
+ // already sized (that one does not wait on a measurement)
+ expect(box.visibleHeight).toBe(0)
+ expect(box.commandMarkdownMaxHeight).toBe(250)
+ expect(box.expandedSuggestionListHeight).toBe(0)
+ expect(box.containerHeight).toBe(753)
+
+ 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 context defaults match the pre-layout box exactly', () => {
+ // MobileSuggestionArea is portaled outside the provider, so its fallbacks are
+ // the defaults; they have to stay identical to the pre-layout values
+ let box = computeComposerBox({headerHeight: 1, measuredHeight: 1, windowHeight: 1})
+ let anchor: ComposerAnchor | undefined
+ const outside = makeProbe(() => {
+ box = React.useContext(ComposerBoxContext)
+ anchor = React.useContext(ComposerAnchorContext)
+ })
+ render()
+
+ expect(box.visibleHeight).toBe(0)
+ expect(box.commandMarkdownMaxHeight).toBe(250)
+ expect(box.expandedSuggestionListHeight).toBe(0)
+ expect(box.singleLineHeight).toBe(36)
+ expect(box.threeLineHeight).toBe(78)
+ expect(anchor?.bottomInset).toBe(0)
+ expect(anchor?.stickyOffset).toEqual({closed: -0, opened: 0})
+})
diff --git a/shared/chat/conversation/composer-viewport-context.tsx b/shared/chat/conversation/composer-viewport-context.tsx
new file mode 100644
index 000000000000..77362c7eb6bc
--- /dev/null
+++ b/shared/chat/conversation/composer-viewport-context.tsx
@@ -0,0 +1,55 @@
+import * as React from 'react'
+import type {SharedValue} from 'react-native-reanimated'
+import {composerStickyOffset, 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
+ /** See composerStickyOffset. */
+ stickyOffset: {closed: number; opened: number}
+ /** reanimated's keyboard offset: 0 while closed, negative while open. */
+ keyboardHeight: SharedValue
+ /** 0 (keyboard closed) to 1 (keyboard fully open). */
+ keyboardProgress: SharedValue
+}
+
+// Only reached off-mobile, or by a consumer mounted outside a conversation: no
+// worklet ever runs there, so a plain object standing in for a shared value is
+// enough (this is what the reanimated adapter's own non-mobile mock does).
+const zeroShared = {
+ addListener: () => {},
+ get: () => 0,
+ modify: () => {},
+ removeListener: () => {},
+ set: () => {},
+ value: 0,
+} as unknown as SharedValue
+
+const emptyAnchor: ComposerAnchor = {
+ bottomInset: 0,
+ keyboardHeight: zeroShared,
+ keyboardProgress: zeroShared,
+ stickyOffset: composerStickyOffset(0),
+}
+
+export const ComposerAnchorContext = React.createContext(emptyAnchor)
+ComposerAnchorContext.displayName = 'ComposerAnchorContext'
+
+/**
+ * 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 ComposerAnchorContext.
+ *
+ * The default is the pre-layout box, so a consumer rendered outside a
+ * conversation gets the same fallbacks it would get before the box is measured.
+ */
+export const ComposerBoxContext = React.createContext(
+ computeComposerBox({headerHeight: 0, measuredHeight: 0, windowHeight: 0})
+)
+ComposerBoxContext.displayName = 'ComposerBoxContext'
diff --git a/shared/chat/conversation/input-area/normal/input.tsx b/shared/chat/conversation/input-area/normal/input.tsx
index 285eda1e0f6f..521a7c412205 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,8 @@ 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 {ComposerAnchorContext, ComposerBoxContext} from '@/chat/conversation/composer-viewport-context'
+import {expandedInputMaxHeight} from '@/chat/conversation/composer-geometry'
import MoreMenuPopup from './moremenu-popup.native'
// ==================== DESKTOP LOW-LEVEL INPUT ====================
@@ -955,12 +955,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 +1210,13 @@ const NativeAnimatedInput = (() => {
return function NativeAnimatedInput(p: NativeAnimatedInputProps) {
'use no memo'
const nativeStyles = useNativeStyles()
- const maxInputArea = React.useContext(MaxInputAreaContext)
+ const {visibleHeight, singleLineHeight, threeLineHeight} = React.useContext(ComposerBoxContext)
+ const {keyboardHeight} = React.useContext(ComposerAnchorContext)
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 +1245,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..ef7e6e487150 100644
--- a/shared/chat/conversation/input-area/suggestors/index.tsx
+++ b/shared/chat/conversation/input-area/suggestors/index.tsx
@@ -10,7 +10,8 @@ import type {PlatformInputProps as Props, RefType as InputRef} from '../normal/i
import {useConversationThreadID} from '../../thread-context'
import {KeyboardStickyView, useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {MaxInputAreaContext} from '../normal/max-input-area-context'
+import {ComposerBoxContext} from '@/chat/conversation/composer-viewport-context'
+import {composerStickyOffset, suggestionAreaHeight} from '@/chat/conversation/composer-geometry'
import {useAnimatedStyle, default as Reanimated} from '@/common-adapters/reanimated'
const positionFallbacks = ['bottom center'] as const
@@ -447,21 +448,24 @@ 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 insets and the keyboard
+ // animation have to come from hooks here rather than from the viewport
const insets = useSafeAreaInsets()
- const maxInputArea = React.useContext(MaxInputAreaContext)
+ const {visibleHeight} = React.useContext(ComposerBoxContext)
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
+ // 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])
+ // the sticky view only translates, it keeps the full window height, so this is
+ // meant to give the list the same box the conversation has — without it the
+ // list's percentage maxHeight resolves against the whole screen and the
+ // bottom-anchored list runs up over the header. it does not currently do that:
+ // visibleHeight is the context default 0 for the reason above, so the height
+ // stays undefined. left in place because it is the intended clamp and costs
+ // nothing; making it bite means getting the viewport past the portal.
const areaStyle = useAnimatedStyle(() => ({
- height: maxInputArea ? Math.max(0, maxInputArea + keyboardHeight.value) : undefined,
+ height: suggestionAreaHeight(visibleHeight, keyboardHeight.value),
}))
return (
diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx
index 7800f4eb543d..e873e5b0a53e 100644
--- a/shared/chat/conversation/list-area/index.tsx
+++ b/shared/chat/conversation/list-area/index.tsx
@@ -33,14 +33,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 {ComposerAnchorContext} from '../composer-viewport-context'
+import {restingScrollOffset, stickyTranslateY} from '../composer-geometry'
type ItemType = T.Chat.Ordinal
const noOrdinals: ReadonlyArray = []
@@ -720,17 +717,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} = React.useContext(ComposerAnchorContext)
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(() => {
@@ -862,25 +855,17 @@ const NativeConversationList = function NativeConversationList() {
const getItemType = useGetItemType()
- const insets = useSafeAreaInsets()
+ const {bottomInset, keyboardHeight, keyboardProgress} = React.useContext(ComposerAnchorContext)
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({
@@ -1056,13 +1041,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)
@@ -1070,9 +1055,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..08f049fdb5e9 100644
--- a/shared/chat/conversation/normal/index.tsx
+++ b/shared/chat/conversation/normal/index.tsx
@@ -22,9 +22,14 @@ 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 {
+ ComposerAnchorContext,
+ ComposerBoxContext,
+ type ComposerAnchor,
+} from '../composer-viewport-context'
+import {composerStickyOffset, computeComposerBox} 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 +126,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 +143,73 @@ 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 height = windowHeight - headerHeight
+ const bottomInset = insets.bottom
+ const {height: keyboardHeight, progress: keyboardProgress} = useReanimatedKeyboardAnimation()
+ // memoized apart from the box on purpose: the list reads only this, so it must
+ // not change identity when the box below is re-measured
+ const anchor = React.useMemo(
+ () => ({
+ bottomInset,
+ keyboardHeight,
+ keyboardProgress,
+ stickyOffset: composerStickyOffset(bottomInset),
+ }),
+ [bottomInset, keyboardHeight, keyboardProgress]
+ )
+ const box = React.useMemo(
+ () => computeComposerBox({headerHeight, measuredHeight, windowHeight}),
+ [headerHeight, measuredHeight, windowHeight]
+ )
+ const {containerHeight} = box
+ const {stickyOffset} = anchor
- const safeStyle = {height, maxHeight: height, minHeight: height}
+ const safeStyle = {height: containerHeight, maxHeight: containerHeight, minHeight: containerHeight}
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 && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
)
}