Skip to content
Open
43 changes: 26 additions & 17 deletions src/components/CollapsibleHeaderOnKeyboard/index.native.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import usePrevious from '@hooks/usePrevious';
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useWindowDimensions from '@hooks/useWindowDimensions';

import isInLandscapeModeUtil from '@libs/isInLandscapeMode';

import variables from '@styles/variables';

import type {LayoutChangeEvent} from 'react-native';

import {useIsFocused} from '@react-navigation/native';
Expand All @@ -15,8 +18,9 @@ import type {CollapsibleHeaderOnKeyboardProps} from './types';
const COLLAPSE_DURATION = 100;
const RESTORE_DURATION = 300;
// Assumed vertical space for the focused input field — used to reserve space above the keyboard.
const VERTICAL_SPACE_FOR_FOCUSED_INPUT = 120;
const VERTICAL_SPACE_FOR_FOCUSED_INPUT = variables.inputHeight + variables.inputPaddingBottom + variables.inputPaddingTop;
const KEYBOARD_OPENING_PROGRESS_THRESHOLDS = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99];
const MIN_HEADER_HEIGHT_ON_COLLAPSE = 8;

function isKeyboardOpeningAtGivenProgress(keyboardProgress: number, prevKeyboardProgress: number, requiredProgress: number[]): boolean {
'worklet';
Expand All @@ -32,7 +36,7 @@ function isKeyboardOpeningAtGivenProgress(keyboardProgress: number, prevKeyboard
* Intended for landscape mode on phones where the keyboard + header can leave no room for inputs.
* Uses height animation (not translateY) so the freed space is reclaimed by the layout below.
*/
function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: CollapsibleHeaderOnKeyboardProps) {
function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0, alwaysCollapseHeaderOnKeyboard = false}: CollapsibleHeaderOnKeyboardProps) {
const isFocused = useIsFocused();
const prevIsFocused = usePrevious(isFocused);
// JS ref guards against re-measurement when the Reanimated.View fires onLayout with height=0
Expand All @@ -45,15 +49,17 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co
const {height: keyboardHeightSV, progress: keyboardProgressSV} = useReanimatedKeyboardAnimation();

const {windowWidth, windowHeight} = useWindowDimensions();
const {top: topSafeAreaInset} = useSafeAreaInsets();
const availableWindowHeight = windowHeight - topSafeAreaInset;
const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
// Keep window dimensions and offset accessible on the UI thread. Stable refs, excluded from deps.
const windowHeightSV = useSharedValue(windowHeight);
const availableWindowHeightSV = useSharedValue(availableWindowHeight);
const collapsibleHeaderOffsetSV = useSharedValue(collapsibleHeaderOffset);
const isFocusedSV = useSharedValue(isFocused);
const isInLandscapeModeSV = useSharedValue(isInLandscapeMode);
useEffect(() => {
windowHeightSV.set(windowHeight);
}, [windowHeight, windowHeightSV]);
availableWindowHeightSV.set(availableWindowHeight);
}, [availableWindowHeight, availableWindowHeightSV]);
useEffect(() => {
collapsibleHeaderOffsetSV.set(collapsibleHeaderOffset);
}, [collapsibleHeaderOffset, collapsibleHeaderOffsetSV]);
Expand All @@ -80,9 +86,9 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co
return;
}

// First measurement, or content changed while header is fully open
// First measurement, or content changed while keyboard is fully open
// (to skip onLayout calls triggered by our own height animation collapsing the view to 0)
if (naturalHeightRef.current === -1 || animatedHeight.get() >= naturalHeightRef.current) {
if (naturalHeightRef.current === -1 || (animatedHeight.get() >= naturalHeightRef.current && height !== naturalHeightRef.current)) {
naturalHeightRef.current = height;
naturalHeight.set(height);
animatedHeight.set(height);
Expand Down Expand Up @@ -124,9 +130,9 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co
() => ({
keyboardHeight: keyboardHeightSV.get(),
keyboardProgress: keyboardProgressSV.get(),
windowHeightValue: windowHeightSV.get(),
availableWindowHeightValue: availableWindowHeightSV.get(),
}),
({keyboardHeight, keyboardProgress, windowHeightValue}, previous) => {
({keyboardHeight, keyboardProgress, availableWindowHeightValue}, previous) => {
// If the screen is not focused, bail out
if (!isFocusedSV.get() || !isInLandscapeModeSV.get()) {
return;
Expand All @@ -141,23 +147,26 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co

// If the keyboard is closing, bail out
const prevKeyboardProgress = previous?.keyboardProgress ?? 0;
if (prevKeyboardProgress >= keyboardProgress) {
if (prevKeyboardProgress > keyboardProgress) {
return;
}

// Only act when the keyboard is starting to open or reaching a threshold, not on every intermediate frame.
// Only act when the keyboard is starting to open, reaching a threshold or fully open, not on every intermediate frame.
const isKeyboardStartingOpening = prevKeyboardProgress === 0 && keyboardProgress > 0;
const isKeyboardOpeningAndReachingThreshold = isKeyboardOpeningAtGivenProgress(keyboardProgress, prevKeyboardProgress, KEYBOARD_OPENING_PROGRESS_THRESHOLDS);
const isKeyboardFullyOpen = keyboardProgress === 1;

if (!isKeyboardStartingOpening && !isKeyboardOpeningAndReachingThreshold) {
if (!isKeyboardStartingOpening && !isKeyboardOpeningAndReachingThreshold && !isKeyboardFullyOpen) {
return;
}

// keyboardHeight is negative when open (e.g. -291), so keyboardTop = windowHeightValue + keyboardHeight.
// keyboardHeight is negative when open (e.g. -291), so keyboardTop = availableWindowHeightValue + keyboardHeight.
// Target header height: give the input exactly the space it needs above the keyboard,
// the header gets what remains. Clamped to [0, naturalHeight].
const keyboardTop = windowHeightValue + keyboardHeight;
const targetHeight = Math.max(0, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get());
const keyboardTop = availableWindowHeightValue + keyboardHeight;
const targetHeight = alwaysCollapseHeaderOnKeyboard
? MIN_HEADER_HEIGHT_ON_COLLAPSE
: Math.max(MIN_HEADER_HEIGHT_ON_COLLAPSE, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get());
const naturalHeightValue = naturalHeight.get();

if (targetHeight >= naturalHeightValue) {
Expand All @@ -182,11 +191,11 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co
// Inner wrapper slides the content upward during landscape keyboard collapse only.
const innerStyle = useAnimatedStyle(() => {
if (animatedHeight.get() >= naturalHeight.get()) {
return {};
return {transform: [{translateY: 0}]};
}

if (!isInLandscapeModeSV.get()) {
return {};
return {transform: [{translateY: 0}]};
}

return {transform: [{translateY: animatedHeight.get() - naturalHeight.get()}]};
Expand Down
6 changes: 6 additions & 0 deletions src/components/CollapsibleHeaderOnKeyboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ type CollapsibleHeaderOnKeyboardProps = {
* component, keyboard, and focused input — e.g. a tab bar below the list.
* The collapse target is reduced by this amount so those elements are not counted twice. */
collapsibleHeaderOffset?: number;

/**
* If true, the header will always collapse on keyboard open,
* regardless if there is enough space for the input above the keyboard.
*/
alwaysCollapseHeaderOnKeyboard?: boolean;
};

// eslint-disable-next-line import/prefer-default-export
Expand Down
6 changes: 6 additions & 0 deletions src/components/Form/FormProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ type FormProviderProps<TFormID extends OnyxFormKey = OnyxFormKey> = FormProps<TF

/** Reference to the outer element */
ref?: ForwardedRef<FormRef>;

/** Styles for the container wrapping the submit button and footer content */
submitButtonAndFooterContainerStyles?: StyleProp<ViewStyle>;

/** Styles for the submit button itself (`submitButtonStyles` targets the wrapping container) */
submitButtonInnerStyles?: StyleProp<ViewStyle>;
};

function FormProvider({
Expand Down
10 changes: 10 additions & 0 deletions src/components/Form/FormWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ type FormWrapperProps = ChildrenProps &
shouldPreventDefaultFocusOnPressSubmit?: boolean;

ref?: ForwardedRef<FormWrapperRef>;

/** Styles for the container wrapping the submit button and footer content */
submitButtonAndFooterContainerStyles?: StyleProp<ViewStyle>;

/** Styles for the submit button itself (`submitButtonStyles` targets the wrapping container) */
submitButtonInnerStyles?: StyleProp<ViewStyle>;
};

function FormWrapper({
Expand Down Expand Up @@ -120,6 +126,8 @@ function FormWrapper({
forwardedFSClass,
sentryLabel = CONST.SENTRY_LABEL.FORM.SUBMIT_BUTTON,
ref,
submitButtonAndFooterContainerStyles,
submitButtonInnerStyles,
}: FormWrapperProps) {
const styles = useThemeStyles();
const formRef = useRef<RNScrollView>(null);
Expand Down Expand Up @@ -223,6 +231,8 @@ function FormWrapper({
shouldBlendOpacity={shouldSubmitButtonBlendOpacity}
shouldPreventDefaultFocusOnPress={shouldPreventDefaultFocusOnPressSubmit}
sentryLabel={sentryLabel}
buttonAndFooterContainerStyles={submitButtonAndFooterContainerStyles}
buttonStyles={submitButtonInnerStyles}
/>
);

Expand Down
8 changes: 6 additions & 2 deletions src/components/FormAlertWithSubmitButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ type FormAlertWithSubmitButtonProps = WithSentryLabel & {

/** Prevents the button from triggering blur on mouse down. */
shouldPreventDefaultFocusOnPress?: boolean;

/** Styles for the container wrapping the submit button and footer content */
buttonAndFooterContainerStyles?: StyleProp<ViewStyle>;
};

function FormAlertWithSubmitButton({
Expand All @@ -117,11 +120,12 @@ function FormAlertWithSubmitButton({
shouldBlendOpacity = false,
addButtonBottomPadding = true,
shouldPreventDefaultFocusOnPress = false,
buttonAndFooterContainerStyles,
shouldShowLoadingImmediatelyOnPress = true,
sentryLabel,
}: FormAlertWithSubmitButtonProps) {
const styles = useThemeStyles();
const style = [!shouldRenderFooterAboveSubmit && footerContent && addButtonBottomPadding ? styles.mb3 : {}, buttonStyles];
const style = [!shouldRenderFooterAboveSubmit && footerContent && addButtonBottomPadding ? styles.mb3 : undefined, buttonStyles];

const {isLoading, startWithLoading} = usePressLoading({isLoading: isOnyxLoading});

Expand Down Expand Up @@ -149,7 +153,7 @@ function FormAlertWithSubmitButton({
errorMessageStyle={errorMessageStyle}
>
{(isOffline: boolean | undefined) => (
<View>
<View style={buttonAndFooterContainerStyles}>
{shouldRenderFooterAboveSubmit && footerContent}
{isOffline && !enabledWhenOffline ? (
<Button
Expand Down
37 changes: 24 additions & 13 deletions src/pages/settings/Agents/AddAgentPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import UserAvatar from '@components/Avatar/UserAvatar';
import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon';
import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormOnyxValues, FormRef} from '@components/Form/types';
Expand All @@ -11,17 +12,18 @@ import TextInput from '@components/TextInput';

import useBeforeRemove from '@hooks/useBeforeRemove';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import useKeyboardState from '@hooks/useKeyboardState';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';

import {buildFileFromAvatarCropResult} from '@libs/AvatarCropUtils';
import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog';
import {isMobile} from '@libs/Browser';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
Expand All @@ -43,6 +45,9 @@ import type {TextInputKeyPressEvent} from 'react-native';
import React, {useCallback, useEffect, useRef} from 'react';
import {View} from 'react-native';

import {PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE, COLLAPSIBLE_HEADER_OFFSET} from './const';
import scrollToMultilineInput from './scrollToMultilineInput';

type AddAgentPageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.AGENTS.ADD>;

type AddAgentPageContentProps = {
Expand All @@ -54,11 +59,14 @@ type AddAgentPageContentProps = {
};

function AddAgentPageContent({route, template}: AddAgentPageContentProps) {
const StyleUtils = useStyleUtils();
const policyID = route.params?.policyID;
const {translate} = useLocalize();
const styles = useThemeStyles();
const {windowWidth, windowHeight} = useWindowDimensions();
const shouldUseScrollableLayout = useIsInLandscapeMode() || (isMobile() && windowWidth > windowHeight);
const {isKeyboardActive} = useKeyboardState();
const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
Comment thread
GCyganek marked this conversation as resolved.
const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive;
const {accountID: ownerAccountID, login: ownerLogin, displayName} = useCurrentUserPersonalDetails();
const defaultAgentName = template?.name ?? (displayName ? translate('addAgentPage.defaultAgentName', displayName) : undefined);
const defaultPrompt = template?.prompt ?? translate('addAgentPage.defaultPrompt');
Expand Down Expand Up @@ -153,6 +161,8 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) {
Navigation.navigate(ROUTES.AGENT_REPORT.getRoute(optimisticReportID), {forceReplace: true});
};

const handleInputFocus = () => scrollToMultilineInput(formRef, isInLandscapeMode);

const agentAvatar = avatarSource ? (
<UserAvatar
source={avatarSource}
Expand All @@ -166,21 +176,22 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) {
testID={AddAgentPage.displayName}
includeSafeAreaPaddingBottom
offlineIndicatorStyle={styles.mtAuto}
shouldEnableMaxHeight={shouldUseScrollableLayout}
>
<HeaderWithBackButton
title={translate('addAgentPage.title')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_AGENTS_NEW.getRoute(policyID ? {policyID} : undefined))}
/>
<CollapsibleHeaderOnKeyboard collapsibleHeaderOffset={COLLAPSIBLE_HEADER_OFFSET}>
<HeaderWithBackButton
title={translate('addAgentPage.title')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_AGENTS_NEW.getRoute(policyID ? {policyID} : undefined))}
/>
</CollapsibleHeaderOnKeyboard>
<FormProvider
ref={formRef}
formID={ONYXKEYS.FORMS.ADD_AGENT_FORM}
onSubmit={handleSubmit}
validate={validate}
submitButtonText={translate('addAgentPage.createAgent')}
style={[styles.flex1, styles.ph5]}
shouldUseScrollView={shouldUseScrollableLayout}
submitFlexEnabled={shouldUseScrollableLayout ? undefined : false}
shouldUseScrollView={isInLandscapeMode}
submitFlexEnabled={false}
shouldHideFixErrorsAlert
enabledWhenOffline
// Block submit until the draft has loaded, so we never create the agent without the preset/photo it will restore.
Expand All @@ -207,7 +218,7 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) {
spellCheck={false}
defaultValue={defaultAgentName}
/>
<View style={[styles.flex1, shouldUseScrollableLayout && styles.minHeight42]}>
<View style={shouldShrinkPromptInput ? StyleUtils.getHeight(PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE) : [isInLandscapeMode ? styles.h42 : styles.flex1]}>
<InputWrapper
InputComponent={TextInput}
inputID={INPUT_IDS.PROMPT}
Expand All @@ -219,10 +230,10 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) {
onKeyPress={submitFormOnModEnter}
defaultValue={defaultPrompt}
multiline
containerStyles={[styles.flex1]}
containerStyles={[styles.h100]}
touchableInputWrapperStyle={[styles.flex1]}
textInputContainerStyles={[styles.flex1]}
inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
onFocus={handleInputFocus}
/>
</View>
<Text style={[styles.textLabelSupporting]}>{`${translate('addAgentPage.copilotNote')} ${translate('workspace.rules.agentRules.disclaimer')}`}</Text>
Expand Down
Loading
Loading