Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 51 additions & 11 deletions package/src/components/AutoCompleteInput/AutoCompleteInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
I18nManager,
TextInput as RNTextInput,
Expand Down Expand Up @@ -72,6 +72,42 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)
const { command, text } = useStateStore(textComposer.state, textComposerStateSelector);
const { enabled } = useStateStore(messageComposer.configState, configStateSelector);

// RN's onChangeText doesn't carry cursor info, and iOS / Android fire
// onChangeText vs onSelectionChange in different orders. Rather than derive
// the caret from a text-length delta (fragile — gets clobbered by re-renders
// and varies across platforms), we hold the latest values reported by native
// and call into the LLC once both have settled.
const latestTextRef = useRef('');
const latestSelectionRef = useRef<{ end: number; start: number }>({
end: 0,
start: 0,
});
const flushHandleRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const flushChange = useCallback(() => {
flushHandleRef.current = null;
textComposer.handleChange({
selection: latestSelectionRef.current,
text: latestTextRef.current,
});
}, [textComposer]);

// Defer to the next task so onChangeText and onSelectionChange both land
// before we forward to the LLC, regardless of platform ordering.
const scheduleChange = useCallback(() => {
if (flushHandleRef.current !== null) return;
flushHandleRef.current = setTimeout(flushChange, 0);
}, [flushChange]);

useEffect(() => {
return () => {
if (flushHandleRef.current !== null) {
clearTimeout(flushHandleRef.current);
flushHandleRef.current = null;
}
};
}, []);

const maxMessageLength = useMemo(() => {
return channel.getConfig()?.max_message_length;
}, [channel]);
Expand All @@ -82,29 +118,33 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)

useEffect(() => {
setLocalText(text);
// Only resync the refs when the text change came from outside (clear after
// send, draft restore, programmatic setText). For changes we triggered
// ourselves, latestTextRef is already up to date and overwriting the
// selection would clobber what onSelectionChange just told us.
if (text !== latestTextRef.current) {
latestTextRef.current = text;
latestSelectionRef.current = { end: text.length, start: text.length };
}
}, [text]);

const handleSelectionChange = useCallback(
(e: TextInputSelectionChangeEvent) => {
const { selection } = e.nativeEvent;
latestSelectionRef.current = selection;
textComposer.setSelection(selection);
scheduleChange();
},
[textComposer],
[scheduleChange, textComposer],
);

const onChangeTextHandler = useCallback(
(newText: string) => {
setLocalText(newText);

textComposer.handleChange({
selection: {
end: newText.length,
start: newText.length,
},
text: newText,
});
latestTextRef.current = newText;
scheduleChange();
},
[textComposer],
[scheduleChange],
);

const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,13 @@ describe('AutoCompleteInput', () => {

const input = queryByTestId('auto-complete-text-input');

// RN fires both events for every keystroke; we forward to the LLC once
// both have settled, so the test mirrors that.
act(() => {
fireEvent.changeText(input, 'hello');
fireEvent(input, 'selectionChange', {
nativeEvent: { selection: { end: 5, start: 5 } },
});
});

await waitFor(() => {
Expand All @@ -113,6 +118,119 @@ describe('AutoCompleteInput', () => {
});
});

it('forwards the real caret position to handleChange when typing in the middle of multi-line text', async () => {
// Regression: when the user inserts a character somewhere other than the
// end of the text (e.g. typing "@" between paragraphs), the original
// implementation passed selection.end = newText.length, which caused the
// LLC mention-trigger regex to miss "@" on multi-line input because it
// only tolerates one whitespace between "@" and end-of-string.
const { textComposer } = channel.messageComposer;
const spyHandleChange = jest.spyOn(textComposer, 'handleChange');

renderComponent({ channelProps: { channel }, client, props: {} });

const input = screen.getByTestId('auto-complete-text-input');

// Seed the input with some multi-line text and place the caret between
// the two leading newlines (position 6 — right after "asdf\n\n", before
// the trailing "\n\n dsfa").
const seeded = 'asdf\n\n\n\n dsfa';
const caret = 6;
act(() => {
fireEvent.changeText(input, seeded);
fireEvent(input, 'selectionChange', {
nativeEvent: { selection: { end: caret, start: caret } },
});
});

await waitFor(() => {
expect(spyHandleChange).toHaveBeenCalledWith({
selection: { end: caret, start: caret },
text: seeded,
});
});

spyHandleChange.mockClear();

// User types "@" at the caret. Both events fire in a single keystroke.
const inserted = 'asdf\n\n@\n\n dsfa';
const newCaret = caret + 1;
act(() => {
fireEvent.changeText(input, inserted);
fireEvent(input, 'selectionChange', {
nativeEvent: { selection: { end: newCaret, start: newCaret } },
});
});

await waitFor(() => {
// Must land right after the inserted "@", not at end-of-string —
// otherwise the LLC mention regex misses "@" because of the trailing
// "\n\n dsfa".
expect(spyHandleChange).toHaveBeenCalledWith({
selection: { end: newCaret, start: newCaret },
text: inserted,
});
});
});

it('forwards the real caret to handleChange when the user deletes "@" and retypes it', async () => {
// Regression: deleting "@" and retyping it on the same single line caused
// the picker to stay hidden on iOS — and on Android even with newlines.
// The bug was that we derived the caret from a text-length delta plus a
// stale ref; the coalesced flush now uses whatever native actually
// reported via onSelectionChange.
const { textComposer } = channel.messageComposer;
const spyHandleChange = jest.spyOn(textComposer, 'handleChange');

renderComponent({ channelProps: { channel }, client, props: {} });

const input = screen.getByTestId('auto-complete-text-input');

// 1. Seed "asdf @ dsfa" with the caret right after "@".
act(() => {
fireEvent.changeText(input, 'asdf @ dsfa');
fireEvent(input, 'selectionChange', {
nativeEvent: { selection: { end: 6, start: 6 } },
});
});

await waitFor(() => {
expect(spyHandleChange).toHaveBeenCalledWith({
selection: { end: 6, start: 6 },
text: 'asdf @ dsfa',
});
});

// 2. Delete the "@" — text shrinks by one, caret moves to position 5.
act(() => {
fireEvent.changeText(input, 'asdf dsfa');
fireEvent(input, 'selectionChange', {
nativeEvent: { selection: { end: 5, start: 5 } },
});
});

spyHandleChange.mockClear();

// 3. Retype "@" at position 5.
act(() => {
fireEvent.changeText(input, 'asdf @ dsfa');
fireEvent(input, 'selectionChange', {
nativeEvent: { selection: { end: 6, start: 6 } },
});
});

await waitFor(() => {
// The cursor must be reported at 6 (right after the new "@"), not
// somewhere stale. With a wrong caret, the LLC slice would include
// " dsfa" after the "@" and the query would be " dsfa" instead of "",
// which returns zero users → picker stays hidden.
expect(spyHandleChange).toHaveBeenCalledWith({
selection: { end: 6, start: 6 },
text: 'asdf @ dsfa',
});
});
});

it('should style the text input with maxHeight that is set by the layout', async () => {
const channelProps = { channel };
const props = { numberOfLines: 10 };
Expand Down
Loading