diff --git a/app/components/keyboard_aware_post_draft_container.tsx b/app/components/keyboard_aware_post_draft_container.tsx index 9592d31c7..4531600aa 100644 --- a/app/components/keyboard_aware_post_draft_container.tsx +++ b/app/components/keyboard_aware_post_draft_container.tsx @@ -110,15 +110,70 @@ export const KeyboardAwarePostDraftContainer = ({ // 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; + // Ref to track if we're transitioning to emoji picker (to preserve cursor position) + const isOpeningEmojiPickerRef = useRef(false); + + // Ref to store preserved cursor position when opening emoji picker + const preservedCursorPositionRef = useRef(null); + + // Ref to store the last cursor position before emoji picker opened + // This is kept even after emoji picker opens to detect late resets + const lastCursorPositionBeforeEmojiPickerRef = useRef(null); + + // Function to preserve cursor position when opening emoji picker + const preserveCursorPositionForEmojiPicker = useCallback(() => { + preservedCursorPositionRef.current = cursorPositionRef.current; + lastCursorPositionBeforeEmojiPickerRef.current = cursorPositionRef.current; + isOpeningEmojiPickerRef.current = true; }, []); + // Function to register cursor position updates from PostInput + const registerCursorPosition = useCallback((cursorPosition: number, valueLength?: number) => { + const previousPosition = cursorPositionRef.current; + + const preservedPosition = preservedCursorPositionRef.current ?? lastCursorPositionBeforeEmojiPickerRef.current; + const isInTransitionPeriod = isOpeningEmojiPickerRef.current || (showInputAccessoryView && lastCursorPositionBeforeEmojiPickerRef.current !== null); + + if (isInTransitionPeriod && preservedPosition !== null) { + let isResettingToEnd = false; + if (typeof valueLength === 'number') { + isResettingToEnd = cursorPosition === valueLength && cursorPosition !== preservedPosition; + } else { + isResettingToEnd = cursorPosition !== preservedPosition && + previousPosition !== cursorPosition && + cursorPosition > preservedPosition && + (previousPosition === preservedPosition || previousPosition < preservedPosition); + } + + if (isResettingToEnd) { + cursorPositionRef.current = preservedPosition; + return; + } + } + + cursorPositionRef.current = cursorPosition; + }, [showInputAccessoryView]); + // Refs to store PostInput callbacks const updateValueRef = useRef> | null>(null); const updateCursorPositionRef = useRef> | null>(null); + // Function to check if we're in emoji picker transition period + const isInEmojiPickerTransition = useCallback(() => { + return isOpeningEmojiPickerRef.current || (showInputAccessoryView && lastCursorPositionBeforeEmojiPickerRef.current !== null); + }, [showInputAccessoryView]); + + // Function to get preserved cursor position if in transition + const getPreservedCursorPosition = useCallback(() => { + return preservedCursorPositionRef.current ?? lastCursorPositionBeforeEmojiPickerRef.current; + }, []); + + // Function to clear preservation flags after emoji is inserted + const clearCursorPositionPreservation = useCallback(() => { + preservedCursorPositionRef.current = null; + lastCursorPositionBeforeEmojiPickerRef.current = null; + }, []); + // Function to register PostInput callbacks const registerPostInputCallbacks = useCallback(( updateValueFn: React.Dispatch>, @@ -127,12 +182,6 @@ export const KeyboardAwarePostDraftContainer = ({ 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 @@ -387,6 +436,8 @@ export const KeyboardAwarePostDraftContainer = ({ // After emoji picker renders, adjust heights and scroll to keep messages visible useEffect(() => { if (showInputAccessoryView) { + isOpeningEmojiPickerRef.current = false; + // Wait one frame to ensure emoji picker has rendered requestAnimationFrame(() => { const emojiPickerHeight = inputAccessoryViewAnimatedHeight.value; @@ -500,6 +551,10 @@ export const KeyboardAwarePostDraftContainer = ({ setIsEmojiSearchFocused, cursorPositionRef, registerCursorPosition, + preserveCursorPositionForEmojiPicker, + clearCursorPositionPreservation, + isInEmojiPickerTransition, + getPreservedCursorPosition, updateValue: updateValueRef.current, updateCursorPosition: updateCursorPositionRef.current, registerPostInputCallbacks, diff --git a/app/components/post_draft/custom_emoji_picker/index.test.tsx b/app/components/post_draft/custom_emoji_picker/index.test.tsx new file mode 100644 index 000000000..986ffd93d --- /dev/null +++ b/app/components/post_draft/custom_emoji_picker/index.test.tsx @@ -0,0 +1,286 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useSharedValue} from 'react-native-reanimated'; + +import {useKeyboardAnimationContext} from '@context/keyboard_animation'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import {EmojiIndicesByAlias, Emojis} from '@utils/emoji'; + +import EmojiPicker from './emoji_picker'; + +import CustomEmojiPicker from './index'; + +jest.mock('@context/keyboard_animation', () => ({ + useKeyboardAnimationContext: jest.fn(), +})); + +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + Reanimated.default.call = () => {}; + return Reanimated; +}); + +jest.mock('./emoji_picker', () => ({ + __esModule: true, + default: jest.fn(), +})); + +let mockOnEmojiPress: ((emoji: string) => void) | null = null; + +jest.mocked(EmojiPicker).mockImplementation((props) => { + mockOnEmojiPress = props.onEmojiPress; + return null; +}); + +describe('CustomEmojiPicker', () => { + const mockUseKeyboardAnimationContext = jest.mocked(useKeyboardAnimationContext); + const mockUpdateValue = jest.fn(); + const mockUpdateCursorPosition = jest.fn(); + const mockClearCursorPositionPreservation = jest.fn(); + const mockCursorPositionRef = {current: 0}; + + const mockHeight = useSharedValue(300); + + const defaultContextValue = { + cursorPositionRef: mockCursorPositionRef, + updateValue: mockUpdateValue, + updateCursorPosition: mockUpdateCursorPosition, + clearCursorPositionPreservation: mockClearCursorPositionPreservation, + } as unknown as ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockCursorPositionRef.current = 0; + mockOnEmojiPress = null; + mockUseKeyboardAnimationContext.mockReturnValue(defaultContextValue); + }); + + const triggerEmojiPress = (emojiName: string) => { + if (mockOnEmojiPress) { + mockOnEmojiPress(emojiName); + } + }; + + describe('emoji insertion at cursor position', () => { + it('should insert emoji at the beginning of text when cursor is at position 0', () => { + mockCursorPositionRef.current = 0; + const initialValue = 'Hello world'; + + renderWithIntlAndTheme( + , + ); + + const emojiName = 'smile'; + triggerEmojiPress(emojiName); + + expect(mockUpdateValue).toHaveBeenCalled(); + const updateFunction = mockUpdateValue.mock.calls[0][0]; + expect(typeof updateFunction).toBe('function'); + + const result = updateFunction(initialValue); + + expect(result.endsWith(initialValue)).toBe(true); + expect(result.length).toBeGreaterThan(initialValue.length); + expect(result.substring(0, initialValue.length)).not.toBe(initialValue); + }); + + it('should insert emoji at the middle of text when cursor is at position 5', () => { + mockCursorPositionRef.current = 5; + const initialValue = 'Hello world'; + + renderWithIntlAndTheme( + , + ); + + const emojiName = 'smile'; + triggerEmojiPress(emojiName); + + expect(mockUpdateValue).toHaveBeenCalled(); + const updateFunction = mockUpdateValue.mock.calls[0][0]; + const result = updateFunction(initialValue); + + expect(result.substring(0, 5)).toBe('Hello'); + expect(result.substring(5)).toContain(' world'); + expect(result.length).toBeGreaterThan(initialValue.length); + + expect(result.substring(0, 11)).not.toBe(initialValue); + }); + + it('should insert emoji at the end of text when cursor is at the end', () => { + mockCursorPositionRef.current = 11; + const initialValue = 'Hello world'; + + renderWithIntlAndTheme( + , + ); + + const emojiName = 'smile'; + triggerEmojiPress(emojiName); + + expect(mockUpdateValue).toHaveBeenCalled(); + const updateFunction = mockUpdateValue.mock.calls[0][0]; + const result = updateFunction(initialValue); + + expect(result.substring(0, 11)).toBe('Hello world'); + expect(result.length).toBeGreaterThan(initialValue.length); + + expect(result.substring(11)).not.toBe(''); + }); + + it('should update cursor position after emoji insertion', () => { + mockCursorPositionRef.current = 5; + + renderWithIntlAndTheme( + , + ); + + const emojiName = 'smile'; + triggerEmojiPress(emojiName); + + const emojiIndex = EmojiIndicesByAlias.get(emojiName); + let insertedTextLength = 0; + if (emojiIndex !== undefined) { + const emoji = Emojis[emojiIndex]; + if (emoji.category === 'custom') { + insertedTextLength = ` :${emojiName}: `.length; + } else { + const unicode = emoji.image; + if (unicode) { + const codeArray = unicode.split('-'); + const convertToUnicode = (acc: string, c: string) => { + return acc + String.fromCodePoint(parseInt(c, 16)); + }; + insertedTextLength = codeArray.reduce(convertToUnicode, '').length; + } else { + insertedTextLength = ` :${emojiName}: `.length; + } + } + } + + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(5 + insertedTextLength); + expect(mockCursorPositionRef.current).toBe(5 + insertedTextLength); + }); + + it('should clear preservation flags after emoji insertion', () => { + mockCursorPositionRef.current = 5; + + renderWithIntlAndTheme( + , + ); + + const emojiName = 'smile'; + triggerEmojiPress(emojiName); + + expect(mockClearCursorPositionPreservation).toHaveBeenCalled(); + }); + }); + + describe('multiple emoji insertions', () => { + it('should insert second emoji at correct position after first emoji', () => { + mockCursorPositionRef.current = 5; + const initialValue = 'Hello world'; + + renderWithIntlAndTheme( + , + ); + + // First emoji insertion + const firstEmojiName = 'smile'; + triggerEmojiPress(firstEmojiName); + + expect(mockUpdateValue).toHaveBeenCalledTimes(1); + const firstUpdateFunction = mockUpdateValue.mock.calls[0][0]; + const valueAfterFirstEmoji = firstUpdateFunction(initialValue); + + // Get the length of the first emoji + const firstEmojiIndex = EmojiIndicesByAlias.get(firstEmojiName); + let firstEmojiLength = 0; + if (firstEmojiIndex !== undefined) { + const emoji = Emojis[firstEmojiIndex]; + if (emoji.category === 'custom') { + firstEmojiLength = ` :${firstEmojiName}: `.length; + } else { + const unicode = emoji.image; + if (unicode) { + const codeArray = unicode.split('-'); + const convertToUnicode = (acc: string, c: string) => { + return acc + String.fromCodePoint(parseInt(c, 16)); + }; + firstEmojiLength = codeArray.reduce(convertToUnicode, '').length; + } else { + firstEmojiLength = ` :${firstEmojiName}: `.length; + } + } + } + + const expectedCursorAfterFirst = 5 + firstEmojiLength; + expect(mockCursorPositionRef.current).toBe(expectedCursorAfterFirst); + + mockCursorPositionRef.current = expectedCursorAfterFirst; + mockUpdateValue.mockClear(); + + const secondEmojiName = 'heart'; + triggerEmojiPress(secondEmojiName); + + expect(mockUpdateValue).toHaveBeenCalledTimes(1); + const secondUpdateFunction = mockUpdateValue.mock.calls[0][0]; + const result = secondUpdateFunction(valueAfterFirstEmoji); + + expect(result.length).toBeGreaterThan(valueAfterFirstEmoji.length); + }); + }); + + describe('custom emoji insertion', () => { + it('should insert custom emoji with :emoji_name: format', () => { + mockCursorPositionRef.current = 5; + const initialValue = 'Hello world'; + + renderWithIntlAndTheme( + , + ); + + // Use an emoji name that doesn't exist in EmojiIndicesByAlias + // This will trigger the fallback to :emoji_name: format + const customEmojiName = 'nonexistent_emoji'; + triggerEmojiPress(customEmojiName); + + expect(mockUpdateValue).toHaveBeenCalled(); + const updateFunction = mockUpdateValue.mock.calls[0][0]; + const result = updateFunction(initialValue); + + // Should insert :nonexistent_emoji: format + expect(result).toContain(` :${customEmojiName}: `); + expect(result.substring(0, 5)).toBe('Hello'); + }); + }); +}); diff --git a/app/components/post_draft/custom_emoji_picker/index.tsx b/app/components/post_draft/custom_emoji_picker/index.tsx index 2ed9cc9d5..f86890a3c 100644 --- a/app/components/post_draft/custom_emoji_picker/index.tsx +++ b/app/components/post_draft/custom_emoji_picker/index.tsx @@ -22,7 +22,7 @@ const CustomEmojiPicker: React.FC = ({ onEmojiPress, setIsEmojiSearchFocused, }) => { - const {cursorPositionRef, updateValue, updateCursorPosition} = useKeyboardAnimationContext(); + const {cursorPositionRef, updateValue, updateCursorPosition, clearCursorPositionPreservation} = useKeyboardAnimationContext(); const handleEmojiPress = useCallback((emojiName: string) => { // If onEmojiPress prop is provided, use it (for other use cases) @@ -73,13 +73,17 @@ const CustomEmojiPicker: React.FC = ({ // Update cursor position state (for Android and to keep state in sync) updateCursorPosition(newCursorPosition); + // Clear preservation flags after emoji is inserted + // This allows normal cursor updates to proceed + clearCursorPositionPreservation?.(); + 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]); + }, [onEmojiPress, updateValue, updateCursorPosition, cursorPositionRef, clearCursorPositionPreservation]); return ( { - if (showInputAccessoryView) { - return; - } if (registerCursorPosition) { - registerCursorPosition(cursorPosition); + // Pass value length so registerCursorPosition can check if cursor is reset to end + registerCursorPosition(cursorPosition, value.length); } - }, [registerCursorPosition, cursorPosition, showInputAccessoryView]); + }, [registerCursorPosition, cursorPosition, showInputAccessoryView, value]); // Register updateValue and updateCursorPosition with context useEffect(() => { if (registerPostInputCallbacks) { registerPostInputCallbacks(updateValue, updateCursorPosition); } - }, [registerPostInputCallbacks, updateValue, updateCursorPosition]); + + // updateValue and updateCursorPosition are stable setState functions, don't need to be in deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [registerPostInputCallbacks]); const [propagateValue, shouldProcessEvent] = useInputPropagation(); @@ -220,7 +226,11 @@ export default function PostInput({ // This must happen before closing the emoji picker setIsEmojiSearchFocused(false); - // Close emoji picker immediately + const wasEmojiPickerOpen = showInputAccessoryView; + if (wasEmojiPickerOpen) { + updateCursorPosition(value.length); + clearCursorPositionPreservation?.(); + } setShowInputAccessoryView(false); if (Platform.OS === 'android') { @@ -291,6 +301,10 @@ export default function PostInput({ setShowInputAccessoryView, showInputAccessoryView, lastKeyboardHeight, + updateCursorPosition, + clearCursorPositionPreservation, + value, + cursorPosition, ]); const handleAndroidKeyboardHide = useCallback(() => { @@ -328,13 +342,36 @@ export default function PostInput({ }, [intl, longMessageAlertShown, maxMessageLength]); const handlePostDraftSelectionChanged = useCallback((event: NativeSyntheticEvent | null, fromHandleTextChange = false) => { - if (showInputAccessoryView && !fromHandleTextChange) { - return; - } + // Always update cursor position, even when input accessory view is shown, + // so emoji insertion works correctly at cursor position const cp = fromHandleTextChange ? cursorPosition : event!.nativeEvent.selection.end; + // Check if cursor is being reset to end when we're transitioning to emoji picker + // This happens when keyboard is dismissed - the TextInput selection resets to end + // Only block if we're actually in a transition period (emoji picker opening or just opened) + const isInTransition = isInEmojiPickerTransition?.(); + const preservedPosition = isInTransition ? getPreservedCursorPosition?.() : null; + + const isCursorAtEnd = cp === value.length; + const cursorPositionChanged = cp !== cursorPosition; + const hasPreservedPosition = preservedPosition !== null; + const preservedPositionChanged = preservedPosition !== cp; + const isResettingToEnd = isCursorAtEnd && cursorPositionChanged && hasPreservedPosition && preservedPositionChanged; + const isPreservedPositionWithinBounds = hasPreservedPosition && preservedPosition! < value.length; + + // If we're opening emoji picker and cursor is being reset to end, ignore it and keep the preserved position + if (!fromHandleTextChange && isResettingToEnd && isInTransition && isPreservedPositionWithinBounds) { + return; + } + + // When emoji picker is open, ignore cursor position updates that move to the end + // unless the value length changed (user typed something). + if (showInputAccessoryView && !fromHandleTextChange && isCursorAtEnd && cursorPositionChanged) { + return; + } + updateCursorPosition(cp); - }, [showInputAccessoryView, cursorPosition, updateCursorPosition]); + }, [cursorPosition, updateCursorPosition, showInputAccessoryView, value.length, isInEmojiPickerTransition, getPreservedCursorPosition]); const handleTextChange = useCallback((newValue: string) => { if (!shouldProcessEvent(newValue)) { 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 index 199f2cf31..e7577f5f8 100644 --- a/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx +++ b/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx @@ -42,6 +42,7 @@ export default function EmojiQuickAction({ showInputAccessoryView, setShowInputAccessoryView, isKeyboardFullyClosed, + preserveCursorPositionForEmojiPicker, } = useKeyboardAnimationContext(); const showEmojiPicker = useCallback(() => { @@ -87,6 +88,9 @@ export default function EmojiQuickAction({ return; } + // This prevents the cursor from being reset to the end when keyboard is dismissed + preserveCursorPositionForEmojiPicker(); + if (Platform.OS === 'android' && isKeyboardVisible()) { dismissKeyboard(); @@ -128,7 +132,7 @@ export default function EmojiQuickAction({ // 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])); + }, [disabled, showInputAccessoryView, lastKeyboardHeight, setShowInputAccessoryView, scheduleKeyboardCheck, preserveCursorPositionForEmojiPicker])); const actionTestID = disabled ? `${testID}.disabled` : testID; const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64); diff --git a/app/context/keyboard_animation.tsx b/app/context/keyboard_animation.tsx index d5d93f254..e9733e58a 100644 --- a/app/context/keyboard_animation.tsx +++ b/app/context/keyboard_animation.tsx @@ -32,7 +32,11 @@ interface KeyboardAnimationContextType { isEmojiSearchFocused: boolean; setIsEmojiSearchFocused: (focused: boolean) => void; cursorPositionRef: React.MutableRefObject; - registerCursorPosition: (cursorPosition: number) => void; + registerCursorPosition: (cursorPosition: number, valueLength?: number) => void; + preserveCursorPositionForEmojiPicker: () => void; + clearCursorPositionPreservation: () => void; + isInEmojiPickerTransition: () => boolean; + getPreservedCursorPosition: () => number | null; updateValue: React.Dispatch> | null; updateCursorPosition: React.Dispatch> | null; registerPostInputCallbacks: ( @@ -108,6 +112,14 @@ export const useKeyboardAnimationContext = () => { const defaultRegisterCursorPosition = useCallback(() => { // No-op fallback }, []); + const defaultPreserveCursorPositionForEmojiPicker = useCallback(() => { + // No-op fallback + }, []); + const defaultClearCursorPositionPreservation = useCallback(() => { + // No-op fallback + }, []); + const defaultIsInEmojiPickerTransition = useCallback(() => false, []); + const defaultGetPreservedCursorPosition = useCallback(() => null, []); const defaultUpdateValue = useRef> | null>(null); const defaultUpdateCursorPosition = useRef> | null>(null); @@ -142,6 +154,10 @@ export const useKeyboardAnimationContext = () => { setIsEmojiSearchFocused: defaultSetIsEmojiSearchFocused, cursorPositionRef: defaultCursorPositionRef, registerCursorPosition: defaultRegisterCursorPosition, + preserveCursorPositionForEmojiPicker: defaultPreserveCursorPositionForEmojiPicker, + clearCursorPositionPreservation: defaultClearCursorPositionPreservation, + isInEmojiPickerTransition: defaultIsInEmojiPickerTransition, + getPreservedCursorPosition: defaultGetPreservedCursorPosition, updateValue: defaultUpdateValue.current, updateCursorPosition: defaultUpdateCursorPosition.current, registerPostInputCallbacks: defaultRegisterPostInputCallbacks, @@ -160,6 +176,10 @@ export const useKeyboardAnimationContext = () => { defaultSetIsEmojiSearchFocused, defaultCursorPositionRef, defaultRegisterCursorPosition, + defaultPreserveCursorPositionForEmojiPicker, + defaultClearCursorPositionPreservation, + defaultIsInEmojiPickerTransition, + defaultGetPreservedCursorPosition, defaultUpdateValue, defaultUpdateCursorPosition, defaultRegisterPostInputCallbacks,