Skip to content
Merged
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
110 changes: 110 additions & 0 deletions shared/chat/conversation/composer-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/// <reference types="jest" />
import {
composerStickyOffset,
computeComposerBox,
expandedInputMaxHeight,
restingScrollOffset,
stickyTranslateY,
} from './composer-geometry'

// The numbers below are literal pixel results, not re-derived from the
// constants: every one of them encodes a shipped fix (the suggestion popup
// clipping its last row, the list jumping on keyboard dismiss, the giphy popup
// resizing), so a change to the model has to be spelled out here.

describe('composerStickyOffset', () => {
test('lifts by the bottom inset only while the keyboard is closed', () => {
expect(composerStickyOffset(34)).toEqual({closed: -34, opened: 0})
expect(composerStickyOffset(0)).toEqual({closed: -0, opened: 0})
})
})

describe('computeComposerBox', () => {
test('publishes the measured box, 0 until it has been laid out', () => {
expect(computeComposerBox(700).visibleHeight).toBe(700)
expect(computeComposerBox(0).visibleHeight).toBe(0)
})

describe('expandedSuggestionListHeight', () => {
test('takes 35% of the box, clamped to [120, 240]', () => {
// 753 * 0.35 = 263.55 -> floored to 263 -> clamped to 240
expect(computeComposerBox(753).expandedSuggestionListHeight).toBe(240)
// 600 * 0.35 = 210, and 600 leaves 416 of reserve, so 210 stands
expect(computeComposerBox(600).expandedSuggestionListHeight).toBe(210)
})

test('never eats the three lines the expanded input keeps for itself', () => {
// 400 - 91 (bar) - 15 (gap) - 78 (three lines) = 216 of reserve, more than
// the 400*0.35=140 preference, so the preference still stands
expect(computeComposerBox(400).expandedSuggestionListHeight).toBe(140)
// 200 leaves only 16 of reserve; the 120 floor must not push past it
expect(computeComposerBox(200).expandedSuggestionListHeight).toBe(16)
// and a box smaller than the input itself reserves nothing
expect(computeComposerBox(100).expandedSuggestionListHeight).toBe(0)
})

test('is 0 before the box has been laid out', () => {
expect(computeComposerBox(0).expandedSuggestionListHeight).toBe(0)
})
})

describe('commandMarkdownMaxHeight', () => {
test('takes the same 35% of the box, but unclamped', () => {
expect(computeComposerBox(753).commandMarkdownMaxHeight).toBe(263)
// deliberately below the suggestion list's 120 floor: this panel scrolls
expect(computeComposerBox(200).commandMarkdownMaxHeight).toBe(70)
})

test('falls back to a fixed backstop before layout', () => {
// it mounts long after layout, so 0 here means "no measurement yet"
expect(computeComposerBox(0).commandMarkdownMaxHeight).toBe(250)
})
})
})

describe('expandedInputMaxHeight', () => {
test('fills the box minus the bar, the gap and anything reserved above it', () => {
// 753 - 91 - 15 = 647
expect(expandedInputMaxHeight(753, 0, 0)).toBe(647)
// keyboard up: 753 - 336 - 91 - 15 = 311
expect(expandedInputMaxHeight(753, -336, 0)).toBe(311)
// with a 200pt suggestion list reserved above it
expect(expandedInputMaxHeight(753, -336, 200)).toBe(111)
})

test('never drops below three lines', () => {
expect(expandedInputMaxHeight(753, -336, 600)).toBe(78)
expect(expandedInputMaxHeight(0, 0, 0)).toBe(78)
})
})

describe('stickyTranslateY', () => {
test('matches the sticky offset at both ends of the keyboard transition', () => {
const stickyOffset = composerStickyOffset(34)
expect(stickyTranslateY(34, 0, 0)).toBe(stickyOffset.closed)
expect(stickyTranslateY(34, -336, 1)).toBe(-336 + stickyOffset.opened)
})

test('interpolates the inset away as the keyboard opens', () => {
expect(stickyTranslateY(34, -168, 0.5)).toBe(-185)
})

test('extrapolates past both ends rather than clamping', () => {
// reanimated's interpolate defaults to EXTEND, and the keyboard's progress
// overshoots on a spring; clamping here would desync the jump button from
// the bar it is supposed to rest on
expect(stickyTranslateY(34, 0, -0.5)).toBe(-51)
expect(stickyTranslateY(34, 0, 1.5)).toBe(17)
})
})

describe('restingScrollOffset', () => {
test('lands the newest message above the keyboard', () => {
expect(restingScrollOffset(34, -336)).toBe(-302)
})

test('clamps to 0 so a closed keyboard cannot push content down', () => {
expect(restingScrollOffset(34, 0)).toBe(0)
expect(restingScrollOffset(34, -20)).toBe(0)
})
})
121 changes: 121 additions & 0 deletions shared/chat/conversation/composer-geometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Geometry of the mobile composer: the box the conversation occupies under the
// navigation header, and everything sized from it — the expandable text input,
// the suggestion popup, the command-markdown panel, and the offsets that keep
// anything pinned to the input bar lined up with it.
//
// Split deliberately in two. computeComposerBox depends on the measured layout,
// so its result changes identity on mount and rotation; the bottom inset and
// the keyboard values do not. They are published as separate contexts (see
// ComposerProvider) so the message list, which reads only the latter, does not
// re-render every time the conversation box is measured. Keep that seam:
// anything layout-derived belongs in the box, anything stable in the anchor,
// and fixed sizes are plain exports.
//
// Dependency-free on purpose: the keyboard-driven helpers below run as
// reanimated worklets on the UI thread, so they may only touch their arguments
// and the constants in this file.

/** Collapsed height of the text input. */
export const singleLineHeight = 36
/** Height of the text input when it is not expanded but has grown. */
export const threeLineHeight = 78
/** Height of the button row under the text input, plus its padding. */
const composerBarHeight = 91
/** Slack left between an expanded input and the top of the conversation box. */
const expandedInputTopGap = 15
/** Share of the conversation box a panel stacked above the input may cover. */
const composerPanelHeightRatio = 0.35
const minExpandedSuggestionListHeight = 120
const maxExpandedSuggestionListHeight = 240
/**
* Used until the conversation reports its height; the markdown mounts long after
* layout, so this is only a backstop against an unbounded body.
*/
const commandMarkdownFallbackMaxHeight = 250

export type ComposerBox = {
/**
* The conversation box as actually laid out; 0 before the first layout, which
* is why every consumer has a fallback.
*/
visibleHeight: number
/** maxHeight of the suggestion list rendered inside an expanded input. */
expandedSuggestionListHeight: number
/** maxHeight of the command-markdown panel above the input. */
commandMarkdownMaxHeight: number
}

/**
* KeyboardStickyView offset for the input bar and anything that has to sit on
* top of it: the bar rides `bottomInset` above the window bottom while the
* keyboard is closed, and flush against the keyboard while it is open.
*/
export const composerStickyOffset = (bottomInset: number) => ({closed: -bottomInset, opened: 0})

/** `measuredHeight` is the conversation box's onLayout height, 0 until laid out. */
export const computeComposerBox = (measuredHeight: number): ComposerBox => {
const visibleHeight = measuredHeight
const panelHeight = Math.floor(visibleHeight * composerPanelHeightRatio)
// an expanded input keeps at least three lines for itself, so the suggestion
// list can never claim more than what is left over above it
const suggestionReserve = Math.max(
0,
visibleHeight - composerBarHeight - expandedInputTopGap - threeLineHeight
)
const preferredSuggestionListHeight = visibleHeight
? Math.max(
minExpandedSuggestionListHeight,
Math.min(maxExpandedSuggestionListHeight, panelHeight)
)
: 0

return {
// deliberately unclamped, unlike the suggestion list: this panel scrolls, so
// a short conversation box should shrink it rather than hold a 120pt floor
commandMarkdownMaxHeight: visibleHeight ? panelHeight : commandMarkdownFallbackMaxHeight,
expandedSuggestionListHeight: Math.min(preferredSuggestionListHeight, suggestionReserve),
visibleHeight,
}
}

/**
* maxHeight of the expanded text input. The input is pinned above the keyboard,
* so the room it can grow into shrinks by the keyboard height, and by whatever
* the suggestion list has reserved above it.
*/
export const expandedInputMaxHeight = (
visibleHeight: number,
keyboardHeight: number,
reservedHeight: number
) => {
'worklet'
return Math.max(
threeLineHeight,
visibleHeight + keyboardHeight - composerBarHeight - expandedInputTopGap - reservedHeight
)
}

/**
* The translation `stickyOffset` produces, for views that have to mirror the
* input bar's position by hand instead of living in a KeyboardStickyView.
* `keyboardProgress` runs 0 (closed) to 1 (open).
*/
export const stickyTranslateY = (
bottomInset: number,
keyboardHeight: number,
keyboardProgress: number
) => {
'worklet'
return keyboardHeight - bottomInset * (1 - keyboardProgress)
}

/**
* Scroll offset the inverted message list rests at. KeyboardChatScrollView sets
* contentInset.top = K - bottomInset and contentOffset.y = -(K - bottomInset)
* while the keyboard is open, so scrolling to 0 would drop the newest message
* behind the keyboard.
*/
export const restingScrollOffset = (bottomInset: number, keyboardHeight: number) => {
'worklet'
return Math.min(keyboardHeight + bottomInset, 0)
}
Loading
Loading