Skip to content

Commit dfbaa3d

Browse files
chore: improve spotlight search (#7406)
* improve: render local spotlight results before backend responds * feat: tests * fix: test * fix: prevent stale and failed search responses from corrupting autocomplete/rooms results * get more than 7 items * fix: render local results before fetch from server * fix: render local search results before remote resolves * cleanup * refactor: centralize local-first search orchestration * chore: remove unuseful comments
1 parent 3549de6 commit dfbaa3d

8 files changed

Lines changed: 532 additions & 75 deletions

File tree

app/containers/MessageComposer/MessageComposer.test.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { colors } from '../../lib/constants/colors';
1515
import { type IRoomContext, RoomContext } from '../../views/RoomView/context';
1616
import * as EmojiKeyboardHook from './hooks/useEmojiKeyboard';
1717
import { initStore } from '../../lib/store/auxStore';
18-
import { search } from '../../lib/methods/search';
18+
import { searchRemote } from '../../lib/methods/search';
1919
import database from '../../lib/database';
2020
import { useMessageComposerApi } from './context';
2121
import { sendFileMessage } from '../../lib/methods/sendFileMessage';
@@ -25,7 +25,8 @@ jest.useFakeTimers();
2525

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

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

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

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

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

549550
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');

app/containers/MessageComposer/hooks/useAutocomplete.ts

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type TAutocompleteItem,
88
type TAutocompleteType
99
} from '../interfaces';
10-
import { search } from '../../../lib/methods/search';
10+
import { searchLocal, searchRemote, type TSearch } from '../../../lib/methods/search';
1111
import { sanitizeLikeString } from '../../../lib/database/utils';
1212
import database from '../../../lib/database';
1313
import { emojis } from '../../../lib/constants/emojis';
@@ -56,6 +56,46 @@ export const useAutocomplete = ({
5656
const [mentionAll, mentionHere] = usePermissions(['mention-all', 'mention-here']);
5757

5858
useEffect(() => {
59+
// Guards against an older (slower) search overwriting the results of a newer one.
60+
// The cleanup runs on every re-run (any type change), so a stale request can never call setItems.
61+
let ignore = false;
62+
63+
const parseUserRoom = (res: TSearch[]): IAutocompleteUserRoom[] => {
64+
const parsedRes: IAutocompleteUserRoom[] = res
65+
// TODO: need to refactor search to have a more predictable return type
66+
.map((item: any) => ({
67+
id: type === '@' ? item._id : item.rid,
68+
title: item.fname || item.name || item.username,
69+
subtitle: item.username || item.name,
70+
outside: item.outside,
71+
t: item.t ?? 'd',
72+
status: item.status,
73+
teamMain: item.teamMain,
74+
type
75+
})) as IAutocompleteUserRoom[];
76+
if (type === '@') {
77+
if (mentionAll && 'all'.includes(text.toLocaleLowerCase())) {
78+
parsedRes.push({
79+
id: 'all',
80+
title: 'all',
81+
subtitle: I18n.t('Notify_all_in_this_room'),
82+
type,
83+
t: 'd'
84+
});
85+
}
86+
if (mentionHere && 'here'.includes(text.toLocaleLowerCase())) {
87+
parsedRes.push({
88+
id: 'here',
89+
title: 'here',
90+
subtitle: I18n.t('Notify_active_in_this_room'),
91+
type,
92+
t: 'd'
93+
});
94+
}
95+
}
96+
return parsedRes;
97+
};
98+
5999
const getAutocomplete = async () => {
60100
try {
61101
if (!rid || !type) {
@@ -76,39 +116,23 @@ export const useAutocomplete = ({
76116
setItems(items);
77117

78118
if (type === '@' || type === '#') {
79-
const res = await search({ text, filterRooms: type === '#', filterUsers: type === '@', rid });
80-
const parsedRes: IAutocompleteUserRoom[] = res
81-
// TODO: need to refactor search to have a more predictable return type
82-
.map((item: any) => ({
83-
id: type === '@' ? item._id : item.rid,
84-
title: item.fname || item.name || item.username,
85-
subtitle: item.username || item.name,
86-
outside: item.outside,
87-
t: item.t ?? 'd',
88-
status: item.status,
89-
teamMain: item.teamMain,
90-
type
91-
})) as IAutocompleteUserRoom[];
92-
if (type === '@') {
93-
if (mentionAll && 'all'.includes(text.toLocaleLowerCase())) {
94-
parsedRes.push({
95-
id: 'all',
96-
title: 'all',
97-
subtitle: I18n.t('Notify_all_in_this_room'),
98-
type,
99-
t: 'd'
100-
});
101-
}
102-
if (mentionHere && 'here'.includes(text.toLocaleLowerCase())) {
103-
parsedRes.push({
104-
id: 'here',
105-
title: 'here',
106-
subtitle: I18n.t('Notify_active_in_this_room'),
107-
type,
108-
t: 'd'
109-
});
110-
}
119+
const searchParams = { text, filterRooms: type === '#', filterUsers: type === '@', rid };
120+
121+
// Paint local results immediately, keeping a loading row at the bottom while the
122+
// backend request is still in flight
123+
const localData = await searchLocal(searchParams);
124+
if (ignore) return;
125+
const parsedLocal = parseUserRoom(localData);
126+
const loadingItem: TAutocompleteItem = { id: 'loading', type: 'loading' };
127+
setItems([...parsedLocal, loadingItem]);
128+
if (parsedLocal.length > 0) {
129+
updateAutocompleteVisible(true);
130+
accessibilityFocusOnInput();
111131
}
132+
133+
const res = await searchRemote({ ...searchParams, localData });
134+
if (ignore) return;
135+
const parsedRes = parseUserRoom(res);
112136
setItems(parsedRes);
113137
if (parsedRes.length > 0) {
114138
updateAutocompleteVisible(true);
@@ -117,6 +141,7 @@ export const useAutocomplete = ({
117141
}
118142
if (type === ':') {
119143
const customEmojis = await getCustomEmojis(text);
144+
if (ignore) return;
120145
const filteredStandardEmojis = emojis.filter(emoji => emoji.indexOf(text) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
121146
let mergedEmojis: IAutocompleteEmoji[] = customEmojis.map(emoji => ({
122147
id: emoji.name,
@@ -148,6 +173,7 @@ export const useAutocomplete = ({
148173
subtitle: command.description,
149174
type
150175
}));
176+
if (ignore) return;
151177
setItems(commands);
152178

153179
if (commands.length > 0) {
@@ -162,6 +188,7 @@ export const useAutocomplete = ({
162188
return;
163189
}
164190
const response = await getCommandPreview(text, rid, commandParams);
191+
if (ignore) return;
165192
if (response.success) {
166193
const previewItems = (response.preview?.items || []).map(item => ({
167194
id: item.id,
@@ -179,6 +206,7 @@ export const useAutocomplete = ({
179206
}
180207
if (type === '!') {
181208
const res = await getListCannedResponse({ text });
209+
if (ignore) return;
182210
if (res.success) {
183211
if (res.cannedResponses.length === 0) {
184212
setItems([
@@ -212,6 +240,9 @@ export const useAutocomplete = ({
212240
}
213241
};
214242
getAutocomplete();
243+
return () => {
244+
ignore = true;
245+
};
215246
}, [text, type, rid, commandParams]);
216247
return items;
217248
};

0 commit comments

Comments
 (0)