Skip to content

Commit ec46dbc

Browse files
committed
refactor: extract composer autocomplete engine to ui-client
1 parent db0b1ad commit ec46dbc

17 files changed

Lines changed: 740 additions & 490 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { createEmojiPopupConfig } from './emojiPopupConfig';
2+
import { emoji } from '../../../app/emoji/client';
3+
4+
const search = async (filter: string, recents: string[] = []) => {
5+
const config = createEmojiPopupConfig({ t: ((key: string) => key) as never, recentEmojis: recents });
6+
const items = (await config.getItemsFromLocal?.(filter)) ?? [];
7+
return items.map(({ _id }) => _id);
8+
};
9+
10+
beforeAll(() => {
11+
emoji.packages.test = {
12+
emojisByCategory: {},
13+
toneList: {},
14+
render: () => '',
15+
renderPicker: () => undefined,
16+
} as never;
17+
18+
const handles = [
19+
':rocket:',
20+
':smile:',
21+
':smiley:',
22+
':sweat_smile:',
23+
':thumbsup:',
24+
':thumbsup_tone1:',
25+
':thumbsup_tone5:',
26+
...Array.from({ length: 15 }, (_, i) => `:cat${i}:`),
27+
];
28+
for (const handle of handles) {
29+
emoji.list[handle] = { emojiPackage: 'test' } as never;
30+
}
31+
});
32+
33+
describe('createEmojiPopupConfig search', () => {
34+
it('matches substrings case-insensitively', async () => {
35+
expect(await search('SMILE')).toEqual(expect.arrayContaining([':smile:', ':smiley:', ':sweat_smile:']));
36+
});
37+
38+
it('ranks exact/prefix matches before substring matches', async () => {
39+
const results = await search('smile');
40+
expect(results[0]).toBe(':smile:');
41+
expect(results.indexOf(':sweat_smile:')).toBeGreaterThan(0);
42+
});
43+
44+
it('boosts recently used emojis', async () => {
45+
expect((await search('smile', ['smiley']))[0]).toBe(':smiley:');
46+
});
47+
48+
it('hides skin tone variants unless explicitly searched', async () => {
49+
expect(await search('thumbsup')).toEqual([':thumbsup:']);
50+
});
51+
52+
it('includes skin tone variants when the search asks for a tone', async () => {
53+
expect(await search('thumbsup_tone')).toEqual(expect.arrayContaining([':thumbsup_tone1:', ':thumbsup_tone5:']));
54+
});
55+
56+
it('limits the number of suggestions', async () => {
57+
expect((await search('cat')).length).toBeLessThanOrEqual(10);
58+
});
59+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { OptionColumn, OptionContent } from '@rocket.chat/fuselage';
2+
import { escapeRegExp } from '@rocket.chat/string-helpers';
3+
import { createAutocompletePopupConfig } from '@rocket.chat/ui-client';
4+
import type { TranslationKey } from '@rocket.chat/ui-contexts';
5+
6+
import { emoji } from '../../../app/emoji/client';
7+
import Emoji from '../Emoji';
8+
9+
export type AutocompletePopupEmojiProps = {
10+
_id: string;
11+
};
12+
13+
export function AutocompletePopupEmoji({ _id }: AutocompletePopupEmojiProps) {
14+
return (
15+
<>
16+
<OptionColumn>
17+
<Emoji emojiHandle={_id} />
18+
</OptionColumn>
19+
<OptionContent>{_id}</OptionContent>
20+
</>
21+
);
22+
}
23+
24+
type CreateEmojiPopupConfigParams = {
25+
t: (key: TranslationKey) => string;
26+
recentEmojis: string[];
27+
};
28+
29+
export const createEmojiPopupConfig = ({ t, recentEmojis }: CreateEmojiPopupConfigParams) =>
30+
createAutocompletePopupConfig<AutocompletePopupEmojiProps>({
31+
trigger: ':',
32+
title: t('Emoji'),
33+
triggerLength: 2,
34+
getItemsFromLocal: async (filter: string) => {
35+
const exactFinalTone = new RegExp('^tone[1-5]:*$');
36+
const colorBlind = new RegExp('tone[1-5]:*$');
37+
const seeColor = new RegExp('_t(?:o|$)(?:n|$)(?:e|$)(?:[1-5]|$)(?::|$)$');
38+
39+
const emojiSort = (recents: string[]) => (a: { _id: string }, b: { _id: string }) => {
40+
const aExact = a._id === key ? 2 : 0;
41+
const bExact = b._id === key ? 2 : 0;
42+
const aPartial = a._id.startsWith(key) ? 1 : 0;
43+
const bPartial = b._id.startsWith(key) ? 1 : 0;
44+
45+
let aScore = aExact + aPartial;
46+
let bScore = bExact + bPartial;
47+
48+
if (recents.includes(a._id)) {
49+
aScore += recents.indexOf(a._id) + 1;
50+
}
51+
if (recents.includes(b._id)) {
52+
bScore += recents.indexOf(b._id) + 1;
53+
}
54+
55+
if (aScore > bScore) {
56+
return -1;
57+
}
58+
if (aScore < bScore) {
59+
return 1;
60+
}
61+
return 0;
62+
};
63+
const filterRegex = new RegExp(escapeRegExp(filter), 'i');
64+
const key = `:${filter}`;
65+
66+
const recents = recentEmojis.map((item) => `:${item}:`);
67+
68+
const collection = emoji.list;
69+
70+
return Object.keys(collection)
71+
.map((_id) => ({ _id }))
72+
.filter(
73+
({ _id }) =>
74+
filterRegex.test(_id) && (exactFinalTone.test(_id.substring(key.length)) || seeColor.test(key) || !colorBlind.test(_id)),
75+
)
76+
.sort(emojiSort(recents))
77+
.slice(0, 10);
78+
},
79+
getItemsFromServer: async () => {
80+
return [];
81+
},
82+
getValue: (item) => `${item._id.substring(1)}`,
83+
renderItem: ({ item }) => <AutocompletePopupEmoji {...item} />,
84+
});

apps/meteor/client/views/room/composer/ComposerBoxPopupEmoji.tsx

Lines changed: 0 additions & 20 deletions
This file was deleted.

apps/meteor/client/views/room/composer/ComposerBoxPopupPreview.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { Box, Skeleton, Tile, Option } from '@rocket.chat/fuselage';
22
import { Random } from '@rocket.chat/random';
3+
import type { AutocompletePopupProps } from '@rocket.chat/ui-client';
34
import { useEndpoint } from '@rocket.chat/ui-contexts';
45
import type { ForwardedRef, ReactNode } from 'react';
56
import { forwardRef, useEffect, useId, useImperativeHandle } from 'react';
67

7-
import type { ComposerBoxPopupProps } from './ComposerBoxPopup';
88
import { useChat } from '../contexts/ChatContext';
99

1010
type ComposerBoxPopupPreviewItem = { _id: string; type: 'image' | 'video' | 'audio' | 'text' | 'other'; value: string; sort?: number };
1111

12-
type ComposerBoxPopupPreviewProps = ComposerBoxPopupProps<ComposerBoxPopupPreviewItem> & {
12+
type ComposerBoxPopupPreviewProps = AutocompletePopupProps<ComposerBoxPopupPreviewItem> & {
1313
title?: ReactNode;
1414
rid: string;
1515
tmid?: string;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { mockAppRoot } from '@rocket.chat/mock-providers';
2+
import { createAutocompletePopupConfig } from '@rocket.chat/ui-client';
3+
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
4+
5+
import { useComposerBoxPopup } from './useComposerBoxPopup';
6+
import type { ChatAPI } from '../../../../lib/chats/ChatAPI';
7+
import { ChatContext } from '../../contexts/ChatContext';
8+
9+
const keys = { ENTER: 13, ESC: 27, ARROW_UP: 38, ARROW_DOWN: 40 };
10+
11+
type Item = { _id: string; username?: string };
12+
13+
const createComposer = (text: string, caret = text.length) => ({
14+
substring: (start: number, end?: number) => text.substring(start, end),
15+
selection: { start: caret, end: caret },
16+
replaceText: jest.fn(),
17+
});
18+
19+
const userOption = createAutocompletePopupConfig<Item>({
20+
trigger: '@',
21+
getItemsFromLocal: async () => [
22+
{ _id: 'u1', username: 'alice' },
23+
{ _id: 'u2', username: 'bob' },
24+
],
25+
getItemsFromServer: async () => [],
26+
getValue: (item) => item.username ?? '',
27+
});
28+
29+
const emojiOption = createAutocompletePopupConfig<Item>({
30+
trigger: ':',
31+
triggerLength: 2,
32+
getItemsFromLocal: async (filter: string) => [{ _id: `:${filter}a:` }, { _id: `:${filter}b:` }],
33+
getItemsFromServer: async () => [],
34+
getValue: (item) => item._id.substring(1),
35+
});
36+
37+
const commandOption = createAutocompletePopupConfig<Item>({
38+
trigger: '/',
39+
triggerAnywhere: false,
40+
getItemsFromLocal: async () => [{ _id: 'archive' }],
41+
getItemsFromServer: async () => [],
42+
getValue: (item) => item._id,
43+
});
44+
45+
const options = [userOption, emojiOption, commandOption];
46+
47+
const setup = (text: string, caret = text.length) => {
48+
const composer = createComposer(text, caret);
49+
const node = document.createElement('textarea');
50+
51+
const { result } = renderHook(() => useComposerBoxPopup(options), {
52+
wrapper: mockAppRoot()
53+
.wrap((children) => <ChatContext.Provider value={{ composer } as unknown as ChatAPI}>{children}</ChatContext.Provider>)
54+
.build(),
55+
});
56+
act(() => result.current.callbackRef(node));
57+
58+
const fire = (type: 'keyUp' | 'keyDown', which: number) => fireEvent[type](node, { which, keyCode: which });
59+
60+
return { result, composer, fire };
61+
};
62+
63+
describe('useComposerBoxPopup (characterization)', () => {
64+
it('opens no popup when the text has no trigger', () => {
65+
const { result, fire } = setup('hello');
66+
fire('keyUp', 0);
67+
expect(result.current.option).toBeUndefined();
68+
});
69+
70+
it('detects the `@` trigger and extracts the filter', () => {
71+
const { result, fire } = setup('hi @al');
72+
fire('keyUp', 0);
73+
expect(result.current.option?.trigger).toBe('@');
74+
expect(result.current.filter).toBe('al');
75+
});
76+
77+
it('respects triggerLength: `:` alone does not open, `:ab` does', () => {
78+
const closed = setup('x :');
79+
closed.fire('keyUp', 0);
80+
expect(closed.result.current.option).toBeUndefined();
81+
82+
const open = setup('x :ab');
83+
open.fire('keyUp', 0);
84+
expect(open.result.current.option?.trigger).toBe(':');
85+
expect(open.result.current.filter).toBe('ab');
86+
});
87+
88+
it('only triggers a start-only (`/`) option at the very start of the text', () => {
89+
const mid = setup('hey /arch');
90+
mid.fire('keyUp', 0);
91+
expect(mid.result.current.option).toBeUndefined();
92+
93+
const start = setup('/arch');
94+
start.fire('keyUp', 0);
95+
expect(start.result.current.option?.trigger).toBe('/');
96+
expect(start.result.current.filter).toBe('arch');
97+
});
98+
99+
it('inserts prefix + value + suffix over the trigger range on select', async () => {
100+
const { result, composer, fire } = setup('hi @al');
101+
fire('keyUp', 0);
102+
await waitFor(() => expect(result.current.focused).toBeDefined());
103+
104+
act(() => result.current.select?.({ _id: 'u1', username: 'alice' }));
105+
106+
expect(composer.replaceText).toHaveBeenCalledWith('@alice ', { start: 3, end: 6 });
107+
});
108+
109+
it('moves focus with arrow keys and wraps around', async () => {
110+
const { result, fire } = setup('hi @');
111+
fire('keyUp', 0);
112+
await waitFor(() => expect(result.current.focused?._id).toBe('u1'));
113+
114+
fire('keyDown', keys.ARROW_DOWN);
115+
expect(result.current.focused?._id).toBe('u2');
116+
117+
fire('keyDown', keys.ARROW_DOWN);
118+
expect(result.current.focused?._id).toBe('u1');
119+
120+
fire('keyDown', keys.ARROW_UP);
121+
expect(result.current.focused?._id).toBe('u2');
122+
});
123+
124+
it('selects the focused item on Enter', async () => {
125+
const { result, composer, fire } = setup('hi @');
126+
fire('keyUp', 0);
127+
await waitFor(() => expect(result.current.focused?._id).toBe('u1'));
128+
129+
fire('keyDown', keys.ENTER);
130+
expect(composer.replaceText).toHaveBeenCalledWith('@alice ', expect.objectContaining({ end: 4 }));
131+
});
132+
133+
it('closes the popup on Escape', async () => {
134+
const { result, fire } = setup('hi @al');
135+
fire('keyUp', 0);
136+
await waitFor(() => expect(result.current.option?.trigger).toBe('@'));
137+
138+
fire('keyUp', keys.ESC);
139+
expect(result.current.option).toBeUndefined();
140+
});
141+
});

0 commit comments

Comments
 (0)