diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts
index 6fcfe01a0..2213495e5 100644
--- a/app/actions/local/thread.ts
+++ b/app/actions/local/thread.ts
@@ -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: {
diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx
index a0c1f4c10..152b6e7e6 100644
--- a/app/components/autocomplete/autocomplete.tsx
+++ b/app/components/autocomplete/autocomplete.tsx
@@ -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 = (
)}
);
+
+ if (usePortal && Platform.OS === 'android') {
+ return (
+
+ {component}
+
+ );
+ }
+
+ return component;
};
export default Autocomplete;
diff --git a/app/screens/emoji_picker/picker/emoji_category_bar/icon.tsx b/app/components/emoji_category_bar/icon.tsx
similarity index 98%
rename from app/screens/emoji_picker/picker/emoji_category_bar/icon.tsx
rename to app/components/emoji_category_bar/icon.tsx
index 8503f28b0..eacfa1e97 100644
--- a/app/screens/emoji_picker/picker/emoji_category_bar/icon.tsx
+++ b/app/components/emoji_category_bar/icon.tsx
@@ -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;
+
diff --git a/app/components/emoji_category_bar/index.tsx b/app/components/emoji_category_bar/index.tsx
new file mode 100644
index 000000000..1c398721e
--- /dev/null
+++ b/app/components/emoji_category_bar/index.tsx
@@ -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 (
+
+ {showActionButtons && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+ {icons.map((icon, index) => (
+
+ ))}
+
+
+ {showActionButtons && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+export default EmojiCategoryBar;
+
diff --git a/app/components/input_accessory_view/index.ts b/app/components/input_accessory_view/index.ts
new file mode 100644
index 000000000..ea723043c
--- /dev/null
+++ b/app/components/input_accessory_view/index.ts
@@ -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';
+
diff --git a/app/components/input_accessory_view/input_accessory_view_container.tsx b/app/components/input_accessory_view/input_accessory_view_container.tsx
new file mode 100644
index 000000000..2447e458b
--- /dev/null
+++ b/app/components/input_accessory_view/input_accessory_view_container.tsx
@@ -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;
+};
+
+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 (
+
+ {children}
+
+ );
+};
+
+export default InputAccessoryViewContainer;
+
diff --git a/app/components/input_accessory_view/input_accessory_view_content.tsx b/app/components/input_accessory_view/input_accessory_view_content.tsx
new file mode 100644
index 000000000..b6d31ec3f
--- /dev/null
+++ b/app/components/input_accessory_view/input_accessory_view_content.tsx
@@ -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 (
+
+ );
+};
+
+export default InputAccessoryViewContent;
+
diff --git a/app/components/keyboard_aware_post_draft_container.tsx b/app/components/keyboard_aware_post_draft_container.tsx
new file mode 100644
index 000000000..9592d31c7
--- /dev/null
+++ b/app/components/keyboard_aware_post_draft_container.tsx
@@ -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['listRef'];
+ contentInset: ReturnType['contentInset'];
+ onScroll: ReturnType['onScroll'];
+ keyboardCurrentHeight: ReturnType['keyboardTranslateY'];
+ postInputContainerHeight: number;
+ onTouchMove?: (event: GestureResponderEvent) => void;
+ onTouchEnd?: () => void;
+};
+
+type Props = {
+ children: ReactNode;
+ renderList: (props: RenderListProps) => ReactNode;
+ textInputNativeID: string;
+ containerStyle?: StyleProp;
+ 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(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> | null>(null);
+ const updateCursorPositionRef = useRef> | null>(null);
+
+ // Function to register PostInput callbacks
+ const registerPostInputCallbacks = useCallback((
+ updateValueFn: React.Dispatch>,
+ updateCursorPositionFn: React.Dispatch>,
+ ) => {
+ 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(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(null);
+ const lastDistanceFromBottomRef = useRef(null);
+ const lastIsSwipingDownRef = useRef(null);
+ const originalEmojiPickerHeightRef = useRef(0);
+ const isGestureActiveRef = useRef(false);
+ const gestureStartedInEmojiPickerRef = useRef(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 = (
+ <>
+
+ {renderList({
+ keyboardCurrentHeight,
+ listRef,
+ contentInset: bottomInset,
+ onScroll,
+ postInputContainerHeight,
+ onTouchMove: handleTouchMove,
+ onTouchEnd: handleTouchEnd,
+ })}
+
+
+
+ {children}
+
+ {showInputAccessoryView && (
+
+
+
+ )}
+
+ >
+ );
+
+ return (
+
+
+ {content}
+
+
+ );
+};
+
diff --git a/app/components/post_draft/custom_emoji_picker/emoji_filtered/emoji_filtered.tsx b/app/components/post_draft/custom_emoji_picker/emoji_filtered/emoji_filtered.tsx
new file mode 100644
index 000000000..fca533d50
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/emoji_filtered/emoji_filtered.tsx
@@ -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 = ({
+ 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 (
+
+
+ {intl.formatMessage({
+ id: 'custom_emoji_picker.search.no_results',
+ defaultMessage: 'No results',
+ })}
+
+
+ );
+ }, [style, intl]);
+
+ const renderItem = useCallback(({item}: ListRenderItemInfo) => {
+ return (
+
+ );
+ }, [
+ onEmojiPress,
+ hideEmojiNames,
+ ]);
+
+ return (
+
+
+
+ );
+};
+
+export default EmojiFiltered;
diff --git a/app/components/post_draft/custom_emoji_picker/emoji_filtered/emoji_item.tsx b/app/components/post_draft/custom_emoji_picker/emoji_filtered/emoji_item.tsx
new file mode 100644
index 000000000..5e957af44
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/emoji_filtered/emoji_item.tsx
@@ -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 (
+
+
+
+
+ {!hideName && {`:${name}:`}}
+
+ );
+};
+
+export default memo(EmojiTouchable);
+
diff --git a/app/components/post_draft/custom_emoji_picker/emoji_filtered/index.tsx b/app/components/post_draft/custom_emoji_picker/emoji_filtered/index.tsx
new file mode 100644
index 000000000..09ba28f4b
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/emoji_filtered/index.tsx
@@ -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));
diff --git a/app/components/post_draft/custom_emoji_picker/emoji_picker/emoji_picker.tsx b/app/components/post_draft/custom_emoji_picker/emoji_picker/emoji_picker.tsx
new file mode 100644
index 000000000..5cb44f72b
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/emoji_picker/emoji_picker.tsx
@@ -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>;
+ emojiPickerHeight: SharedValue;
+}
+
+const styles = StyleSheet.create({
+ flex: {
+ flex: 1,
+ },
+});
+
+const EmojiPicker: React.FC = ({
+ customEmojisEnabled,
+ imageUrl,
+ file,
+ recentEmojis,
+ customEmojis,
+ onEmojiPress,
+ testID = '',
+ isEmojiSearchFocused,
+ setIsEmojiSearchFocused,
+ emojiPickerHeight,
+}) => {
+ const theme = useTheme();
+ const serverUrl = useServerUrl();
+ const [searchTerm, setSearchTerm] = useState();
+ 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 = (
+
+ );
+
+ const EmojiSection = (
+
+ );
+
+ return (
+
+
+ {!isEmojiSearchFocused && EmojiSection}
+ {isEmojiSearchFocused && EmojiList}
+
+ );
+};
+
+export default EmojiPicker;
diff --git a/app/components/post_draft/custom_emoji_picker/emoji_picker/index.tsx b/app/components/post_draft/custom_emoji_picker/emoji_picker/index.tsx
new file mode 100644
index 000000000..f3bb76417
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/emoji_picker/index.tsx
@@ -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));
diff --git a/app/components/post_draft/custom_emoji_picker/emoji_picker_header/emoji_picker_header.tsx b/app/components/post_draft/custom_emoji_picker/emoji_picker_header/emoji_picker_header.tsx
new file mode 100644
index 000000000..94a37b861
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/emoji_picker_header/emoji_picker_header.tsx
@@ -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>;
+ emojiPickerHeight: SharedValue;
+ isSelectingEmojiRef?: React.MutableRefObject;
+}
+
+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 = ({
+ 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(null);
+ const isReducingHeight = useRef(false);
+
+ const searchRef = useRef(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 (
+
+
+
+
+ {Platform.OS !== 'android' &&
+
+ }
+
+ );
+};
+
+export default EmojiPickerHeader;
diff --git a/app/components/post_draft/custom_emoji_picker/emoji_picker_header/index.tsx b/app/components/post_draft/custom_emoji_picker/emoji_picker_header/index.tsx
new file mode 100644
index 000000000..27883ab91
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/emoji_picker_header/index.tsx
@@ -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));
diff --git a/app/components/post_draft/custom_emoji_picker/emoji_sections/index.tsx b/app/components/post_draft/custom_emoji_picker/emoji_sections/index.tsx
new file mode 100644
index 000000000..827db5357
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/emoji_sections/index.tsx
@@ -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 = {};
+
+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 | null>(null);
+
+ const sections: SectionListItem[] = useMemo(() => {
+ const emojisPerRow = isTablet ? EMOJIS_PER_ROW_TABLET : EMOJIS_PER_ROW;
+ const sectionsArray = CategoryNames.map((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((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(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(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) => {
+ if (item.type === 'section') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }, [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 ? : 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 (
+
+
+
+
+ );
+}
+
diff --git a/app/components/post_draft/custom_emoji_picker/index.tsx b/app/components/post_draft/custom_emoji_picker/index.tsx
new file mode 100644
index 000000000..2ed9cc9d5
--- /dev/null
+++ b/app/components/post_draft/custom_emoji_picker/index.tsx
@@ -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;
+ onEmojiPress?: (emoji: string) => void;
+ setIsEmojiSearchFocused: React.Dispatch>;
+ isEmojiSearchFocused: boolean;
+}
+
+const CustomEmojiPicker: React.FC = ({
+ 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 (
+
+ );
+};
+
+export default CustomEmojiPicker;
diff --git a/app/components/post_draft/draft_input/draft_input.tsx b/app/components/post_draft/draft_input/draft_input.tsx
index 9235edfef..adaef77d9 100644
--- a/app/components/post_draft/draft_input/draft_input.tsx
+++ b/app/components/post_draft/draft_input/draft_input.tsx
@@ -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();
- const focus = useCallback(() => {
- inputRef.current?.focus();
- }, []);
+ }, [updatePostInputTop]);
// Render
const postInputTestID = `${testID}.post.input`;
diff --git a/app/components/post_draft/post_draft.tsx b/app/components/post_draft/post_draft.tsx
index 0afba7fe7..a7bc99ce4 100644
--- a/app/components/post_draft/post_draft.tsx
+++ b/app/components/post_draft/post_draft.tsx
@@ -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' && }
>
);
}
diff --git a/app/components/post_draft/post_input/post_input.tsx b/app/components/post_draft/post_input/post_input.tsx
index 115578be7..03f2caf82 100644
--- a/app/components/post_draft/post_input/post_input.tsx
+++ b/app/components/post_draft/post_input/post_input.tsx
@@ -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();
- 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 | 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}
/>
);
}
diff --git a/app/components/post_draft/quick_actions/attachment_quick_action/index.test.tsx b/app/components/post_draft/quick_actions/attachment_quick_action/index.test.tsx
new file mode 100644
index 000000000..a8975c538
--- /dev/null
+++ b/app/components/post_draft/quick_actions/attachment_quick_action/index.test.tsx
@@ -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);
+ });
+
+ describe('user interactions', () => {
+ it('should call onUploadFiles when button is pressed', async () => {
+ const onUploadFiles = jest.fn();
+ const {getByTestId} = renderWithIntlAndTheme(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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,
+ }),
+ );
+ });
+ });
+ });
+});
+
diff --git a/app/components/post_draft/quick_actions/attachment_quick_action/index.tsx b/app/components/post_draft/quick_actions/attachment_quick_action/index.tsx
new file mode 100644
index 000000000..ca686136f
--- /dev/null
+++ b/app/components/post_draft/quick_actions/attachment_quick_action/index.tsx
@@ -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 (
+
+
+
+ );
+}
+
diff --git a/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx b/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx
deleted file mode 100644
index c5198299a..000000000
--- a/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx
+++ /dev/null
@@ -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 (
-
- {!isTablet &&
-
- }
-
-
-
- );
-};
-
-export default CameraType;
diff --git a/app/components/post_draft/quick_actions/camera_quick_action/index.tsx b/app/components/post_draft/quick_actions/camera_quick_action/index.tsx
deleted file mode 100644
index 8285861ff..000000000
--- a/app/components/post_draft/quick_actions/camera_quick_action/index.tsx
+++ /dev/null
@@ -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 (
-
- );
- }, [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 (
-
-
-
- );
-}
diff --git a/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx b/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx
new file mode 100644
index 000000000..199f2cf31
--- /dev/null
+++ b/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx
@@ -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 (
+
+
+
+ );
+}
+
diff --git a/app/components/post_draft/quick_actions/file_quick_action/index.tsx b/app/components/post_draft/quick_actions/file_quick_action/index.tsx
deleted file mode 100644
index aacf9652a..000000000
--- a/app/components/post_draft/quick_actions/file_quick_action/index.tsx
+++ /dev/null
@@ -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 (
-
-
-
- );
-}
-
diff --git a/app/components/post_draft/quick_actions/image_quick_action/index.tsx b/app/components/post_draft/quick_actions/image_quick_action/index.tsx
deleted file mode 100644
index 75c8e3e68..000000000
--- a/app/components/post_draft/quick_actions/image_quick_action/index.tsx
+++ /dev/null
@@ -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 (
-
-
-
- );
-}
-
diff --git a/app/components/post_draft/quick_actions/input_quick_action/index.tsx b/app/components/post_draft/quick_actions/input_quick_action/index.tsx
index 1afb31614..7175ac73d 100644
--- a/app/components/post_draft/quick_actions/input_quick_action/index.tsx
+++ b/app/components/post_draft/quick_actions/input_quick_action/index.tsx
@@ -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` :
diff --git a/app/components/post_draft/quick_actions/post_priority_action/index.tsx b/app/components/post_draft/quick_actions/post_priority_action/index.tsx
index 924d17309..e38912c95 100644
--- a/app/components/post_draft/quick_actions/post_priority_action/index.tsx
+++ b/app/components/post_draft/quick_actions/post_priority_action/index.tsx
@@ -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);
diff --git a/app/components/post_draft/quick_actions/quick_actions.test.tsx b/app/components/post_draft/quick_actions/quick_actions.test.tsx
index 0e403d406..72a783d43 100644
--- a/app/components/post_draft/quick_actions/quick_actions.test.tsx
+++ b/app/components/post_draft/quick_actions/quick_actions.test.tsx
@@ -8,6 +8,7 @@ import QuickActions from './quick_actions';
describe('Quick Actions', () => {
const baseProps: Parameters[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();
- 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();
+ 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();
+ 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();
+ 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();
- 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ const attachmentAction = getByTestId('test-quick-actions.attachment_action');
+ expect(attachmentAction).not.toBeDisabled();
+ });
+ });
+
});
diff --git a/app/components/post_draft/quick_actions/quick_actions.tsx b/app/components/post_draft/quick_actions/quick_actions.tsx
index 8e5a542d8..e289e3f95 100644
--- a/app/components/post_draft/quick_actions/quick_actions.tsx
+++ b/app/components/post_draft/quick_actions/quick_actions.tsx
@@ -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}
>
+
)}
-
-
-
+ {canShowEmojiPicker && (
+
+ )}
{isPostPriorityEnabled && canShowPostPriority && (
{
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 (
{
+ 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(() => {
diff --git a/app/components/post_list/post_list.test.tsx b/app/components/post_list/post_list.test.tsx
index 36df645bc..5247dbcb3 100644
--- a/app/components/post_list/post_list.test.tsx
+++ b/app/components/post_list/post_list.test.tsx
@@ -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>(),
};
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,
diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx
index 1e181cfe6..ed6a4cc06 100644
--- a/app/components/post_list/post_list.tsx
+++ b/app/components/post_list/post_list.tsx
@@ -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;
+ listRef?: React.RefObject>;
+ 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>(null);
+ const {
+ keyboardTranslateY: keyboardHeightValue,
+ bottomInset: contentInset,
+ onScroll: onScrollProp,
+ postInputContainerHeight,
+ keyboardHeight,
+ isKeyboardFullyOpen,
+ isKeyboardFullyClosed,
+ inputAccessoryViewAnimatedHeight,
+ isInputAccessoryViewMode,
+ } = useKeyboardAnimationContext();
+
const onScrollEndIndexListener = useRef();
const onViewableItemsChangedListener = useRef();
const scrolledToHighlighted = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const [showScrollToEndBtn, setShowScrollToEndBtn] = useState(false);
const [lastPostId, setLastPostId] = useState(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) => {
+ const internalOnScroll = useCallback((event: NativeSyntheticEvent) => {
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 (
<>
{location !== Screens.PERMALINK &&
diff --git a/app/components/post_list/scroll_to_end_view.tsx b/app/components/post_list/scroll_to_end_view.tsx
index 1c02b9889..c33206d7e 100644
--- a/app/components/post_list/scroll_to_end_view.tsx
+++ b/app/components/post_list/scroll_to_end_view.tsx
@@ -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(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;
diff --git a/app/components/touchable_emoji/index.tsx b/app/components/touchable_emoji/index.tsx
index 225c6b343..1d877dbde 100644
--- a/app/components/touchable_emoji/index.tsx
+++ b/app/components/touchable_emoji/index.tsx
@@ -14,6 +14,7 @@ type Props = {
category?: string;
name: string;
onEmojiPress: (emoji: string) => void;
+ preventDoubleTap?: boolean;
size?: number;
style?: StyleProp;
}
@@ -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 (
diff --git a/app/components/touchable_emoji/skinned_emoji.tsx b/app/components/touchable_emoji/skinned_emoji.tsx
index 8f566b334..8c95fdae8 100644
--- a/app/components/touchable_emoji/skinned_emoji.tsx
+++ b/app/components/touchable_emoji/skinned_emoji.tsx
@@ -14,13 +14,14 @@ import {isValidNamedEmoji} from '@utils/emoji/helpers';
type Props = {
name: string;
onEmojiPress: (emoji: string) => void;
+ preventDoubleTap?: boolean;
size?: number;
style?: StyleProp;
}
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 (
([
]);
export const SCREENS_AS_BOTTOM_SHEET = new Set([
+ ATTACHMENT_OPTIONS,
BOTTOM_SHEET,
DRAFT_SCHEDULED_POST_OPTIONS,
EMOJI_PICKER,
diff --git a/app/context/keyboard_animation.tsx b/app/context/keyboard_animation.tsx
new file mode 100644
index 000000000..d5d93f254
--- /dev/null
+++ b/app/context/keyboard_animation.tsx
@@ -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;
+ bottomInset: SharedValue;
+ scrollOffset: SharedValue;
+ keyboardHeight: SharedValue;
+ scrollPosition: SharedValue;
+ onScroll: (event: unknown) => void;
+ postInputContainerHeight: number;
+ inputRef: React.MutableRefObject;
+ blurInput: () => void;
+ focusInput: () => void;
+ blurAndDismissKeyboard: () => Promise;
+ isKeyboardFullyOpen: SharedValue;
+ isKeyboardFullyClosed: SharedValue;
+ isKeyboardInTransition: SharedValue;
+ showInputAccessoryView: boolean;
+ isInputAccessoryViewMode: SharedValue;
+ setShowInputAccessoryView: (show: boolean) => void;
+ lastKeyboardHeight: number;
+ inputAccessoryViewAnimatedHeight: SharedValue;
+ isTransitioningFromCustomView: SharedValue;
+ closeInputAccessoryView: () => void;
+ scrollToEnd: () => void;
+ isEmojiSearchFocused: boolean;
+ setIsEmojiSearchFocused: (focused: boolean) => void;
+ cursorPositionRef: React.MutableRefObject;
+ registerCursorPosition: (cursorPosition: number) => void;
+ updateValue: React.Dispatch> | null;
+ updateCursorPosition: React.Dispatch> | null;
+ registerPostInputCallbacks: (
+ updateValueFn: React.Dispatch>,
+ updateCursorPositionFn: React.Dispatch>
+ ) => void;
+}
+
+const KeyboardAnimationContext = createContext(null);
+
+export const KeyboardAnimationProvider = ({
+ children,
+ value,
+}: {
+ children: ReactNode;
+ value: KeyboardAnimationContextType;
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+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(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(0);
+ const defaultRegisterCursorPosition = useCallback(() => {
+ // No-op fallback
+ }, []);
+
+ const defaultUpdateValue = useRef> | null>(null);
+ const defaultUpdateCursorPosition = useRef> | 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;
+};
+
diff --git a/app/hooks/keyboardAnimation.test.ts b/app/hooks/keyboardAnimation.test.ts
new file mode 100644
index 000000000..7fc3ea138
--- /dev/null
+++ b/app/hooks/keyboardAnimation.test.ts
@@ -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[0];
+
+ act(() => {
+ result.current.onScroll(mockScrollEvent);
+ });
+
+ expect(result.current.scrollPosition.value).toBe(100 + result.current.bottomInset.value);
+ });
+ });
+});
+
diff --git a/app/hooks/keyboardAnimation.ts b/app/hooks/keyboardAnimation.ts
new file mode 100644
index 000000000..146826b59
--- /dev/null
+++ b/app/hooks/keyboardAnimation.ts
@@ -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,
+ };
+};
diff --git a/app/hooks/useFocusAfterEmojiDismiss.ts b/app/hooks/useFocusAfterEmojiDismiss.ts
new file mode 100644
index 000000000..dde368275
--- /dev/null
+++ b/app/hooks/useFocusAfterEmojiDismiss.ts
@@ -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,
+ focusInput: () => void,
+) => {
+ const {
+ showInputAccessoryView,
+ setShowInputAccessoryView,
+ isInputAccessoryViewMode,
+ inputAccessoryViewAnimatedHeight,
+ keyboardTranslateY,
+ isTransitioningFromCustomView,
+ setIsEmojiSearchFocused,
+ } = useKeyboardAnimationContext();
+
+ const [isManuallyFocusingAfterEmojiDismiss, setIsManuallyFocusingAfterEmojiDismiss] = useState(false);
+ const isDismissingEmojiPicker = useRef(false);
+ const focusTimeoutRef = useRef(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,
+ };
+};
diff --git a/app/hooks/useInputAccessoryView.ts b/app/hooks/useInputAccessoryView.ts
new file mode 100644
index 000000000..679b28916
--- /dev/null
+++ b/app/hooks/useInputAccessoryView.ts
@@ -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;
+ isKeyboardFullyOpen: SharedValue;
+};
+
+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,
+ };
+};
+
diff --git a/app/hooks/useKeyboardAwarePostDraft.test.ts b/app/hooks/useKeyboardAwarePostDraft.test.ts
new file mode 100644
index 000000000..f28786d12
--- /dev/null
+++ b/app/hooks/useKeyboardAwarePostDraft.test.ts
@@ -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);
+ });
+ });
+});
+
diff --git a/app/hooks/useKeyboardAwarePostDraft.ts b/app/hooks/useKeyboardAwarePostDraft.ts
new file mode 100644
index 000000000..594430c85
--- /dev/null
+++ b/app/hooks/useKeyboardAwarePostDraft.ts
@@ -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>(null);
+ const inputRef = useRef();
+ 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,
+ };
+};
+
diff --git a/app/hooks/useKeyboardScrollAdjustment.ts b/app/hooks/useKeyboardScrollAdjustment.ts
new file mode 100644
index 000000000..bd981f2c3
--- /dev/null
+++ b/app/hooks/useKeyboardScrollAdjustment.ts
@@ -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>,
+ scrollPosition: SharedValue,
+ scrollOffset: SharedValue,
+ enabled = true,
+ isInputAccessoryViewMode?: SharedValue,
+ isTransitioningFromCustomView?: SharedValue,
+) => {
+ // 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],
+ );
+};
diff --git a/app/hooks/use_screen_visibility.test.ts b/app/hooks/use_screen_visibility.test.ts
new file mode 100644
index 000000000..6f60d4b0b
--- /dev/null
+++ b/app/hooks/use_screen_visibility.test.ts
@@ -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(undefined);
+
+jest.mock('@store/navigation_store', () => ({
+ getVisibleScreen: jest.fn(() => mockSubject.value),
+ getSubject: jest.fn(() => mockSubject),
+}));
+
+describe('useIsScreenVisible', () => {
+ const mockNavigationStore = NavigationStore as jest.Mocked;
+
+ 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);
+
+ 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;
+ }
+ return {unsubscribe: unsubscribe2} as unknown as ReturnType;
+ });
+
+ 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);
+ });
+ });
+});
+
diff --git a/app/hooks/use_screen_visibility.ts b/app/hooks/use_screen_visibility.ts
new file mode 100644
index 000000000..09d95729c
--- /dev/null
+++ b/app/hooks/use_screen_visibility.ts
@@ -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;
+};
+
diff --git a/app/screens/attachment_options/attachment_options.test.tsx b/app/screens/attachment_options/attachment_options.test.tsx
new file mode 100644
index 000000000..b45f2d826
--- /dev/null
+++ b/app/screens/attachment_options/attachment_options.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
+
+ // 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(getByText('Files and media')).toBeTruthy();
+ });
+ });
+});
+
diff --git a/app/screens/attachment_options/attachment_options.tsx b/app/screens/attachment_options/attachment_options.tsx
new file mode 100644
index 000000000..d2bb525a2
--- /dev/null
+++ b/app/screens/attachment_options/attachment_options.tsx
@@ -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 = ({
+ 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 (
+
+ {!isTablet &&
+
+ }
+
+
+
+
+
+ );
+ };
+
+ const snapPoints = useMemo(() => {
+ const componentHeight = TITLE_HEIGHT + bottomSheetSnapPoint(4, ITEM_HEIGHT);
+ return [1, componentHeight];
+ }, []);
+
+ return (
+
+ );
+};
+
+export default AttachmentOptions;
+
diff --git a/app/screens/attachment_options/index.tsx b/app/screens/attachment_options/index.tsx
new file mode 100644
index 000000000..ca64d0c58
--- /dev/null
+++ b/app/screens/attachment_options/index.tsx
@@ -0,0 +1,5 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export {default} from './attachment_options';
+
diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx
index 4d9736f74..0d86e1814 100644
--- a/app/screens/channel/channel.tsx
+++ b/app/screens/channel/channel.tsx
@@ -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 = ({
- {shouldRender &&
-
-
-
+ {shouldRender && (
+
+ )}
+
+ ) : (
+ shouldRender && (
+
-
- <>
- {scheduledPostCount > 0 &&
-
- }
- >
-
-
- }
+ )
+ )}
{showFloatingCallContainer && shouldRender &&
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 (
+
+ (
+
+ )}
+ >
+ {scheduledPostCount > 0 &&
+
+ }
+
+
+
+ );
+};
+
+export default ChannelContent;
+
diff --git a/app/screens/channel/channel_post_list/channel_post_list.tsx b/app/screens/channel/channel_post_list/channel_post_list.tsx
index 486369252..f131787ba 100644
--- a/app/screens/channel/channel_post_list/channel_post_list.tsx
+++ b/app/screens/channel/channel_post_list/channel_post_list.tsx
@@ -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>;
isCRTEnabled: boolean;
lastViewedAt: number;
- nativeID: string;
posts: PostModel[];
shouldShowJoinLeaveMessages: boolean;
+ listRef: React.RefObject>;
+ 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}
/>
);
diff --git a/app/screens/edit_post/edit_post.test.tsx b/app/screens/edit_post/edit_post.test.tsx
index 21e781a2f..5a3d8707c 100644
--- a/app/screens/edit_post/edit_post.test.tsx
+++ b/app/screens/edit_post/edit_post.test.tsx
@@ -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) => {
- fireEvent.press(screen.getByTestId('edit_post.quick_actions.file_action'));
+ const triggerFileUpload = async (screen: ReturnType) => {
+ 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, fileId: string) => {
- fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
+ const triggerFileRemoval = async (screen: ReturnType, 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');
diff --git a/app/screens/edit_post/edit_post.tsx b/app/screens/edit_post/edit_post.tsx
index 12597d7a6..3fed6c49a 100644
--- a/app/screens/edit_post/edit_post.tsx
+++ b/app/screens/edit_post/edit_post.tsx
@@ -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)}
>
-
-
- {Boolean((errorLine || errorExtra)) &&
-
- }
-
-
-
-
-
+ {Platform.OS === 'ios' ? (
+
+
+
+ {Boolean((errorLine || errorExtra)) &&
+
+ }
+
+
+
+
+
+
+
+ ) : (
+ <>
+
+
+ {Boolean((errorLine || errorExtra)) &&
+
+ }
+
+
+
+
+
+
+ >
+ )}
-
);
};
diff --git a/app/screens/edit_post/edit_post_input/edit_post_input.test.tsx b/app/screens/edit_post/edit_post_input/edit_post_input.test.tsx
index 3a180356d..92110c445 100644
--- a/app/screens/edit_post/edit_post_input/edit_post_input.test.tsx
+++ b/app/screens/edit_post/edit_post_input/edit_post_input.test.tsx
@@ -91,9 +91,7 @@ describe('EditPostInput', () => {
const {queryByTestId} = renderWithEverything(, {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(, {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();
});
diff --git a/app/screens/edit_post/edit_post_input/edit_post_input.tsx b/app/screens/edit_post/edit_post_input/edit_post_input.tsx
index c719af976..a03f215c6 100644
--- a/app/screens/edit_post/edit_post_input/edit_post_input.tsx
+++ b/app/screens/edit_post/edit_post_input/edit_post_input.tsx
@@ -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}
/>
>
diff --git a/app/screens/emoji_picker/picker/emoji_category_bar/index.tsx b/app/screens/emoji_picker/picker/emoji_category_bar/index.tsx
deleted file mode 100644
index 3734449f0..000000000
--- a/app/screens/emoji_picker/picker/emoji_category_bar/index.tsx
+++ /dev/null
@@ -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 (
-
- {icons.map((icon, index) => (
-
- ))}
-
- );
-};
-
-export default EmojiCategoryBar;
diff --git a/app/screens/emoji_picker/picker/footer/index.tsx b/app/screens/emoji_picker/picker/footer/index.tsx
index dcf1adc9c..e2ce0f8d7 100644
--- a/app/screens/emoji_picker/picker/footer/index.tsx
+++ b/app/screens/emoji_picker/picker/footer/index.tsx
@@ -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, 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(() => {
diff --git a/app/screens/emoji_picker/picker/sections/emoji_row.tsx b/app/screens/emoji_picker/picker/sections/emoji_row.tsx
index dc5e23780..2da9d9557 100644
--- a/app/screens/emoji_picker/picker/sections/emoji_row.tsx
+++ b/app/screens/emoji_picker/picker/sections/emoji_row.tsx
@@ -71,6 +71,7 @@ export default function EmojiRow({emojis, file, imageUrl, onEmojiPress}: EmojiRo
name={emoji.name}
onEmojiPress={onEmojiPress}
category={emoji.category}
+ preventDoubleTap={false}
/>
);
})}
diff --git a/app/screens/emoji_picker/picker/sections/index.tsx b/app/screens/emoji_picker/picker/sections/index.tsx
index 6ed18b48f..ad16d6f5f 100644
--- a/app/screens/emoji_picker/picker/sections/index.tsx
+++ b/app/screens/emoji_picker/picker/sections/index.tsx
@@ -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';
diff --git a/app/screens/emoji_picker/picker/sections/section_header.tsx b/app/screens/emoji_picker/picker/sections/section_header.tsx
index 1a1c4a497..3afcb3781 100644
--- a/app/screens/emoji_picker/picker/sections/section_header.tsx
+++ b/app/screens/emoji_picker/picker/sections/section_header.tsx
@@ -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'),
},
diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx
index 4fcd5467e..a8dfde1e0 100644
--- a/app/screens/home/channel_list/channel_list.tsx
+++ b/app/screens/home/channel_list/channel_list.tsx
@@ -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 (
<>
diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx
index 2d06b0476..4fefa3285 100644
--- a/app/screens/home/index.tsx
+++ b/app/screens/home/index.tsx
@@ -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 (
+
+ );
+ };
+ TabBarComponent.displayName = 'TabBarComponent';
+
return (
(
- )}
+ tabBar={TabBarComponent}
>
{
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;
diff --git a/app/screens/navigation.test.ts b/app/screens/navigation.test.ts
index 1cf57486c..3f7f086ba 100644
--- a/app/screens/navigation.test.ts
+++ b/app/screens/navigation.test.ts
@@ -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) {
expect(Navigation.showModal).toHaveBeenCalledWith({
stack: {
@@ -33,8 +50,8 @@ function expectShowModalOverCurrentContext(screen: string, props?: Record, isTablet: boolean) {
- if (isTablet) {
+function expectOpenAsBottomSheetCalledWith(props: FirstArgument, 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);
+ });
+});
diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts
index 604ed9fad..7b876068c 100644
--- a/app/screens/navigation.ts
+++ b/app/screens/navigation.ts
@@ -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);
diff --git a/app/screens/permalink/permalink.tsx b/app/screens/permalink/permalink.tsx
index 210098967..3c97d942f 100644
--- a/app/screens/permalink/permalink.tsx
+++ b/app/screens/permalink/permalink.tsx
@@ -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 = (
-
+ const postListContent = (
+ <>
@@ -340,7 +345,15 @@ function Permalink({
testID='permalink.jump_to_recent_messages.button'
/>
-
+ >
+ );
+
+ content = Platform.OS === 'ios' ? (
+
+ {postListContent}
+
+ ) : (
+ postListContent
);
}
diff --git a/app/screens/thread/thread.tsx b/app/screens/thread/thread.tsx
index df58658ce..26c264a11 100644
--- a/app/screens/thread/thread.tsx
+++ b/app/screens/thread/thread.tsx
@@ -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 = ({
{Boolean(rootPost) &&
-
-
-
+
-
- <>
- {scheduledPostCount > 0 &&
-
- }
- >
-
+ ) : (
+
-
+ ))
}
{showFloatingCallContainer &&
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 (
+
+ (
+
+ )}
+ >
+ {scheduledPostCount > 0 &&
+
+ }
+
+
+
+ );
+};
+
+export default ThreadContent;
+
diff --git a/app/screens/thread/thread_post_list/thread_post_list.tsx b/app/screens/thread/thread_post_list/thread_post_list.tsx
index 1c8e70c80..e000435ba 100644
--- a/app/screens/thread/thread_post_list/thread_post_list.tsx
+++ b/app/screens/thread/thread_post_list/thread_post_list.tsx
@@ -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>;
+ 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={}
testID='thread.post_list'
+ listRef={listRef}
+ onTouchMove={onTouchMove}
+ onTouchEnd={onTouchEnd}
/>
);
diff --git a/app/store/navigation_store.ts b/app/store/navigation_store.ts
index 60b15f7fe..1b3cd22af 100644
--- a/app/store/navigation_store.ts
+++ b/app/store/navigation_store.ts
@@ -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) => {
diff --git a/app/utils/grapheme.test.ts b/app/utils/grapheme.test.ts
new file mode 100644
index 000000000..8bacd8c7c
--- /dev/null
+++ b/app/utils/grapheme.test.ts
@@ -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);
+ });
+ });
+});
+
diff --git a/app/utils/grapheme.ts b/app/utils/grapheme.ts
new file mode 100644
index 000000000..2d97fe698
--- /dev/null
+++ b/app/utils/grapheme.ts
@@ -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,
+ };
+}
+
diff --git a/app/utils/keyboard.ts b/app/utils/keyboard.ts
new file mode 100644
index 000000000..60e380c64
--- /dev/null
+++ b/app/utils/keyboard.ts
@@ -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 => {
+ 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();
+};
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index ce6578f6d..7b3b01ea0 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -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",
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index f32c86ad4..5ca191564 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 032d58235..83dee9041 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 7d3809bc5..30c78af1e 100644
--- a/package.json
+++ b/package.json
@@ -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 @@
]
}
}
-}
\ No newline at end of file
+}
diff --git a/scripts/android-16kb-pagesize/apply-patch.js b/scripts/android-16kb-pagesize/apply-patch.js
index 418c86f7c..276542c7b 100644
--- a/scripts/android-16kb-pagesize/apply-patch.js
+++ b/scripts/android-16kb-pagesize/apply-patch.js
@@ -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');
diff --git a/test/setup.ts b/test/setup.ts
index 17e9cf9a4..de4b4eba8 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -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: () => ([