Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions shared/chat/conversation/command-markdown.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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 (
<Kb.Box2
direction="vertical"
Expand Down
143 changes: 143 additions & 0 deletions shared/chat/conversation/composer-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/// <reference types="jest" />
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)
})
})
155 changes: 155 additions & 0 deletions shared/chat/conversation/composer-geometry.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading