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:
parent
6b0437a6e3
commit
462d6d5052
6 changed files with 430 additions and 24 deletions
|
|
@ -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,
|
||||
|
|
|
|||
286
app/components/post_draft/custom_emoji_picker/index.test.tsx
Normal file
286
app/components/post_draft/custom_emoji_picker/index.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue