Skip to content
Open
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
9 changes: 5 additions & 4 deletions app/containers/MessageComposer/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { colors } from '../../lib/constants/colors';
import { type IRoomContext, RoomContext } from '../../views/RoomView/context';
import * as EmojiKeyboardHook from './hooks/useEmojiKeyboard';
import { initStore } from '../../lib/store/auxStore';
import { search } from '../../lib/methods/search';
import { searchRemote } from '../../lib/methods/search';
import database from '../../lib/database';
import { useMessageComposerApi } from './context';
import { sendFileMessage } from '../../lib/methods/sendFileMessage';
Expand All @@ -25,7 +25,8 @@ jest.useFakeTimers();

// Ensure search returns at least one item so autocomplete renders
jest.mock('../../lib/methods/search', () => ({
search: jest.fn(() => [{ _id: 'u1', username: 'john', name: 'John' }])
searchLocal: jest.fn(() => []),
searchRemote: jest.fn(() => [{ _id: 'u1', username: 'john', name: 'John' }])
}));

jest.mock('../../lib/services/restApi', () => ({
Expand Down Expand Up @@ -458,7 +459,7 @@ describe('MessageComposer', () => {

test('select @ user inserts mention and sends, autocomplete hides', async () => {
const onSendMessage = jest.fn();
(search as unknown as jest.Mock).mockImplementationOnce(() => [{ _id: 'u1', username: 'john', name: 'John' }]);
(searchRemote as unknown as jest.Mock).mockImplementationOnce(() => [{ _id: 'u1', username: 'john', name: 'John' }]);
render(<Render context={{ onSendMessage }} />);

await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
Expand Down Expand Up @@ -543,7 +544,7 @@ describe('MessageComposer', () => {

test('select # room inserts channel and sends, autocomplete hides', async () => {
const onSendMessage = jest.fn();
(search as unknown as jest.Mock).mockImplementationOnce(() => [{ rid: 'r1', name: 'general', t: 'c' }]);
(searchRemote as unknown as jest.Mock).mockImplementationOnce(() => [{ rid: 'r1', name: 'general', t: 'c' }]);
render(<Render context={{ onSendMessage }} />);

await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
Expand Down
97 changes: 64 additions & 33 deletions app/containers/MessageComposer/hooks/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type TAutocompleteItem,
type TAutocompleteType
} from '../interfaces';
import { search } from '../../../lib/methods/search';
import { searchLocal, searchRemote, type TSearch } from '../../../lib/methods/search';
import { sanitizeLikeString } from '../../../lib/database/utils';
import database from '../../../lib/database';
import { emojis } from '../../../lib/constants/emojis';
Expand Down Expand Up @@ -56,6 +56,46 @@ export const useAutocomplete = ({
const [mentionAll, mentionHere] = usePermissions(['mention-all', 'mention-here']);

useEffect(() => {
// Guards against an older (slower) search overwriting the results of a newer one.
// The cleanup runs on every re-run (any type change), so a stale request can never call setItems.
let ignore = false;

const parseUserRoom = (res: TSearch[]): IAutocompleteUserRoom[] => {
const parsedRes: IAutocompleteUserRoom[] = res
// TODO: need to refactor search to have a more predictable return type
.map((item: any) => ({
id: type === '@' ? item._id : item.rid,
title: item.fname || item.name || item.username,
subtitle: item.username || item.name,
outside: item.outside,
t: item.t ?? 'd',
status: item.status,
teamMain: item.teamMain,
type
})) as IAutocompleteUserRoom[];
if (type === '@') {
if (mentionAll && 'all'.includes(text.toLocaleLowerCase())) {
parsedRes.push({
id: 'all',
title: 'all',
subtitle: I18n.t('Notify_all_in_this_room'),
type,
t: 'd'
});
}
if (mentionHere && 'here'.includes(text.toLocaleLowerCase())) {
parsedRes.push({
id: 'here',
title: 'here',
subtitle: I18n.t('Notify_active_in_this_room'),
type,
t: 'd'
});
}
}
return parsedRes;
};

const getAutocomplete = async () => {
try {
if (!rid || !type) {
Expand All @@ -76,39 +116,23 @@ export const useAutocomplete = ({
setItems(items);

if (type === '@' || type === '#') {
const res = await search({ text, filterRooms: type === '#', filterUsers: type === '@', rid });
const parsedRes: IAutocompleteUserRoom[] = res
// TODO: need to refactor search to have a more predictable return type
.map((item: any) => ({
id: type === '@' ? item._id : item.rid,
title: item.fname || item.name || item.username,
subtitle: item.username || item.name,
outside: item.outside,
t: item.t ?? 'd',
status: item.status,
teamMain: item.teamMain,
type
})) as IAutocompleteUserRoom[];
if (type === '@') {
if (mentionAll && 'all'.includes(text.toLocaleLowerCase())) {
parsedRes.push({
id: 'all',
title: 'all',
subtitle: I18n.t('Notify_all_in_this_room'),
type,
t: 'd'
});
}
if (mentionHere && 'here'.includes(text.toLocaleLowerCase())) {
parsedRes.push({
id: 'here',
title: 'here',
subtitle: I18n.t('Notify_active_in_this_room'),
type,
t: 'd'
});
}
const searchParams = { text, filterRooms: type === '#', filterUsers: type === '@', rid };

// Paint local results immediately, keeping a loading row at the bottom while the
// backend request is still in flight
const localData = await searchLocal(searchParams);
if (ignore) return;
const parsedLocal = parseUserRoom(localData);
const loadingItem: TAutocompleteItem = { id: 'loading', type: 'loading' };
setItems([...parsedLocal, loadingItem]);
if (parsedLocal.length > 0) {
updateAutocompleteVisible(true);
accessibilityFocusOnInput();
}

const res = await searchRemote({ ...searchParams, localData });
if (ignore) return;
const parsedRes = parseUserRoom(res);
setItems(parsedRes);
Comment thread
OtavioStasiak marked this conversation as resolved.
if (parsedRes.length > 0) {
updateAutocompleteVisible(true);
Expand All @@ -117,6 +141,7 @@ export const useAutocomplete = ({
}
if (type === ':') {
const customEmojis = await getCustomEmojis(text);
if (ignore) return;
const filteredStandardEmojis = emojis.filter(emoji => emoji.indexOf(text) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
let mergedEmojis: IAutocompleteEmoji[] = customEmojis.map(emoji => ({
id: emoji.name,
Expand Down Expand Up @@ -148,6 +173,7 @@ export const useAutocomplete = ({
subtitle: command.description,
type
}));
if (ignore) return;
setItems(commands);

if (commands.length > 0) {
Expand All @@ -162,6 +188,7 @@ export const useAutocomplete = ({
return;
}
const response = await getCommandPreview(text, rid, commandParams);
if (ignore) return;
if (response.success) {
const previewItems = (response.preview?.items || []).map(item => ({
id: item.id,
Expand All @@ -179,6 +206,7 @@ export const useAutocomplete = ({
}
if (type === '!') {
const res = await getListCannedResponse({ text });
if (ignore) return;
if (res.success) {
if (res.cannedResponses.length === 0) {
setItems([
Expand Down Expand Up @@ -212,6 +240,9 @@ export const useAutocomplete = ({
}
};
getAutocomplete();
return () => {
ignore = true;
};
}, [text, type, rid, commandParams]);
return items;
};
Loading
Loading