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: () => ([