mattermost-mobile-upstream/app/components/keyboard_aware_post_draft_container.tsx
Carlos Garcia f2857a97d1
Mm 67525 bump min version (#9640)
* bumps minSdkVersion to 30

Older versions are past end-of-life for security patches. voximplant service patch is also updated with minSdkVersion set to 30.

* removes manifest attributes no longer needed when minSdk is 30

* removes guard to for version lower than O

* removes permission check for versions lower than 28

* remove tests related to unsupported android sdk versions

* bumps iOS minSdkVersion to 18

* removes iOS 13/15 guards in hardware-keyboard

* removes iOS 15 guards in NotificationService

* removes iOS 15 guard in Gekidou network delegate

since gekidou is iOS only other platform guards are not needed

* removes version guard no longer needed in voximplant patch

* removes hasWriteStoragePermission dead stub and its call site

* removes unused setSoftKeyboardToAdjustResize from rnutils

The function had no call sites in JS/TS code and the iOS implementation
was a no-op.

* removes version guards and updates config after min version bump

* updates stale iOS 17 simulator references in CI to iOS 18.1

Co-Authored-By: Carlos Garcia <carlos.garcia@mattermost.com>

* fixes malformed patch file headers

- voximplant patch: restore missing diff --git header and index line
  for VIForegroundServiceModule.java, fix stale hunk counter (+11 -> +7)
- notifications patch: fix stale hunk line count (+91 -> +89) after
  KITKAT branch removal

Co-Authored-By: Carlos Garcia <carlos.garcia@mattermost.com>

* remove code below the supported OS versions

* Bump IntuneMAM SDK to 21.8.0 and MSAL to 2.15.0

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-09-16 10:53:06 +00:00

190 lines
6.8 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, 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 {useKeyboardState} from '@context/keyboard_state';
import useDidMount from '@hooks/did_mount';
import {dismissKeyboard} from '@utils/keyboard';
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: -stateContext.postInputTranslateY.value}],
};
},
[],
);
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]);
return (
<KeyboardGestureArea
textInputNativeID={textInputNativeID}
style={styles.gestureArea}
enableSwipeToDismiss={false} // this applies only to Android
>
<View style={styles.gestureArea}>
<View style={containerStyle}>
{renderList()}
</View>
<Animated.View
style={[
inputContainerAnimatedStyle,
styles.inputContainer,
]}
>
<View onLayout={onLayout}>
{children}
</View>
</Animated.View>
{showInputAccessoryView && <CustomEmojiPicker/>}
</View>
</KeyboardGestureArea>
);
};