Fix Emoji insert at end of post instead of caret position (#9473)

* Fix Emoji insert at end of post instead of caret position

* copilot code suggestions

* Added test
This commit is contained in:
Rajat Dabade 2026-02-02 19:32:11 +05:30 committed by GitHub
parent 6b0437a6e3
commit 462d6d5052
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 430 additions and 24 deletions

View file

@ -110,15 +110,70 @@ export const KeyboardAwarePostDraftContainer = ({
// Ref to store cursor position from PostInput
const cursorPositionRef = useRef<number>(0);
// Function to register cursor position updates from PostInput
const registerCursorPosition = useCallback((cursorPosition: number) => {
cursorPositionRef.current = cursorPosition;
// Ref to track if we're transitioning to emoji picker (to preserve cursor position)
const isOpeningEmojiPickerRef = useRef<boolean>(false);
// Ref to store preserved cursor position when opening emoji picker
const preservedCursorPositionRef = useRef<number | null>(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<number | null>(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<React.Dispatch<React.SetStateAction<string>> | null>(null);
const updateCursorPositionRef = useRef<React.Dispatch<React.SetStateAction<number>> | 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<React.SetStateAction<string>>,
@ -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,

View file

@ -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<typeof useKeyboardAnimationContext>;
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(
<CustomEmojiPicker
height={mockHeight}
setIsEmojiSearchFocused={jest.fn()}
isEmojiSearchFocused={false}
/>,
);
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(
<CustomEmojiPicker
height={mockHeight}
setIsEmojiSearchFocused={jest.fn()}
isEmojiSearchFocused={false}
/>,
);
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(
<CustomEmojiPicker
height={mockHeight}
setIsEmojiSearchFocused={jest.fn()}
isEmojiSearchFocused={false}
/>,
);
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(
<CustomEmojiPicker
height={mockHeight}
setIsEmojiSearchFocused={jest.fn()}
isEmojiSearchFocused={false}
/>,
);
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(
<CustomEmojiPicker
height={mockHeight}
setIsEmojiSearchFocused={jest.fn()}
isEmojiSearchFocused={false}
/>,
);
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(
<CustomEmojiPicker
height={mockHeight}
setIsEmojiSearchFocused={jest.fn()}
isEmojiSearchFocused={false}
/>,
);
// 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(
<CustomEmojiPicker
height={mockHeight}
setIsEmojiSearchFocused={jest.fn()}
isEmojiSearchFocused={false}
/>,
);
// 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');
});
});
});

View file

@ -22,7 +22,7 @@ const CustomEmojiPicker: React.FC<Props> = ({
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<Props> = ({
// 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 (
<EmojiPicker

View file

@ -141,24 +141,30 @@ export default function PostInput({
scrollOffset,
registerCursorPosition,
registerPostInputCallbacks,
isInEmojiPickerTransition,
getPreservedCursorPosition,
clearCursorPositionPreservation,
} = useKeyboardAnimationContext();
// Register cursor position updates with context
// Always update cursorPositionRef, even when input accessory view is shown,
// so emoji insertion works correctly at cursor position
useEffect(() => {
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<TextInputSelectionChangeEventData> | 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)) {

View file

@ -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);

View file

@ -32,7 +32,11 @@ interface KeyboardAnimationContextType {
isEmojiSearchFocused: boolean;
setIsEmojiSearchFocused: (focused: boolean) => void;
cursorPositionRef: React.MutableRefObject<number>;
registerCursorPosition: (cursorPosition: number) => void;
registerCursorPosition: (cursorPosition: number, valueLength?: number) => void;
preserveCursorPositionForEmojiPicker: () => void;
clearCursorPositionPreservation: () => void;
isInEmojiPickerTransition: () => boolean;
getPreservedCursorPosition: () => number | null;
updateValue: React.Dispatch<React.SetStateAction<string>> | null;
updateCursorPosition: React.Dispatch<React.SetStateAction<number>> | 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<React.Dispatch<React.SetStateAction<string>> | null>(null);
const defaultUpdateCursorPosition = useRef<React.Dispatch<React.SetStateAction<number>> | 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,