New keyboard library Setup (#9278) (#9413)

* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* fix buid issue

* Fix the cancel state to emoji picker

* use portals for autocomplete

* fix permalink extra space in post list

* Portal only for android

---------





(cherry picked from commit e39b27be7e)

Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Mattermost Build 2026-01-14 08:18:06 +02:00 committed by GitHub
parent 9632cfa141
commit e74b07051e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 6659 additions and 734 deletions

View file

@ -4,7 +4,7 @@
import {defineMessages} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {ActionType, General, Navigation, Screens} from '@constants';
import {ActionType, Events, General, Navigation, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getTranslations} from '@i18n';
import {getChannelById} from '@queries/servers/channel';
@ -135,6 +135,8 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
subtitle = subtitle.replace('{channelName}', channel.displayName);
}
DeviceEventEmitter.emit(Events.CLOSE_INPUT_ACCESSORY_VIEW);
goToScreen(Screens.THREAD, '', {rootId}, {
topBar: {
title: {

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Portal} from '@gorhom/portal';
import React, {useMemo, useState} from 'react';
import {Platform, type StyleProp, useWindowDimensions, type ViewStyle} from 'react-native';
import Animated, {type SharedValue, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
@ -65,6 +66,7 @@ type Props = {
autocompleteProviders?: AutocompleteProviders;
useAllAvailableSpace?: boolean;
horizontalPadding?: number;
usePortal?: boolean;
}
type AutocompleteProviders = {
@ -101,6 +103,7 @@ const Autocomplete = ({
autocompleteProviders = defaultAutocompleteProviders,
useAllAvailableSpace = false,
horizontalPadding = 8,
usePortal = false,
}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
@ -146,7 +149,7 @@ const Autocomplete = ({
return s;
}, [style.base, style.borders, style.shadow, horizontalPadding, containerAnimatedStyle, hasElements, containerStyle]);
return (
const component = (
<Animated.View
testID='autocomplete'
style={containerStyles}
@ -224,6 +227,16 @@ const Autocomplete = ({
</>)}
</Animated.View>
);
if (usePortal && Platform.OS === 'android') {
return (
<Portal>
{component}
</Portal>
);
}
return component;
};
export default Autocomplete;

View file

@ -22,6 +22,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 2,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.56),
@ -54,3 +55,4 @@ const EmojiCategoryBarIcon = ({currentIndex, icon, index, scrollToIndex, theme}:
};
export default EmojiCategoryBarIcon;

View file

@ -0,0 +1,173 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {ScrollView, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {selectEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import {usePreventDoubleTap} from '@hooks/utils';
import {deleteLastGrapheme} from '@utils/grapheme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmojiCategoryBarIcon from './icon';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
height: 55,
paddingHorizontal: 12,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
borderTopWidth: 1,
flexDirection: 'row',
alignItems: 'center',
},
actionButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
actionIcon: {
color: changeOpacity(theme.centerChannelColor, 0.56),
},
scrollableContainer: {
flex: 1,
marginHorizontal: 8,
},
categoriesContainer: {
flexDirection: 'row',
alignItems: 'center',
},
separator: {
width: 1,
height: 20,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginHorizontal: 8,
},
}));
type Props = {
onSelect?: (index: number | undefined) => void;
}
const EmojiCategoryBar = ({onSelect}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {currentIndex, icons} = useEmojiCategoryBar();
const keyboardContext = useKeyboardAnimationContext();
const {
focusInput,
updateValue,
updateCursorPosition,
cursorPositionRef,
} = keyboardContext;
// Only show keyboard/delete buttons if we're in input accessory view mode
// Check if updateValue is available (not null) to determine if we're in input accessory context
const showActionButtons = updateValue !== null;
const scrollToIndex = useCallback((index: number) => {
if (onSelect) {
onSelect(index);
return;
}
selectEmojiCategoryBarSection(index);
}, [onSelect]);
const handleKeyboardPress = usePreventDoubleTap(useCallback(() => {
focusInput();
}, [focusInput]));
const deleteCharFromCurrentCursorPosition = useCallback(() => {
if (!updateValue || !updateCursorPosition || !cursorPositionRef) {
return;
}
const currentCursorPosition = cursorPositionRef.current;
if (currentCursorPosition === 0) {
return;
}
updateValue((value: string) => {
const result = deleteLastGrapheme(value, currentCursorPosition);
cursorPositionRef.current = result.cursorPosition;
updateCursorPosition(result.cursorPosition);
return result.text;
});
}, [updateValue, updateCursorPosition, cursorPositionRef]);
if (!icons) {
return null;
}
return (
<View
style={styles.container}
testID='emoji_picker.category_bar'
>
{showActionButtons && (
<>
<TouchableWithFeedback
onPress={handleKeyboardPress}
style={styles.actionButton}
type='opacity'
testID='emoji_picker.keyboard_button'
>
<CompassIcon
name='keyboard-outline'
size={20}
style={styles.actionIcon}
/>
</TouchableWithFeedback>
<View style={styles.separator}/>
</>
)}
<ScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
style={styles.scrollableContainer}
contentContainerStyle={styles.categoriesContainer}
>
{icons.map((icon, index) => (
<EmojiCategoryBarIcon
currentIndex={currentIndex}
key={icon.key}
icon={icon.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</ScrollView>
{showActionButtons && (
<>
<View style={styles.separator}/>
<TouchableWithFeedback
onPress={deleteCharFromCurrentCursorPosition}
style={styles.actionButton}
type='opacity'
testID='emoji_picker.delete_button'
>
<CompassIcon
name='backspace-outline'
size={20}
style={styles.actionIcon}
/>
</TouchableWithFeedback>
</>
)}
</View>
);
};
export default EmojiCategoryBar;

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default as InputAccessoryViewContainer} from './input_accessory_view_container';
export {default as InputAccessoryViewContent} from './input_accessory_view_content';

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ReactNode} from 'react';
import Animated, {useAnimatedStyle, type SharedValue} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
children: ReactNode;
animatedHeight: SharedValue<number>;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
overflow: 'hidden',
width: '100%',
},
}));
/**
* InputAccessoryViewContainer - Container for input accessory view content
*/
const InputAccessoryViewContainer = ({
children,
animatedHeight,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const animatedStyle = useAnimatedStyle(() => {
return {
height: animatedHeight.value,
};
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Animated.View
style={[
styles.container,
animatedStyle,
]}
>
{children}
</Animated.View>
);
};
export default InputAccessoryViewContainer;

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import CustomEmojiPicker from '@components/post_draft/custom_emoji_picker';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
/**
* InputAccessoryViewContent - Content for input accessory view
*/
const InputAccessoryViewContent = () => {
const {inputAccessoryViewAnimatedHeight, isEmojiSearchFocused, setIsEmojiSearchFocused} = useKeyboardAnimationContext();
return (
<CustomEmojiPicker
height={inputAccessoryViewAnimatedHeight}
isEmojiSearchFocused={isEmojiSearchFocused}
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
/>
);
};
export default InputAccessoryViewContent;

View file

@ -0,0 +1,584 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode} from 'react';
import {BackHandler, DeviceEventEmitter, Keyboard, Platform, type StyleProp, StyleSheet, type ViewStyle, View, type LayoutChangeEvent, type GestureResponderEvent} from 'react-native';
import {KeyboardGestureArea} from 'react-native-keyboard-controller';
import Animated, {runOnJS, useAnimatedReaction, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {InputAccessoryViewContainer, InputAccessoryViewContent} from '@components/input_accessory_view';
import {Events} from '@constants';
import {KeyboardAnimationProvider} from '@context/keyboard_animation';
import {useIsTablet, useWindowDimensions} from '@hooks/device';
import {useInputAccessoryView} from '@hooks/useInputAccessoryView';
import {useKeyboardAwarePostDraft} from '@hooks/useKeyboardAwarePostDraft';
const isIOS = Platform.OS === 'ios';
const Wrapper = isIOS ? KeyboardGestureArea : View;
type RenderListProps = {
listRef: ReturnType<typeof useKeyboardAwarePostDraft>['listRef'];
contentInset: ReturnType<typeof useKeyboardAwarePostDraft>['contentInset'];
onScroll: ReturnType<typeof useKeyboardAwarePostDraft>['onScroll'];
keyboardCurrentHeight: ReturnType<typeof useKeyboardAwarePostDraft>['keyboardTranslateY'];
postInputContainerHeight: number;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
};
type Props = {
children: ReactNode;
renderList: (props: RenderListProps) => ReactNode;
textInputNativeID: string;
containerStyle?: StyleProp<ViewStyle>;
isThreadView?: boolean;
enabled?: boolean;
onEmojiSearchFocusChange?: (focused: boolean) => void;
};
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,
isThreadView = false,
enabled = true,
onEmojiSearchFocusChange,
}: Props) => {
const {height: windowHeight} = useWindowDimensions();
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
const effectiveWindowHeight = isTablet ? windowHeight : windowHeight - insets.bottom;
const {
keyboardTranslateY: keyboardCurrentHeight,
listRef,
inputRef,
contentInset: bottomInset,
onScroll,
postInputContainerHeight,
setPostInputContainerHeight,
inputContainerAnimatedStyle,
keyboardHeight,
scrollOffset,
scrollPosition,
blurInput,
focusInput,
blurAndDismissKeyboard,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
} = useKeyboardAwarePostDraft(isThreadView, enabled);
const {
showInputAccessoryView,
setShowInputAccessoryView,
lastKeyboardHeight,
inputAccessoryViewAnimatedHeight,
} = useInputAccessoryView({
keyboardHeight,
isKeyboardFullyOpen,
});
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
useEffect(() => {
onEmojiSearchFocusChange?.(isEmojiSearchFocused);
}, [isEmojiSearchFocused, onEmojiSearchFocusChange]);
// Ref to store cursor position from PostInput
const cursorPositionRef = useRef<number>(0);
// Function to register cursor position updates from PostInput
const registerCursorPosition = useCallback((cursorPosition: number) => {
cursorPositionRef.current = cursorPosition;
}, []);
// Refs to store PostInput callbacks
const updateValueRef = useRef<React.Dispatch<React.SetStateAction<string>> | null>(null);
const updateCursorPositionRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
// Function to register PostInput callbacks
const registerPostInputCallbacks = useCallback((
updateValueFn: React.Dispatch<React.SetStateAction<string>>,
updateCursorPositionFn: React.Dispatch<React.SetStateAction<number>>,
) => {
updateValueRef.current = updateValueFn;
updateCursorPositionRef.current = updateCursorPositionFn;
if (updateValueFn) {
updateValueFn((currentValue: string) => {
cursorPositionRef.current = currentValue.length;
return currentValue;
});
}
}, []);
// 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.
setPostInputContainerHeight((prevHeight) => {
const roundedPrevHeight = Math.round(prevHeight);
if (roundedPrevHeight !== heightToSet) {
return heightToSet;
}
return prevHeight;
});
}
}, [setPostInputContainerHeight]);
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]);
// Refs for tracking emoji picker swipe-to-dismiss gesture
const previousTouchYRef = useRef<number | null>(null);
const lastDistanceFromBottomRef = useRef<number | null>(null);
const lastIsSwipingDownRef = useRef<boolean | null>(null);
const originalEmojiPickerHeightRef = useRef<number>(0);
const isGestureActiveRef = useRef<boolean>(false);
const gestureStartedInEmojiPickerRef = useRef<boolean>(false);
// Shared value to track scroll adjustment during emoji picker animation
const animatedScrollAdjustment = useSharedValue(0);
// Callback to perform scroll adjustment
const performScrollAdjustment = useCallback((targetOffset: number) => {
listRef.current?.scrollToOffset({
offset: targetOffset,
animated: false,
});
}, [listRef]);
// React to animatedScrollAdjustment changes and scroll the list accordingly
// This enables smooth scrolling as emoji picker animates
useAnimatedReaction(
() => animatedScrollAdjustment.value,
(current, previous) => {
// Only scroll if value actually changed and is valid
if (previous !== null && current !== previous && current !== 0) {
runOnJS(performScrollAdjustment)(current);
}
},
[animatedScrollAdjustment],
);
// Handle touch move: track finger position and adjust emoji picker height
const handleTouchMove = useCallback((event: GestureResponderEvent) => {
if (!showInputAccessoryView || Keyboard.isVisible()) {
return;
}
// Get finger Y position on screen
const fingerY = event.nativeEvent.pageY;
if (fingerY == null) {
return;
}
// On first touch, check if gesture started within emoji picker bounds
if (!isGestureActiveRef.current) {
const currentEmojiPickerHeight = inputAccessoryViewAnimatedHeight.value;
const emojiPickerTopEdge = effectiveWindowHeight - postInputContainerHeight - currentEmojiPickerHeight;
const emojiPickerBottomEdge = effectiveWindowHeight - postInputContainerHeight;
// Check if touch is within emoji picker area
const isTouchInEmojiPicker = fingerY >= emojiPickerTopEdge && fingerY <= emojiPickerBottomEdge;
if (!isTouchInEmojiPicker) {
return;
}
isGestureActiveRef.current = true;
gestureStartedInEmojiPickerRef.current = true;
}
// Only process if gesture started in emoji picker
if (!gestureStartedInEmojiPickerRef.current) {
return;
}
const distanceFromBottom = effectiveWindowHeight - fingerY;
// Subtract input container height to get emoji picker height
const emojiPickerHeight = distanceFromBottom - postInputContainerHeight;
const maxHeight = originalEmojiPickerHeightRef.current;
const clampedHeight = emojiPickerHeight < 0 ? 0 : Math.min(emojiPickerHeight, maxHeight);
inputAccessoryViewAnimatedHeight.value = clampedHeight;
bottomInset.value = clampedHeight;
lastDistanceFromBottomRef.current = clampedHeight;
lastIsSwipingDownRef.current = previousTouchYRef.current !== null && fingerY > previousTouchYRef.current;
previousTouchYRef.current = fingerY;
}, [showInputAccessoryView, postInputContainerHeight, inputAccessoryViewAnimatedHeight, bottomInset, effectiveWindowHeight]);
// Callback to dismiss emoji picker after animation completes
const dismissEmojiPicker = useCallback(() => {
// Reset emoji search focus when dismissing emoji picker
setIsEmojiSearchFocused(false);
setShowInputAccessoryView(false);
isInputAccessoryViewMode.value = false;
bottomInset.value = 0;
scrollOffset.value = 0;
keyboardHeight.value = 0;
}, [setShowInputAccessoryView, isInputAccessoryViewMode, bottomInset, scrollOffset, keyboardHeight, setIsEmojiSearchFocused]);
const closeInputAccessoryView = useCallback(() => {
// Reset emoji search focus when closing emoji picker
setIsEmojiSearchFocused(false);
setShowInputAccessoryView(false);
isInputAccessoryViewMode.value = false;
isTransitioningFromCustomView.value = false;
inputAccessoryViewAnimatedHeight.value = withTiming(0, {duration: 200});
bottomInset.value = withTiming(0, {duration: 200});
scrollOffset.value = withTiming(0, {duration: 200});
keyboardHeight.value = 0;
}, [inputAccessoryViewAnimatedHeight, bottomInset, scrollOffset, keyboardHeight, setShowInputAccessoryView, isInputAccessoryViewMode, isTransitioningFromCustomView, setIsEmojiSearchFocused]);
const scrollToEnd = useCallback(() => {
const activeHeight = Math.max(keyboardHeight.value, inputAccessoryViewAnimatedHeight.value);
const targetOffset = -activeHeight;
listRef.current?.scrollToOffset({offset: targetOffset, animated: true});
}, [listRef, keyboardHeight, inputAccessoryViewAnimatedHeight]);
useEffect(() => {
if (Platform.OS !== 'android') {
return undefined;
}
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showInputAccessoryView) {
closeInputAccessoryView();
return true;
}
return false;
});
return () => backHandler.remove();
}, [showInputAccessoryView, closeInputAccessoryView]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.CLOSE_INPUT_ACCESSORY_VIEW, () => {
closeInputAccessoryView();
});
return () => listener.remove();
}, [closeInputAccessoryView]);
// Handle touch end: decide whether to collapse or expand emoji picker
const handleTouchEnd = useCallback(() => {
isGestureActiveRef.current = false;
// Only process if gesture started in emoji picker
if (!gestureStartedInEmojiPickerRef.current) {
return;
}
if (lastDistanceFromBottomRef.current !== null && lastIsSwipingDownRef.current !== null) {
const currentInsetHeight = lastDistanceFromBottomRef.current;
const currentScrollValue = scrollPosition.value;
if (lastIsSwipingDownRef.current) {
// User was swiping DOWN → Collapse and dismiss emoji picker
// Calculate scroll positions: as bottomInset decreases from current to 0,
// list should scroll from current position to final position
const startScrollOffset = -currentInsetHeight + currentScrollValue;
const endScrollOffset = currentScrollValue;
// Animate emoji picker height to 0
inputAccessoryViewAnimatedHeight.value = withTiming(
0,
{duration: 250},
() => {
runOnJS(dismissEmojiPicker)();
},
);
bottomInset.value = withTiming(0, {duration: 250});
// Animate scroll position from start to end - this makes list scroll down smoothly
animatedScrollAdjustment.value = startScrollOffset;
animatedScrollAdjustment.value = withTiming(endScrollOffset, {
duration: 250,
}, () => {
animatedScrollAdjustment.value = 0;
});
} else {
// User was swiping UP → Expand to full height
const targetHeight = originalEmojiPickerHeightRef.current;
// Calculate scroll positions: as bottomInset increases from current to targetHeight,
// list should scroll from current position to final position
const startScrollOffset = -currentInsetHeight + currentScrollValue;
const endScrollOffset = -targetHeight + currentScrollValue;
inputAccessoryViewAnimatedHeight.value = withTiming(targetHeight, {duration: 250});
bottomInset.value = withTiming(targetHeight, {duration: 250});
// Animate scroll position from start to end - this makes list scroll up smoothly
animatedScrollAdjustment.value = startScrollOffset;
animatedScrollAdjustment.value = withTiming(endScrollOffset, {
duration: 250,
}, () => {
animatedScrollAdjustment.value = 0;
});
}
}
previousTouchYRef.current = null;
lastDistanceFromBottomRef.current = null;
lastIsSwipingDownRef.current = null;
gestureStartedInEmojiPickerRef.current = false;
}, [inputAccessoryViewAnimatedHeight, dismissEmojiPicker, bottomInset, scrollPosition, animatedScrollAdjustment]);
useLayoutEffect(() => {
if (showInputAccessoryView && Platform.OS === 'ios') {
keyboardHeight.value = 0;
}
}, [showInputAccessoryView, keyboardHeight]);
// After emoji picker renders, adjust heights and scroll to keep messages visible
useEffect(() => {
if (showInputAccessoryView) {
// Wait one frame to ensure emoji picker has rendered
requestAnimationFrame(() => {
const emojiPickerHeight = inputAccessoryViewAnimatedHeight.value;
const currentScroll = scrollPosition.value;
originalScrollBeforeEmojiPicker.value = currentScroll;
originalEmojiPickerHeightRef.current = emojiPickerHeight;
// For inverted list: when bottomInset increases, content shifts UP visually. Scroll UP to compensate.
const targetContentOffset = currentScroll - emojiPickerHeight;
bottomInset.value = emojiPickerHeight;
scrollOffset.value = emojiPickerHeight;
listRef.current?.scrollToOffset({
offset: targetContentOffset,
animated: false,
});
});
}
// Only depend on showInputAccessoryView - the effect should only run when emoji picker visibility changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showInputAccessoryView]);
// Track if we've already restored scroll for current emoji picker closing (Android only)
// Use SharedValue instead of ref so it can be accessed in worklets
const hasRestoredScrollForEmojiPicker = useSharedValue(false);
// Store the original scroll value when emoji picker opens, so we can restore it when closing
const originalScrollBeforeEmojiPicker = useSharedValue(0);
// Reset restoration flag when emoji picker opens
useEffect(() => {
if (showInputAccessoryView) {
hasRestoredScrollForEmojiPicker.value = false;
originalScrollBeforeEmojiPicker.value = 0;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showInputAccessoryView]);
// Callback to restore scroll when emoji picker closes (called from worklet)
const restoreScrollAfterEmojiPickerClose = useCallback((previousHeight: number, currentScroll: number) => {
if (listRef.current && previousHeight > 0) {
listRef.current.scrollToOffset({
offset: currentScroll,
animated: false,
});
}
// ref is not required to be in deps because it is a stable reference
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Android: Watch for emoji picker closing and restore scroll position when both height and bottomInset reach 0
const isAndroid = Platform.OS === 'android';
useAnimatedReaction(
() => ({
height: inputAccessoryViewAnimatedHeight.value,
bottomInset: bottomInset.value,
}),
(current, previous) => {
if (!isAndroid) {
return;
}
// When emoji picker closes: height goes to 0 AND bottomInset reaches 0. Check previous.bottomInset > 0 because bottomInset affects scroll.
const shouldRestoreScroll = previous !== null &&
previous.bottomInset !== undefined &&
previous.bottomInset > 0 &&
current.height === 0 &&
current.bottomInset === 0 &&
!hasRestoredScrollForEmojiPicker.value;
if (shouldRestoreScroll) {
hasRestoredScrollForEmojiPicker.value = true;
const currentScroll = scrollPosition.value;
const emojiPickerHeight = previous.bottomInset;
runOnJS(restoreScrollAfterEmojiPickerClose)(emojiPickerHeight, currentScroll);
}
},
[inputAccessoryViewAnimatedHeight, bottomInset, scrollPosition, restoreScrollAfterEmojiPickerClose],
);
const keyboardAnimationValues = useMemo(() => ({
keyboardTranslateY: keyboardCurrentHeight,
bottomInset,
scrollOffset,
keyboardHeight,
scrollPosition,
onScroll,
postInputContainerHeight,
inputRef,
blurInput,
focusInput,
blurAndDismissKeyboard,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
showInputAccessoryView,
setShowInputAccessoryView,
lastKeyboardHeight,
inputAccessoryViewAnimatedHeight,
isTransitioningFromCustomView,
closeInputAccessoryView,
scrollToEnd,
isEmojiSearchFocused,
setIsEmojiSearchFocused,
cursorPositionRef,
registerCursorPosition,
updateValue: updateValueRef.current,
updateCursorPosition: updateCursorPositionRef.current,
registerPostInputCallbacks,
// Shared values don't need to be in dependencies - they're stable references
// Only include non-shared-value dependencies that can actually change
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [
onScroll,
postInputContainerHeight,
inputRef,
blurInput,
focusInput,
blurAndDismissKeyboard,
showInputAccessoryView,
setShowInputAccessoryView,
lastKeyboardHeight,
closeInputAccessoryView,
scrollToEnd,
isEmojiSearchFocused,
setIsEmojiSearchFocused,
registerCursorPosition,
registerPostInputCallbacks,
]);
const wrapperProps = useMemo(() => {
if (isIOS) {
return {
textInputNativeID,
offset: postInputContainerHeight,
style: styles.gestureArea,
};
}
return {style: styles.gestureArea};
}, [textInputNativeID, postInputContainerHeight]);
// On iOS, use KeyboardGestureArea for interactive keyboard dismissal
// On Android, KeyboardGestureArea is Android 11+ only, but we want native behavior
// So we conditionally use it only on iOS
// KeyboardGestureArea will be a no-op on Android if rendered, but we avoid it for clarity
const content = (
<>
<View style={containerStyle}>
{renderList({
keyboardCurrentHeight,
listRef,
contentInset: bottomInset,
onScroll,
postInputContainerHeight,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd,
})}
</View>
<Animated.View
style={[
inputContainerAnimatedStyle,
styles.inputContainer,
]}
>
<View onLayout={onLayout}>
{children}
</View>
{showInputAccessoryView && (
<InputAccessoryViewContainer
animatedHeight={inputAccessoryViewAnimatedHeight}
>
<InputAccessoryViewContent/>
</InputAccessoryViewContainer>
)}
</Animated.View>
</>
);
return (
<KeyboardAnimationProvider value={keyboardAnimationValues}>
<Wrapper {...wrapperProps}>
{content}
</Wrapper>
</KeyboardAnimationProvider>
);
};

View file

@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Fuse from 'fuse.js';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, Platform, Text, View, type ListRenderItemInfo} from 'react-native';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {getEmojis, searchEmojis} from '@utils/emoji/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import EmojiItem from './emoji_item';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
type Props = {
customEmojis: CustomEmojiModel[];
skinTone: string;
searchTerm: string;
onEmojiPress: (emojiName: string) => void;
hideEmojiNames?: boolean;
};
export const SEARCH_BAR_HEIGHT = 56; // paddingTop: 12 + paddingBottom: 12 + search bar ~32px
export const SEARCH_CONTAINER_PADDING = 8; // paddingVertical: 4 * 2
export const SEARCH_VISIBILITY_OFFSET = 40; // Extra height to ensure search values are visible
export const SEARCH_VISIBILITY_OFFSET_ANDROID = 60;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
paddingVertical: 4,
paddingHorizontal: 12,
},
listContainer: {
flexGrow: 1,
justifyContent: 'center',
},
noResultContainer: {
height: 40,
margin: 10,
},
noResultText: {
color: theme.centerChannelColor,
opacity: 0.72,
},
};
});
const EmojiFiltered: React.FC<Props> = ({
customEmojis,
skinTone,
searchTerm = '',
onEmojiPress,
hideEmojiNames = false,
}) => {
const {keyboardHeight} = useKeyboardAnimationContext();
const emojis = useMemo(() => getEmojis(skinTone, customEmojis), [skinTone, customEmojis]);
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
// Calculate dynamic height: keyboardHeight + search bar height + search container padding + visibility offset
// On Android, use increased visibility offset to account for bottom safe area
const animatedStyle = useAnimatedStyle(() => {
const calculatedKeyboardHeight = Math.max(keyboardHeight.value, 0);
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
const calculatedHeight = calculatedKeyboardHeight + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
return {
height: calculatedHeight,
};
}, [keyboardHeight]);
const fuse = useMemo(() => {
const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true};
return new Fuse(emojis, options);
}, [emojis]);
const data = useMemo(() => {
if (!searchTerm) {
return emojis;
}
return searchEmojis(fuse, searchTerm);
}, [emojis, fuse, searchTerm]);
const keyExtractor = (item: string) => item;
const renderEmpty = useCallback(() => {
return (
<View style={style.noResultContainer}>
<Text style={style.noResultText}>
{intl.formatMessage({
id: 'custom_emoji_picker.search.no_results',
defaultMessage: 'No results',
})}
</Text>
</View>
);
}, [style, intl]);
const renderItem = useCallback(({item}: ListRenderItemInfo<string>) => {
return (
<EmojiItem
onEmojiPress={onEmojiPress}
name={item}
hideName={hideEmojiNames}
shouldDismissKeyboard={false}
/>
);
}, [
onEmojiPress,
hideEmojiNames,
]);
return (
<Animated.View style={[style.container, animatedStyle]}>
<FlatList
data={data}
initialNumToRender={30}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='always'
keyExtractor={keyExtractor}
ListEmptyComponent={renderEmpty}
renderItem={renderItem}
removeClippedSubviews={false}
horizontal={true}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={data.length === 0 && style.listContainer}
/>
</Animated.View>
);
};
export default EmojiFiltered;

View file

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback} from 'react';
import {Keyboard, Text, TouchableOpacity, View} from 'react-native';
import Emoji from '@components/emoji';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type TouchableEmojiProps = {
name: string;
onEmojiPress: (emojiName: string) => void;
hideName?: boolean;
shouldDismissKeyboard?: boolean;
}
const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 40,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
overflow: 'hidden',
},
emojiContainer: {
marginRight: 5,
},
emoji: {
color: '#000',
},
emojiText: {
fontSize: 13,
color: theme.centerChannelColor,
},
};
});
const EmojiTouchable = ({name, onEmojiPress, hideName = false, shouldDismissKeyboard = true}: TouchableEmojiProps) => {
const theme = useTheme();
const style = getStyleSheetFromTheme(theme);
const onPress = useCallback(() => {
if (shouldDismissKeyboard && Keyboard.isVisible()) {
Keyboard.dismiss();
}
onEmojiPress(name);
}, [name, onEmojiPress, shouldDismissKeyboard]);
return (
<TouchableOpacity
onPress={onPress}
style={style.container}
>
<View style={style.emojiContainer}>
<Emoji
emojiName={name}
textStyle={style.emoji}
size={30}
/>
</View>
{!hideName && <Text style={style.emojiText}>{`:${name}:`}</Text>}
</TouchableOpacity>
);
};
export default memo(EmojiTouchable);

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {queryEmojiPreferences} from '@queries/servers/preference';
import EmojiFiltered from './emoji_filtered';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
skinTone: queryEmojiPreferences(database, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(EmojiFiltered));

View file

@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import {searchCustomEmojis} from '@actions/remote/custom_emoji';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {getKeyboardAppearanceFromTheme} from '@utils/theme';
import EmojiFiltered from '../emoji_filtered';
import EmojiPickerHeader from '../emoji_picker_header';
import CustomEmojiSections from '../emoji_sections';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type {SharedValue} from 'react-native-reanimated';
type Props = {
customEmojis: CustomEmojiModel[];
customEmojisEnabled: boolean;
onEmojiPress: (emoji: string) => void;
imageUrl?: string;
file?: ExtractedFileInfo;
recentEmojis: string[];
testID?: string;
isEmojiSearchFocused: boolean;
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
emojiPickerHeight: SharedValue<number>;
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const EmojiPicker: React.FC<Props> = ({
customEmojisEnabled,
imageUrl,
file,
recentEmojis,
customEmojis,
onEmojiPress,
testID = '',
isEmojiSearchFocused,
setIsEmojiSearchFocused,
emojiPickerHeight,
}) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const [searchTerm, setSearchTerm] = useState<string|undefined>();
const isSelectingEmoji = useRef(false);
const onCancelSearch = useCallback(() => {
setSearchTerm(undefined);
setIsEmojiSearchFocused(false);
isSelectingEmoji.current = false;
}, [setIsEmojiSearchFocused]);
const searchCustom = debounce((text: string) => {
if (text && text.length > 1) {
searchCustomEmojis(serverUrl, text);
}
}, 500);
const onChangeSearchTerm = useCallback((text: string) => {
setSearchTerm(text);
searchCustom(text.replace(/^:|:$/g, '').trim());
}, [searchCustom]);
const term = searchTerm?.replace(/^:|:$/g, '').trim() || '';
// Wrap onEmojiPress to set flag before emoji selection
const handleEmojiPress = useCallback((emojiName: string) => {
isSelectingEmoji.current = true;
onEmojiPress(emojiName);
}, [onEmojiPress]);
const EmojiList = (
<EmojiFiltered
customEmojis={customEmojis}
searchTerm={term}
onEmojiPress={handleEmojiPress}
hideEmojiNames={isEmojiSearchFocused}
/>
);
const EmojiSection = (
<CustomEmojiSections
customEmojis={customEmojis}
customEmojisEnabled={customEmojisEnabled}
imageUrl={imageUrl}
file={file}
onEmojiPress={onEmojiPress}
recentEmojis={recentEmojis}
/>
);
return (
<View
style={styles.flex}
testID='emoji_picker'
>
<EmojiPickerHeader
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
onCancel={onCancelSearch}
onChangeText={onChangeSearchTerm}
testID={`${testID}.search_bar`}
value={searchTerm}
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
emojiPickerHeight={emojiPickerHeight}
isSelectingEmojiRef={isSelectingEmoji}
/>
{!isEmojiSearchFocused && EmojiSection}
{isEmojiSearchFocused && EmojiList}
</View>
);
};
export default EmojiPicker;

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeConfigBooleanValue, observeRecentReactions} from '@queries/servers/system';
import EmojiPicker from './emoji_picker';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
customEmojisEnabled: observeConfigBooleanValue(database, 'EnableCustomEmoji'),
customEmojis: queryAllCustomEmojis(database).observe(),
recentEmojis: observeRecentReactions(database),
}));
export default withDatabase(enhanced(EmojiPicker));

View file

@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {DeviceEventEmitter, Platform, View, type LayoutChangeEvent} from 'react-native';
import {useKeyboardState} from 'react-native-keyboard-controller';
import {Easing, useAnimatedReaction, useSharedValue, withTiming, type SharedValue} from 'react-native-reanimated';
import SearchBar, {type SearchProps, type SearchRef} from '@components/search';
import {Events} from '@constants';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {setEmojiSkinTone} from '@hooks/emoji_category_bar';
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
import SkinToneSelector from '@screens/emoji_picker/picker/header/skintone_selector';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const SEARCH_BAR_HEIGHT = 56; // paddingTop: 12 + paddingBottom: 12 + search bar ~32px
const SEARCH_CONTAINER_PADDING = 8; // paddingVertical: 4 * 2
const SEARCH_VISIBILITY_OFFSET = 40; // Extra height to ensure search values are visible
const SEARCH_VISIBILITY_OFFSET_ANDROID = 60;
type Props = SearchProps & {
skinTone: string;
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
emojiPickerHeight: SharedValue<number>;
isSelectingEmojiRef?: React.MutableRefObject<boolean>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
flex: {flex: 1},
row: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingTop: 12,
paddingBottom: 12,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08),
backgroundColor: theme.centerChannelBg,
},
}));
const EmojiPickerHeader: React.FC<Props> = ({
skinTone,
emojiPickerHeight,
setIsEmojiSearchFocused,
isSelectingEmojiRef,
...props
}) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {lastKeyboardHeight: contextLastKeyboardHeight, showInputAccessoryView, isInputAccessoryViewMode} = useKeyboardAnimationContext();
const containerWidth = useSharedValue(0);
const isSearching = useSharedValue(false);
const [showKeyboard, setShowKeyboard] = useState(false);
const keyboardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isReducingHeight = useRef(false);
const searchRef = useRef<SearchRef>(null);
// Use useKeyboardState to get keyboard height
const {height: keyboardHeight} = useKeyboardState();
const keyboardHeightShared = useSharedValue(keyboardHeight);
useEffect(() => {
const req = requestAnimationFrame(() => {
setEmojiSkinTone(skinTone);
});
return () => cancelAnimationFrame(req);
}, [skinTone]);
// Update SharedValue when keyboard height changes
useEffect(() => {
keyboardHeightShared.value = keyboardHeight;
}, [keyboardHeight, keyboardHeightShared]);
// Reset search state when emoji picker is dismissed to prevent reaction from firing
useEffect(() => {
if (!showInputAccessoryView) {
isSearching.value = false;
setIsEmojiSearchFocused(false);
setShowKeyboard(false);
isReducingHeight.current = false;
if (keyboardTimeoutRef.current) {
clearTimeout(keyboardTimeoutRef.current);
keyboardTimeoutRef.current = null;
}
}
}, [showInputAccessoryView, isSearching, setIsEmojiSearchFocused]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (keyboardTimeoutRef.current) {
clearTimeout(keyboardTimeoutRef.current);
}
};
}, []);
// Update emoji picker height when keyboard height changes and search is focused
useAnimatedReaction(
() => keyboardHeightShared.value,
(currentKeyboardH, previousKeyboardH) => {
// Only update if:
// 1. Search is focused
// 2. Emoji picker is still showing (not being dismissed)
// 3. Keyboard height actually changed
if (
isSearching.value &&
isInputAccessoryViewMode.value &&
previousKeyboardH !== undefined &&
previousKeyboardH !== currentKeyboardH
) {
// Skip if keyboard height is 0 (onFocus handler already set correct height using lastKeyboardHeight)
if (currentKeyboardH === 0) {
return;
}
// Calculate total height: keyboardHeight + search bar height + search container padding + visibility offset
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
const targetHeight = Platform.OS === 'android' ? SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset : currentKeyboardH + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
emojiPickerHeight.value = withTiming(targetHeight, {
duration: 250,
easing: Easing.out(Easing.ease),
});
}
},
[keyboardHeightShared, isSearching, emojiPickerHeight, isInputAccessoryViewMode],
);
const onBlur = useCallback(() => {
// Ignore blur events during height reduction process
if (isReducingHeight.current) {
return;
}
if (isSelectingEmojiRef?.current) {
isSelectingEmojiRef.current = false;
return;
}
setIsEmojiSearchFocused(false);
isSearching.value = false;
setShowKeyboard(false);
DeviceEventEmitter.emit(Events.EMOJI_PICKER_SEARCH_FOCUSED, false);
// Clear any pending keyboard timeout
if (keyboardTimeoutRef.current) {
clearTimeout(keyboardTimeoutRef.current);
keyboardTimeoutRef.current = null;
}
// Only restore height if emoji picker is still showing
// If emoji picker is being dismissed (showInputAccessoryView is false),
// PostInput.onFocus already handles setting height to 0
if (showInputAccessoryView) {
// Restore to original emoji picker height (without search-related additions)
const originalHeight = contextLastKeyboardHeight > 0 ? contextLastKeyboardHeight : DEFAULT_INPUT_ACCESSORY_HEIGHT;
emojiPickerHeight.value = withTiming(originalHeight, {duration: 250});
}
}, [emojiPickerHeight, isSearching, setIsEmojiSearchFocused, contextLastKeyboardHeight, showInputAccessoryView, isSelectingEmojiRef]);
const onFocus = useCallback(() => {
if (Platform.OS === 'android' && showKeyboard) {
isReducingHeight.current = false;
return;
}
setIsEmojiSearchFocused(true);
DeviceEventEmitter.emit(Events.EMOJI_PICKER_SEARCH_FOCUSED, true);
// Use last keyboard height from context if available, otherwise use default input accessory height
// The useAnimatedReaction will handle real-time updates when keyboard actually opens
const keyboardH = contextLastKeyboardHeight > 0 ? contextLastKeyboardHeight : DEFAULT_INPUT_ACCESSORY_HEIGHT;
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
const targetHeight = Platform.OS === 'android' ? SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset : keyboardH + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
if (Platform.OS === 'android') {
setShowKeyboard(false);
isReducingHeight.current = true;
}
// Set height immediately (without animation) to prevent jump
emojiPickerHeight.value = withTiming(targetHeight, {duration: 250});
isSearching.value = true;
// On Android, delay keyboard opening to allow height reduction to render first
if (Platform.OS === 'android') {
if (keyboardTimeoutRef.current) {
clearTimeout(keyboardTimeoutRef.current);
}
// Blur immediately to prevent keyboard from opening
searchRef.current?.blur();
const handleDelayedFocus = () => {
setShowKeyboard(true);
requestAnimationFrame(() => {
searchRef.current?.focus();
});
keyboardTimeoutRef.current = null;
};
keyboardTimeoutRef.current = setTimeout(handleDelayedFocus, 500);
}
}, [emojiPickerHeight, isSearching, setIsEmojiSearchFocused, contextLastKeyboardHeight, showKeyboard]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
containerWidth.value = e.nativeEvent.layout.width;
}, [containerWidth]);
return (
<View
onLayout={onLayout}
style={styles.row}
>
<View style={styles.flex}>
<SearchBar
{...props}
onBlur={onBlur}
onFocus={onFocus}
ref={searchRef}
showSoftInputOnFocus={Platform.OS !== 'android' || showKeyboard}
/>
</View>
{Platform.OS !== 'android' &&
<SkinToneSelector
skinTone={skinTone}
containerWidth={containerWidth}
isSearching={isSearching}
/>
}
</View>
);
};
export default EmojiPickerHeader;

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {queryEmojiPreferences} from '@queries/servers/preference';
import EmojiPickerHeader from './emoji_picker_header';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
skinTone: queryEmojiPreferences(database, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(EmojiPickerHeader));

View file

@ -0,0 +1,263 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list';
import {chunk} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View, StyleSheet} from 'react-native';
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
import EmojiCategoryBar from '@components/emoji_category_bar';
import {EMOJI_CATEGORY_ICONS, EMOJI_ROW_MARGIN, EMOJI_SIZE, EMOJIS_PER_PAGE, EMOJIS_PER_ROW, EMOJIS_PER_ROW_TABLET} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import {setEmojiCategoryBarIcons, setEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import EmojiRow, {type EmojiSectionRow} from '@screens/emoji_picker/picker/sections/emoji_row';
import SectionFooter from '@screens/emoji_picker/picker/sections/section_footer';
import SectionHeader, {type EmojiSection} from '@screens/emoji_picker/picker/sections/section_header';
import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji';
import {fillEmoji} from '@utils/emoji/helpers';
import type {CustomEmojiModel} from '@database/models/server';
type SectionListItem = EmojiSection | EmojiSectionRow;
const categoryToI18n: Record<string, CategoryTranslation> = {};
const emptyEmoji: EmojiAlias = {
name: '',
short_name: '',
aliases: [],
};
const keyExtractor = (item: SectionListItem) => {
return (item.type === 'section' ? `${item.key}` : `${item.sectionIndex}-${item.index}-${item.category}`);
};
const getItemType = (item: SectionListItem) => item.type;
const styles = StyleSheet.create({
container: {flex: 1},
containerStyle: {paddingBottom: 50, paddingHorizontal: 12},
});
type Props = {
customEmojis: CustomEmojiModel[];
customEmojisEnabled: boolean;
imageUrl?: string;
file?: ExtractedFileInfo;
onEmojiPress: (emoji: string) => void;
recentEmojis: string[];
};
CategoryNames.forEach((name: string) => {
if (CategoryTranslations.has(name) && CategoryMessage.has(name)) {
categoryToI18n[name] = {
id: CategoryTranslations.get(name)!,
defaultMessage: CategoryMessage.get(name)!,
icon: EMOJI_CATEGORY_ICONS[name],
};
}
});
export default function CustomEmojiSections({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress, recentEmojis}: Props) {
const [customEmojiPage, setCustomEmojiPage] = useState(() => Math.ceil(customEmojis.length / EMOJIS_PER_PAGE));
const [fetchingCustomEmojis, setFetchingCustomEmojis] = useState(false);
const [loadedAllCustomEmojis, setLoadedAllCustomEmojis] = useState(false);
const scrollingToIndex = useRef(false);
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const {currentIndex, selectedIndex} = useEmojiCategoryBar();
const list = useRef<FlashList<SectionListItem> | null>(null);
const sections: SectionListItem[] = useMemo(() => {
const emojisPerRow = isTablet ? EMOJIS_PER_ROW_TABLET : EMOJIS_PER_ROW;
const sectionsArray = CategoryNames.map<EmojiSection>((category) => {
return {
type: 'section',
...categoryToI18n[category],
key: category,
};
});
if (imageUrl || file) {
sectionsArray.unshift({
type: 'section',
id: 'emoji_picker.default',
defaultMessage: 'Default',
icon: 'bookmark-outline',
key: 'default',
});
}
return sectionsArray.reduce<SectionListItem[]>((acc, section, sectionIndex) => {
acc.push(section);
const emojiIndices = EmojiIndicesByCategory.get('default')?.get(section.key);
let emojiArray: EmojiAlias[][];
switch (section.key) {
case 'custom': {
const builtInCustom = emojiIndices.map(fillEmoji.bind(null, 'custom'));
const mapCustom = (ce: CustomEmojiModel) => ({
aliases: [],
name: ce.name,
short_name: '',
});
const custom = customEmojisEnabled ? customEmojis.map(mapCustom) : [];
emojiArray = chunk<EmojiAlias>(builtInCustom.concat(custom), emojisPerRow);
break;
}
case 'recent': {
const recentMap = (emoji: string) => ({
aliases: [],
name: emoji,
short_name: '',
});
if (recentEmojis.length === 0) {
acc.pop();
return acc;
}
emojiArray = chunk<EmojiAlias>(recentEmojis.map(recentMap), emojisPerRow);
break;
}
case 'default':
acc.push({
type: 'row',
emojis: [{
aliases: [],
name: imageUrl || file?.name || '',
short_name: imageUrl || file?.name || '',
category: 'image',
}],
sectionIndex,
category: section.key,
index: 0,
});
return acc;
default:
emojiArray = chunk(emojiIndices.map(fillEmoji.bind(null, section.key)), emojisPerRow);
break;
}
for (let index = 0; index < emojiArray.length; index++) {
const d = emojiArray[index];
const emojis = d.length < emojisPerRow ? d.concat(new Array(emojisPerRow - d.length).fill(emptyEmoji)) : d;
acc.push({
type: 'row',
emojis,
sectionIndex,
category: section.key,
index,
});
}
return acc;
}, []);
}, [customEmojis, customEmojisEnabled, file, imageUrl, isTablet, recentEmojis]);
const stickyHeaderIndices = useMemo(() =>
sections.
map((item, index) => (item.type === 'section' ? index : undefined)).
filter((item) => item !== undefined) as number[],
[sections]);
const renderItem = useCallback(({item}: ListRenderItemInfo<SectionListItem>) => {
if (item.type === 'section') {
return (
<SectionHeader
key={item.key}
section={item}
/>
);
}
return (
<EmojiRow
key={`${item.sectionIndex}-${item.index}-${item.category}`}
emojis={item.emojis}
file={file}
imageUrl={imageUrl}
onEmojiPress={onEmojiPress}
/>
);
}, [file, imageUrl, onEmojiPress]);
const scrollToIndex = useCallback((index: number) => {
scrollingToIndex.current = true;
list.current?.scrollToIndex({animated: false, index: stickyHeaderIndices[index], viewOffset: 0});
setEmojiCategoryBarSection(index);
setTimeout(() => {
scrollingToIndex.current = false;
}, 250);
}, [stickyHeaderIndices]);
const loadMoreCustomEmojis = useCallback(async () => {
if (!customEmojisEnabled || fetchingCustomEmojis || loadedAllCustomEmojis) {
return;
}
setFetchingCustomEmojis(true);
const {data, error} = await fetchCustomEmojis(serverUrl, customEmojiPage, EMOJIS_PER_PAGE);
if (data?.length) {
setCustomEmojiPage(customEmojiPage + 1);
} else if (!error && (data && data.length < EMOJIS_PER_PAGE)) {
setLoadedAllCustomEmojis(true);
}
setFetchingCustomEmojis(false);
}, [customEmojisEnabled, fetchingCustomEmojis, loadedAllCustomEmojis, serverUrl, customEmojiPage]);
const handleStickyHeaderIndexChanged = useCallback((index: number) => {
if (scrollingToIndex.current) {
return;
}
const stickyIndex = stickyHeaderIndices.indexOf(index);
if (stickyIndex !== -1 && currentIndex !== stickyIndex) {
requestAnimationFrame(() => {
setEmojiCategoryBarSection(stickyIndex);
});
}
}, [currentIndex, stickyHeaderIndices]);
const renderFooter = useMemo(() => {
return fetchingCustomEmojis ? <SectionFooter/> : null;
}, [fetchingCustomEmojis]);
useEffect(() => {
setEmojiCategoryBarIcons(sections.filter((s) => s.type === 'section').map((s) => ({
key: s.key,
icon: s.icon,
})));
}, [sections]);
useEffect(() => {
if (selectedIndex != null) {
scrollToIndex(selectedIndex);
}
// do not include scrollToIndex in dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
return (
<View style={styles.container}>
<FlashList
contentContainerStyle={styles.containerStyle}
data={sections}
estimatedItemSize={EMOJI_SIZE + EMOJI_ROW_MARGIN}
getItemType={getItemType}
keyExtractor={keyExtractor}
ListFooterComponent={renderFooter}
onEndReachedThreshold={0.5}
onEndReached={loadMoreCustomEmojis}
onStickyHeaderIndexChanged={handleStickyHeaderIndexChanged}
ref={list}
renderItem={renderItem}
stickyHeaderIndices={stickyHeaderIndices}
/>
<EmojiCategoryBar/>
</View>
);
}

View file

@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {type SharedValue} from 'react-native-reanimated';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {EmojiIndicesByAlias, Emojis} from '@utils/emoji';
import EmojiPicker from './emoji_picker';
type Props = {
height: SharedValue<number>;
onEmojiPress?: (emoji: string) => void;
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
isEmojiSearchFocused: boolean;
}
const CustomEmojiPicker: React.FC<Props> = ({
height,
isEmojiSearchFocused,
onEmojiPress,
setIsEmojiSearchFocused,
}) => {
const {cursorPositionRef, updateValue, updateCursorPosition} = useKeyboardAnimationContext();
const handleEmojiPress = useCallback((emojiName: string) => {
// If onEmojiPress prop is provided, use it (for other use cases)
if (onEmojiPress) {
onEmojiPress(emojiName);
return;
}
// Otherwise, use context-based insertion (for input accessory view)
if (!updateValue || !updateCursorPosition || !cursorPositionRef) {
return;
}
const name = emojiName.trim();
// Calculate the inserted text first to determine its length
let insertedText = '';
if (EmojiIndicesByAlias.get(name)) {
const emoji = Emojis[EmojiIndicesByAlias.get(name)!];
if (emoji.category === 'custom') {
insertedText = ` :${emojiName}: `;
} else {
const unicode = emoji.image;
if (unicode) {
const codeArray = unicode.split('-');
const convertToUnicode = (acc: string, c: string) => {
return acc + String.fromCodePoint(parseInt(c, 16));
};
insertedText = codeArray.reduce(convertToUnicode, '');
} else {
insertedText = ` :${emojiName}: `;
}
}
} else {
insertedText = ` :${emojiName}: `;
}
// Read cursor position and calculate new position immediately
// This ensures rapid clicks use the correct position
const currentCursorPosition = cursorPositionRef.current;
const insertedTextLength = insertedText.length;
const newCursorPosition = currentCursorPosition + insertedTextLength;
// Update cursorPositionRef IMMEDIATELY (before React processes the update)
// This ensures the next rapid click uses the updated position
cursorPositionRef.current = newCursorPosition;
// Update cursor position state (for Android and to keep state in sync)
updateCursorPosition(newCursorPosition);
const insertEmoji = (v: string): string => {
// Use the captured cursor position from when the function was created
return v.slice(0, currentCursorPosition) + insertedText + v.slice(currentCursorPosition);
};
updateValue(insertEmoji);
}, [onEmojiPress, updateValue, updateCursorPosition, cursorPositionRef]);
return (
<EmojiPicker
onEmojiPress={handleEmojiPress}
testID='custom_emoji_picker'
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
isEmojiSearchFocused={isEmojiSearchFocused}
emojiPickerHeight={height}
/>
);
};
export default CustomEmojiPicker;

View file

@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {Screens} from '@constants';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@ -23,8 +24,6 @@ import Uploads from '../uploads';
import Header from './header';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
export type Props = {
testID?: string;
channelId: string;
@ -60,9 +59,8 @@ export type Props = {
scheduledPostsEnabled: boolean;
}
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
const SCHEDULED_POST_PICKER_BUTTON = 'close-scheduled-post-picker';
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
@ -140,14 +138,11 @@ function DraftInput({
const theme = useTheme();
const isTablet = useIsTablet();
const {inputRef, focusInput: focus} = useKeyboardAnimationContext();
const handleLayout = useCallback((e: LayoutChangeEvent) => {
updatePostInputTop(e.nativeEvent.layout.height);
}, []);
const inputRef = useRef<PasteInputRef>();
const focus = useCallback(() => {
inputRef.current?.focus();
}, []);
}, [updatePostInputTop]);
// Render
const postInputTestID = `${testID}.post.input`;

View file

@ -2,13 +2,10 @@
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {Platform} from 'react-native';
import Autocomplete from '@components/autocomplete';
import {ExtraKeyboard} from '@context/extra_keyboard';
import {useServerUrl} from '@context/server';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useKeyboardHeight} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import Archived from './archived';
@ -55,8 +52,6 @@ function PostDraft({
const [cursorPosition, setCursorPosition] = useState(message.length);
const [postInputTop, setPostInputTop] = useState(0);
const [isFocused, setIsFocused] = useState(false);
const keyboardHeight = useKeyboardHeight();
const kbHeight = Platform.OS === 'ios' ? keyboardHeight : 0; // useKeyboardHeight is already deducting the keyboard height on Android
const headerHeight = useDefaultHeaderHeight();
const serverUrl = useServerUrl();
@ -64,9 +59,9 @@ function PostDraft({
useEffect(() => {
setValue(message);
setCursorPosition(message.length);
}, [channelId, rootId]);
}, [channelId, message, rootId]);
const autocompletePosition = AUTOCOMPLETE_ADJUST + kbHeight + postInputTop;
const autocompletePosition = postInputTop + AUTOCOMPLETE_ADJUST;
const autocompleteAvailableSpace = containerHeight - autocompletePosition - (isChannelScreen ? headerHeight : 0);
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);
@ -120,6 +115,7 @@ function PostDraft({
shouldDirectlyReact={!Boolean(files?.length)}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
usePortal={true}
/>
) : null;
@ -127,7 +123,6 @@ function PostDraft({
<>
{draftHandler}
{autoComplete}
{Platform.OS !== 'android' && <ExtraKeyboard/>}
</>
);
}

View file

@ -10,15 +10,18 @@ import {
Alert, AppState, type AppStateStatus, DeviceEventEmitter, type EmitterSubscription, Keyboard,
type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData,
} from 'react-native';
import {runOnUI} from 'react-native-reanimated';
import {updateDraftMessage} from '@actions/local/draft';
import {userTyping} from '@actions/websocket/users';
import {Events, Screens} from '@constants';
import {useExtraKeyboardContext} from '@context/extra_keyboard';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useInputPropagation} from '@hooks/input';
import {useFocusAfterEmojiDismiss} from '@hooks/useFocusAfterEmojiDismiss';
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
import NavigationStore from '@store/navigation_store';
import {handleDraftUpdate} from '@utils/draft';
import {extractFileInfo} from '@utils/file';
@ -122,7 +125,41 @@ export default function PostInput({
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const managedConfig = useManagedConfig<ManagedConfig>();
const keyboardContext = useExtraKeyboardContext();
const {
setShowInputAccessoryView,
showInputAccessoryView,
isInputAccessoryViewMode,
inputAccessoryViewAnimatedHeight,
keyboardTranslateY,
isTransitioningFromCustomView,
setIsEmojiSearchFocused,
isEmojiSearchFocused,
keyboardHeight,
lastKeyboardHeight,
bottomInset,
scrollOffset,
registerCursorPosition,
registerPostInputCallbacks,
} = useKeyboardAnimationContext();
// Register cursor position updates with context
useEffect(() => {
if (showInputAccessoryView) {
return;
}
if (registerCursorPosition) {
registerCursorPosition(cursorPosition);
}
}, [registerCursorPosition, cursorPosition, showInputAccessoryView]);
// Register updateValue and updateCursorPosition with context
useEffect(() => {
if (registerPostInputCallbacks) {
registerPostInputCallbacks(updateValue, updateCursorPosition);
}
}, [registerPostInputCallbacks, updateValue, updateCursorPosition]);
const [propagateValue, shouldProcessEvent] = useInputPropagation();
const lastTypingEventSent = useRef(0);
@ -132,22 +169,25 @@ export default function PostInput({
const [longMessageAlertShown, setLongMessageAlertShown] = useState(false);
// Handle focus after emoji picker dismissal
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, [inputRef]);
const {
focus: focusWithEmojiDismiss,
isDismissingEmojiPicker,
focusTimeoutRef,
isManuallyFocusingAfterEmojiDismiss,
} = useFocusAfterEmojiDismiss(inputRef, focusInput);
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
const maxHeight = isTablet ? 150 : 88;
const pasteInputStyle = useMemo(() => {
return {...style.input, maxHeight};
}, [maxHeight, style.input]);
const handleAndroidKeyboardHide = () => {
onBlur();
};
const handleAndroidKeyboardShow = () => {
onFocus();
};
const onBlur = useCallback(() => {
keyboardContext?.registerTextInputBlur();
handleDraftUpdate({
serverUrl,
channelId,
@ -155,12 +195,111 @@ export default function PostInput({
value,
});
setIsFocused(false);
}, [keyboardContext, serverUrl, channelId, rootId, value, setIsFocused]);
}, [serverUrl, channelId, rootId, value, setIsFocused]);
// Handle press event (fires BEFORE onFocus) - dismiss emoji picker before keyboard opens
const handlePress = useCallback(() => {
focusWithEmojiDismiss();
}, [focusWithEmojiDismiss]);
const onFocus = useCallback(() => {
keyboardContext?.registerTextInputFocus();
// Ignore focus events during emoji picker dismissal - handled manually
if (Platform.OS === 'android' && (isDismissingEmojiPicker.current || focusTimeoutRef.current || isManuallyFocusingAfterEmojiDismiss)) {
return;
}
// On Android, ignore focus events when emoji search is focused
// This prevents the emoji picker from closing when the search bar gets focus
if (Platform.OS === 'android' && isEmojiSearchFocused) {
return;
}
setIsFocused(true);
}, [setIsFocused, keyboardContext]);
// Reset emoji search focus immediately to prevent jumping
// This must happen before closing the emoji picker
setIsEmojiSearchFocused(false);
// Close emoji picker immediately
setShowInputAccessoryView(false);
if (Platform.OS === 'android') {
keyboardTranslateY.value = inputAccessoryViewAnimatedHeight.value;
inputAccessoryViewAnimatedHeight.value = 0;
isInputAccessoryViewMode.value = false;
// IMPORTANT: Reset isTransitioningFromCustomView when keyboard opens
// This ensures emoji picker can be opened again after keyboard appears
isTransitioningFromCustomView.value = false;
// Reset bottomInset and scrollOffset so the scroll restoration can trigger when emoji picker closes
bottomInset.value = 0;
scrollOffset.value = 0;
return;
}
// Transition from emoji picker to keyboard
if (showInputAccessoryView) {
// Use actual keyboard height instead of emoji picker height to ensure consistency
// This prevents height accumulation when transitioning multiple times
// Use default keyboard height if no keyboard height has been recorded yet
// This prevents input container from going to bottom when keyboard hasn't been opened
const targetKeyboardHeight = keyboardHeight.value || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
// Set transition flag FIRST synchronously to prevent keyboard handlers from interfering
// This must be set before disabling input accessory view mode to avoid race conditions
isTransitioningFromCustomView.value = true;
// Collapse emoji picker instantly
inputAccessoryViewAnimatedHeight.value = 0;
// Set input container height to keyboard height to ensure correct final position
// This ensures the height always matches the keyboard, preventing accumulation
keyboardTranslateY.value = targetKeyboardHeight;
// Use runOnUI to disable input accessory view mode atomically
// This ensures the transition flag is visible when keyboard handlers start processing
runOnUI(() => {
'worklet';
// Disable custom view mode to allow keyboard handlers to work
// This is done AFTER setting transition flag to prevent race conditions
isInputAccessoryViewMode.value = false;
})();
// Safety net: In rare cases (app backgrounding, system interruptions, rapid toggling),
// the keyboard onEnd event might not fire, leaving us stuck in transition state.
// This timeout ensures we recover after 1 second if that happens.
setTimeout(() => {
if (isTransitioningFromCustomView.value) {
isTransitioningFromCustomView.value = false;
}
}, 1000);
}
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isDismissingEmojiPicker,
focusTimeoutRef,
isManuallyFocusingAfterEmojiDismiss,
isEmojiSearchFocused,
setIsFocused,
setIsEmojiSearchFocused,
setShowInputAccessoryView,
showInputAccessoryView,
lastKeyboardHeight,
]);
const handleAndroidKeyboardHide = useCallback(() => {
onBlur();
}, [onBlur]);
const handleAndroidKeyboardShow = useCallback(() => {
onFocus();
}, [onFocus]);
const checkMessageLength = useCallback((newValue: string) => {
const valueLength = newValue.trim().length;
@ -189,10 +328,13 @@ export default function PostInput({
}, [intl, longMessageAlertShown, maxMessageLength]);
const handlePostDraftSelectionChanged = useCallback((event: NativeSyntheticEvent<TextInputSelectionChangeEventData> | null, fromHandleTextChange = false) => {
if (showInputAccessoryView && !fromHandleTextChange) {
return;
}
const cp = fromHandleTextChange ? cursorPosition : event!.nativeEvent.selection.end;
updateCursorPosition(cp);
}, [updateCursorPosition, cursorPosition]);
}, [showInputAccessoryView, cursorPosition, updateCursorPosition]);
const handleTextChange = useCallback((newValue: string) => {
if (!shouldProcessEvent(newValue)) {
@ -286,7 +428,7 @@ export default function PostInput({
keyboardShowListener?.remove();
keyboardHideListener?.remove();
});
}, []);
}, [handleAndroidKeyboardHide, handleAndroidKeyboardShow]);
useEffect(() => {
const listener = AppState.addEventListener('change', onAppStateChange);
@ -311,6 +453,12 @@ export default function PostInput({
listener.remove();
updateDraftMessage(serverUrl, channelId, rootId, lastNativeValue.current); // safe draft on unmount
};
// - updateValue, updateCursorPosition, propagateValue are stable setState/hook functions
// - inputRef is a ref (stable reference, doesn't need to be in deps)
// - serverUrl, value, lastNativeValue are either stable or we want their latest values when event fires
// - We need to recreate the listener when channelId/rootId changes to check the correct source screen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateValue, channelId, rootId]);
useEffect(() => {
@ -318,6 +466,10 @@ export default function PostInput({
propagateValue(value);
lastNativeValue.current = value;
}
// - propagateValue is from useInputPropagation hook (stable reference, doesn't need to be in deps)
// - lastNativeValue is a ref (stable reference, doesn't need to be in deps)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const events = useMemo(() => ({
@ -336,11 +488,13 @@ export default function PostInput({
onBlur={onBlur}
onChangeText={handleTextChange}
onFocus={onFocus}
onPress={Platform.OS === 'android' ? handlePress : undefined}
onPaste={onPaste}
onSelectionChange={handlePostDraftSelectionChanged}
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
ref={inputRef}
showSoftInputOnFocus={Platform.OS === 'android' ? (!showInputAccessoryView || isManuallyFocusingAfterEmojiDismiss) : true}
smartPunctuation='disable'
submitBehavior='newline'
style={pasteInputStyle}
@ -349,6 +503,7 @@ export default function PostInput({
textContentType='none'
value={value}
autoCapitalize='sentences'
nativeID={testID}
/>
);
}

View file

@ -0,0 +1,306 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, waitFor} from '@testing-library/react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {openAttachmentOptions} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import AttachmentQuickAction from '.';
jest.mock('react-native-keyboard-controller', () => ({
KeyboardController: {
dismiss: jest.fn(() => Promise.resolve()),
},
}));
jest.mock('@context/keyboard_animation', () => ({
useKeyboardAnimationContext: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
openAttachmentOptions: jest.fn(),
}));
describe('AttachmentQuickAction', () => {
const mockCloseInputAccessoryView = jest.fn();
const mockKeyboardControllerDismiss = jest.mocked(KeyboardController.dismiss);
const mockOpenAttachmentOptions = jest.mocked(openAttachmentOptions);
const mockUseKeyboardAnimationContext = jest.mocked(useKeyboardAnimationContext);
const baseProps = {
disabled: false,
fileCount: 0,
onUploadFiles: jest.fn(),
maxFilesReached: false,
maxFileCount: 10,
testID: 'test-attachment',
};
beforeEach(() => {
jest.clearAllMocks();
mockUseKeyboardAnimationContext.mockReturnValue({
closeInputAccessoryView: mockCloseInputAccessoryView,
} as unknown as ReturnType<typeof useKeyboardAnimationContext>);
});
describe('user interactions', () => {
it('should call onUploadFiles when button is pressed', async () => {
const onUploadFiles = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
onUploadFiles={onUploadFiles}
/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockCloseInputAccessoryView).toHaveBeenCalledTimes(1);
expect(mockKeyboardControllerDismiss).toHaveBeenCalledTimes(1);
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
});
});
it('should not call onUploadFiles when button is disabled', async () => {
const onUploadFiles = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
disabled={true}
onUploadFiles={onUploadFiles}
/>,
);
const button = getByTestId('test-attachment.disabled');
fireEvent.press(button);
// Should not trigger any actions when disabled
expect(mockCloseInputAccessoryView).not.toHaveBeenCalled();
expect(mockKeyboardControllerDismiss).not.toHaveBeenCalled();
expect(mockOpenAttachmentOptions).not.toHaveBeenCalled();
});
it('should close input accessory view before opening bottom sheet', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...baseProps}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
// closeInputAccessoryView should be called before openAttachmentOptions
expect(mockCloseInputAccessoryView).toHaveBeenCalledTimes(1);
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
});
});
it('should dismiss keyboard before opening bottom sheet', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...baseProps}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockKeyboardControllerDismiss).toHaveBeenCalledTimes(1);
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
});
});
});
describe('bottom sheet opening', () => {
it('should call openAttachmentOptions with intl, theme, and props', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...baseProps}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
onUploadFiles: baseProps.onUploadFiles,
maxFilesReached: false,
canUploadFiles: true,
testID: 'test-attachment',
fileCount: 0,
maxFileCount: 10,
}),
);
});
});
it('should pass correct props to openAttachmentOptions', async () => {
const onUploadFiles = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
onUploadFiles={onUploadFiles}
fileCount={5}
maxFilesReached={false}
maxFileCount={10}
disabled={false}
testID='custom-test-id'
/>,
);
const button = getByTestId('custom-test-id');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
onUploadFiles,
fileCount: 5,
maxFilesReached: false,
canUploadFiles: true,
testID: 'custom-test-id',
maxFileCount: 10,
}),
);
});
});
it('should pass canUploadFiles as false when disabled', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
disabled={true}
/>,
);
const button = getByTestId('test-attachment.disabled');
fireEvent.press(button);
// When disabled, openAttachmentOptions should not be called
expect(mockOpenAttachmentOptions).not.toHaveBeenCalled();
});
it('should pass intl and theme to openAttachmentOptions', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...baseProps}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.objectContaining({
formatMessage: expect.any(Function),
}), // intl
expect.objectContaining({
centerChannelColor: expect.anything(),
}), // theme
expect.any(Object), // props
);
});
});
});
describe('edge cases', () => {
it('should handle fileCount prop correctly', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
fileCount={3}
/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
fileCount: 3,
}),
);
});
});
it('should handle maxFilesReached prop correctly', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
maxFilesReached={true}
/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
maxFilesReached: true,
}),
);
});
});
it('should handle maxFileCount prop correctly', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
maxFileCount={20}
/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
maxFileCount: 20,
}),
);
});
});
it('should handle default fileCount when not provided', async () => {
const propsWithoutFileCount = {
...baseProps,
};
delete (propsWithoutFileCount as {fileCount?: number}).fileCount;
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...propsWithoutFileCount}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
fileCount: 0,
}),
);
});
});
});
});

View file

@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {openAttachmentOptions} from '@screens/navigation';
import {dismissKeyboard} from '@utils/keyboard';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
iconContainer: {
display: 'flex',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
borderRadius: 20,
alignItems: 'center',
alignSelf: 'center',
justifyContent: 'center',
width: 40,
height: 40,
marginRight: 8,
},
icon: {
alignSelf: 'center',
},
};
});
export default function AttachmentQuickAction({
disabled,
fileCount = 0,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const {closeInputAccessoryView} = useKeyboardAnimationContext();
const style = getStyleSheet(theme);
const iconColor = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
const openFileAttachmentOptions = useCallback(async () => {
closeInputAccessoryView();
await dismissKeyboard();
openAttachmentOptions(intl, theme, {
onUploadFiles,
maxFilesReached,
canUploadFiles: !disabled,
testID,
fileCount,
maxFileCount,
});
}, [closeInputAccessoryView, intl, theme, onUploadFiles, maxFilesReached, disabled, testID, fileCount, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={openFileAttachmentOptions}
style={style.iconContainer}
type={'opacity'}
>
<CompassIcon
name='plus'
size={ICON_SIZE}
color={iconColor}
style={style.icon}
/>
</TouchableWithFeedback>
);
}

View file

@ -1,84 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import SlideUpPanelItem from '@components/slide_up_panel_item';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {dismissBottomSheet} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {CameraOptions} from 'react-native-image-picker';
type Props = {
onPress: (options: CameraOptions) => void;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
title: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
marginBottom: 8,
},
}));
const CameraType = ({onPress}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
const style = getStyle(theme);
const intl = useIntl();
const onPhoto = async () => {
const options: CameraOptions = {
quality: 0.8,
mediaType: 'photo',
saveToPhotos: true,
};
await dismissBottomSheet();
onPress(options);
};
const onVideo = async () => {
const options: CameraOptions = {
videoQuality: 'high',
mediaType: 'video',
saveToPhotos: true,
};
await dismissBottomSheet();
onPress(options);
};
return (
<View>
{!isTablet &&
<FormattedText
id='mobile.camera_type.title'
defaultMessage='Camera options'
style={style.title}
/>
}
<SlideUpPanelItem
leftIcon='camera-outline'
onPress={onPhoto}
testID='camera_type.photo'
text={intl.formatMessage({id: 'camera_type.photo.option', defaultMessage: 'Capture Photo'})}
/>
<SlideUpPanelItem
leftIcon='video-outline'
onPress={onVideo}
testID='camera_type.video'
text={intl.formatMessage({id: 'camera_type.video.option', defaultMessage: 'Record Video'})}
/>
</View>
);
};
export default CameraType;

View file

@ -1,97 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import {bottomSheet} from '@screens/navigation';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {changeOpacity} from '@utils/theme';
import CameraType from './camera_type';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
import type {CameraOptions} from 'react-native-image-picker';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function CameraQuickAction({
disabled,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID,
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback((options: CameraOptions) => {
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromCamera(options);
}, [intl, onUploadFiles]);
const renderContent = useCallback(() => {
return (
<CameraType
onPress={handleButtonPress}
/>
);
}, [handleButtonPress]);
const openSelectorModal = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
bottomSheet({
title: intl.formatMessage({id: 'mobile.camera_type.title', defaultMessage: 'Camera options'}),
renderContent,
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT) + TITLE_HEIGHT],
theme,
closeButtonId: 'camera-close-id',
});
}, [intl, theme, renderContent, maxFilesReached, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={openSelectorModal}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='camera-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import {Platform, StyleSheet} from 'react-native';
import {runOnJS, runOnUI} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
import {usePreventDoubleTap} from '@hooks/utils';
import {dismissKeyboard, isKeyboardVisible} from '@utils/keyboard';
import {changeOpacity} from '@utils/theme';
type Props = {
testID?: string;
disabled?: boolean;
};
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function EmojiQuickAction({
testID,
disabled,
}: Props) {
const theme = useTheme();
const {
isInputAccessoryViewMode,
isTransitioningFromCustomView,
keyboardHeight,
lastKeyboardHeight,
inputAccessoryViewAnimatedHeight,
showInputAccessoryView,
setShowInputAccessoryView,
isKeyboardFullyClosed,
} = useKeyboardAnimationContext();
const showEmojiPicker = useCallback(() => {
setShowInputAccessoryView(true);
}, [setShowInputAccessoryView]);
const checkCallbackRef = useRef<(() => void) | null>(null);
const scheduleKeyboardCheck = useCallback(() => {
const checkKeyboard = () => {
runOnUI(() => {
'worklet';
const currentKeyboardHeight = keyboardHeight.value;
const targetHeight = currentKeyboardHeight || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
if (isKeyboardFullyClosed.value || keyboardHeight.value === 0) {
// Match iOS order: Set SharedValues first, then trigger React render
// This ensures values are set before emoji picker component mounts
isInputAccessoryViewMode.value = true;
inputAccessoryViewAnimatedHeight.value = targetHeight;
// Trigger React render to mount emoji picker component
runOnJS(showEmojiPicker)();
} else if (checkCallbackRef.current) {
runOnJS(checkCallbackRef.current)();
}
})();
};
checkCallbackRef.current = () => {
requestAnimationFrame(checkKeyboard);
};
checkKeyboard();
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastKeyboardHeight, showEmojiPicker]);
const handleButtonPress = usePreventDoubleTap(useCallback(() => {
// Prevent opening if already showing or transitioning
if (disabled || showInputAccessoryView || isTransitioningFromCustomView.value) {
return;
}
if (Platform.OS === 'android' && isKeyboardVisible()) {
dismissKeyboard();
// Wait for keyboard to be fully dismissed before showing emoji picker
// This prevents the emoji picker from appearing above the keyboard
// Start checking after a small delay to give keyboard dismissal time to start
setTimeout(scheduleKeyboardCheck, 50);
return;
}
// CRITICAL: Execute all shared value updates atomically on UI thread.
// Why? When KeyboardController.dismiss() is called, it immediately fires keyboard events
// (onStart, onMove, onEnd) on the UI thread. If we set isInputAccessoryViewMode on the
// JS thread, there's a race condition - the keyboard handlers might fire BEFORE the flag
// propagates to the UI thread, causing them to process the dismiss event incorrectly.
// By running everything on UI thread, we guarantee isInputAccessoryViewMode is set
// BEFORE keyboard events fire, ensuring they are properly ignored.
runOnUI(() => {
'worklet';
// Determine target height for emoji picker
const currentKeyboardHeight = keyboardHeight.value;
const targetHeight = currentKeyboardHeight || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
// Enable custom view mode before keyboard dismissal to prevent keyboard handlers from interfering
isInputAccessoryViewMode.value = true;
// Set emoji picker height to target
// Note: Don't modify `height` yet - it will be set to 0 after emoji picker mounts
// This keeps the input container at the keyboard position while emoji picker renders
inputAccessoryViewAnimatedHeight.value = targetHeight;
// Trigger React render to mount emoji picker component
runOnJS(setShowInputAccessoryView)(true);
// Dismiss keyboard
runOnJS(dismissKeyboard)();
})();
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled, showInputAccessoryView, lastKeyboardHeight, setShowInputAccessoryView, scheduleKeyboardCheck]));
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='emoticon-happy-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -1,72 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function FileQuickAction({
disabled,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromFiles(undefined, true);
}, [intl, maxFileCount, maxFilesReached, onUploadFiles]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='paperclip'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -1,74 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function ImageQuickAction({
disabled,
fileCount = 0,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromPhotoGallery(maxFileCount - fileCount);
}, [onUploadFiles, fileCount, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='image-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -6,7 +6,9 @@ import React, {useCallback} from 'react';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {useFocusAfterEmojiDismiss} from '@hooks/useFocusAfterEmojiDismiss';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
@ -38,6 +40,11 @@ export default function InputQuickAction({
focus,
}: Props) {
const theme = useTheme();
const {inputRef} = useKeyboardAnimationContext();
// Use hook to handle focus after emoji picker dismissal
const {focus: focusWithEmojiDismiss} = useFocusAfterEmojiDismiss(inputRef, focus);
const onPress = useCallback(() => {
updateValue((v) => {
if (inputType === 'at') {
@ -49,8 +56,8 @@ export default function InputQuickAction({
}
return '/';
});
focus();
}, [inputType, updateValue, focus]);
focusWithEmojiDismiss();
}, [inputType, updateValue, focusWithEmojiDismiss]);
const actionTestID = disabled ?
`${testID}.disabled` :

View file

@ -3,15 +3,17 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet} from 'react-native';
import {StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens} from '@constants';
import {ICON_SIZE} from '@constants/post_draft';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {openAsBottomSheet} from '@screens/navigation';
import {dismissKeyboard} from '@utils/keyboard';
import {changeOpacity} from '@utils/theme';
type Props = {
@ -38,9 +40,11 @@ export default function PostPriorityAction({
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const {closeInputAccessoryView} = useKeyboardAnimationContext();
const onPress = useCallback(() => {
Keyboard.dismiss();
const onPress = useCallback(async () => {
closeInputAccessoryView();
await dismissKeyboard();
const title = isTablet ? intl.formatMessage({id: 'post_priority.picker.title', defaultMessage: 'Message priority'}) : '';
@ -55,7 +59,7 @@ export default function PostPriorityAction({
closeButtonId: POST_PRIORITY_PICKER_BUTTON,
},
});
}, [isTablet, intl, theme, postPriority, updatePostPriority]);
}, [closeInputAccessoryView, isTablet, intl, theme, postPriority, updatePostPriority]);
const iconName = 'alert-circle-outline';
const iconColor = changeOpacity(theme.centerChannelColor, 0.64);

View file

@ -8,6 +8,7 @@ import QuickActions from './quick_actions';
describe('Quick Actions', () => {
const baseProps: Parameters<typeof QuickActions>[0] = {
testID: 'test-quick-actions',
canUploadFiles: true,
fileCount: 0,
isPostPriorityEnabled: true,
@ -24,21 +25,149 @@ describe('Quick Actions', () => {
focus: jest.fn(),
};
it('Should render slash command if canShowSlashCommands is true', () => {
const props = {
...baseProps,
canShowSlashCommands: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('slash-input-action')).toBeDefined();
describe('slash commands', () => {
it('should render slash command if canShowSlashCommands is true', () => {
const props = {
...baseProps,
canShowSlashCommands: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeDefined();
});
it('should not render slash command if canShowSlashCommands is false', () => {
const props = {
...baseProps,
canShowSlashCommands: false,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeNull();
});
it('should render slash command by default when canShowSlashCommands is not provided', () => {
const props = {
...baseProps,
};
delete (props as {canShowSlashCommands?: boolean}).canShowSlashCommands;
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeDefined();
});
});
it('Should not render slash command if canShowSlashCommands is false', () => {
const props = {
...baseProps,
canShowSlashCommands: false,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('slash-input-action')).toBeNull();
describe('post priority', () => {
it('should render post priority action when both isPostPriorityEnabled and canShowPostPriority are true', () => {
const props = {
...baseProps,
isPostPriorityEnabled: true,
canShowPostPriority: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeDefined();
});
it('should not render post priority action when isPostPriorityEnabled is false', () => {
const props = {
...baseProps,
isPostPriorityEnabled: false,
canShowPostPriority: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
});
it('should not render post priority action when canShowPostPriority is false', () => {
const props = {
...baseProps,
isPostPriorityEnabled: true,
canShowPostPriority: false,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
});
it('should not render post priority action when canShowPostPriority is undefined', () => {
const props = {
...baseProps,
isPostPriorityEnabled: true,
};
delete (props as {canShowPostPriority?: boolean}).canShowPostPriority;
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
});
});
describe('input action disabled states', () => {
it('should disable at input action when value ends with @', () => {
const props = {
...baseProps,
value: 'test @',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const atAction = getByTestId('test-quick-actions.at_input_action.disabled');
expect(atAction).toBeDisabled();
});
it('should enable at input action when value does not end with @', () => {
const props = {
...baseProps,
value: 'test message',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const atAction = getByTestId('test-quick-actions.at_input_action');
expect(atAction).not.toBeDisabled();
});
it('should enable at input action when value is empty', () => {
const props = {
...baseProps,
value: '',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const atAction = getByTestId('test-quick-actions.at_input_action');
expect(atAction).not.toBeDisabled();
});
it('should disable slash input action when value is not empty', () => {
const props = {
...baseProps,
value: 'test message',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const slashAction = getByTestId('test-quick-actions.slash_input_action.disabled');
expect(slashAction).toBeDisabled();
});
it('should enable slash input action when value is empty', () => {
const props = {
...baseProps,
value: '',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const slashAction = getByTestId('test-quick-actions.slash_input_action');
expect(slashAction).not.toBeDisabled();
});
});
describe('attachment action props', () => {
it('should pass disabled=true when canUploadFiles is false', () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const attachmentAction = getByTestId('test-quick-actions.attachment_action.disabled');
expect(attachmentAction).toBeDisabled();
});
it('should pass disabled=false when canUploadFiles is true', () => {
const props = {
...baseProps,
canUploadFiles: true,
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const attachmentAction = getByTestId('test-quick-actions.attachment_action');
expect(attachmentAction).not.toBeDisabled();
});
});
});

View file

@ -4,9 +4,8 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import CameraAction from './camera_quick_action';
import FileAction from './file_quick_action';
import ImageAction from './image_quick_action';
import AttachmentAction from './attachment_quick_action';
import EmojiAction from './emoji_quick_action';
import InputAction from './input_quick_action';
import PostPriorityAction from './post_priority_action';
@ -17,6 +16,7 @@ type Props = {
isPostPriorityEnabled: boolean;
canShowPostPriority?: boolean;
canShowSlashCommands?: boolean;
canShowEmojiPicker?: boolean;
maxFileCount: number;
// Draft Handler
@ -35,6 +35,7 @@ const style = StyleSheet.create({
display: 'flex',
flexDirection: 'row',
height: QUICK_ACTIONS_HEIGHT,
marginLeft: 8,
},
});
@ -46,6 +47,7 @@ export default function QuickActions({
isPostPriorityEnabled,
canShowSlashCommands = true,
canShowPostPriority,
canShowEmojiPicker = true,
maxFileCount,
updateValue,
addFiles,
@ -58,9 +60,8 @@ export default function QuickActions({
const atInputActionTestID = `${testID}.at_input_action`;
const slashInputActionTestID = `${testID}.slash_input_action`;
const fileActionTestID = `${testID}.file_action`;
const imageActionTestID = `${testID}.image_action`;
const cameraActionTestID = `${testID}.camera_action`;
const emojiActionTestID = `${testID}.emoji_action`;
const attachmentActionTestID = `${testID}.attachment_action`;
const postPriorityActionTestID = `${testID}.post_priority_action`;
const uploadProps = {
@ -76,6 +77,10 @@ export default function QuickActions({
testID={testID}
style={style.quickActionsContainer}
>
<AttachmentAction
testID={attachmentActionTestID}
{...uploadProps}
/>
<InputAction
testID={atInputActionTestID}
disabled={atDisabled}
@ -92,18 +97,11 @@ export default function QuickActions({
focus={focus}
/>
)}
<FileAction
testID={fileActionTestID}
{...uploadProps}
/>
<ImageAction
testID={imageActionTestID}
{...uploadProps}
/>
<CameraAction
testID={cameraActionTestID}
{...uploadProps}
/>
{canShowEmojiPicker && (
<EmojiAction
testID={emojiActionTestID}
/>
)}
{isPostPriorityEnabled && canShowPostPriority && (
<PostPriorityAction
testID={postPriorityActionTestID}

View file

@ -7,6 +7,7 @@ import {Text, TouchableOpacity, View} from 'react-native';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import CompassIcon from '@components/compass_icon';
import {SEARCH} from '@constants/screens';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {usePreventDoubleTap} from '@hooks/utils';
import {makeStyleSheetFromTheme} from '@utils/theme';
@ -44,11 +45,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) => {
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const {blurAndDismissKeyboard} = useKeyboardAnimationContext();
const onPress = usePreventDoubleTap(useCallback(async () => {
await blurAndDismissKeyboard();
const onPress = usePreventDoubleTap(useCallback(() => {
const rootId = post.rootId || post.id;
fetchAndSwitchToThread(serverUrl, rootId);
}, [post.id, post.rootId, serverUrl]));
}, [blurAndDismissKeyboard, post.id, post.rootId, serverUrl]));
return (
<View

View file

@ -5,7 +5,8 @@ import AgentPost from '@agents/components/agent_post';
import {isAgentPost} from '@agents/utils';
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
import {Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
import {removePost} from '@actions/local/post';
import {showPermalink} from '@actions/remote/permalink';
@ -17,7 +18,7 @@ import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
import {POST_TIME_TO_FAIL} from '@constants/post';
import * as Screens from '@constants/screens';
import {useHideExtraKeyboardIfNeeded} from '@context/extra_keyboard';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@ -155,6 +156,7 @@ const Post = ({
const serverUrl = useServerUrl();
const theme = useTheme();
const isTablet = useIsTablet();
const {blurAndDismissKeyboard, closeInputAccessoryView, showInputAccessoryView} = useKeyboardAnimationContext();
const styles = getStyleSheet(theme);
const isAutoResponder = fromAutoResponder(post);
const isPendingOrFailed = isPostPendingOrFailed(post);
@ -185,7 +187,7 @@ const Post = ({
return false;
}, [customEmojiNames, post.message]);
const handlePostPress = useCallback(() => {
const handlePostPress = useCallback(async () => {
if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) {
showPermalink(serverUrl, '', post.id);
return;
@ -197,6 +199,7 @@ const Post = ({
} else if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) {
// BoR posts cannot have replies, so don't open threads screen for them
if (!borPost && [Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
await blurAndDismissKeyboard();
const postRootId = post.rootId || post.id;
fetchAndSwitchToThread(serverUrl, postRootId);
}
@ -205,17 +208,19 @@ const Post = ({
setTimeout(() => {
pressDetected.current = false;
}, 300);
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost]);
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost, blurAndDismissKeyboard]);
const handlePress = useHideExtraKeyboardIfNeeded(() => {
const handlePress = useCallback(() => {
pressDetected.current = true;
KeyboardController.dismiss();
if (post) {
setTimeout(handlePostPress, 300);
}
}, [handlePostPress, post]);
const showPostOptions = useHideExtraKeyboardIfNeeded(() => {
const showPostOptions = useCallback(async () => {
if (!post) {
return;
}
@ -228,7 +233,11 @@ const Post = ({
return;
}
Keyboard.dismiss();
if (showInputAccessoryView) {
closeInputAccessoryView();
}
await blurAndDismissKeyboard();
const passProps = {sourceScreen: location, post, showAddReaction, serverUrl};
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
@ -239,11 +248,7 @@ const Post = ({
title,
props: passProps,
});
}, [
canDelete, hasBeenDeleted, intl,
isEphemeral, isPendingOrFailed, isTablet, isSystemPost,
location, post, serverUrl, showAddReaction, theme,
]);
}, [post, isSystemPost, canDelete, hasBeenDeleted, isPendingOrFailed, isEphemeral, blurAndDismissKeyboard, closeInputAccessoryView, showInputAccessoryView, location, showAddReaction, serverUrl, isTablet, intl, theme]);
const [, rerender] = useState(false);
useEffect(() => {

View file

@ -2,8 +2,8 @@
// See LICENSE.txt for license information.
import {act} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {DeviceEventEmitter, Platform} from 'react-native';
import React, {createRef, type ComponentProps} from 'react';
import {DeviceEventEmitter, type FlatList, Platform} from 'react-native';
import * as localPostFunctions from '@actions/local/post';
import * as postFunctions from '@actions/remote/post';
@ -75,11 +75,11 @@ describe('components/post_list/PostList', () => {
customEmojiNames: [],
lastViewedAt: 0,
location: Screens.CHANNEL,
nativeID: 'post-list',
posts: mockPosts,
savedPostIds: new Set(),
testID: 'post_list',
shouldShowJoinLeaveMessages: false,
listRef: createRef<FlatList<string | PostModel>>(),
};
it('renders correctly with basic props', () => {
@ -370,7 +370,7 @@ describe('components/post_list/PostList', () => {
// which causes the content offset to shift
act(() => {
flatList.props.onScroll({
flatList.props.onMomentumScrollEnd({
nativeEvent: {
contentOffset: {y: 200},
...unrelatedNativeEventsAttributes,
@ -383,7 +383,7 @@ describe('components/post_list/PostList', () => {
// if user post an image, scroll to bottom being called to push offset to 0, which causes the "New Messages" message to disappear
act(() => {
flatList.props.onScroll({
flatList.props.onMomentumScrollEnd({
nativeEvent: {
contentOffset: {y: 0},
...unrelatedNativeEventsAttributes,

View file

@ -3,8 +3,9 @@
import {FlatList} from '@stream-io/flat-list-mvcp';
import React, {type ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native';
import Animated, {type AnimatedStyle} from 'react-native-reanimated';
import {DeviceEventEmitter, type GestureResponderEvent, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native';
import {useKeyboardState} from 'react-native-keyboard-controller';
import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useSharedValue, type AnimatedStyle} from 'react-native-reanimated';
import {removePost} from '@actions/local/post';
import {fetchPosts, fetchPostThread} from '@actions/remote/post';
@ -15,8 +16,10 @@ import Post from '@components/post_list/post';
import ThreadOverview from '@components/post_list/thread_overview';
import {Events, Screens} from '@constants';
import {PostTypes} from '@constants/post';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
import {getDateForDateLine, preparePostList} from '@utils/post_list';
import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config';
@ -42,7 +45,6 @@ type Props = {
isPostAcknowledgementEnabled?: boolean;
lastViewedAt: number;
location: AvailableScreens;
nativeID: string;
onEndReached?: () => void;
posts: PostModel[];
rootId?: string;
@ -55,6 +57,9 @@ type Props = {
testID: string;
currentCallBarVisible?: boolean;
savedPostIds: Set<string>;
listRef?: React.RefObject<FlatList<string | PostModel>>;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
}
type onScrollEndIndexListenerEvent = (endIndex: number) => void;
@ -96,7 +101,6 @@ const PostList = ({
isPostAcknowledgementEnabled,
lastViewedAt,
location,
nativeID,
onEndReached,
posts,
rootId,
@ -106,18 +110,60 @@ const PostList = ({
showNewMessageLine = true,
testID,
savedPostIds,
listRef,
onTouchMove,
onTouchEnd,
}: Props) => {
const firstIdInPosts = posts[0]?.id;
const listRef = useRef<FlatList<string | PostModel>>(null);
const {
keyboardTranslateY: keyboardHeightValue,
bottomInset: contentInset,
onScroll: onScrollProp,
postInputContainerHeight,
keyboardHeight,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
inputAccessoryViewAnimatedHeight,
isInputAccessoryViewMode,
} = useKeyboardAnimationContext();
const onScrollEndIndexListener = useRef<onScrollEndIndexListenerEvent>();
const onViewableItemsChangedListener = useRef<ViewableItemsChangedListenerEvent>();
const scrolledToHighlighted = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const [showScrollToEndBtn, setShowScrollToEndBtn] = useState(false);
const [lastPostId, setLastPostId] = useState<string | undefined>(firstIdInPosts);
const [progressViewOffset, setProgressViewOffset] = useState(postInputContainerHeight);
const [emojiPickerPadding, setEmojiPickerPadding] = useState(0);
const theme = useTheme();
const serverUrl = useServerUrl();
const {isVisible: isKeyboardVisible} = useKeyboardState();
// Update progressViewOffset to position RefreshControl correctly when keyboard-aware props are applied.
// Only update when keyboard state changes (fully open ↔ fully closed) to prevent flickering during animation.
const prevIsFullyOpen = useSharedValue(false);
const prevIsFullyClosed = useSharedValue(true);
useAnimatedReaction(
() => ({
isFullyOpen: isKeyboardFullyOpen.value,
isFullyClosed: isKeyboardFullyClosed.value,
keyboardTranslateY: keyboardHeightValue.value,
}),
({isFullyOpen, isFullyClosed, keyboardTranslateY}) => {
// Only update when state actually changes (transition detected)
const stateChanged = (prevIsFullyClosed.value !== isFullyClosed) || (prevIsFullyOpen.value !== isFullyOpen);
if (stateChanged && (isFullyOpen || isFullyClosed)) {
const offset = postInputContainerHeight + keyboardTranslateY;
runOnJS(setProgressViewOffset)(offset);
}
prevIsFullyOpen.value = isFullyOpen;
prevIsFullyClosed.value = isFullyClosed;
},
[postInputContainerHeight],
);
const orderedPosts = useMemo(() => {
return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, currentTimezone, location === Screens.THREAD, savedPostIds);
}, [posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, currentTimezone, location, savedPostIds]);
@ -129,8 +175,16 @@ const PostList = ({
const isNewMessage = lastPostId ? firstIdInPosts !== lastPostId : false;
const scrollToEnd = useCallback(() => {
listRef.current?.scrollToOffset({offset: 0, animated: true});
}, []);
const activeHeight = Math.max(keyboardHeight.value, inputAccessoryViewAnimatedHeight.value);
const targetOffset = -activeHeight;
listRef?.current?.scrollToOffset({offset: targetOffset, animated: true});
setShowScrollToEndBtn(false);
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listRef]);
useEffect(() => {
const t = setTimeout(() => {
@ -182,15 +236,19 @@ const PostList = ({
}, [disablePullToRefresh, location, channelId, rootId, posts, serverUrl]);
const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => {
listRef.current?.scrollToIndex({
if (index < 0 || !listRef?.current) {
return;
}
listRef.current.scrollToIndex({
animated,
index,
viewOffset: applyOffset ? Platform.select({ios: -45, default: 0}) : 0,
viewPosition: 1, // 0 is at bottom
});
}, []);
}, [listRef]);
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const internalOnScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const {y} = event.nativeEvent.contentOffset;
const isThresholdReached = y > CONTENT_OFFSET_THRESHOLD;
@ -332,7 +390,7 @@ const PostList = ({
scrolledToHighlighted.current = true;
// eslint-disable-next-line max-nested-callbacks
const index = orderedPosts.findIndex((p) => p.type === 'post' && p.value.currentPost.id === highlightedId);
if (index >= 0 && listRef.current) {
if (index >= 0 && listRef?.current) {
listRef.current?.scrollToIndex({
animated: true,
index,
@ -344,12 +402,51 @@ const PostList = ({
}, 500);
return () => clearTimeout(t);
// - listRef is a ref (stable reference, doesn't need to be in deps)
// - scrolledToHighlighted is a ref (stable reference, doesn't need to be in deps)
// - We only need to re-run when the posts list changes or the highlighted post changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderedPosts, highlightedId]);
// Sync emoji picker padding from SharedValue to React state
// This ensures the padding updates when SharedValues change
useAnimatedReaction(
() => {
const shouldAddEmojiPickerPadding = Platform.OS === 'android' && !isKeyboardVisible && isInputAccessoryViewMode.value;
const emojiPickerHeight = shouldAddEmojiPickerPadding ? (inputAccessoryViewAnimatedHeight.value || DEFAULT_INPUT_ACCESSORY_HEIGHT) : 0;
return emojiPickerHeight;
},
(emojiPickerHeight) => {
runOnJS(setEmojiPickerPadding)(emojiPickerHeight);
},
[isKeyboardVisible],
);
// Combine contentContainerStyle with padding style
// Use regular style with state value synced from SharedValues
const contentContainerStyleWithPadding = useMemo(() => {
const paddingStyle = {paddingTop: location === Screens.PERMALINK ? 0 : postInputContainerHeight + emojiPickerPadding};
return contentContainerStyle ? [contentContainerStyle, paddingStyle] : paddingStyle;
}, [location, postInputContainerHeight, emojiPickerPadding, contentContainerStyle]);
// contentInset only for dynamic keyboard height
const animatedProps = useAnimatedProps(
() => ({
contentInset: {
top: contentInset.value, // For inverted FlatList, applies to visual bottom
},
}),
[contentInset],
);
return (
<>
<Animated.FlatList
contentContainerStyle={contentContainerStyle}
animatedProps={animatedProps}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior='never'
contentContainerStyle={contentContainerStyleWithPadding}
data={orderedPosts}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
@ -359,12 +456,13 @@ const PostList = ({
ListFooterComponent={footer}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={10}
nativeID={nativeID}
onEndReached={onEndReached}
onEndReachedThreshold={0.9}
onScroll={onScroll}
onScroll={onScrollProp}
onMomentumScrollEnd={internalOnScroll}
onScrollToIndexFailed={onScrollToIndexFailed}
onViewableItemsChanged={onViewableItemsChanged}
progressViewOffset={progressViewOffset}
ref={listRef}
removeClippedSubviews={true}
renderItem={renderItem}
@ -374,6 +472,8 @@ const PostList = ({
testID={`${testID}.flat_list`}
inverted={true}
refreshing={refreshing}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onRefresh={onRefresh}
/>
{location !== Screens.PERMALINK &&

View file

@ -3,14 +3,13 @@
import React, {useRef} from 'react';
import {useIntl} from 'react-intl';
import {Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle} from 'react-native';
import {Pressable, Text, View, type ViewStyle} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {useIsTablet, useKeyboardHeight, useViewPosition} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -25,7 +24,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
buttonStyle: {
position: 'absolute',
alignSelf: 'center',
bottom: -100,
bottom: 0,
flexDirection: 'row',
},
shadow: {
@ -65,6 +64,10 @@ type Props = {
testID?: string;
};
const SCROLL_TO_END_BOTTOM_OFFSET = 15;
const SCROLL_TO_END_BUTTON_WIDTH = 40;
const SCROLL_TO_END_BADGE_MAX_WIDTH = 169;
const ScrollToEndView = ({
onPress,
isNewMessage,
@ -74,36 +77,31 @@ const ScrollToEndView = ({
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const isTablet = useIsTablet();
const styles = getStyleFromTheme(theme);
const {keyboardTranslateY, postInputContainerHeight, inputAccessoryViewAnimatedHeight} = useKeyboardAnimationContext();
// On iOS we have to take account of the keyboard.
// We cannot use `useKeyboardOverlap` here because of the positioning of the element.
const guidingViewRef = useRef<View>(null);
const keyboardHeight = useKeyboardHeight();
const viewPosition = useViewPosition(guidingViewRef, []);
const dimensions = useWindowDimensions();
const bottomSpace = (dimensions.height - viewPosition);
const keyboardOverlap = Platform.select({ios: Math.max(0, keyboardHeight - bottomSpace), default: 0});
// Thread view on iPads has to take into account the insets
const insets = useSafeAreaInsets();
const shouldAdjustBottom = (Platform.OS === 'ios') && isTablet && (location === Screens.THREAD) && !keyboardHeight;
const bottomAdjustment = shouldAdjustBottom ? insets.bottom : 0;
const message = location === Screens.THREAD ? intl.formatMessage({id: 'postList.scrollToBottom.newReplies', defaultMessage: 'New replies'}) : intl.formatMessage({id: 'postList.scrollToBottom.newMessages', defaultMessage: 'New messages'});
const animatedStyle = useAnimatedStyle(
() => ({
transform: [
{
translateY: withTiming(showScrollToEndBtn ? -100 - keyboardOverlap - bottomAdjustment : -15, {duration: 300}),
},
],
maxWidth: withTiming(isNewMessage ? 169 : 40, {duration: 300}),
opacity: withTiming(showScrollToEndBtn ? 1 : 0),
}),
[showScrollToEndBtn, isNewMessage, keyboardOverlap, bottomAdjustment],
() => {
const activeHeight = Math.max(keyboardTranslateY.value, inputAccessoryViewAnimatedHeight.value);
return {
transform: [
{
translateY: showScrollToEndBtn ? -postInputContainerHeight - activeHeight - SCROLL_TO_END_BOTTOM_OFFSET : -SCROLL_TO_END_BOTTOM_OFFSET,
},
],
maxWidth: withTiming(isNewMessage ? SCROLL_TO_END_BADGE_MAX_WIDTH : SCROLL_TO_END_BUTTON_WIDTH, {duration: 300}),
opacity: withTiming(showScrollToEndBtn ? 1 : 0),
};
},
[showScrollToEndBtn, isNewMessage, keyboardTranslateY, inputAccessoryViewAnimatedHeight, postInputContainerHeight],
);
const scrollButtonStyles = isNewMessage ? styles.scrollToEndBadge : styles.scrollToEndButton;

View file

@ -14,6 +14,7 @@ type Props = {
category?: string;
name: string;
onEmojiPress: (emoji: string) => void;
preventDoubleTap?: boolean;
size?: number;
style?: StyleProp<ViewStyle>;
}
@ -22,14 +23,16 @@ const CATEGORIES_WITH_SKINS = ['people-body'];
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const TouchableEmoji = ({category, name, onEmojiPress, size = 30, style}: Props) => {
const onPress = usePreventDoubleTap(useCallback(() => onEmojiPress(name), [name, onEmojiPress]));
const TouchableEmoji = ({category, name, onEmojiPress, preventDoubleTap = true, size = 30, style}: Props) => {
const handlePress = useCallback(() => onEmojiPress(name), [name, onEmojiPress]);
const onPress = preventDoubleTap ? usePreventDoubleTap(handlePress) : handlePress;
if (category && CATEGORIES_WITH_SKINS.includes(category)) {
return (
<SkinnedEmoji
name={name}
onEmojiPress={onEmojiPress}
preventDoubleTap={preventDoubleTap}
size={size}
style={style}
/>

View file

@ -14,13 +14,14 @@ import {isValidNamedEmoji} from '@utils/emoji/helpers';
type Props = {
name: string;
onEmojiPress: (emoji: string) => void;
preventDoubleTap?: boolean;
size?: number;
style?: StyleProp<ViewStyle>;
}
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const SkinnedEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
const SkinnedEmoji = ({name, onEmojiPress, preventDoubleTap = true, size = 30, style}: Props) => {
const skinTone = useEmojiSkinTone();
const emojiName = useMemo(() => {
const skinnedEmoji = `${name}_${skinCodes[skinTone]}`;
@ -30,9 +31,10 @@ const SkinnedEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
return skinnedEmoji;
}, [name, skinTone]);
const onPress = usePreventDoubleTap(useCallback(() => {
const handlePress = useCallback(() => {
onEmojiPress(emojiName);
}, [emojiName, onEmojiPress]));
}, [emojiName, onEmojiPress]);
const onPress = preventDoubleTap ? usePreventDoubleTap(handlePress) : handlePress;
return (
<View

View file

@ -37,4 +37,7 @@ export default keyMirror({
ACTIVE_SCREEN: null,
ACTIVE_SERVER_CHANGED: null,
FILE_ADD_REMOVED: null,
KEYBOARD_STATE_CHANGED: null,
CLOSE_INPUT_ACCESSORY_VIEW: null,
EMOJI_PICKER_SEARCH_FOCUSED: null,
});

View file

@ -6,6 +6,7 @@ import PLAYBOOKS_SCREENS from '@playbooks/constants/screens';
export const ABOUT = 'About';
export const ACCOUNT = 'Account';
export const APPS_FORM = 'AppForm';
export const ATTACHMENT_OPTIONS = 'AttachmentOptions';
export const BOTTOM_SHEET = 'BottomSheet';
export const BROWSE_CHANNELS = 'BrowseChannels';
export const CALL = 'Call';
@ -94,6 +95,7 @@ export default {
ABOUT,
ACCOUNT,
APPS_FORM,
ATTACHMENT_OPTIONS,
BOTTOM_SHEET,
BROWSE_CHANNELS,
CALL,
@ -206,6 +208,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
]);
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
ATTACHMENT_OPTIONS,
BOTTOM_SHEET,
DRAFT_SCHEDULED_POST_OPTIONS,
EMOJI_PICKER,

View file

@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {createContext, useContext, useMemo, useRef, useCallback, type ReactNode} from 'react';
import {useSharedValue, type SharedValue} from 'react-native-reanimated';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
interface KeyboardAnimationContextType {
keyboardTranslateY: SharedValue<number>;
bottomInset: SharedValue<number>;
scrollOffset: SharedValue<number>;
keyboardHeight: SharedValue<number>;
scrollPosition: SharedValue<number>;
onScroll: (event: unknown) => void;
postInputContainerHeight: number;
inputRef: React.MutableRefObject<PasteInputRef | undefined>;
blurInput: () => void;
focusInput: () => void;
blurAndDismissKeyboard: () => Promise<void>;
isKeyboardFullyOpen: SharedValue<boolean>;
isKeyboardFullyClosed: SharedValue<boolean>;
isKeyboardInTransition: SharedValue<boolean>;
showInputAccessoryView: boolean;
isInputAccessoryViewMode: SharedValue<boolean>;
setShowInputAccessoryView: (show: boolean) => void;
lastKeyboardHeight: number;
inputAccessoryViewAnimatedHeight: SharedValue<number>;
isTransitioningFromCustomView: SharedValue<boolean>;
closeInputAccessoryView: () => void;
scrollToEnd: () => void;
isEmojiSearchFocused: boolean;
setIsEmojiSearchFocused: (focused: boolean) => void;
cursorPositionRef: React.MutableRefObject<number>;
registerCursorPosition: (cursorPosition: number) => void;
updateValue: React.Dispatch<React.SetStateAction<string>> | null;
updateCursorPosition: React.Dispatch<React.SetStateAction<number>> | null;
registerPostInputCallbacks: (
updateValueFn: React.Dispatch<React.SetStateAction<string>>,
updateCursorPositionFn: React.Dispatch<React.SetStateAction<number>>
) => void;
}
const KeyboardAnimationContext = createContext<KeyboardAnimationContextType | null>(null);
export const KeyboardAnimationProvider = ({
children,
value,
}: {
children: ReactNode;
value: KeyboardAnimationContextType;
}) => {
return (
<KeyboardAnimationContext.Provider value={value}>
{children}
</KeyboardAnimationContext.Provider>
);
};
const DEFAULT_POST_INPUT_HEIGHT = 91;
export const useKeyboardAnimationContext = () => {
const context = useContext(KeyboardAnimationContext);
// Always create default values (hooks must be called unconditionally)
const defaultKeyboardTranslateY = useSharedValue(0);
const defaultBottomInset = useSharedValue(0);
const defaultScrollOffset = useSharedValue(0);
const defaultKeyboardHeight = useSharedValue(0);
const defaultScrollPosition = useSharedValue(0);
const defaultIsKeyboardFullyOpen = useSharedValue(false);
const defaultIsKeyboardFullyClosed = useSharedValue(true);
const defaultIsKeyboardInTransition = useSharedValue(false);
const defaultInputRef = useRef<PasteInputRef | undefined>(undefined);
const defaultIsInputAccessoryViewMode = useSharedValue(false);
const defaultIsTransitioningFromCustomView = useSharedValue(false);
const defaultInputAccessoryViewAnimatedHeight = useSharedValue(0);
const defaultOnScroll = useCallback(() => {
// No-op fallback
}, []);
const defaultBlurInput = useCallback(() => {
// No-op fallback
}, []);
const defaultFocusInput = useCallback(() => {
// No-op fallback
}, []);
const defaultBlurAndDismissKeyboard = useCallback(async () => {
// No-op fallback
}, []);
const defaultSetShowInputAccessoryView = useCallback(() => {
// No-op fallback
}, []);
const defaultCloseInputAccessoryView = useCallback(() => {
// No-op fallback
}, []);
const defaultScrollToEnd = useCallback(() => {
// No-op fallback
}, []);
const defaultSetIsEmojiSearchFocused = useCallback(() => {
// No-op fallback
}, []);
const defaultCursorPositionRef = useRef<number>(0);
const defaultRegisterCursorPosition = useCallback(() => {
// No-op fallback
}, []);
const defaultUpdateValue = useRef<React.Dispatch<React.SetStateAction<string>> | null>(null);
const defaultUpdateCursorPosition = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
const defaultRegisterPostInputCallbacks = useCallback(() => {
// No-op fallback
}, []);
const fallbackValue = useMemo(() => ({
keyboardTranslateY: defaultKeyboardTranslateY,
bottomInset: defaultBottomInset,
scrollOffset: defaultScrollOffset,
keyboardHeight: defaultKeyboardHeight,
scrollPosition: defaultScrollPosition,
onScroll: defaultOnScroll,
postInputContainerHeight: DEFAULT_POST_INPUT_HEIGHT,
inputRef: defaultInputRef,
blurInput: defaultBlurInput,
focusInput: defaultFocusInput,
blurAndDismissKeyboard: defaultBlurAndDismissKeyboard,
isKeyboardFullyOpen: defaultIsKeyboardFullyOpen,
isKeyboardFullyClosed: defaultIsKeyboardFullyClosed,
isKeyboardInTransition: defaultIsKeyboardInTransition,
setShowInputAccessoryView: defaultSetShowInputAccessoryView,
showInputAccessoryView: false,
lastKeyboardHeight: 0,
isInputAccessoryViewMode: defaultIsInputAccessoryViewMode,
inputAccessoryViewAnimatedHeight: defaultInputAccessoryViewAnimatedHeight,
isTransitioningFromCustomView: defaultIsTransitioningFromCustomView,
closeInputAccessoryView: defaultCloseInputAccessoryView,
scrollToEnd: defaultScrollToEnd,
isEmojiSearchFocused: false,
setIsEmojiSearchFocused: defaultSetIsEmojiSearchFocused,
cursorPositionRef: defaultCursorPositionRef,
registerCursorPosition: defaultRegisterCursorPosition,
updateValue: defaultUpdateValue.current,
updateCursorPosition: defaultUpdateCursorPosition.current,
registerPostInputCallbacks: defaultRegisterPostInputCallbacks,
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [
defaultOnScroll,
defaultInputRef,
defaultBlurInput,
defaultFocusInput,
defaultBlurAndDismissKeyboard,
defaultSetShowInputAccessoryView,
defaultCloseInputAccessoryView,
defaultScrollToEnd,
defaultSetIsEmojiSearchFocused,
defaultCursorPositionRef,
defaultRegisterCursorPosition,
defaultUpdateValue,
defaultUpdateCursorPosition,
defaultRegisterPostInputCallbacks,
]);
// If context exists, return it; otherwise return fallback
return context || fallbackValue;
};

View file

@ -0,0 +1,490 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import {useKeyboardHandler} from 'react-native-keyboard-controller';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
import {useKeyboardAnimation} from './keyboardAnimation';
jest.mock('react-native-keyboard-controller', () => ({
useKeyboardHandler: jest.fn(),
}));
jest.mock('react-native-reanimated', () => {
const sharedValues = new Map();
return {
...jest.requireActual('react-native-reanimated/mock'),
useSharedValue: jest.fn((initial) => {
const key = Math.random();
const sv = {
value: initial,
_key: key,
};
sharedValues.set(key, sv);
return sv;
}),
useDerivedValue: jest.fn((fn) => ({
value: fn(),
})),
useAnimatedScrollHandler: jest.fn((handlers) => (event: {contentOffset: {y: number}}) => {
if (handlers.onScroll) {
handlers.onScroll(event);
}
}),
};
});
describe('useKeyboardAnimation', () => {
const mockUseKeyboardHandler = useKeyboardHandler as jest.Mock;
let keyboardHandlerCallbacks: {
onStart?: (e: {height: number; progress: number}) => void;
onInteractive?: (e: {height: number; progress: number}) => void;
onMove?: (e: {height: number; progress: number}) => void;
onEnd?: (e: {height: number; progress: number}) => void;
} = {};
beforeEach(() => {
jest.clearAllMocks();
keyboardHandlerCallbacks = {};
mockUseKeyboardHandler.mockImplementation((callbacks) => {
keyboardHandlerCallbacks = callbacks;
});
});
describe('initialization', () => {
it('should initialize with default values', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
expect(result.current.keyboardTranslateY.value).toBe(0);
expect(result.current.bottomInset.value).toBe(0);
expect(result.current.scrollOffset.value).toBe(0);
expect(result.current.scrollPosition.value).toBe(0);
expect(result.current.keyboardHeight.value).toBe(0);
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
expect(result.current.isKeyboardFullyClosed.value).toBe(true);
expect(result.current.isKeyboardInTransition.value).toBe(false);
expect(result.current.onScroll).toBeDefined();
});
it('should call useKeyboardHandler with callbacks', () => {
renderHook(() => useKeyboardAnimation(100, true, false, 0, false, true));
expect(mockUseKeyboardHandler).toHaveBeenCalled();
expect(keyboardHandlerCallbacks.onStart).toBeDefined();
expect(keyboardHandlerCallbacks.onInteractive).toBeDefined();
expect(keyboardHandlerCallbacks.onMove).toBeDefined();
expect(keyboardHandlerCallbacks.onEnd).toBeDefined();
});
});
describe('enabled prop', () => {
it('should skip processing when enabled is false', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, false),
);
const initialHeight = result.current.keyboardTranslateY.value;
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
});
it('should process events when enabled is true', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(300);
});
});
describe('enableAnimation prop', () => {
it('should skip processing when enableAnimation is false', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, false, false, 0, false, true),
);
const initialHeight = result.current.keyboardTranslateY.value;
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
});
});
describe('onStart callback', () => {
it('should update keyboard height and state on start', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardHeight.value).toBe(300);
expect(result.current.keyboardTranslateY.value).toBe(300);
expect(result.current.bottomInset.value).toBe(300);
// isKeyboardFullyOpen is set to false in onStart to prevent jerky behavior
// It will be set to true in onEnd when animation completes
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
expect(result.current.isKeyboardInTransition.value).toBe(false);
// Call onEnd to finalize the state
act(() => {
keyboardHandlerCallbacks.onEnd?.({
height: 300,
progress: 1,
});
});
// After onEnd, keyboard should be marked as fully open
expect(result.current.isKeyboardFullyOpen.value).toBe(true);
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
expect(result.current.isKeyboardInTransition.value).toBe(false);
});
it('should handle partial progress on start', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 150,
progress: 0.5,
});
});
expect(result.current.keyboardTranslateY.value).toBe(150);
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
expect(result.current.isKeyboardInTransition.value).toBe(true);
});
it('should ignore adjustment events from KeyboardGestureArea', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
// Set initial keyboard height
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
const initialHeight = result.current.keyboardTranslateY.value;
// Try to trigger adjustment event (height = keyboardHeight - postInputContainerHeight)
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 200, // 300 - 100
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
});
});
describe('onInteractive callback', () => {
it('should update values during interactive drag', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
// First call onStart to set initial height (required for onInteractive to work)
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
// Then call onInteractive to simulate interactive drag
act(() => {
keyboardHandlerCallbacks.onInteractive?.({
height: 250,
progress: 0.8,
});
});
expect(result.current.keyboardTranslateY.value).toBe(250);
expect(result.current.scrollOffset.value).toBe(250);
expect(result.current.bottomInset.value).toBe(250);
});
it('should track keyboard closing state', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
// Set initial height
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
// Simulate closing (height decreasing)
act(() => {
keyboardHandlerCallbacks.onInteractive?.({
height: 200,
progress: 0.7,
});
});
// isKeyboardClosing is internal state, not exposed in return value
// We verify closing behavior by checking that height decreases
expect(result.current.keyboardTranslateY.value).toBeLessThan(300);
});
it('should ignore events matching postInputContainerHeight', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
const initialHeight = result.current.keyboardTranslateY.value;
act(() => {
keyboardHandlerCallbacks.onInteractive?.({
height: 100, // matches postInputContainerHeight
progress: 0.5,
});
});
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
});
});
describe('onMove callback', () => {
it('should update values during keyboard movement', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 250,
progress: 0.7,
});
});
act(() => {
keyboardHandlerCallbacks.onMove?.({
height: 280,
progress: 0.9,
});
});
expect(result.current.keyboardTranslateY.value).toBe(280);
expect(result.current.scrollOffset.value).toBe(280);
expect(result.current.bottomInset.value).toBe(280);
});
it('should ignore onMove events when keyboard is closing', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
act(() => {
keyboardHandlerCallbacks.onInteractive?.({
height: 200,
progress: 0.7,
});
});
act(() => {
keyboardHandlerCallbacks.onMove?.({
height: 150,
progress: 0.5,
});
});
expect(result.current.keyboardTranslateY.value).toBe(200);
expect(result.current.scrollOffset.value).toBe(200);
expect(result.current.bottomInset.value).toBe(200);
});
it('should handle negative heights from programmatic dismiss', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onMove?.({
height: -50, // negative height from KeyboardController.dismiss()
progress: 0,
});
});
expect(result.current.keyboardTranslateY.value).toBe(50); // Math.abs applied
});
});
describe('onEnd callback', () => {
it('should finalize state when keyboard fully opens', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
act(() => {
keyboardHandlerCallbacks.onEnd?.({
height: 300,
progress: 1,
});
});
expect(result.current.isKeyboardFullyOpen.value).toBe(true);
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
expect(result.current.isKeyboardInTransition.value).toBe(false);
});
it('should finalize state when keyboard fully closes', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 0,
progress: 0,
});
});
act(() => {
keyboardHandlerCallbacks.onEnd?.({
height: 0,
progress: 0,
});
});
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
expect(result.current.isKeyboardFullyClosed.value).toBe(true);
expect(result.current.isKeyboardInTransition.value).toBe(false);
expect(result.current.keyboardTranslateY.value).toBe(0);
});
});
describe('tablet and tab bar adjustment', () => {
it('should apply tab bar adjustment for tablets in non-thread view', () => {
const safeAreaBottom = 20;
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, true, safeAreaBottom, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
const expectedAdjustment = BOTTOM_TAB_HEIGHT + safeAreaBottom;
expect(result.current.keyboardTranslateY.value).toBe(300 - expectedAdjustment);
});
it('should apply safeAreaBottom adjustment for thread view', () => {
const safeAreaBottom = 20;
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, true, safeAreaBottom, true, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(300);
});
it('should apply safeAreaBottom adjustment for mobile', () => {
const safeAreaBottom = 20;
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, safeAreaBottom, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(300 - safeAreaBottom);
});
});
describe('scroll handler', () => {
it('should track scroll position with bottomInset', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
// Set bottomInset first
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 200,
progress: 1,
});
});
const mockScrollEvent = {
contentOffset: {y: 100},
} as unknown as Parameters<typeof result.current.onScroll>[0];
act(() => {
result.current.onScroll(mockScrollEvent);
});
expect(result.current.scrollPosition.value).toBe(100 + result.current.bottomInset.value);
});
});
});

View file

@ -0,0 +1,525 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useKeyboardHandler} from 'react-native-keyboard-controller';
import {useAnimatedScrollHandler, useDerivedValue, useSharedValue} from 'react-native-reanimated';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
export const useKeyboardAnimation = (
postInputContainerHeight: number,
enableAnimation = true,
isTablet = false,
safeAreaBottom = 0,
isThreadView = false,
enabled = true,
) => {
/**
* progress: Keyboard animation progress (0 = closed, 1 = fully open)
* Used for: Tracking keyboard animation state
*/
const progress = useSharedValue(0);
/**
* keyboardTranslateY: Keyboard height (adjusted for tab bar) used to animate input container position
*
* How it works: This value represents the keyboard height and is used with negative translateY
* in useKeyboardAwarePostDraft.ts (transform: [{translateY: -keyboardTranslateY.value}]) to move the input
* container UP by the keyboard height amount. Higher keyboard height = input moves up more.
*
* Smoothed during interactive gestures to prevent jerky movements
*/
const keyboardTranslateY = useSharedValue(0);
/**
* bottomInset: Bottom inset for the scroll view
* Adds padding at the bottom of scroll content so it doesn't hide behind keyboard
*/
const bottomInset = useSharedValue(0);
/**
* scrollOffset: Scroll offset adjustment when keyboard opens
* Ensures the scroll view scrolls to the right position when keyboard appears
*/
const scrollOffset = useSharedValue(0);
/**
* scroll: Tracks the current scroll position of the ScrollView
* Used to calculate proper offset when keyboard opens
*/
const scrollPosition = useSharedValue(0);
/**
* keyboardHeight: The exact height of the keyboard from events
* Always reflects the true keyboard height without smoothing
*/
const keyboardHeight = useSharedValue(0);
/**
* isKeyboardClosing: Tracks if keyboard is currently closing (detected in onInteractive)
* Used to prevent height jumps in onMove when user releases finger mid-swipe
*/
const isKeyboardClosing = useSharedValue(false);
/**
* isInteractiveGesture: Tracks if we're in an interactive gesture (user touching keyboard)
* Used to: Distinguish between normal keyboard opening vs stale events after gesture
*/
const isInteractiveGesture = useSharedValue(false);
/**
* isKeyboardFullyOpen: True when keyboard is fully open (height > 0 and progress === 1)
*/
const isKeyboardFullyOpen = useSharedValue(false);
/**
* isKeyboardFullyClosed: True when keyboard is fully closed (height === 0)
*/
const isKeyboardFullyClosed = useSharedValue(true);
/**
* isKeyboardInTransition: True when keyboard is animating between open/closed states
*/
const isKeyboardInTransition = useSharedValue(false);
/**
* isEnabled: Shared value to track if keyboard handling is enabled for this screen
* Used to prevent processing keyboard events when screen is not visible
* useDerivedValue automatically tracks the enabled prop and updates reactively
*/
const isEnabled = useDerivedValue(() => enabled, [enabled]);
// Calculate tab bar adjustment (only for tablets, not in thread view)
// This accounts for the tab bar height + safe area bottom that gets hidden when keyboard opens
// Thread views don't have a tab bar, so no adjustment is needed
// For tablets in thread view, also don't subtract safeAreaBottom since the input container
// should move up by the full keyboard height to clear the keyboard completely
let tabBarAdjustment: number;
if (isTablet && !isThreadView) {
// Channel view on tablet: account for tab bar + safe area
tabBarAdjustment = BOTTOM_TAB_HEIGHT + safeAreaBottom;
} else if (isTablet && isThreadView) {
// Thread view on tablet: no adjustment needed (no tab bar, full keyboard height)
tabBarAdjustment = 0;
} else {
// Mobile devices: account for safe area only
tabBarAdjustment = safeAreaBottom;
}
/**
* isInputAccessoryViewMode: Whether we're showing input accessory view (emoji picker) instead of keyboard
* When true, keyboard handlers are ignored to prevent interference with the emoji picker
*/
const isInputAccessoryViewMode = useSharedValue(false);
/**
* isTransitioningFromCustomView: Special mode when transitioning from custom view to keyboard
* Prevents height updates during the transition to avoid jumps
*/
const isTransitioningFromCustomView = useSharedValue(false);
// ------------------------------------------------------------------
// KEYBOARD EVENT HANDLERS
// ------------------------------------------------------------------
/**
* useKeyboardHandler: Subscribe to keyboard lifecycle events
*
* IMPORTANT: All callbacks here are WORKLETS (run on UI thread)
* The "worklet" directive tells Reanimated to compile this function for the UI thread
* This enables 60fps smooth animations without JavaScript thread delays
*
* Event lifecycle:
* 1. onStart: Keyboard animation begins
* 2. onInteractive: User is dragging keyboard interactively
* 3. onMove: Keyboard position changes during animation
* 4. onEnd: Keyboard animation completes
*/
useKeyboardHandler({
/**
* onStart: Called when keyboard animation starts
* @param e - Event object with keyboard information
*/
onStart: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// Ignore keyboard events during transition from custom view to keyboard
if (isTransitioningFromCustomView.value) {
return;
}
// Skip processing if screen is not enabled/visible or if animations are disabled
if (!isEnabled.value || !enableAnimation) {
return;
}
// Ignore adjustment event from KeyboardGestureArea (fires after keyboard fully opens)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// We use both offset prop AND manual animation, so this would cause flicker
// Example: keyboard opens at 346px → then adjustment event at 229px (346 - 117 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
progress.value = e.progress;
// Store the exact keyboard height
keyboardHeight.value = e.height;
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// CRITICAL FIX: On real iOS devices, onStart can fire with progress: 1 before animation completes
// Even if progress is 1, we should NOT set isKeyboardFullyOpen to true in onStart because:
// 1. onMove events may still come with lower progress values
// 2. Only onEnd with progress === 1 should mark the keyboard as fully open
// This prevents jerky behavior where the input container jumps down and back up
const wasAlreadyOpen = keyboardTranslateY.value > 0;
// Always treat as transitioning if we might receive more events (even with progress: 1)
// Only exception: if height is 0 (keyboard closing)
const shouldTreatAsTransitioning = e.height > 0 && (e.progress < 1 || wasAlreadyOpen);
keyboardTranslateY.value = adjustedHeight;
// Update keyboard state flags
// NEVER mark as fully open in onStart - always wait for onEnd to confirm
isKeyboardFullyClosed.value = e.height === 0;
isKeyboardFullyOpen.value = false; // Always false in onStart - onEnd will set it correctly
isKeyboardInTransition.value = e.height > 0 && shouldTreatAsTransitioning;
// When keyboard closes (height: 0), preserve isKeyboardClosing flag if it was true
// This prevents onMove events from processing stale closing animation values
// Only reset if keyboard was actually open (not already closing)
if (e.height === 0) {
// If keyboard was closing, keep the flag true so onMove knows to ignore stale events
// If keyboard was opening, reset the flag
if (!isKeyboardClosing.value) {
isKeyboardClosing.value = false;
}
// Always reset interactive gesture flag when keyboard closes
isInteractiveGesture.value = false;
}
// Update scroll view insets and offsets
// bottomInset: Adds bottom padding to scroll content
bottomInset.value = adjustedHeight;
// CRITICAL FIX: Don't update scrollOffset in onStart if progress === 1 and keyboard wasn't already open
// On real iOS devices, onStart can fire with progress: 1 BEFORE the animation starts
// This causes scrollOffset to jump immediately to the final value, triggering a scroll
// Then onMove events come with intermediate values, causing jerky "down then up" behavior
// Solution: Only update scrollOffset in onStart if progress < 1 OR keyboard was already open
// Let onMove handle the smooth animation for opening keyboards
// Note: wasAlreadyOpen is checked BEFORE keyboardTranslateY.value is updated, so use that
if (e.progress < 1 || wasAlreadyOpen || e.height === 0) {
// scrollOffset: Ensures the scroll view scrolls with the keyboard animation
scrollOffset.value = adjustedHeight;
} else {
// Don't update scrollOffset yet - wait for onMove to animate smoothly
// This prevents the initial jump when keyboard opens
}
},
/**
* onInteractive: Called continuously while user drags keyboard interactively
* This provides smooth real-time updates as the user swipes
*
* @param e - Event object with keyboard information
*/
onInteractive: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// Ignore keyboard events during transition from custom view to keyboard
if (isTransitioningFromCustomView.value) {
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!isEnabled.value || !enableAnimation) {
return;
}
if (parseInt(e.height.toString()) === postInputContainerHeight) {
return;
}
// Ignore stale interactive events during screen navigation
// When navigating from channel (keyboard open) to thread, the channel's keyboard dismissal
// can trigger stale onInteractive events that arrive at the newly mounted thread screen.
// If keyboard is fully closed (keyboardTranslateY === 0), any onInteractive event is stale and should be ignored.
if (keyboardTranslateY.value === 0) {
return;
}
const previousKeyboardTranslateY = keyboardTranslateY.value;
if (keyboardTranslateY.value > 0) {
isInteractiveGesture.value = true;
}
// Calculate adjusted height first to compare properly
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// Track if keyboard is closing (adjusted height decreasing) or opening (adjusted height increasing)
// Compare adjusted heights, not raw event height vs adjusted height
// This detects direction changes mid-gesture for smooth animations
if (adjustedHeight < previousKeyboardTranslateY) {
// Keyboard is closing - height is decreasing
isKeyboardClosing.value = true;
} else if (adjustedHeight > previousKeyboardTranslateY) {
// User changed direction - swiped back up
isKeyboardClosing.value = false;
}
// If adjustedHeight === previousKeyboardTranslateY, keep current isKeyboardClosing state
keyboardTranslateY.value = adjustedHeight;
scrollOffset.value = adjustedHeight;
bottomInset.value = adjustedHeight;
// Update keyboard state flags
isKeyboardFullyClosed.value = e.height === 0;
isKeyboardFullyOpen.value = e.height > 0 && e.progress === 1;
isKeyboardInTransition.value = e.height > 0 && e.progress < 1;
},
/**
* onMove: Called continuously as keyboard animates (programmatic or gesture)
* Similar to onInteractive but for all keyboard movements
*
* @param e - Event object with keyboard information
*/
onMove: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// During transition from custom view, don't update keyboardHeight from onMove events
// These events can be stale/out-of-order. Wait for onEnd to get the final correct height.
if (isTransitioningFromCustomView.value) {
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!isEnabled.value || !enableAnimation) {
return;
}
// CRITICAL FIX: Ignore stale onMove events after keyboard closes
// When keyboard closes, onStart fires with height: 0, but onMove may still fire with stale values
// If keyboardHeight is 0 (closed), ignore any onMove events that try to set positive heights
if (keyboardHeight.value === 0 && e.height > 0) {
return;
}
// If keyboard is closing (detected in onInteractive), ignore onMove events
// This prevents stale closing animation values from causing jumps
if (isKeyboardClosing.value) {
return;
}
// Ignore adjustment event from KeyboardGestureArea (fires after keyboard fully opens)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// We use both offset prop AND manual animation, so this would cause flicker
// Example: keyboard opens at 346px → then adjustment event at 229px (346 - 117 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
// CRITICAL FIX: Ignore invalid onMove events with progress > 1
// On real iOS devices, sometimes onMove fires with progress > 1 (e.g., 1.266)
// These are invalid events that can cause the input container to jump
// Progress should always be between 0 and 1 for valid keyboard animation events
if (e.progress > 1) {
return;
}
const absHeight = Math.abs(e.height) - (tabBarAdjustment * e.progress); // Use Math.abs because programmatic dismiss (KeyboardController.dismiss()) reports negative heights
// CRITICAL FIX: On real iOS devices, onStart can fire with progress: 1 before animation completes
// Then onMove events come with lower heights/progress, causing jerky behavior
// If we've already reached the final keyboard height (isKeyboardFullyOpen or keyboardHeight matches final),
// ignore onMove events that would reduce keyboardTranslateY below the final value
// BUT: Always allow scrollOffset and bottomInset to update during animation for smooth scrolling
const finalHeight = keyboardHeight.value;
const finalAdjustedHeight = finalHeight > 0 ? finalHeight - tabBarAdjustment : 0;
const hasReachedFinalState = finalHeight > 0 && keyboardTranslateY.value >= finalAdjustedHeight * 0.95; // 95% threshold to account for rounding
if (hasReachedFinalState && absHeight < keyboardTranslateY.value && e.progress < 1) {
// Still update scrollOffset and bottomInset even if we ignore keyboardTranslateY
// This ensures the list scrolls smoothly with the keyboard animation
scrollOffset.value = absHeight;
bottomInset.value = absHeight;
return;
}
// Ignore stale/incorrect events ONLY during/after interactive gestures
// After user releases finger mid-swipe up, onMove sometimes gets wrong height values
// Example: keyboard at 346px, user releases, onMove reports 80px (stale from earlier)
// This check only applies during interactive gestures, not during normal keyboard opening
if (isInteractiveGesture.value && absHeight < keyboardTranslateY.value && e.progress < 1) {
return;
}
keyboardTranslateY.value = absHeight;
scrollOffset.value = absHeight;
bottomInset.value = absHeight;
// Update keyboard state flags
isKeyboardFullyClosed.value = absHeight === 0;
isKeyboardFullyOpen.value = absHeight > 0 && e.progress === 1;
isKeyboardInTransition.value = absHeight > 0 && e.progress < 1;
},
onEnd: (e) => {
'worklet';
// Skip processing if screen is not enabled/visible or if animations are disabled
if (!isEnabled.value) {
return;
}
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!enableAnimation) {
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
return;
}
// Ignore adjustment event from KeyboardGestureArea (can fire in onEnd too)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// Example: keyboard opens at 346px → then adjustment event at 255px (346 - 91 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
// Store if we were transitioning from custom view before clearing the flag
const wasTransitioningFromCustomView = isTransitioningFromCustomView.value;
// Reset state flags
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
isInteractiveGesture.value = false;
// Use e.progress (from event) not progress.value (shared value might be stale)
if (e.progress === 1) {
// CRITICAL FIX: Update keyboardHeight FIRST from onEnd event (most reliable source)
// During transition, onMove events can be stale/out-of-order, so onEnd's height is authoritative
// Store previous value for stale check before updating
const previousKeyboardHeight = keyboardHeight.value;
keyboardHeight.value = e.height;
// Use same calculation as onInteractive/onMove for consistency
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// Ignore stale/out-of-order events
// If keyboard is supposed to be closed (previousKeyboardHeight = 0) but we get an open event,
// it's a stale event from before the close - ignore it
if (previousKeyboardHeight === 0 && e.height > 0) {
return;
}
// If transitioning from custom view, always update keyboardTranslateY to match keyboard
// This ensures correct positioning when emoji picker height != keyboard height
if (wasTransitioningFromCustomView) {
keyboardTranslateY.value = adjustedHeight;
} else if (Math.abs(keyboardTranslateY.value - adjustedHeight) > 1) {
// For normal keyboard opening, only adjust if significantly different
keyboardTranslateY.value = adjustedHeight;
}
// Update bottomInset and scrollOffset to match final keyboard height
// This ensures messages don't get hidden behind keyboard
bottomInset.value = adjustedHeight;
scrollOffset.value = adjustedHeight;
isKeyboardFullyOpen.value = true;
isKeyboardFullyClosed.value = false;
isKeyboardInTransition.value = false;
}
// Use e.progress (from event) not progress.value (shared value might be stale)
if (e.progress === 0) {
// Only set to 0 if not already close to 0
if (Math.abs(keyboardTranslateY.value) > 0.5) {
keyboardTranslateY.value = 0;
}
isKeyboardFullyOpen.value = false;
isKeyboardFullyClosed.value = true;
isKeyboardInTransition.value = false;
scrollOffset.value = 0;
// CRITICAL FIX: Reset scrollPosition when keyboard closes
// scrollPosition = contentOffsetY + bottomInset
// Before bottomInset becomes 0, calculate actual content offset: scrollPosition - bottomInset
// This preserves the user's scroll position even if iOS resets the list to 0
const currentBottomInset = bottomInset.value;
if (scrollPosition.value > currentBottomInset) {
scrollPosition.value = scrollPosition.value - currentBottomInset;
}
bottomInset.value = 0;
// Reset closing flag when keyboard fully closes
isKeyboardClosing.value = false;
isInteractiveGesture.value = false;
}
},
});
// ------------------------------------------------------------------
// SCROLL HANDLER
// ------------------------------------------------------------------
/**
* Handle scroll events on the UI thread for smooth performance
* Tracks scroll position to calculate proper keyboard offset
*/
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
const newScrollPosition = e.contentOffset.y + bottomInset.value;
// Preserve scrollPosition when keyboard is closed and list resets to 0
// This prevents iOS's list reset from overwriting the preserved scroll position
if (bottomInset.value > 0 || e.contentOffset.y > 0 || scrollPosition.value === 0) {
scrollPosition.value = newScrollPosition;
}
},
});
return {
keyboardTranslateY,
onScroll,
bottomInset,
scrollOffset,
keyboardHeight,
scrollPosition,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
};
};

View file

@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useEffect, useRef, useState} from 'react';
import {Platform} from 'react-native';
import {runOnUI} from 'react-native-reanimated';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
/**
* Hook to handle focusing input after emoji picker is dismissed on Android.
* On Android, when the emoji picker is open and user tries to focus the input,
* the emoji picker closes but the keyboard doesn't open. This hook handles
* the delayed focus logic to ensure the keyboard opens properly.
*
*/
export const useFocusAfterEmojiDismiss = (
inputRef: React.MutableRefObject<PasteInputRef | undefined>,
focusInput: () => void,
) => {
const {
showInputAccessoryView,
setShowInputAccessoryView,
isInputAccessoryViewMode,
inputAccessoryViewAnimatedHeight,
keyboardTranslateY,
isTransitioningFromCustomView,
setIsEmojiSearchFocused,
} = useKeyboardAnimationContext();
const [isManuallyFocusingAfterEmojiDismiss, setIsManuallyFocusingAfterEmojiDismiss] = useState(false);
const isDismissingEmojiPicker = useRef(false);
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Handle focus after emoji picker is dismissed
useEffect(() => {
if (Platform.OS === 'android' && isManuallyFocusingAfterEmojiDismiss && !showInputAccessoryView) {
isDismissingEmojiPicker.current = false;
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
inputRef.current?.blur();
const handleDelayedFocus = () => {
focusInput();
setIsManuallyFocusingAfterEmojiDismiss(false);
focusTimeoutRef.current = null;
};
focusTimeoutRef.current = setTimeout(handleDelayedFocus, 200);
return () => {
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
focusTimeoutRef.current = null;
}
};
}
return undefined;
}, [isManuallyFocusingAfterEmojiDismiss, showInputAccessoryView, inputRef, focusInput]);
// Wrapped focus function that handles emoji picker dismissal
const focusWithEmojiDismiss = useCallback(() => {
if (Platform.OS === 'android' && showInputAccessoryView) {
isDismissingEmojiPicker.current = true;
setIsManuallyFocusingAfterEmojiDismiss(true);
runOnUI(() => {
'worklet';
inputAccessoryViewAnimatedHeight.value = 0;
keyboardTranslateY.value = 0;
isInputAccessoryViewMode.value = false;
isTransitioningFromCustomView.value = true;
})();
setIsEmojiSearchFocused(false);
setShowInputAccessoryView(false);
} else {
focusInput();
}
}, [
showInputAccessoryView,
inputAccessoryViewAnimatedHeight,
setShowInputAccessoryView,
isInputAccessoryViewMode,
keyboardTranslateY,
isTransitioningFromCustomView,
setIsEmojiSearchFocused,
focusInput,
]);
return {
focus: focusWithEmojiDismiss,
isDismissingEmojiPicker,
focusTimeoutRef,
isManuallyFocusingAfterEmojiDismiss,
};
};

View file

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useState} from 'react';
import {runOnJS, useAnimatedReaction, useSharedValue, type SharedValue} from 'react-native-reanimated';
/**
* Default height for the input accessory view when keyboard height is unknown
*/
export const DEFAULT_INPUT_ACCESSORY_HEIGHT = 335;
type UseInputAccessoryViewParams = {
keyboardHeight: SharedValue<number>;
isKeyboardFullyOpen: SharedValue<boolean>;
};
export const useInputAccessoryView = ({
keyboardHeight,
isKeyboardFullyOpen,
}: UseInputAccessoryViewParams) => {
const [showInputAccessoryView, setShowInputAccessoryView] = useState(false);
const [lastKeyboardHeight, setLastKeyboardHeight] = useState(0);
const inputAccessoryViewAnimatedHeight = useSharedValue(0);
useAnimatedReaction(
() => ({
height: keyboardHeight.value,
isFullyOpen: isKeyboardFullyOpen.value,
}),
(current, previous) => {
if (
current.isFullyOpen &&
current.height > 0 &&
(!previous || !previous.isFullyOpen || previous.height !== current.height)
) {
runOnJS(setLastKeyboardHeight)(current.height);
}
},
[keyboardHeight, isKeyboardFullyOpen],
);
return {
showInputAccessoryView,
setShowInputAccessoryView,
lastKeyboardHeight,
setLastKeyboardHeight,
inputAccessoryViewAnimatedHeight,
};
};

View file

@ -0,0 +1,360 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import {KeyboardController} from 'react-native-keyboard-controller';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useIsTablet} from '@hooks/device';
import {useKeyboardAnimation} from './keyboardAnimation';
import {useKeyboardAwarePostDraft} from './useKeyboardAwarePostDraft';
import {useKeyboardScrollAdjustment} from './useKeyboardScrollAdjustment';
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
OS: 'ios',
select: jest.fn((obj) => obj.ios),
}));
jest.mock('react-native-keyboard-controller', () => ({
KeyboardController: {
dismiss: jest.fn(() => Promise.resolve()),
},
}));
jest.mock('react-native-reanimated', () => {
const sharedValues = new Map();
return {
...jest.requireActual('react-native-reanimated/mock'),
useSharedValue: jest.fn((initial) => {
const key = Math.random();
const sv = {
value: initial,
_key: key,
};
sharedValues.set(key, sv);
return sv;
}),
useDerivedValue: jest.fn((fn) => ({
value: fn(),
})),
useAnimatedStyle: jest.fn((fn) => fn()),
useAnimatedScrollHandler: jest.fn((handlers) => (event: {contentOffset: {y: number}}) => {
if (handlers.onScroll) {
handlers.onScroll(event);
}
}),
useAnimatedReaction: jest.fn(),
};
});
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: jest.fn(),
}));
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn(),
}));
jest.mock('./keyboardAnimation', () => ({
useKeyboardAnimation: jest.fn(),
}));
jest.mock('./useKeyboardScrollAdjustment', () => ({
useKeyboardScrollAdjustment: jest.fn(),
}));
describe('useKeyboardAwarePostDraft', () => {
const mockUseKeyboardAnimation = useKeyboardAnimation as jest.Mock;
const mockUseKeyboardScrollAdjustment = useKeyboardScrollAdjustment as jest.Mock;
const mockUseIsTablet = useIsTablet as jest.Mock;
const mockUseSafeAreaInsets = useSafeAreaInsets as jest.Mock;
const mockKeyboardControllerDismiss = KeyboardController.dismiss as jest.Mock;
const mockKeyboardAnimationReturn = {
keyboardTranslateY: {value: 0},
bottomInset: {value: 0},
scrollOffset: {value: 0},
scrollPosition: {value: 0},
onScroll: jest.fn(),
isKeyboardFullyOpen: {value: false},
isKeyboardFullyClosed: {value: true},
isKeyboardInTransition: {value: false},
isInputAccessoryViewMode: {value: false},
isTransitioningFromCustomView: {value: false},
};
beforeEach(() => {
jest.clearAllMocks();
mockUseIsTablet.mockReturnValue(false);
mockUseSafeAreaInsets.mockReturnValue({bottom: 0, top: 0, left: 0, right: 0});
mockUseKeyboardAnimation.mockReturnValue(mockKeyboardAnimationReturn);
mockUseKeyboardScrollAdjustment.mockImplementation(() => {});
});
describe('initialization', () => {
it('should initialize with default values', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(result.current.postInputContainerHeight).toBe(91);
expect(result.current.listRef.current).toBeNull();
expect(result.current.inputRef.current).toBeUndefined();
expect(result.current.keyboardTranslateY).toBe(mockKeyboardAnimationReturn.keyboardTranslateY);
expect(result.current.contentInset).toBe(mockKeyboardAnimationReturn.bottomInset);
expect(result.current.onScroll).toBe(mockKeyboardAnimationReturn.onScroll);
expect(result.current.blurInput).toBeDefined();
expect(result.current.focusInput).toBeDefined();
expect(result.current.blurAndDismissKeyboard).toBeDefined();
});
it('should call useKeyboardAnimation with correct parameters', () => {
renderHook(() => useKeyboardAwarePostDraft(false, true));
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
91, // DEFAULT_POST_INPUT_HEIGHT
true, // isIOS
false, // isTablet
0, // insets.bottom
false, // isThreadView
true, // enabled
);
});
it('should call useKeyboardScrollAdjustment with correct parameters', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(mockUseKeyboardScrollAdjustment).toHaveBeenCalledWith(
result.current.listRef,
mockKeyboardAnimationReturn.scrollPosition,
mockKeyboardAnimationReturn.scrollOffset,
true, // isIOS
mockKeyboardAnimationReturn.isInputAccessoryViewMode,
mockKeyboardAnimationReturn.isTransitioningFromCustomView,
);
});
});
describe('isThreadView parameter', () => {
it('should pass isThreadView=true to useKeyboardAnimation', () => {
renderHook(() => useKeyboardAwarePostDraft(true));
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
91,
true,
false,
0,
true, // isThreadView
true,
);
});
});
describe('enabled parameter', () => {
it('should pass enabled=false to useKeyboardAnimation', () => {
renderHook(() => useKeyboardAwarePostDraft(false, false));
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
91,
true,
false,
0,
false,
false, // enabled
);
});
});
describe('postInputContainerHeight', () => {
it('should update postInputContainerHeight when setPostInputContainerHeight is called', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
act(() => {
result.current.setPostInputContainerHeight(150);
});
expect(result.current.postInputContainerHeight).toBe(150);
});
it('should update postInputContainerHeight state', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
act(() => {
result.current.setPostInputContainerHeight(150);
});
expect(result.current.postInputContainerHeight).toBe(150);
});
});
describe('input ref callbacks', () => {
it('should call blur on inputRef when blurInput is called', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
const mockBlur = jest.fn();
// @ts-expect-error Test mock - only need blur method
result.current.inputRef.current = {blur: mockBlur};
act(() => {
result.current.blurInput();
});
expect(mockBlur).toHaveBeenCalled();
});
it('should not throw when blurInput is called without inputRef', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(() => {
act(() => {
result.current.blurInput();
});
}).not.toThrow();
});
it('should call focus on inputRef when focusInput is called', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
const mockFocus = jest.fn();
// @ts-expect-error Test mock - only need focus method
result.current.inputRef.current = {focus: mockFocus};
act(() => {
result.current.focusInput();
});
expect(mockFocus).toHaveBeenCalled();
});
it('should not throw when focusInput is called without inputRef', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(() => {
act(() => {
result.current.focusInput();
});
}).not.toThrow();
});
});
describe('blurAndDismissKeyboard', () => {
it('should reset shared values and dismiss keyboard', async () => {
const mockKeyboardTranslateY = {value: 300};
const mockBottomInset = {value: 300};
const mockScrollOffset = {value: 300};
const mockBlur = jest.fn();
mockUseKeyboardAnimation.mockReturnValue({
...mockKeyboardAnimationReturn,
keyboardTranslateY: mockKeyboardTranslateY,
bottomInset: mockBottomInset,
scrollOffset: mockScrollOffset,
});
const {result} = renderHook(() => useKeyboardAwarePostDraft());
// @ts-expect-error Test mock - only need blur method
result.current.inputRef.current = {blur: mockBlur};
await act(async () => {
await result.current.blurAndDismissKeyboard();
});
expect(mockKeyboardTranslateY.value).toBe(0);
expect(mockBottomInset.value).toBe(0);
expect(mockScrollOffset.value).toBe(0);
expect(mockBlur).toHaveBeenCalled();
expect(mockKeyboardControllerDismiss).toHaveBeenCalled();
});
it('should handle missing inputRef gracefully', async () => {
const mockKeyboardTranslateY = {value: 300};
const mockBottomInset = {value: 300};
const mockScrollOffset = {value: 300};
mockUseKeyboardAnimation.mockReturnValue({
...mockKeyboardAnimationReturn,
keyboardTranslateY: mockKeyboardTranslateY,
bottomInset: mockBottomInset,
scrollOffset: mockScrollOffset,
});
const {result} = renderHook(() => useKeyboardAwarePostDraft());
await act(async () => {
await result.current.blurAndDismissKeyboard();
});
expect(mockKeyboardTranslateY.value).toBe(0);
expect(mockBottomInset.value).toBe(0);
expect(mockScrollOffset.value).toBe(0);
expect(mockKeyboardControllerDismiss).toHaveBeenCalled();
});
});
describe('inputContainerAnimatedStyle', () => {
it('should return animated style with translateY on iOS', () => {
const mockKeyboardTranslateY = {value: 200};
mockUseKeyboardAnimation.mockReturnValue({
...mockKeyboardAnimationReturn,
keyboardTranslateY: mockKeyboardTranslateY,
});
const {result} = renderHook(() => useKeyboardAwarePostDraft());
const style = result.current.inputContainerAnimatedStyle;
expect(style).toEqual({
transform: [{translateY: -200}],
});
});
});
describe('tablet and safe area handling', () => {
it('should pass tablet and safe area values to useKeyboardAnimation', () => {
mockUseIsTablet.mockReturnValue(true);
mockUseSafeAreaInsets.mockReturnValue({bottom: 20, top: 0, left: 0, right: 0});
renderHook(() => useKeyboardAwarePostDraft());
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
91,
true,
true, // isTablet
20, // insets.bottom
false,
true,
);
});
});
describe('return values', () => {
it('should return all expected properties', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(result.current).toHaveProperty('keyboardTranslateY');
expect(result.current).toHaveProperty('listRef');
expect(result.current).toHaveProperty('inputRef');
expect(result.current).toHaveProperty('contentInset');
expect(result.current).toHaveProperty('onScroll');
expect(result.current).toHaveProperty('postInputContainerHeight');
expect(result.current).toHaveProperty('setPostInputContainerHeight');
expect(result.current).toHaveProperty('inputContainerAnimatedStyle');
expect(result.current).toHaveProperty('keyboardHeight');
expect(result.current).toHaveProperty('scrollOffset');
expect(result.current).toHaveProperty('scrollPosition');
expect(result.current).toHaveProperty('blurInput');
expect(result.current).toHaveProperty('focusInput');
expect(result.current).toHaveProperty('blurAndDismissKeyboard');
expect(result.current).toHaveProperty('isKeyboardFullyOpen');
expect(result.current).toHaveProperty('isKeyboardFullyClosed');
expect(result.current).toHaveProperty('isKeyboardInTransition');
});
it('should return keyboardHeight as alias for keyboardTranslateY', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(result.current.keyboardHeight).toBe(result.current.keyboardTranslateY);
});
});
});

View file

@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useRef, useState} from 'react';
import {FlatList, Platform} from 'react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
import {useAnimatedStyle} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useIsTablet} from '@hooks/device';
import {useKeyboardAnimation} from './keyboardAnimation';
import {useKeyboardScrollAdjustment} from './useKeyboardScrollAdjustment';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
import type PostModel from '@typings/database/models/servers/post';
/**
* Default height for post input container
* Empirically measured value for iOS to prevent content shifting on initial render
*/
const DEFAULT_POST_INPUT_HEIGHT = 91;
const isIOS = Platform.OS === 'ios';
export const useKeyboardAwarePostDraft = (isThreadView = false, enabled = true) => {
const [postInputContainerHeight, setPostInputContainerHeight] = useState(DEFAULT_POST_INPUT_HEIGHT);
const listRef = useRef<FlatList<string | PostModel>>(null);
const inputRef = useRef<PasteInputRef>();
const isTablet = useIsTablet();
const insets = useSafeAreaInsets();
const {
keyboardTranslateY,
bottomInset,
scrollOffset,
scrollPosition,
onScroll,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
} = useKeyboardAnimation(postInputContainerHeight, isIOS, isTablet, insets.bottom, isThreadView, enabled);
// Only apply scroll adjustment on iOS, Android uses native keyboard handling
// Also pass isInputAccessoryViewMode and isTransitioningFromCustomView to control scroll behavior
useKeyboardScrollAdjustment(listRef, scrollPosition, scrollOffset, isIOS, isInputAccessoryViewMode, isTransitioningFromCustomView);
const inputContainerAnimatedStyle = useAnimatedStyle(
() => {
return {
transform: [{translateY: isIOS ? -keyboardTranslateY.value : 0}],
};
},
[],
);
const blurInput = useCallback(() => {
inputRef.current?.blur();
}, []);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
const blurAndDismissKeyboard = useCallback(async () => {
bottomInset.value = 0;
scrollOffset.value = 0;
keyboardTranslateY.value = 0;
inputRef.current?.blur();
await KeyboardController.dismiss();
}, [keyboardTranslateY, bottomInset, scrollOffset]);
return {
keyboardTranslateY,
listRef,
inputRef,
contentInset: bottomInset,
onScroll,
postInputContainerHeight,
setPostInputContainerHeight,
inputContainerAnimatedStyle,
keyboardHeight: keyboardTranslateY,
scrollOffset,
scrollPosition,
blurInput,
focusInput,
blurAndDismissKeyboard,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
};
};

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback} from 'react';
import {runOnJS, useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
import type PostModel from '@typings/database/models/servers/post';
import type {FlatList} from 'react-native';
/**
* Custom hook to handle automatic scrolling when keyboard opens
* Keeps messages in the same relative position to the input container
* Only enabled on iOS - Android uses native keyboard handling
*/
export const useKeyboardScrollAdjustment = (
scrollViewRef: React.RefObject<FlatList<string | PostModel>>,
scrollPosition: SharedValue<number>,
scrollOffset: SharedValue<number>,
enabled = true,
isInputAccessoryViewMode?: SharedValue<boolean>,
isTransitioningFromCustomView?: SharedValue<boolean>,
) => {
// Callback to scroll the view (needs to be stable reference for runOnJS)
const scrollToOffset = useCallback(
(offsetValue: number, scrollValue: number) => {
scrollViewRef.current?.scrollToOffset({
offset: -offsetValue + scrollValue,
animated: false,
});
},
[scrollViewRef],
);
// Watch for scrollOffset changes and adjust scroll position accordingly
useAnimatedReaction(
() => ({
scrollOffset: scrollOffset.value,
scrollPosition: scrollPosition.value,
isInputAccessoryViewMode: isInputAccessoryViewMode?.value || false,
isTransitioning: isTransitioningFromCustomView?.value || false,
}),
(current, previous) => {
'worklet';
// Skip scroll adjustment when:
// - Input Accessory view (emoji picker) is active
// - Transitioning from custom view to keyboard (heights are same, no scroll needed)
// - scrollOffset hasn't actually changed (prevents re-scrolling after transition ends)
const scrollOffsetChanged = previous === null || Math.abs(current.scrollOffset - (previous?.scrollOffset || 0)) > 0.5;
// Don't scroll when keyboard closes - let the list naturally return to position
if (current.scrollOffset === 0) {
return;
}
// Perform scroll adjustment when keyboard is opening or height changes
// scrollPosition is preserved (not reset to 0) by keyboardAnimation.ts
if (enabled && !current.isInputAccessoryViewMode && !current.isTransitioning && scrollOffsetChanged) {
runOnJS(scrollToOffset)(current.scrollOffset, current.scrollPosition);
}
},
[scrollPosition, scrollOffset, enabled, isInputAccessoryViewMode, isTransitioningFromCustomView],
);
};

View file

@ -0,0 +1,248 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import {BehaviorSubject} from 'rxjs';
import {Screens} from '@constants';
import NavigationStore from '@store/navigation_store';
import {useIsScreenVisible} from './use_screen_visibility';
const mockSubject = new BehaviorSubject<string | undefined>(undefined);
jest.mock('@store/navigation_store', () => ({
getVisibleScreen: jest.fn(() => mockSubject.value),
getSubject: jest.fn(() => mockSubject),
}));
describe('useIsScreenVisible', () => {
const mockNavigationStore = NavigationStore as jest.Mocked<typeof NavigationStore>;
beforeEach(() => {
jest.clearAllMocks();
mockSubject.next(undefined);
});
describe('initialization', () => {
it('should return false when componentId is undefined', () => {
const {result} = renderHook(() => useIsScreenVisible(undefined));
expect(result.current).toBe(false);
});
it('should return true when componentId matches visible screen on mount', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
expect(result.current).toBe(true);
});
it('should return false when componentId does not match visible screen on mount', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
expect(result.current).toBe(false);
});
});
describe('subscription updates', () => {
it('should update when visible screen changes to match componentId', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
expect(result.current).toBe(false);
act(() => {
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
});
it('should update when visible screen changes away from componentId', () => {
mockSubject.next(Screens.THREAD);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.THREAD);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
expect(result.current).toBe(true);
act(() => {
mockSubject.next(Screens.CHANNEL);
});
expect(result.current).toBe(false);
});
it('should handle multiple screen changes', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
act(() => {
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
act(() => {
mockSubject.next(Screens.CHANNEL);
});
expect(result.current).toBe(false);
act(() => {
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
});
it('should handle undefined visible screen', () => {
mockSubject.next(Screens.THREAD);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.THREAD);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
expect(result.current).toBe(true);
act(() => {
mockSubject.next(undefined);
});
expect(result.current).toBe(false);
});
});
describe('componentId changes', () => {
it('should update when componentId changes', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result, rerender} = renderHook(
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
{initialProps: {componentId: Screens.CHANNEL}},
);
expect(result.current).toBe(true);
rerender({componentId: Screens.THREAD});
expect(result.current).toBe(false);
});
it('should set to false when componentId changes to undefined', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result, rerender} = renderHook(
({componentId}: {componentId?: typeof Screens.CHANNEL}) => useIsScreenVisible(componentId),
{initialProps: {componentId: Screens.CHANNEL}},
);
expect(result.current).toBe(true);
rerender({componentId: undefined});
expect(result.current).toBe(false);
});
it('should subscribe to new componentId when it changes', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result, rerender} = renderHook(
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
{initialProps: {componentId: Screens.CHANNEL}},
);
rerender({componentId: Screens.THREAD});
act(() => {
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
});
});
describe('cleanup', () => {
it('should unsubscribe when component unmounts', () => {
const unsubscribe = jest.fn();
const subscribeSpy = jest.spyOn(mockSubject, 'subscribe').mockReturnValue({
unsubscribe,
} as unknown as ReturnType<typeof mockSubject.subscribe>);
const {unmount} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
unmount();
expect(unsubscribe).toHaveBeenCalled();
subscribeSpy.mockRestore();
});
it('should unsubscribe when componentId changes', () => {
const unsubscribe1 = jest.fn();
const unsubscribe2 = jest.fn();
let callCount = 0;
const subscribeSpy = jest.spyOn(mockSubject, 'subscribe').mockImplementation(() => {
callCount++;
if (callCount === 1) {
return {unsubscribe: unsubscribe1} as unknown as ReturnType<typeof mockSubject.subscribe>;
}
return {unsubscribe: unsubscribe2} as unknown as ReturnType<typeof mockSubject.subscribe>;
});
const {rerender} = renderHook(
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
{initialProps: {componentId: Screens.CHANNEL}},
);
rerender({componentId: Screens.THREAD as typeof Screens.CHANNEL | typeof Screens.THREAD});
expect(unsubscribe1).toHaveBeenCalled();
subscribeSpy.mockRestore();
});
});
describe('edge cases', () => {
it('should handle rapid screen changes', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
act(() => {
mockSubject.next(Screens.THREAD);
mockSubject.next(Screens.CHANNEL);
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
});
it('should handle same screen being set multiple times', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
expect(result.current).toBe(true);
act(() => {
mockSubject.next(Screens.CHANNEL);
mockSubject.next(Screens.CHANNEL);
});
expect(result.current).toBe(true);
});
});
});

View file

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import NavigationStore from '@store/navigation_store';
import type {AvailableScreens} from '@typings/screens/navigation';
export const useIsScreenVisible = (componentId?: AvailableScreens): boolean => {
const [isVisible, setIsVisible] = useState(() => {
if (!componentId) {
return false;
}
return NavigationStore.getVisibleScreen() === componentId;
});
useEffect(() => {
if (!componentId) {
setIsVisible(false);
return undefined;
}
setIsVisible(NavigationStore.getVisibleScreen() === componentId);
const subscription = NavigationStore.getSubject().subscribe((visibleScreen) => {
setIsVisible(visibleScreen === componentId);
});
return () => {
subscription.unsubscribe();
};
}, [componentId]);
return isVisible;
};

View file

@ -0,0 +1,430 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, waitFor} from '@testing-library/react-native';
import {Alert} from 'react-native';
import {Screens} from '@constants';
import {dismissBottomSheet} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import PickerUtil from '@utils/file/file_picker';
import AttachmentOptions from './attachment_options';
jest.mock('@screens/navigation', () => ({
dismissBottomSheet: jest.fn(() => Promise.resolve()),
}));
jest.mock('@utils/file/file_picker');
jest.mock('@utils/file', () => ({
fileMaxWarning: jest.fn((...args) => `Maximum ${args[1]} files allowed`),
uploadDisabledWarning: jest.fn(() => 'File uploads are disabled'),
}));
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn(() => false),
}));
describe('AttachmentOptions', () => {
const mockDismissBottomSheet = dismissBottomSheet as jest.Mock;
const mockAttachFileFromPhotoGallery = jest.fn();
const mockAttachFileFromCamera = jest.fn();
const mockAttachFileFromFiles = jest.fn();
const baseProps = {
componentId: 'Channel' as const,
onUploadFiles: jest.fn(),
maxFileCount: 10,
fileCount: 0,
maxFilesReached: false,
canUploadFiles: true,
testID: 'test-attachment-options',
};
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Alert, 'alert');
(PickerUtil as jest.Mock).mockImplementation(() => ({
attachFileFromPhotoGallery: mockAttachFileFromPhotoGallery,
attachFileFromCamera: mockAttachFileFromCamera,
attachFileFromFiles: mockAttachFileFromFiles,
}));
});
describe('action handlers', () => {
it('should call picker.attachFileFromPhotoGallery when photo library is selected', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(10);
});
});
it('should call picker.attachFileFromCamera when take photo is selected', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
const takePhotoItem = getByTestId('file_attachment.take_photo');
fireEvent.press(takePhotoItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(mockAttachFileFromCamera).toHaveBeenCalledWith({
quality: 0.8,
mediaType: 'photo',
saveToPhotos: true,
});
});
});
it('should call picker.attachFileFromCamera when take video is selected', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
const takeVideoItem = getByTestId('file_attachment.take_video');
fireEvent.press(takeVideoItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(mockAttachFileFromCamera).toHaveBeenCalledWith({
quality: 0.8,
videoQuality: 'high',
mediaType: 'video',
saveToPhotos: true,
});
});
});
it('should call picker.attachFileFromFiles when attach file is selected', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
const attachFileItem = getByTestId('file_attachment.attach_file');
fireEvent.press(attachFileItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(mockAttachFileFromFiles).toHaveBeenCalledWith(undefined, true);
});
});
});
describe('validation - canUploadFiles', () => {
it('should show alert and not call picker when canUploadFiles is false for photo library', async () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'File uploads are disabled',
);
expect(mockAttachFileFromPhotoGallery).not.toHaveBeenCalled();
});
});
it('should show alert and not call picker when canUploadFiles is false for camera', async () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const takePhotoItem = getByTestId('file_attachment.take_photo');
fireEvent.press(takePhotoItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'File uploads are disabled',
);
expect(mockAttachFileFromCamera).not.toHaveBeenCalled();
});
});
it('should show alert and not call picker when canUploadFiles is false for attach file', async () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const attachFileItem = getByTestId('file_attachment.attach_file');
fireEvent.press(attachFileItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'File uploads are disabled',
);
expect(mockAttachFileFromFiles).not.toHaveBeenCalled();
});
});
});
describe('validation - maxFilesReached', () => {
it('should show alert and not call picker when maxFilesReached is true for photo library', async () => {
const props = {
...baseProps,
maxFilesReached: true,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'Maximum 10 files allowed',
);
expect(mockAttachFileFromPhotoGallery).not.toHaveBeenCalled();
});
});
it('should show alert and not call picker when maxFilesReached is true for camera', async () => {
const props = {
...baseProps,
maxFilesReached: true,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const takePhotoItem = getByTestId('file_attachment.take_photo');
fireEvent.press(takePhotoItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'Maximum 10 files allowed',
);
expect(mockAttachFileFromCamera).not.toHaveBeenCalled();
});
});
it('should show alert and not call picker when maxFilesReached is true for attach file', async () => {
const props = {
...baseProps,
maxFilesReached: true,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const attachFileItem = getByTestId('file_attachment.attach_file');
fireEvent.press(attachFileItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'Maximum 10 files allowed',
);
expect(mockAttachFileFromFiles).not.toHaveBeenCalled();
});
});
it('should not show alert when maxFilesReached is true but maxFileCount is undefined', async () => {
const props = {
...baseProps,
maxFilesReached: true,
maxFileCount: undefined,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).not.toHaveBeenCalled();
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
});
});
});
describe('selection limit calculation', () => {
it('should pass correct selection limit when fileCount is less than maxFileCount', async () => {
const props = {
...baseProps,
fileCount: 3,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(7);
});
});
it('should pass undefined selection limit when maxFileCount is not provided', async () => {
const props = {
...baseProps,
maxFileCount: undefined,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(undefined);
});
});
it('should pass undefined selection limit when fileCount equals maxFileCount', async () => {
const props = {
...baseProps,
fileCount: 10,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(0);
});
});
});
describe('edge cases', () => {
it('should use default closeButtonId when not provided', () => {
const props = {
...baseProps,
};
delete (props as {closeButtonId?: string}).closeButtonId;
renderWithIntlAndTheme(<AttachmentOptions {...props}/>);
// Component should render without errors
});
it('should use default fileCount when not provided', async () => {
const props = {
...baseProps,
};
delete (props as {fileCount?: number}).fileCount;
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(10);
});
});
it('should use default maxFilesReached when not provided', async () => {
const props = {
...baseProps,
};
delete (props as {maxFilesReached?: boolean}).maxFilesReached;
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
});
});
it('should use default canUploadFiles when not provided', async () => {
const props = {
...baseProps,
};
delete (props as {canUploadFiles?: boolean}).canUploadFiles;
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
});
});
});
describe('tablet rendering', () => {
it('should not render title when isTablet is true', () => {
const useIsTablet = require('@hooks/device').useIsTablet;
useIsTablet.mockReturnValue(true);
const {queryByText} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
expect(queryByText('Files and media')).toBeNull();
});
it('should render title when isTablet is false', () => {
const useIsTablet = require('@hooks/device').useIsTablet;
useIsTablet.mockReturnValue(false);
const {getByText} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
expect(getByText('Files and media')).toBeTruthy();
});
});
});

View file

@ -0,0 +1,188 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Alert, View} from 'react-native';
import {type CameraOptions} from 'react-native-image-picker';
import FormattedText from '@components/formatted_text';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import BottomSheet from '@screens/bottom_sheet';
import {dismissBottomSheet} from '@screens/navigation';
import {fileMaxWarning, uploadDisabledWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {AvailableScreens} from '@typings/screens/navigation';
const TITLE_HEIGHT = 54;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
title: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
marginBottom: 8,
},
}));
type Props = {
componentId: AvailableScreens;
closeButtonId?: string;
onUploadFiles: (files: ExtractedFileInfo[]) => void;
maxFileCount?: number;
fileCount?: number;
maxFilesReached?: boolean;
canUploadFiles?: boolean;
testID?: string;
}
const AttachmentOptions: React.FC<Props> = ({
componentId,
closeButtonId = 'attachment-close-id',
onUploadFiles,
maxFileCount,
fileCount = 0,
maxFilesReached = false,
canUploadFiles = true,
testID,
}) => {
const theme = useTheme();
const isTablet = useIsTablet();
const intl = useIntl();
const styles = getStyleSheet(theme);
const picker = useMemo(() => new PickerUtil(intl, onUploadFiles), [intl, onUploadFiles]);
const checkCanUpload = () => {
if (!canUploadFiles) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
uploadDisabledWarning(intl),
);
return false;
}
return true;
};
const checkMaxFiles = () => {
if (maxFilesReached && maxFileCount) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return true;
}
return false;
};
const onChooseFromPhotoLibrary = async () => {
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
if (!checkCanUpload() || checkMaxFiles()) {
return;
}
const selectionLimit = maxFileCount ? maxFileCount - fileCount : undefined;
picker.attachFileFromPhotoGallery(selectionLimit);
};
const onTakePhoto = async () => {
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
if (!checkCanUpload() || checkMaxFiles()) {
return;
}
const options: CameraOptions = {
quality: 0.8,
mediaType: 'photo',
saveToPhotos: true,
};
picker.attachFileFromCamera(options);
};
const onTakeVideo = async () => {
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
if (!checkCanUpload() || checkMaxFiles()) {
return;
}
const options: CameraOptions = {
quality: 0.8,
videoQuality: 'high',
mediaType: 'video',
saveToPhotos: true,
};
picker.attachFileFromCamera(options);
};
const onAttachFile = async () => {
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
if (!checkCanUpload() || checkMaxFiles()) {
return;
}
picker.attachFileFromFiles(undefined, true);
};
const renderContent = () => {
return (
<View>
{!isTablet &&
<FormattedText
id='mobile.file_attachment.title'
defaultMessage='Files and media'
style={styles.title}
/>
}
<SlideUpPanelItem
leftIcon='image-outline'
onPress={onChooseFromPhotoLibrary}
testID='file_attachment.photo_library'
text={intl.formatMessage({id: 'mobile.file_upload.library', defaultMessage: 'Choose from photo library'})}
/>
<SlideUpPanelItem
leftIcon='camera-outline'
onPress={onTakePhoto}
testID='file_attachment.take_photo'
text={intl.formatMessage({id: 'mobile.file_upload.camera_photo', defaultMessage: 'Take a photo'})}
/>
<SlideUpPanelItem
leftIcon='video-outline'
onPress={onTakeVideo}
testID='file_attachment.take_video'
text={intl.formatMessage({id: 'mobile.file_upload.camera_video', defaultMessage: 'Take a video'})}
/>
<SlideUpPanelItem
leftIcon='paperclip'
onPress={onAttachFile}
testID='file_attachment.attach_file'
text={intl.formatMessage({id: 'mobile.file_upload.browse', defaultMessage: 'Attach a file'})}
/>
</View>
);
};
const snapPoints = useMemo(() => {
const componentHeight = TITLE_HEIGHT + bottomSheetSnapPoint(4, ITEM_HEIGHT);
return [1, componentHeight];
}, []);
return (
<BottomSheet
componentId={componentId}
renderContent={renderContent}
closeButtonId={closeButtonId}
snapPoints={snapPoints}
testID={testID}
/>
);
};
export default AttachmentOptions;

View file

@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './attachment_options';

View file

@ -1,27 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, type LayoutChangeEvent, StyleSheet} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {storeLastViewedChannelIdAndServer, removeLastViewedChannelIdAndServer} from '@actions/app/global';
import FloatingCallContainer from '@calls/components/floating_call_container';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {Screens} from '@constants';
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useChannelSwitch} from '@hooks/channel_switch';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {useTeamSwitch} from '@hooks/team_switch';
import {useIsScreenVisible} from '@hooks/use_screen_visibility';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import ChannelPostList from './channel_post_list';
import ChannelContent from './channel_content';
import ChannelHeader from './header';
import useGMasDMNotice from './use_gm_as_dm_notice';
@ -46,8 +44,6 @@ type ChannelProps = {
scheduledPostCount: number;
};
const edges: Edge[] = ['left', 'right'];
const styles = StyleSheet.create({
flex: {
flex: 1,
@ -80,6 +76,19 @@ const Channel = ({
const defaultHeight = useDefaultHeaderHeight();
const [containerHeight, setContainerHeight] = useState(0);
const shouldRender = !switchingTeam && !switchingChannels && shouldRenderPosts && Boolean(channelId);
const isVisible = useIsScreenVisible(componentId);
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
const safeAreaViewEdges: Edge[] = useMemo(() => {
if (isTablet) {
return ['left', 'right'];
}
if (isEmojiSearchFocused) {
return ['left', 'right'];
}
return ['left', 'right', 'bottom'];
}, [isTablet, isEmojiSearchFocused]);
const handleBack = useCallback(() => {
popTopScreen(componentId);
}, [componentId]);
@ -120,7 +129,7 @@ const Channel = ({
<SafeAreaView
style={styles.flex}
mode='margin'
edges={edges}
edges={safeAreaViewEdges}
testID='channel.screen'
onLayout={onLayout}
nativeID={componentId ? SecurityManager.getShieldScreenId(componentId) : undefined}
@ -134,29 +143,31 @@ const Channel = ({
shouldRenderBookmarks={shouldRender}
shouldRenderChannelBanner={includeChannelBanner}
/>
{shouldRender &&
<ExtraKeyboardProvider>
<View style={[styles.flex, {marginTop}]}>
<ChannelPostList
{Platform.OS === 'ios' ? (
<KeyboardProvider>
{shouldRender && (
<ChannelContent
channelId={channelId}
marginTop={marginTop}
scheduledPostCount={scheduledPostCount}
containerHeight={containerHeight}
enabled={isVisible || shouldRender}
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
/>
)}
</KeyboardProvider>
) : (
shouldRender && (
<ChannelContent
channelId={channelId}
nativeID={channelId}
marginTop={marginTop}
scheduledPostCount={scheduledPostCount}
containerHeight={containerHeight}
enabled={isVisible || shouldRender}
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
/>
</View>
<>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator scheduledPostCount={scheduledPostCount}/>
}
</>
<PostDraft
channelId={channelId}
testID='channel.post_draft'
containerHeight={containerHeight}
isChannelScreen={true}
canShowPostPriority={true}
location={Screens.CHANNEL}
/>
</ExtraKeyboardProvider>
}
)
)}
{showFloatingCallContainer && shouldRender &&
<FloatingCallContainer
channelId={channelId}

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PortalProvider} from '@gorhom/portal';
import React from 'react';
import {StyleSheet} from 'react-native';
import {KeyboardAwarePostDraftContainer} from '@components/keyboard_aware_post_draft_container';
import PostDraft from '@components/post_draft';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {Screens} from '@constants';
import ChannelPostList from './channel_post_list';
type ChannelContentProps = {
channelId: string;
marginTop: number;
scheduledPostCount: number;
containerHeight: number;
enabled?: boolean;
onEmojiSearchFocusChange?: (focused: boolean) => void;
}
const CHANNEL_POST_DRAFT_TESTID = 'channel.post_draft';
// This follows the same pattern as draft_input.tsx: `${testID}.post.input`
const CHANNEL_POST_INPUT_NATIVE_ID = `${CHANNEL_POST_DRAFT_TESTID}.post.input`;
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const ChannelContent = ({
channelId,
marginTop,
scheduledPostCount,
containerHeight,
enabled = true,
onEmojiSearchFocusChange,
}: ChannelContentProps) => {
return (
<PortalProvider>
<KeyboardAwarePostDraftContainer
textInputNativeID={CHANNEL_POST_INPUT_NATIVE_ID}
containerStyle={[styles.flex, {marginTop}]}
enabled={enabled}
onEmojiSearchFocusChange={onEmojiSearchFocusChange}
renderList={({listRef, onTouchMove, onTouchEnd}) => (
<ChannelPostList
channelId={channelId}
listRef={listRef}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
)}
>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator scheduledPostCount={scheduledPostCount}/>
}
<PostDraft
channelId={channelId}
testID={CHANNEL_POST_DRAFT_TESTID}
containerHeight={containerHeight}
isChannelScreen={true}
canShowPostPriority={true}
location={Screens.CHANNEL}
/>
</KeyboardAwarePostDraftContainer>
</PortalProvider>
);
};
export default ChannelContent;

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {type StyleProp, StyleSheet, type ViewStyle, DeviceEventEmitter} from 'react-native';
import {type StyleProp, StyleSheet, type ViewStyle, DeviceEventEmitter, type FlatList, type GestureResponderEvent} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {markChannelAsRead, unsetActiveChannelOnServer} from '@actions/remote/channel';
@ -26,9 +26,11 @@ type Props = {
contentContainerStyle?: StyleProp<AnimatedStyle<ViewStyle>>;
isCRTEnabled: boolean;
lastViewedAt: number;
nativeID: string;
posts: PostModel[];
shouldShowJoinLeaveMessages: boolean;
listRef: React.RefObject<FlatList<string | PostModel>>;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
}
const edges: Edge[] = [];
@ -39,7 +41,8 @@ const styles = StyleSheet.create({
const ChannelPostList = ({
channelId, contentContainerStyle, isCRTEnabled,
lastViewedAt, nativeID, posts, shouldShowJoinLeaveMessages,
lastViewedAt, posts, shouldShowJoinLeaveMessages,
listRef, onTouchMove, onTouchEnd,
}: Props) => {
const appState = useAppState();
const isTablet = useIsTablet();
@ -122,12 +125,14 @@ const ChannelPostList = ({
footer={intro}
lastViewedAt={lastViewedAt}
location={Screens.CHANNEL}
nativeID={nativeID}
onEndReached={onEndReached}
posts={posts}
shouldShowJoinLeaveMessages={shouldShowJoinLeaveMessages}
showMoreMessages={true}
testID='channel.post_list'
listRef={listRef}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
);

View file

@ -1,10 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, waitFor} from '@testing-library/react-native';
import React from 'react';
import {Alert} from 'react-native';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import * as Navigation from '@screens/navigation';
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import PickerUtil from '@utils/file/file_picker';
@ -13,6 +15,7 @@ import EditPost from './edit_post';
import type {Database} from '@nozbe/watermelondb';
import type PostModel from '@typings/database/models/servers/post';
import type {IntlShape} from 'react-intl';
jest.mock('@utils/file/file_picker');
jest.mock('@managers/draft_upload_manager', () => ({
@ -22,6 +25,13 @@ jest.mock('@managers/draft_upload_manager', () => ({
registerErrorHandler: jest.fn(() => jest.fn()),
cancel: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
openAttachmentOptions: jest.fn(),
buildNavigationButton: jest.fn((id: string, testID: string) => ({id, testID})),
dismissBottomSheet: jest.fn(() => Promise.resolve()),
dismissModal: jest.fn(),
setButtons: jest.fn(),
}));
const TEST_CONFIG = {
serverUrl: 'baseHandler.test.com',
@ -110,6 +120,14 @@ describe('Edit Post', () => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
attachFileFromPhotoGallery: jest.fn(() => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
attachFileFromCamera: jest.fn(() => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
}) as unknown as PickerUtil);
};
@ -120,12 +138,30 @@ describe('Edit Post', () => {
});
};
const triggerFileUpload = (screen: ReturnType<typeof renderEditPost>) => {
fireEvent.press(screen.getByTestId('edit_post.quick_actions.file_action'));
const triggerFileUpload = async (screen: ReturnType<typeof renderEditPost>) => {
let onUploadFilesCallback: ((files: ExtractedFileInfo[]) => void) | undefined;
jest.mocked(Navigation.openAttachmentOptions).mockImplementation((intl, theme, props) => {
onUploadFilesCallback = props?.onUploadFiles;
return undefined;
});
await act(async () => {
fireEvent.press(screen.getByTestId('edit_post.quick_actions.attachment_action'));
});
if (onUploadFilesCallback) {
await act(async () => {
const mockIntl = {formatMessage: jest.fn()} as unknown as IntlShape;
const mockPicker = new PickerUtil(mockIntl, onUploadFilesCallback as (files: ExtractedFileInfo[]) => void);
await mockPicker.attachFileFromFiles(undefined, true);
});
}
};
const triggerFileRemoval = (screen: ReturnType<typeof renderEditPost>, fileId: string) => {
fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
const triggerFileRemoval = async (screen: ReturnType<typeof renderEditPost>, fileId: string) => {
await act(async () => {
fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
});
};
beforeAll(async () => {
@ -159,53 +195,61 @@ describe('Edit Post', () => {
});
describe('File Upload Validation', () => {
it('should show error when file uploads are disabled', () => {
it('should show error when file uploads are disabled', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, canUploadFiles: false};
const screen = renderEditPost(props);
triggerFileUpload(screen);
await triggerFileUpload(screen);
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
});
});
it('should show error when maximum file count is reached', () => {
it('should show error when maximum file count is reached', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, maxFileCount: 1};
const screen = renderEditPost(props);
triggerFileUpload(screen);
await triggerFileUpload(screen);
expect(screen.getByText(ERROR_MESSAGES.maxFilesReached)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.maxFilesReached)).toBeVisible();
});
});
it('should show error when file size exceeds limit', () => {
it('should show error when file size exceeds limit', async () => {
setupPickerMock(TEST_FILES.largeFile);
const props = {...baseProps, maxFileSize: 1000};
const screen = renderEditPost(props);
triggerFileUpload(screen);
await triggerFileUpload(screen);
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
});
});
});
describe('File Upload Integration', () => {
it('should integrate with DraftEditPostUploadManager for successful uploads', () => {
it('should integrate with DraftEditPostUploadManager for successful uploads', async () => {
setupPickerMock(TEST_FILES.newFile);
const screen = renderEditPost();
triggerFileUpload(screen);
await triggerFileUpload(screen);
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
TEST_CONFIG.serverUrl,
TEST_FILES.newFile,
baseProps.post.channelId,
baseProps.post.rootId,
0,
true,
expect.any(Function),
);
expect(DraftEditPostUploadManager.registerErrorHandler).toHaveBeenCalledWith(
TEST_FILES.newFile.clientId,
expect.any(Function),
);
await waitFor(() => {
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
TEST_CONFIG.serverUrl,
TEST_FILES.newFile,
baseProps.post.channelId,
baseProps.post.rootId,
0,
true,
expect.any(Function),
);
expect(DraftEditPostUploadManager.registerErrorHandler).toHaveBeenCalledWith(
TEST_FILES.newFile.clientId,
expect.any(Function),
);
});
});
});
@ -215,49 +259,57 @@ describe('Edit Post', () => {
setupPickerMock(TEST_FILES.newFile);
});
it('should remove newly uploaded files without confirmation', () => {
it('should remove newly uploaded files without confirmation', async () => {
const screen = renderEditPost();
triggerFileUpload(screen);
triggerFileRemoval(screen, TEST_FILES.newFile.id);
await triggerFileUpload(screen);
await triggerFileRemoval(screen, TEST_FILES.newFile.id);
expect(Alert.alert).not.toHaveBeenCalled();
expect(DraftEditPostUploadManager.cancel).toHaveBeenCalledWith(TEST_FILES.newFile.clientId);
await waitFor(() => {
expect(DraftEditPostUploadManager.cancel).toHaveBeenCalledWith(TEST_FILES.newFile.clientId);
});
});
it('should show confirmation dialog for existing files', () => {
it('should show confirmation dialog for existing files', async () => {
const screen = renderEditPost();
triggerFileRemoval(screen, TEST_FILES.existingFile1.id);
expect(Alert.alert).toHaveBeenCalledWith(
ERROR_MESSAGES.confirmDelete,
'Are you sure you want to remove test-1?',
expect.any(Array),
);
await triggerFileRemoval(screen, TEST_FILES.existingFile1.id);
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
ERROR_MESSAGES.confirmDelete,
'Are you sure you want to remove test-1?',
expect.any(Array),
);
});
});
});
describe('Error Display', () => {
it('should display upload error in PostError component when file upload fails', () => {
it('should display upload error in PostError component when file upload fails', async () => {
setupPickerMock(TEST_FILES.largeFile);
const props = {...baseProps, maxFileSize: 1000};
const screen = renderEditPost(props);
// Trigger file upload that will cause size error
triggerFileUpload(screen);
await triggerFileUpload(screen);
// Verify that the PostError component is displayed with the error
expect(screen.getByTestId('edit_post.message.input.error')).toBeVisible();
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
await waitFor(() => {
expect(screen.getByTestId('edit_post.message.input.error')).toBeVisible();
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
});
});
it('should display upload error with divider when error is present', () => {
it('should display upload error with divider when error is present', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, canUploadFiles: false};
const screen = renderEditPost(props);
// Trigger file upload that will cause upload disabled error
triggerFileUpload(screen);
await triggerFileUpload(screen);
// Verify that the error is displayed
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
});
// Verify that the PostError component has the hasError styling applied
const editPostInput = screen.getByTestId('edit_post.message.input');

View file

@ -4,6 +4,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Keyboard, type LayoutChangeEvent, Platform, View, StyleSheet} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import {SafeAreaView, type Edge} from 'react-native-safe-area-context';
import {deletePost, editPost} from '@actions/remote/post';
@ -123,6 +124,9 @@ const EditPost = ({
useEffect(() => {
toggleSaveButton(false);
// No dependencies to avoid unnecessary re-renders since this is a one-time effect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@ -450,44 +454,88 @@ const EditPost = ({
onLayout={onLayout}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<ExtraKeyboardProvider>
<View
style={styles.body}
ref={mainView}
>
{Boolean((errorLine || errorExtra)) &&
<PostError
errorExtra={errorExtra}
errorLine={errorLine}
/>
}
<View style={styles.inputContainer}>
<EditPostInput
hasError={Boolean(errorLine || errorExtra)}
message={postMessage}
onChangeText={onInputChangeText}
onTextSelectionChange={onTextSelectionChange}
inputRef={postInputRef}
post={post}
postFiles={postFiles}
addFiles={addFiles}
/>
</View>
</View>
</ExtraKeyboardProvider>
{Platform.OS === 'ios' ? (
<KeyboardProvider>
<ExtraKeyboardProvider>
<View
style={styles.body}
ref={mainView}
>
{Boolean((errorLine || errorExtra)) &&
<PostError
errorExtra={errorExtra}
errorLine={errorLine}
/>
}
<View style={styles.inputContainer}>
<EditPostInput
hasError={Boolean(errorLine || errorExtra)}
message={postMessage}
onChangeText={onInputChangeText}
onTextSelectionChange={onTextSelectionChange}
inputRef={postInputRef}
post={post}
postFiles={postFiles}
addFiles={addFiles}
/>
</View>
</View>
</ExtraKeyboardProvider>
<Autocomplete
channelId={post.channelId}
shouldDirectlyReact={false}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onAutocompleteChangeText}
value={postMessage}
cursorPosition={cursorPosition}
position={animatedAutocompletePosition}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
/>
</KeyboardProvider>
) : (
<>
<ExtraKeyboardProvider>
<View
style={styles.body}
ref={mainView}
>
{Boolean((errorLine || errorExtra)) &&
<PostError
errorExtra={errorExtra}
errorLine={errorLine}
/>
}
<View style={styles.inputContainer}>
<EditPostInput
hasError={Boolean(errorLine || errorExtra)}
message={postMessage}
onChangeText={onInputChangeText}
onTextSelectionChange={onTextSelectionChange}
inputRef={postInputRef}
post={post}
postFiles={postFiles}
addFiles={addFiles}
/>
</View>
</View>
</ExtraKeyboardProvider>
<Autocomplete
channelId={post.channelId}
shouldDirectlyReact={false}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onAutocompleteChangeText}
value={postMessage}
cursorPosition={cursorPosition}
position={animatedAutocompletePosition}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
/>
</>
)}
</SafeAreaView>
<Autocomplete
channelId={post.channelId}
shouldDirectlyReact={false}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onAutocompleteChangeText}
value={postMessage}
cursorPosition={cursorPosition}
position={animatedAutocompletePosition}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
/>
</EditPostProvider>
);
};

View file

@ -91,9 +91,7 @@ describe('EditPostInput', () => {
const {queryByTestId} = renderWithEverything(<EditPostInput {...props}/>, {database, serverUrl});
expect(queryByTestId('edit_post.quick_actions')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.at_input_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.file_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.image_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.camera_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.attachment_action')).toBeNull();
});
it('should not render both uploads and quick actions when server version is unsupported', () => {
@ -106,10 +104,8 @@ describe('EditPostInput', () => {
it('should render quick actions in edit mode', () => {
const {getByTestId, queryByTestId} = renderWithEverything(<EditPostInput {...baseProps}/>, {database, serverUrl});
expect(getByTestId('edit_post.quick_actions')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.attachment_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.at_input_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.file_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.image_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.camera_action')).toBeVisible();
expect(queryByTestId('edit_post.quick_actions.slash_input_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.post_priority_action')).toBeNull();
});

View file

@ -67,6 +67,7 @@ const EditPostInput = ({
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
const focus = useCallback(() => {
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateValue = useCallback((valueOrUpdater: string | ((prevValue: string) => string)) => {
@ -144,6 +145,7 @@ const EditPostInput = ({
canShowPostPriority={false}
postPriority={INITIAL_PRIORITY}
canShowSlashCommands={false}
canShowEmojiPicker={false}
focus={focus}
/>
</>

View file

@ -1,67 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {useTheme} from '@context/theme';
import {selectEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmojiCategoryBarIcon from './icon';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
justifyContent: 'space-between',
backgroundColor: theme.centerChannelBg,
height: 55,
paddingHorizontal: 12,
paddingTop: 11,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
borderTopWidth: 1,
flexDirection: 'row',
},
}));
type Props = {
onSelect?: (index: number | undefined) => void;
}
const EmojiCategoryBar = ({onSelect}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {currentIndex, icons} = useEmojiCategoryBar();
const scrollToIndex = useCallback((index: number) => {
if (onSelect) {
onSelect(index);
return;
}
selectEmojiCategoryBarSection(index);
}, []);
if (!icons) {
return null;
}
return (
<View
style={styles.container}
testID='emoji_picker.category_bar'
>
{icons.map((icon, index) => (
<EmojiCategoryBarIcon
currentIndex={currentIndex}
key={icon.key}
icon={icon.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</View>
);
};
export default EmojiCategoryBar;

View file

@ -6,12 +6,11 @@ import React, {useCallback} from 'react';
import {Platform} from 'react-native';
import Animated, {useAnimatedStyle, withTiming, type SharedValue} from 'react-native-reanimated';
import EmojiCategoryBar from '@components/emoji_category_bar';
import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import {selectEmojiCategoryBarSection} from '@hooks/emoji_category_bar';
import EmojiCategoryBar from '../emoji_category_bar';
function waitForSheetExtended(animatedSheetState: SharedValue<number>, callback: () => void, depth = 250) {
if (animatedSheetState.value === SHEET_STATE.EXTENDED) {
callback();
@ -36,6 +35,9 @@ const PickerFooter = (props: BottomSheetFooterProps) => {
waitForSheetExtended(animatedSheetState, () => {
selectEmojiCategoryBarSection(index);
});
// animatedSheetState and expand are stable and don't need to be in dependencies to avoid unnecessary callback recreation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const animatedStyle = useAnimatedStyle(() => {

View file

@ -71,6 +71,7 @@ export default function EmojiRow({emojis, file, imageUrl, onEmojiPress}: EmojiRo
name={emoji.name}
onEmojiPress={onEmojiPress}
category={emoji.category}
preventDoubleTap={false}
/>
);
})}

View file

@ -8,6 +8,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View, StyleSheet} from 'react-native';
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
import EmojiCategoryBar from '@components/emoji_category_bar';
import {EMOJI_CATEGORY_ICONS, EMOJI_ROW_MARGIN, EMOJI_SIZE, EMOJIS_PER_PAGE, EMOJIS_PER_ROW, EMOJIS_PER_ROW_TABLET} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
@ -15,8 +16,6 @@ import {setEmojiCategoryBarIcons, setEmojiCategoryBarSection, useEmojiCategoryBa
import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji';
import {fillEmoji} from '@utils/emoji/helpers';
import EmojiCategoryBar from '../emoji_category_bar';
import EmojiRow, {type EmojiSectionRow} from './emoji_row';
import SectionFooter from './section_footer';
import SectionHeader, {type EmojiSection} from './section_header';

View file

@ -29,9 +29,10 @@ const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
height: SECTION_HEADER_HEIGHT,
justifyContent: 'center',
backgroundColor: theme.centerChannelBg,
paddingHorizontal: 12,
},
sectionTitle: {
color: changeOpacity(theme.centerChannelColor, 0.2),
color: changeOpacity(theme.centerChannelColor, 0.56),
textTransform: 'uppercase',
...typography('Heading', 75, 'SemiBold'),
},

View file

@ -157,6 +157,10 @@ const ChannelListScreen = (props: ChannelProps) => {
if (!props.hasCurrentUser || !props.currentUserId) {
refetchCurrentUser(serverUrl, props.currentUserId);
}
// - serverUrl is stable from useServerUrl hook
// - We only need to re-run when the current user state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.currentUserId, props.hasCurrentUser]);
// Init the rate app. Only run the effect on the first render if ToS is not open
@ -168,12 +172,12 @@ const ChannelListScreen = (props: ChannelProps) => {
if (!NavigationStore.isToSOpen()) {
tryRunAppReview(props.launchType, props.coldStart);
}
}, []);
}, [props.launchType, props.coldStart]);
useEffect(() => {
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
PerformanceMetricsManager.measureTimeToInteraction();
}, []);
}, [serverUrl]);
return (
<>

View file

@ -7,6 +7,7 @@ import {NavigationContainer, DefaultTheme} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
import {useKeyboardState} from 'react-native-keyboard-controller';
import {enableFreeze, enableScreens} from 'react-native-screens';
import {autoUpdateTimezone} from '@actions/remote/user';
@ -66,11 +67,18 @@ export function HomeScreen(props: HomeProps) {
const theme = useTheme();
const intl = useIntl();
const appState = useAppState();
const keyboardState = useKeyboardState();
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = React.useState(false);
useEffect(() => {
SecurityManager.start();
}, []);
useEffect(() => {
// Hide tab bar when keyboard opens, show when it closes
DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, !keyboardState.isVisible);
}, [keyboardState.isVisible]);
const handleFindChannels = useCallback(() => {
if (!NavigationStore.getScreensInStack().includes(Screens.FIND_CHANNELS)) {
findChannels(
@ -147,6 +155,28 @@ export function HomeScreen(props: HomeProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.EMOJI_PICKER_SEARCH_FOCUSED, (focused: boolean) => {
setIsEmojiSearchFocused(focused);
});
return () => listener.remove();
}, []);
const TabBarComponent = (tabProps: BottomTabBarProps) => {
if (isEmojiSearchFocused) {
return null;
}
return (
<TabBar
{...tabProps}
theme={theme}
/>
);
};
TabBarComponent.displayName = 'TabBarComponent';
return (
<View
style={styles.flex}
@ -170,11 +200,7 @@ export function HomeScreen(props: HomeProps) {
<Tab.Navigator
screenOptions={{headerShown: false, freezeOnBlur: false, lazy: true}}
backBehavior='none'
tabBar={(tabProps: BottomTabBarProps) => (
<TabBar
{...tabProps}
theme={theme}
/>)}
tabBar={TabBarComponent}
>
<Tab.Screen
name={Screens.HOME}

View file

@ -67,6 +67,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.APPS_FORM:
screen = withServerDatabase(require('@screens/apps_form').default);
break;
case Screens.ATTACHMENT_OPTIONS:
screen = withServerDatabase(require('@screens/attachment_options').default);
break;
case Screens.BOTTOM_SHEET:
screen = withServerDatabase(require('@screens/bottom_sheet').default);
break;

View file

@ -5,12 +5,29 @@ import {Navigation} from 'react-native-navigation';
import {Events, Preferences, Screens} from '@constants';
import NavigationStore from '@store/navigation_store';
import {isTablet} from '@utils/helpers';
import {openAsBottomSheet} from './navigation';
import {openAsBottomSheet, openAttachmentOptions} from './navigation';
import type {FirstArgument} from '@typings/utils/utils';
import type {IntlShape} from 'react-intl';
jest.mock('@utils/helpers', () => ({
...jest.requireActual('@utils/helpers'),
isTablet: jest.fn(),
}));
jest.mock('@components/compass_icon', () => {
function CompassIcon() {
return null;
}
CompassIcon.getImageSourceSync = jest.fn().mockReturnValue({});
return {
__esModule: true,
default: CompassIcon,
};
});
function expectShowModalCalledWith(screen: string, title: string, props?: Record<string, unknown>) {
expect(Navigation.showModal).toHaveBeenCalledWith({
stack: {
@ -33,8 +50,8 @@ function expectShowModalOverCurrentContext(screen: string, props?: Record<string
expectShowModalCalledWith(screen, '', props);
}
function expectOpenAsBottomSheetCalledWith(props: FirstArgument<typeof openAsBottomSheet>, isTablet: boolean) {
if (isTablet) {
function expectOpenAsBottomSheetCalledWith(props: FirstArgument<typeof openAsBottomSheet>, isTabletDevice: boolean) {
if (isTabletDevice) {
expectShowModalCalledWith(props.screen, props.title, {closeButtonId: props.closeButtonId, ...props.props});
} else {
expectShowModalOverCurrentContext(props.screen, props.props);
@ -107,3 +124,68 @@ describe('openUserProfileModal', () => {
}, false);
});
});
describe('openAttachmentOptions', () => {
const intl = {
formatMessage: jest.fn(({defaultMessage}) => defaultMessage),
} as unknown as IntlShape;
const theme = Preferences.THEMES.denim;
const mockOnUploadFiles = jest.fn();
const props = {
onUploadFiles: mockOnUploadFiles,
maxFilesReached: false,
canUploadFiles: true,
testID: 'test-attachment',
fileCount: 0,
maxFileCount: 5,
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(isTablet).mockReturnValue(false);
});
it('should call openAsBottomSheet with correct parameters on non-tablet', () => {
openAttachmentOptions(intl, theme, props);
expectOpenAsBottomSheetCalledWith({
screen: Screens.ATTACHMENT_OPTIONS,
title: 'Files and media',
closeButtonId: 'attachment-close-id',
theme,
props,
}, false);
});
it('should call openAsBottomSheet with correct parameters on tablet', () => {
jest.mocked(isTablet).mockReturnValue(true);
openAttachmentOptions(intl, theme, props);
expectOpenAsBottomSheetCalledWith({
screen: Screens.ATTACHMENT_OPTIONS,
title: 'Files and media',
closeButtonId: 'attachment-close-id',
theme,
props,
}, true);
});
it('should handle optional props correctly', () => {
const minimalProps = {
onUploadFiles: mockOnUploadFiles,
maxFilesReached: true,
canUploadFiles: false,
};
openAttachmentOptions(intl, theme, minimalProps);
expectOpenAsBottomSheetCalledWith({
screen: Screens.ATTACHMENT_OPTIONS,
title: 'Files and media',
closeButtonId: 'attachment-close-id',
theme,
props: minimalProps,
}, false);
});
});

View file

@ -26,6 +26,7 @@ import type {LaunchProps} from '@typings/launch';
import type {AvailableScreens, NavButtons} from '@typings/screens/navigation';
import type {ComponentProps} from 'react';
import type {IntlShape} from 'react-intl';
import type {Asset} from 'react-native-image-picker';
const alpha = {
from: 0,
@ -913,6 +914,28 @@ export function openAsBottomSheet({closeButtonId, screen, theme, title, props}:
}
}
export function openAttachmentOptions(
intl: IntlShape,
theme: Theme,
props: {
onUploadFiles: (files: Asset[]) => void;
maxFilesReached: boolean;
canUploadFiles: boolean;
testID?: string;
fileCount?: number;
maxFileCount?: number;
},
) {
const title = intl.formatMessage({id: 'mobile.file_attachment.title', defaultMessage: 'Files and media'});
openAsBottomSheet({
closeButtonId: 'attachment-close-id',
screen: Screens.ATTACHMENT_OPTIONS,
theme,
title,
props,
});
}
export const showAppForm = async (form: AppForm, context: AppContext) => {
const passProps = {form, context};
showModal(Screens.APPS_FORM, form.title || '', passProps);

View file

@ -3,7 +3,8 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, TouchableOpacity, View} from 'react-native';
import {Alert, Platform, Text, TouchableOpacity, View} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import Animated from 'react-native-reanimated';
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
@ -17,7 +18,6 @@ import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import PostList from '@components/post_list';
import {Screens} from '@constants';
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
@ -263,6 +263,12 @@ function Permalink({
});
setLoading(false);
})();
// - serverUrl is stable from useServerUrl hook (doesn't need to be in deps)
// - postId, isTeamMember, currentTeamId are props that don't change for a given permalink screen
// - setError, setLoading, setChannelId, setPosts are stable setState functions
// - We only need to re-run when channelId, rootId, isCRTEnabled, or teamName changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelId, rootId, isCRTEnabled, teamName]);
const handleClose = useCallback(() => {
@ -314,8 +320,8 @@ function Permalink({
/>
);
} else {
content = (
<ExtraKeyboardProvider>
const postListContent = (
<>
<View style={style.postList}>
<PostList
highlightedId={postId}
@ -327,7 +333,6 @@ function Permalink({
channelId={channel!.id}
rootId={rootId}
testID='permalink.post_list'
nativeID={Screens.PERMALINK}
highlightPinnedOrSaved={false}
/>
</View>
@ -340,7 +345,15 @@ function Permalink({
testID='permalink.jump_to_recent_messages.button'
/>
</View>
</ExtraKeyboardProvider>
</>
);
content = Platform.OS === 'ios' ? (
<KeyboardProvider>
{postListContent}
</KeyboardProvider>
) : (
postListContent
);
}

View file

@ -2,26 +2,26 @@
// See LICENSE.txt for license information.
import {uniqueId} from 'lodash';
import React, {useCallback, useEffect, useState} from 'react';
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, type LayoutChangeEvent, StyleSheet} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {storeLastViewedThreadIdAndServer, removeLastViewedThreadIdAndServer} from '@actions/app/global';
import FloatingCallContainer from '@calls/components/floating_call_container';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
import RoundedHeaderContext from '@components/rounded_header_context';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {Screens} from '@constants';
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useIsScreenVisible} from '@hooks/use_screen_visibility';
import SecurityManager from '@managers/security_manager';
import {popTopScreen, setButtons} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import ThreadPostList from './thread_post_list';
import ThreadContent from './thread_content';
import type PostModel from '@typings/database/models/servers/post';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -37,8 +37,6 @@ type ThreadProps = {
scheduledPostCount: number;
};
const edges: Edge[] = ['left', 'right'];
const styles = StyleSheet.create({
flex: {flex: 1},
});
@ -54,6 +52,20 @@ const Thread = ({
scheduledPostCount,
}: ThreadProps) => {
const [containerHeight, setContainerHeight] = useState(0);
const isVisible = useIsScreenVisible(componentId);
const isTablet = useIsTablet();
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
// Remove bottom safe area when emoji search is focused to eliminate gap between emoji picker and keyboard
const safeAreaViewEdges: Edge[] = useMemo(() => {
if (isTablet) {
return ['left', 'right'];
}
if (isEmojiSearchFocused) {
return ['left', 'right'];
}
return ['left', 'right', 'bottom'];
}, [isTablet, isEmojiSearchFocused]);
const close = useCallback(() => {
popTopScreen(componentId);
@ -96,7 +108,7 @@ const Thread = ({
}
setButtons(componentId, {rightButtons: []});
};
}, [rootId]);
}, [rootId, componentId, isCRTEnabled]);
useDidUpdate(() => {
if (!rootPost) {
@ -115,37 +127,34 @@ const Thread = ({
<SafeAreaView
style={styles.flex}
mode='margin'
edges={edges}
edges={safeAreaViewEdges}
testID='thread.screen'
onLayout={onLayout}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<RoundedHeaderContext/>
{Boolean(rootPost) &&
<ExtraKeyboardProvider>
<View style={styles.flex}>
<ThreadPostList
nativeID={rootId}
(Platform.OS === 'ios' ? (
<KeyboardProvider>
<ThreadContent
rootId={rootId}
rootPost={rootPost!}
scheduledPostCount={scheduledPostCount}
containerHeight={containerHeight}
enabled={isVisible}
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
/>
</View>
<>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator
isThread={true}
scheduledPostCount={scheduledPostCount}
/>
}
</>
<PostDraft
channelId={rootPost!.channelId}
</KeyboardProvider>
) : (
<ThreadContent
rootId={rootId}
testID='thread.post_draft'
rootPost={rootPost!}
scheduledPostCount={scheduledPostCount}
containerHeight={containerHeight}
isChannelScreen={false}
location={Screens.THREAD}
enabled={isVisible}
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
/>
</ExtraKeyboardProvider>
))
}
{showFloatingCallContainer &&
<FloatingCallContainer

View file

@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PortalProvider} from '@gorhom/portal';
import React from 'react';
import {StyleSheet} from 'react-native';
import {KeyboardAwarePostDraftContainer} from '@components/keyboard_aware_post_draft_container';
import PostDraft from '@components/post_draft';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {Screens} from '@constants';
import ThreadPostList from './thread_post_list';
import type PostModel from '@typings/database/models/servers/post';
type ThreadContentProps = {
rootId: string;
rootPost: PostModel;
scheduledPostCount: number;
containerHeight: number;
enabled?: boolean;
onEmojiSearchFocusChange?: (focused: boolean) => void;
}
const THREAD_POST_DRAFT_TESTID = 'thread.post_draft';
// This follows the same pattern as draft_input.tsx: `${testID}.post.input`
const THREAD_POST_INPUT_NATIVE_ID = `${THREAD_POST_DRAFT_TESTID}.post.input`;
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const ThreadContent = ({
rootId,
rootPost,
scheduledPostCount,
containerHeight,
enabled = true,
onEmojiSearchFocusChange,
}: ThreadContentProps) => {
return (
<PortalProvider>
<KeyboardAwarePostDraftContainer
textInputNativeID={THREAD_POST_INPUT_NATIVE_ID}
containerStyle={styles.flex}
isThreadView={true}
enabled={enabled}
onEmojiSearchFocusChange={onEmojiSearchFocusChange}
renderList={({listRef, onTouchMove, onTouchEnd}) => (
<ThreadPostList
rootPost={rootPost}
listRef={listRef}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
)}
>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator
isThread={true}
scheduledPostCount={scheduledPostCount}
/>
}
<PostDraft
channelId={rootPost.channelId}
rootId={rootId}
testID={THREAD_POST_DRAFT_TESTID}
containerHeight={containerHeight}
isChannelScreen={false}
location={Screens.THREAD}
/>
</KeyboardAwarePostDraftContainer>
</PortalProvider>
);
};
export default ThreadContent;

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {ActivityIndicator, StyleSheet, View} from 'react-native';
import {ActivityIndicator, type FlatList, type GestureResponderEvent, StyleSheet, View} from 'react-native';
import {fetchPostThread} from '@actions/remote/post';
import {markThreadAsRead} from '@actions/remote/thread';
@ -22,12 +22,14 @@ import type ThreadModel from '@typings/database/models/servers/thread';
type Props = {
channelLastViewedAt: number;
isCRTEnabled: boolean;
nativeID: string;
posts: PostModel[];
rootPost: PostModel;
teamId: string;
thread?: ThreadModel;
version?: string;
listRef: React.RefObject<FlatList<string | PostModel>>;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
}
const styles = StyleSheet.create({
@ -38,7 +40,8 @@ const styles = StyleSheet.create({
const ThreadPostList = ({
channelLastViewedAt, isCRTEnabled,
nativeID, posts, rootPost, teamId, thread, version,
posts, rootPost, teamId, thread, version,
listRef, onTouchMove, onTouchEnd,
}: Props) => {
const appState = useAppState();
const serverUrl = useServerUrl();
@ -103,7 +106,6 @@ const ThreadPostList = ({
isCRTEnabled={isCRTEnabled}
lastViewedAt={lastViewedAt}
location={Screens.THREAD}
nativeID={nativeID}
onEndReached={onEndReached}
posts={threadPosts}
rootId={rootPost.id}
@ -112,6 +114,9 @@ const ThreadPostList = ({
header={header}
footer={<View style={styles.footer}/>}
testID='thread.post_list'
listRef={listRef}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
);

View file

@ -83,6 +83,13 @@ class NavigationStoreSingleton {
if (index > -1) {
this.modalsInStack.splice(index, 1);
}
// Restore the previous screen as visible when modal is dismissed
// This ensures that the underlying screen (e.g., Channel/Thread) becomes enabled again
const visibleScreen = this.screensInStack[0];
if (visibleScreen) {
this.subject.next(visibleScreen);
}
};
setToSOpen = (open: boolean) => {

163
app/utils/grapheme.test.ts Normal file
View file

@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {deleteLastGrapheme} from './grapheme';
describe('deleteLastGrapheme', () => {
describe('Simple ASCII characters', () => {
it('should delete a single character', () => {
const result = deleteLastGrapheme('Hello', 5);
expect(result.text).toBe('Hell');
expect(result.cursorPosition).toBe(4);
});
it('should delete character in the middle', () => {
const result = deleteLastGrapheme('Hello World', 5);
expect(result.text).toBe('Hell World');
expect(result.cursorPosition).toBe(4);
});
it('should delete character at the start', () => {
const result = deleteLastGrapheme('Hello', 1);
expect(result.text).toBe('ello');
expect(result.cursorPosition).toBe(0);
});
});
describe('Basic emojis', () => {
it('should delete a simple emoji', () => {
const result = deleteLastGrapheme('Hello 😀', 8);
expect(result.text).toBe('Hello ');
expect(result.cursorPosition).toBe(6);
});
it('should delete emoji in the middle', () => {
const result = deleteLastGrapheme('😀 World', 2);
expect(result.text).toBe(' World');
expect(result.cursorPosition).toBe(0);
});
it('should delete emoji at the start', () => {
const result = deleteLastGrapheme('😀 Hello', 2);
expect(result.text).toBe(' Hello');
expect(result.cursorPosition).toBe(0);
});
it('should delete multiple emojis correctly', () => {
const result = deleteLastGrapheme('😀❤️🎉', 6);
expect(result.text).toBe('😀❤️');
expect(result.cursorPosition).toBe(4);
});
});
describe('Complex Unicode characters', () => {
it('should delete flag emoji (🇺🇸)', () => {
const result = deleteLastGrapheme('Hello 🇺🇸', 10);
expect(result.text).toBe('Hello ');
expect(result.cursorPosition).toBe(6);
});
it('should delete flag emoji at start', () => {
const result = deleteLastGrapheme('🇺🇸 Hello', 4);
expect(result.text).toBe(' Hello');
expect(result.cursorPosition).toBe(0);
});
it('should delete family emoji (👨‍👩‍👧‍👦)', () => {
const result = deleteLastGrapheme('Family 👨‍👩‍👧‍👦', 18);
expect(result.text).toBe('Family ');
expect(result.cursorPosition).toBe(7);
});
it('should delete emoji with skin tone modifier (👨🏿)', () => {
const result = deleteLastGrapheme('Hello 👨🏿', 10);
expect(result.text).toBe('Hello ');
expect(result.cursorPosition).toBe(6);
});
it('should delete emoji with zero-width joiner', () => {
const result = deleteLastGrapheme('👨‍👩‍👧‍👦', 11);
expect(result.text).toBe('');
expect(result.cursorPosition).toBe(0);
});
});
describe('Mixed text and emojis', () => {
it('should delete emoji from mixed content', () => {
const result = deleteLastGrapheme('Hello 😀 World', 8);
expect(result.text).toBe('Hello World');
expect(result.cursorPosition).toBe(6);
});
it('should delete text character before emoji', () => {
const result = deleteLastGrapheme('Hello😀', 5);
expect(result.text).toBe('Hell😀');
expect(result.cursorPosition).toBe(4);
});
it('should delete emoji between text', () => {
const result = deleteLastGrapheme('Hello 😀 World', 8);
expect(result.text).toBe('Hello World');
expect(result.cursorPosition).toBe(6);
});
});
describe('Edge cases', () => {
it('should handle empty string', () => {
const result = deleteLastGrapheme('', 0);
expect(result.text).toBe('');
expect(result.cursorPosition).toBe(0);
});
it('should handle cursor at start', () => {
const result = deleteLastGrapheme('Hello', 0);
expect(result.text).toBe('Hello');
expect(result.cursorPosition).toBe(0);
});
it('should handle cursor beyond text length', () => {
const result = deleteLastGrapheme('Hello', 10);
expect(result.text).toBe('Hell');
expect(result.cursorPosition).toBe(4);
});
it('should handle single character', () => {
const result = deleteLastGrapheme('H', 1);
expect(result.text).toBe('');
expect(result.cursorPosition).toBe(0);
});
it('should handle single emoji', () => {
const result = deleteLastGrapheme('😀', 2);
expect(result.text).toBe('');
expect(result.cursorPosition).toBe(0);
});
it('should handle only whitespace', () => {
const result = deleteLastGrapheme(' ', 2);
expect(result.text).toBe(' ');
expect(result.cursorPosition).toBe(1);
});
});
describe('Real-world scenarios', () => {
it('should handle message with emojis', () => {
const result = deleteLastGrapheme('Great job! 🎉👏', 15);
expect(result.text).toBe('Great job! 🎉');
expect(result.cursorPosition).toBe(13);
});
it('should handle message with flag', () => {
const result = deleteLastGrapheme('From 🇺🇸', 9);
expect(result.text).toBe('From ');
expect(result.cursorPosition).toBe(5);
});
it('should handle message with mixed content', () => {
const result = deleteLastGrapheme('Hello 👨‍👩‍👧‍👦 family!', 17);
expect(result.text).toBe('Hello family!');
expect(result.cursorPosition).toBe(6);
});
});
});

43
app/utils/grapheme.ts Normal file
View file

@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import GraphemeSplitter from 'grapheme-splitter';
/**
* Deletes the last grapheme cluster (character) from a string before the cursor position.
* Handles complex Unicode characters like emojis, flags, and modifiers correctly.
*
* @param text - The text to delete from
* @param cursorPosition - The cursor position (0-based index)
* @returns Object with updated text and new cursor position
*/
export function deleteLastGrapheme(text: string, cursorPosition: number): {
text: string;
cursorPosition: number;
} {
if (cursorPosition === 0) {
return {text, cursorPosition: 0};
}
const adjustedCursorPosition = cursorPosition > text.length ? text.length : cursorPosition;
const splitter = new GraphemeSplitter();
const textBeforeCursor = text.slice(0, adjustedCursorPosition);
const clusters = splitter.splitGraphemes(textBeforeCursor);
if (clusters.length === 0) {
return {text, cursorPosition};
}
// Remove last grapheme cluster
clusters.pop();
const joinedClusters = clusters.join('');
const updatedText = joinedClusters + text.slice(adjustedCursorPosition);
const newCursorPosition = joinedClusters.length;
return {
text: updatedText,
cursorPosition: newCursorPosition,
};
}

32
app/utils/keyboard.ts Normal file
View file

@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Keyboard, Platform} from 'react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
/**
* Dismisses the keyboard using platform-specific implementation.
* - iOS: Uses KeyboardController.dismiss() which provides better control
* - Android: Uses React Native's Keyboard.dismiss() since KeyboardProvider
* is not used on Android (to avoid layout issues)
*/
export const dismissKeyboard = async (): Promise<void> => {
if (Platform.OS === 'ios') {
await KeyboardController.dismiss();
} else {
Keyboard.dismiss();
}
};
/**
* Checks if the keyboard is currently visible.
* - iOS: Uses KeyboardController.isVisible() for accurate keyboard state
* - Android: Uses React Native's Keyboard.isVisible() since KeyboardProvider
* is not used on Android (to avoid layout issues)
*/
export const isKeyboardVisible = (): boolean => {
if (Platform.OS === 'ios') {
return KeyboardController.isVisible();
}
return Keyboard.isVisible();
};

View file

@ -113,8 +113,6 @@
"browse_channels.title": "Browse channels",
"burn_on_read.label.title": "BURN ON READ ({duration})",
"calls.join_call_avatars.bottom_sheet_title": "Call participants",
"camera_type.photo.option": "Capture Photo",
"camera_type.video.option": "Record Video",
"center_panel.archived.closeChannel": "Close Channel",
"channel_add_members.add_members.button": "Add Members",
"channel_bookmark_add.link": "Link",
@ -296,6 +294,7 @@
"create_post.deactivated": "You are viewing an archived channel with a deactivated user.",
"create_post.thread_reply": "Reply to this thread...",
"create_post.write": "Write to {channelDisplayName}",
"custom_emoji_picker.search.no_results": "No results",
"custom_status.expiry_dropdown.custom": "Custom",
"custom_status.expiry_dropdown.date_and_time": "Date and Time",
"custom_status.expiry_dropdown.dont_clear": "Don't clear",
@ -655,7 +654,6 @@
"mobile.calls_you_2": "You",
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.",
"mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.camera_type.title": "Camera options",
"mobile.channel_add_members.error": "There has been an error and we could not add those users to the channel.",
"mobile.channel_info.alertNo": "No",
"mobile.channel_info.alertYes": "Yes",
@ -707,7 +705,12 @@
"mobile.error_handler.button": "Relaunch",
"mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n",
"mobile.error_handler.title": "Unexpected error occurred",
"mobile.file_attachment.title": "Files and media",
"mobile.file_upload.browse": "Attach a file",
"mobile.file_upload.camera_photo": "Take a photo",
"mobile.file_upload.camera_video": "Take a video",
"mobile.file_upload.disabled2": "File uploads from mobile are disabled.",
"mobile.file_upload.library": "Choose from photo library",
"mobile.file_upload.max_warning": "Uploads limited to {count} files maximum.",
"mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.",
"mobile.files_paste.error_dismiss": "Dismiss",

View file

@ -1433,6 +1433,27 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-keyboard-controller (1.19.3):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-netinfo (11.4.1):
- React-Core
- react-native-network-client (1.9.1):
@ -2251,6 +2272,7 @@ DEPENDENCIES:
- react-native-document-picker (from `../node_modules/react-native-document-picker`)
- "react-native-emm (from `../node_modules/@mattermost/react-native-emm`)"
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- "react-native-network-client (from `../node_modules/@mattermost/react-native-network-client`)"
- react-native-notifications (from `../node_modules/react-native-notifications`)
@ -2455,6 +2477,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@mattermost/react-native-emm"
react-native-image-picker:
:path: "../node_modules/react-native-image-picker"
react-native-keyboard-controller:
:path: "../node_modules/react-native-keyboard-controller"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-network-client:
@ -2588,7 +2612,7 @@ SPEC CHECKSUMS:
BlueRSA: 893ae5412de48a508a487067564dbcf9cd49df71
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
CocoaLumberjack: 6a459bc897d6d80bd1b8c78482ec7ad05dffc3f0
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819
EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b
Expo: 1687edb10c76b0c0f135306d6ae245379f50ed54
@ -2605,8 +2629,8 @@ SPEC CHECKSUMS:
FBLazyVector: 23d8c5470c648a635893dc0956c6dbaead54b656
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
Gekidou: 94199ed8d31b900f2caeb9d5739c19bed9defc4a
glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8
hermes-engine: b2187dbe13edb0db8fcb2a93a69c1987a30d98a4
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
HMSegmentedControl: 34c1f54d822d8308e7b24f5d901ec674dfa31352
JitsiWebRTC: b47805ab5668be38e7ee60e2258f49badfe8e1d0
KituraContracts: e845e60dc8627ad0a76fa55ef20a45451d8f830b
@ -2649,9 +2673,10 @@ SPEC CHECKSUMS:
react-native-background-timer: 4638ae3bee00320753647900b21260b10587b6f7
react-native-cameraroll: 9f7159bae7c7b02db8815dc1591d01dc0f527d9a
react-native-cookies: d648ab7025833b977c0b19e142503034f5f29411
react-native-document-picker: 3ffdb92f95177227477077cfebabf429ad4e73a4
react-native-emm: 0873c06dc36ac2b68b398726e5a8e2a8d47da929
react-native-image-picker: 1279ed5533ab248197e8a05f928f3d826fe5a31e
react-native-document-picker: 81d00edb1cbbc18b1b0b92e762600c39ae523d7b
react-native-emm: c7b71de7822a005bc0f0c52a8c83d6f22e525ab6
react-native-image-picker: 9a8ef343925265cca1f014acae4724078014ef50
react-native-keyboard-controller: e595243f2caa27295102f87d8adff363c76c82df
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-network-client: 209fc0c5b0bd7e367825fb9ca9bfa610ab5ec797
react-native-notifications: 3bafa1237ae8a47569a84801f17d80242fe9f6a5

32
package-lock.json generated
View file

@ -18,6 +18,7 @@
"@formatjs/intl-pluralrules": "5.4.4",
"@formatjs/intl-relativetimeformat": "11.4.11",
"@gorhom/bottom-sheet": "5.1.2",
"@gorhom/portal": "1.0.14",
"@mattermost/calls": "github:mattermost/calls-common#ab53c24053b89e4d3d853bbe1244892899517f45",
"@mattermost/compass-icons": "0.1.48",
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
@ -63,6 +64,7 @@
"expo-web-browser": "14.0.2",
"fflate": "0.8.2",
"fuse.js": "7.1.0",
"grapheme-splitter": "1.0.4",
"html-entities": "2.6.0",
"js-sha256": "0.11.1",
"mime-db": "1.54.0",
@ -84,6 +86,7 @@
"react-native-image-picker": "8.2.0",
"react-native-incall-manager": "4.2.0",
"react-native-keyboard-aware-scroll-view": "0.9.5",
"react-native-keyboard-controller": "1.19.3",
"react-native-keychain": "10.0.0",
"react-native-localize": "3.4.1",
"react-native-math-view": "3.9.5",
@ -4205,6 +4208,8 @@
},
"node_modules/@gorhom/portal": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz",
"integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==",
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.1"
@ -13994,6 +13999,11 @@
"version": "4.2.11",
"license": "ISC"
},
"node_modules/grapheme-splitter": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
},
"node_modules/graphemer": {
"version": "1.4.0",
"dev": true,
@ -19328,6 +19338,28 @@
"react-native": ">=0.48.4"
}
},
"node_modules/react-native-keyboard-controller": {
"version": "1.19.3",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.19.3.tgz",
"integrity": "sha512-62vfV/xbZ8xGurbOUIgsDDkgVY4NsTpY7ZjVA97XibUcC/9pXVVyqBAI79dmp+TurSuPnBenf08Va1DJOZf9lg==",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-keyboard-controller/node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
"integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-keychain": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/react-native-keychain/-/react-native-keychain-10.0.0.tgz",

View file

@ -19,6 +19,7 @@
"@formatjs/intl-pluralrules": "5.4.4",
"@formatjs/intl-relativetimeformat": "11.4.11",
"@gorhom/bottom-sheet": "5.1.2",
"@gorhom/portal": "1.0.14",
"@mattermost/calls": "github:mattermost/calls-common#ab53c24053b89e4d3d853bbe1244892899517f45",
"@mattermost/compass-icons": "0.1.48",
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
@ -64,6 +65,7 @@
"expo-web-browser": "14.0.2",
"fflate": "0.8.2",
"fuse.js": "7.1.0",
"grapheme-splitter": "1.0.4",
"html-entities": "2.6.0",
"js-sha256": "0.11.1",
"mime-db": "1.54.0",
@ -85,6 +87,7 @@
"react-native-image-picker": "8.2.0",
"react-native-incall-manager": "4.2.0",
"react-native-keyboard-aware-scroll-view": "0.9.5",
"react-native-keyboard-controller": "1.19.3",
"react-native-keychain": "10.0.0",
"react-native-localize": "3.4.1",
"react-native-math-view": "3.9.5",
@ -252,4 +255,4 @@
]
}
}
}
}

View file

@ -31,13 +31,6 @@ const COLORS = {
cyan: '\x1b[36m',
};
// Files to apply from the diff (excluding node_modules and package-lock.json)
// git apply will handle everything including file renames
const FILES_TO_EXCLUDE = [
'node_modules/*',
'package-lock.json',
];
// ============================================================================
// UTILITIES
// ============================================================================
@ -76,19 +69,16 @@ function applyDiffChanges(diffPath, dryRun) {
}
if (dryRun) {
log(' [DRY RUN] Would apply all changes using git apply', 'yellow');
log(' (excluding node_modules and package-lock.json)', 'yellow');
log(' [DRY RUN] Would apply all changes using patch', 'yellow');
return;
}
try {
log(' Applying all changes (including file renames)...', 'cyan');
// Build exclude arguments
const excludeArgs = FILES_TO_EXCLUDE.map((pattern) => `--exclude='${pattern}'`).join(' ');
// Apply the diff, excluding node_modules and package-lock.json
exec(`git apply ${excludeArgs} ${diffPath}`, {silent: true});
// Use patch command instead of git apply for better compatibility
// patch is more forgiving with context matching
exec(`patch -p1 < ${diffPath}`, {silent: true});
log(' ✓ All changes applied successfully', 'green');
log(' ✓ Patch files renamed automatically', 'green');

View file

@ -293,6 +293,7 @@ jest.doMock('react-native', () => {
const Keyboard = {
...RNKeyboard,
isVisible: jest.fn(() => false),
dismiss: jest.fn(),
addListener: jest.fn(() => ({
remove: jest.fn(),
@ -418,6 +419,21 @@ jest.mock('../node_modules/react-native/Libraries/EventEmitter/NativeEventEmitte
};
});
jest.mock('react-native-keyboard-controller', () => {
return {
KeyboardProvider: ({children}: {children: React.ReactNode}) => children,
KeyboardController: {
dismiss: jest.fn(),
isVisible: jest.fn(() => false),
},
useKeyboardHandler: jest.fn(),
useKeyboardState: jest.fn(() => ({
isVisible: false,
})),
KeyboardGestureArea: ({children}: {children: React.ReactNode}) => children,
};
});
jest.mock('react-native-localize', () => ({
getTimeZone: () => 'World/Somewhere',
getLocales: () => ([