nexo/apps/mattermost/app/components/keyboard_aware_post_draft_container.tsx

204 lines
7.2 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, type ReactNode} from 'react';
import {BackHandler, DeviceEventEmitter, Platform, type StyleProp, StyleSheet, type ViewStyle, View, type LayoutChangeEvent} from 'react-native';
import {KeyboardGestureArea} from 'react-native-keyboard-controller';
import Animated, {scrollTo, useAnimatedStyle} from 'react-native-reanimated';
import {scheduleOnUI} from 'react-native-worklets';
import CustomEmojiPicker from '@components/post_draft/custom_emoji_picker';
import {Events} from '@constants';
import {isEdgeToEdge} from '@constants/device';
import {useKeyboardState} from '@context/keyboard_state';
import useDidMount from '@hooks/did_mount';
import {dismissKeyboard} from '@utils/keyboard';
// Use KeyboardGestureArea on iOS and Android 35+ (with edge-to-edge)
// Android < 35 uses native keyboard handling with adjustResize
const Wrapper = isEdgeToEdge ? KeyboardGestureArea : View;
type Props = {
children: ReactNode;
renderList: () => ReactNode;
textInputNativeID: string;
containerStyle?: StyleProp<ViewStyle>;
};
const styles = StyleSheet.create({
gestureArea: {
justifyContent: 'flex-end',
flex: 1,
},
inputContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
});
/**
* Wrapper component that provides keyboard-aware behavior for post draft screens
* Handles keyboard animations, scroll adjustments, and input container positioning
*/
export const KeyboardAwarePostDraftContainer = ({
children,
renderList,
textInputNativeID,
containerStyle,
}: Props) => {
const {
stateContext,
showInputAccessoryView,
closeInputAccessoryView,
listRef,
} = useKeyboardState();
const inputContainerAnimatedStyle = useAnimatedStyle(
() => {
return {
transform: [{translateY: isEdgeToEdge ? -stateContext.postInputTranslateY.value : 0}],
};
},
[],
);
useDidMount(() => {
return () => {
dismissKeyboard();
};
});
// Ref to track if a layout update is already scheduled
const layoutUpdateScheduledRef = useRef(false);
const pendingHeightRef = useRef<number | null>(null);
// Helper to apply the batched height update
const applyBatchedHeightUpdate = useCallback(() => {
layoutUpdateScheduledRef.current = false;
if (pendingHeightRef.current !== null) {
const heightToSet = pendingHeightRef.current;
pendingHeightRef.current = null;
// Only update if the rounded height changed by more than 0.5px (a real change).
// This prevents jitter in FlatList paddingTop and improves performance.
const currentHeight = stateContext.postInputContainerHeight.value;
const roundedCurrentHeight = Math.round(currentHeight);
if (roundedCurrentHeight !== heightToSet) {
stateContext.postInputContainerHeight.value = heightToSet;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onLayout = useCallback((e: LayoutChangeEvent) => {
const newHeight = e.nativeEvent.layout.height;
const roundedHeight = Math.round(newHeight);
// Store the latest height value
pendingHeightRef.current = roundedHeight;
// If an update is already scheduled, skip scheduling another one
// This batches all layout updates during animations to a single update per frame
if (layoutUpdateScheduledRef.current) {
return;
}
// Schedule update for next frame to batch rapid layout changes during animations
layoutUpdateScheduledRef.current = true;
requestAnimationFrame(applyBatchedHeightUpdate);
}, [applyBatchedHeightUpdate]);
useEffect(() => {
if (Platform.OS !== 'android') {
return undefined;
}
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showInputAccessoryView) {
closeInputAccessoryView();
return true;
}
return false;
});
return () => backHandler.remove();
}, [closeInputAccessoryView, showInputAccessoryView]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.CLOSE_INPUT_ACCESSORY_VIEW, () => {
closeInputAccessoryView();
});
return () => listener.remove();
}, [closeInputAccessoryView]);
const scrollToEmojiPickerCompensation = useCallback((offset: number) => {
scheduleOnUI(() => scrollTo(listRef, 0, offset, false));
}, [listRef]);
// After emoji picker renders, adjust heights and scroll to keep messages visible
// On iOS, contentInset changes cause the list to shift, so we need to scroll to compensate
// On Android, marginBottom is used instead and doesn't require scroll adjustment
useEffect(() => {
if (showInputAccessoryView) {
// Wait one frame to ensure emoji picker has rendered
requestAnimationFrame(() => {
// Use targetHeight instead of inputAccessoryHeight because inputAccessoryHeight
// is still animating (via withTiming) when this runs, so we'd capture a tiny intermediate value
const emojiPickerHeight = stateContext.targetHeight.value;
const currentScroll = stateContext.scrollPosition.value;
// Only perform scroll adjustment on iOS
// Android uses marginBottom which doesn't require scroll compensation
if (Platform.OS === 'ios' && listRef.current) {
// For inverted list: when bottomInset increases, content shifts UP visually. Scroll UP to compensate.
scrollToEmojiPickerCompensation(currentScroll - emojiPickerHeight);
}
});
}
// Only depend on showInputAccessoryView - the effect should only run when emoji picker visibility changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showInputAccessoryView]);
const wrapperProps = useMemo(() => {
if (isEdgeToEdge) {
return {
textInputNativeID,
style: styles.gestureArea,
};
}
return {style: styles.gestureArea};
}, [textInputNativeID]);
return (
<Wrapper
{...wrapperProps}
enableSwipeToDismiss={false} // this applies only to Android
>
<View style={styles.gestureArea}>
<View style={containerStyle}>
{renderList()}
</View>
<Animated.View
style={[
inputContainerAnimatedStyle,
isEdgeToEdge && styles.inputContainer,
]}
>
<View onLayout={onLayout}>
{children}
</View>
</Animated.View>
{showInputAccessoryView && <CustomEmojiPicker/>}
</View>
</Wrapper>
);
};