From 1e08966ede9f279eceaa5f31a9123a0a88eb379c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 9 Sep 2026 14:01:47 -0400 Subject: [PATCH] refactor(chat): publish composer geometry, not a bare measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MaxInputAreaContext published a single number, so five modules each re-derived the composer's geometry from it plus their own insets and keyboard animation. The sticky offset existed twice byte-identically, the `- inputAreaHeight - 15` term twice in one file, and the 0.35 panel ratio twice across two files with different clamping. Re-syncing those copies is what the last two suggestion-popup fixes were doing. computeComposerBox now owns every derivation and every constant. The model ships as two contexts, split on whether a value comes from layout: the box changes identity when the conversation is measured, while the bottom inset, the sticky offset and the keyboard shared values do not. The message list reads only the latter, so measuring the box no longer re-renders it. Keep that seam — merging the two costs a list render on every mount and rotation, and there is a test that fails if you do. The keyboard-driven parts stay animated: they are worklet helpers taking the shared value's current frame, never React state. Behaviour is unchanged. Where two call sites disagreed today they still do: the command-markdown panel keeps its unclamped 35% and its 250pt pre-layout backstop, while the suggestion list keeps its [120, 240] clamp, its leftover-reserve cap and its 0. The suggestion popup is portaled to a host outside the router, so the conversation's contexts cannot reach it; it keeps its local hooks and its height clamp stays inert exactly as before, now documented. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015rccpV5nLxxC5opF5xzrz7 --- shared/chat/conversation/command-markdown.tsx | 12 +- .../conversation/composer-geometry.test.ts | 143 +++++++++++++++ shared/chat/conversation/composer-geometry.ts | 155 ++++++++++++++++ .../composer-viewport-context.test.tsx | 171 ++++++++++++++++++ .../composer-viewport-context.tsx | 55 ++++++ .../conversation/input-area/normal/input.tsx | 34 +--- .../normal/max-input-area-context.tsx | 3 - .../input-area/suggestors/index.tsx | 30 +-- shared/chat/conversation/list-area/index.tsx | 55 ++---- shared/chat/conversation/normal/index.tsx | 107 ++++++----- 10 files changed, 635 insertions(+), 130 deletions(-) create mode 100644 shared/chat/conversation/composer-geometry.test.ts create mode 100644 shared/chat/conversation/composer-geometry.ts create mode 100644 shared/chat/conversation/composer-viewport-context.test.tsx create mode 100644 shared/chat/conversation/composer-viewport-context.tsx delete mode 100644 shared/chat/conversation/input-area/normal/max-input-area-context.tsx 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 && } + + + + + + + + + + + + + + - - - - + + + ) }