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 && } + + + + + + + + + + + + + + - - - - + + + ) }