diff --git a/app/components/post_draft/quick_actions/input_quick_action/index.test.tsx b/app/components/post_draft/quick_actions/input_quick_action/index.test.tsx
index b1f66534b..7f7b94fa9 100644
--- a/app/components/post_draft/quick_actions/input_quick_action/index.test.tsx
+++ b/app/components/post_draft/quick_actions/input_quick_action/index.test.tsx
@@ -1,55 +1,337 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper';
import InputQuickAction from '.';
+jest.mock('@context/keyboard_animation', () => ({
+ useKeyboardAnimationContext: jest.fn(),
+}));
+
+jest.mock('@hooks/useFocusAfterEmojiDismiss', () => ({
+ useFocusAfterEmojiDismiss: jest.fn((inputRef, focusInput) => ({
+ focus: focusInput,
+ isDismissingEmojiPicker: {current: false},
+ focusTimeoutRef: {current: null},
+ isManuallyFocusingAfterEmojiDismiss: false,
+ })),
+}));
+
describe('InputQuickAction', () => {
- it('should add If theres existing text and it doesnt end with a space, add a space before @', () => {
- const updateValue = jest.fn();
- const testID = 'test-id';
- const inputType = 'at';
- const focus = jest.fn();
+ const mockUseKeyboardAnimationContext = jest.mocked(useKeyboardAnimationContext);
+ const mockUpdateCursorPosition = jest.fn();
+ const mockInputRef = {current: undefined};
- const {getByTestId} = renderWithIntlAndTheme(
- );
-
- const icon = getByTestId('test-id');
- fireEvent.press(icon);
-
- expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
- const updateFunction = updateValue.mock.calls[0][0];
- expect(updateFunction('')).toBe('@');
- expect(focus).toHaveBeenCalled();
+ beforeEach(() => {
+ jest.clearAllMocks();
});
- it('should add space before @ if there is existing text and it doesnt end with a space', () => {
- const updateValue = jest.fn();
- const testID = 'test-id';
- const inputType = 'at';
- const focus = jest.fn();
+ describe('fallback behavior (no cursor position context)', () => {
+ beforeEach(() => {
+ mockUseKeyboardAnimationContext.mockReturnValue({
+ inputRef: mockInputRef,
+ cursorPositionRef: undefined,
+ updateCursorPosition: undefined,
+ } as unknown as ReturnType);
+ });
- const {getByTestId} = renderWithIntlAndTheme(
- );
+ it('should add @ to empty string', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'at';
+ const focus = jest.fn();
- const icon = getByTestId('test-id');
- fireEvent.press(icon);
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
- expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
- const updateFunction = updateValue.mock.calls[0][0];
- expect(updateFunction('Hello')).toBe('Hello @');
- expect(focus).toHaveBeenCalled();
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('')).toBe('@');
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('should add space before @ if there is existing text and it doesnt end with a space', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'at';
+ const focus = jest.fn();
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('Hello @');
+ expect(focus).toHaveBeenCalled();
+ });
});
+ describe('cursor position insertion', () => {
+ let cursorPositionRef: {current: number};
+
+ beforeEach(() => {
+ cursorPositionRef = {current: 0};
+ mockUseKeyboardAnimationContext.mockReturnValue({
+ inputRef: mockInputRef,
+ cursorPositionRef,
+ updateCursorPosition: mockUpdateCursorPosition,
+ } as unknown as ReturnType);
+ });
+
+ describe('@ input type', () => {
+ it('should insert @ at cursor position at the beginning', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'at';
+ const focus = jest.fn();
+ cursorPositionRef.current = 0;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('@Hello');
+ expect(cursorPositionRef.current).toBe(1);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('should insert @ at cursor position in the middle', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'at';
+ const focus = jest.fn();
+ cursorPositionRef.current = 3;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('Hel@lo');
+ expect(cursorPositionRef.current).toBe(4);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(4);
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('should insert @ at cursor position at the end', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'at';
+ const focus = jest.fn();
+ cursorPositionRef.current = 5;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('Hello @');
+ expect(cursorPositionRef.current).toBe(7);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('should add space before @ when cursor is at the end and previous char is not space', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'at';
+ const focus = jest.fn();
+ cursorPositionRef.current = 5;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('Hello @');
+ expect(cursorPositionRef.current).toBe(7);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('should not add space before @ when cursor is at the end and previous char is space', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'at';
+ const focus = jest.fn();
+ cursorPositionRef.current = 6;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello ')).toBe('Hello @');
+ expect(cursorPositionRef.current).toBe(7);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('should not add space before @ when cursor is at the beginning', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'at';
+ const focus = jest.fn();
+ cursorPositionRef.current = 0;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('@Hello');
+ expect(cursorPositionRef.current).toBe(1);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
+ expect(focus).toHaveBeenCalled();
+ });
+ });
+
+ describe('slash input type', () => {
+ it('should insert / at cursor position at the beginning', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'slash';
+ const focus = jest.fn();
+ cursorPositionRef.current = 0;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('/Hello');
+ expect(cursorPositionRef.current).toBe(1);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('should insert / at cursor position in the middle', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'slash';
+ const focus = jest.fn();
+ cursorPositionRef.current = 3;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('Hel/lo');
+ expect(cursorPositionRef.current).toBe(4);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(4);
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('should insert / at cursor position at the end', () => {
+ const updateValue = jest.fn();
+ const testID = 'test-id';
+ const inputType = 'slash';
+ const focus = jest.fn();
+ cursorPositionRef.current = 5;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ );
+
+ const icon = getByTestId('test-id');
+ fireEvent.press(icon);
+
+ expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
+ const updateFunction = updateValue.mock.calls[0][0];
+ expect(updateFunction('Hello')).toBe('Hello/');
+ expect(cursorPositionRef.current).toBe(6);
+ expect(mockUpdateCursorPosition).toHaveBeenCalledWith(6);
+ expect(focus).toHaveBeenCalled();
+ });
+ });
+ });
});
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 7175ac73d..5b9f49ab0 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
@@ -40,24 +40,54 @@ export default function InputQuickAction({
focus,
}: Props) {
const theme = useTheme();
- const {inputRef} = useKeyboardAnimationContext();
+ const {inputRef, cursorPositionRef, updateCursorPosition} = useKeyboardAnimationContext();
// Use hook to handle focus after emoji picker dismissal
const {focus: focusWithEmojiDismiss} = useFocusAfterEmojiDismiss(inputRef, focus);
const onPress = useCallback(() => {
- updateValue((v) => {
- if (inputType === 'at') {
- // If there's existing text and it doesn't end with a space, add a space before @
- if (v.length > 0 && !v.endsWith(' ')) {
- return `${v} @`;
+ if (cursorPositionRef && updateCursorPosition) {
+ const currentCursorPosition = cursorPositionRef.current;
+
+ updateValue((v) => {
+ if (inputType === 'at') {
+ let insertedText = '@';
+ const charBeforeCursor = currentCursorPosition > 0 ? v[currentCursorPosition - 1] : '';
+
+ if (currentCursorPosition === v.length && currentCursorPosition > 0 && charBeforeCursor !== ' ') {
+ insertedText = ' @';
+ }
+
+ const newValue = v.slice(0, currentCursorPosition) + insertedText + v.slice(currentCursorPosition);
+ const newCursorPosition = currentCursorPosition + insertedText.length;
+
+ cursorPositionRef.current = newCursorPosition;
+ updateCursorPosition(newCursorPosition);
+
+ return newValue;
}
- return `${v}@`;
- }
- return '/';
- });
+
+ const newValue = v.slice(0, currentCursorPosition) + '/' + v.slice(currentCursorPosition);
+ const newCursorPosition = currentCursorPosition + 1;
+
+ cursorPositionRef.current = newCursorPosition;
+ updateCursorPosition(newCursorPosition);
+
+ return newValue;
+ });
+ } else {
+ updateValue((v) => {
+ if (inputType === 'at') {
+ if (v.length > 0 && !v.endsWith(' ')) {
+ return `${v} @`;
+ }
+ return `${v}@`;
+ }
+ return '/';
+ });
+ }
focusWithEmojiDismiss();
- }, [inputType, updateValue, focusWithEmojiDismiss]);
+ }, [inputType, updateValue, focusWithEmojiDismiss, cursorPositionRef, updateCursorPosition]);
const actionTestID = disabled ?
`${testID}.disabled` :