Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ function ComposerBoxPopup<
>({ title, items, focused, select, renderItem = ({ item }: { item: T }) => <>{JSON.stringify(item)}</> }: ComposerBoxPopupProps<T>) {
const { t } = useTranslation();
const id = useId();
const composerBoxPopupRef = useRef<HTMLElement>(null);
const popupSizes = useContentBoxSize(composerBoxPopupRef);
const popupRef = useRef<HTMLElement>(null);
const popupSizes = useContentBoxSize(popupRef);

const variant = popupSizes && popupSizes.inlineSize < 480 ? 'small' : 'large';

Expand All @@ -52,6 +52,8 @@ function ComposerBoxPopup<
if (item.disabled) {
return t('Unavailable_in_encrypted_channels');
}

return undefined;
};

const itemsFlat = useMemo(
Expand Down Expand Up @@ -80,7 +82,7 @@ function ComposerBoxPopup<

return (
<Box position='relative'>
<Tile ref={composerBoxPopupRef} padding={0} role='menu' mbe={8} overflow='hidden' aria-labelledby={id} name='ComposerBoxPopup'>
<Tile ref={popupRef} padding={0} role='menu' mbe={8} overflow='hidden' aria-labelledby={id} name='ComposerBoxPopup'>
{title && (
<Box bg='tint' pi={16} pb={8} id={id}>
{title}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Optional } from '@rocket.chat/core-typings';
import type { ReactNode } from 'react';

export type ComposerPopupOption<T extends { _id: string; sort?: number } = { _id: string; sort?: number }> = {
Expand All @@ -17,6 +16,7 @@ export type ComposerPopupOption<T extends { _id: string; sort?: number } = { _id

matchSelectorRegex?: RegExp;
preview?: boolean;
enablePreviewQuery?: (filter: unknown) => boolean;

getValue: (item: T) => string;

Expand All @@ -25,7 +25,7 @@ export type ComposerPopupOption<T extends { _id: string; sort?: number } = { _id
};

export const createMessageBoxPopupConfig = <T extends { _id: string; sort?: number }>(
partial: Optional<ComposerPopupOption<T>, 'getValue'>,
partial: Omit<ComposerPopupOption<T>, 'getValue'> & Partial<Pick<ComposerPopupOption<T>, 'getValue'>>,
): ComposerPopupOption<T> => {
return {
blurOnSelectItem: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type EditableTextAdapter = {
textBeforeCaret(): string;
caret(): number;
replaceRange(text: string, start: number, end: number): void;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';

import { useComposerBoxPopup } from './useComposerBoxPopup';
import { createMessageBoxPopupConfig } from '../ComposerPopupOption';
import type { EditableTextAdapter } from '../editableTextAdapter';

const keys = { ENTER: 13, ESC: 27, ARROW_UP: 38, ARROW_DOWN: 40 };

type Item = { _id: string; username?: string };

const createComposer = (text: string, caret = text.length) => ({
substring: (start: number, end?: number) => text.substring(start, end),
selection: { start: caret, end: caret },
replaceText: jest.fn(),
});

const adapterFor = (composer: ReturnType<typeof createComposer>): EditableTextAdapter => ({
textBeforeCaret: () => composer.substring(0, composer.selection.start),
caret: () => composer.selection.start,
replaceRange: (text, start, end) => composer.replaceText(text, { start, end }),
});

const userOption = createMessageBoxPopupConfig<Item>({
trigger: '@',
getItemsFromLocal: async () => [
{ _id: 'u1', username: 'alice' },
{ _id: 'u2', username: 'bob' },
],
getItemsFromServer: async () => [],
getValue: (item) => item.username ?? '',
});

const emojiOption = createMessageBoxPopupConfig<Item>({
trigger: ':',
triggerLength: 2,
getItemsFromLocal: async (filter: string) => [{ _id: `:${filter}a:` }, { _id: `:${filter}b:` }],
getItemsFromServer: async () => [],
getValue: (item) => item._id.substring(1),
});

const commandOption = createMessageBoxPopupConfig<Item>({
trigger: '/',
triggerAnywhere: false,
getItemsFromLocal: async () => [{ _id: 'archive' }],
getItemsFromServer: async () => [],
getValue: (item) => item._id,
});

const options = [userOption, emojiOption, commandOption];

const setup = (text: string, caret = text.length) => {
const composer = createComposer(text, caret);
const node = document.createElement('textarea');

const { result } = renderHook(() => useComposerBoxPopup(options, adapterFor(composer)), {
wrapper: mockAppRoot().build(),
});
act(() => result.current.callbackRef(node));

const fire = (type: 'keyUp' | 'keyDown', which: number) => fireEvent[type](node, { which, keyCode: which });

return { result, composer, fire };
};

describe('useComposerBoxPopup (characterization)', () => {
it('opens no popup when the text has no trigger', () => {
const { result, fire } = setup('hello');
fire('keyUp', 0);
expect(result.current.option).toBeUndefined();
});

it('detects the `@` trigger and extracts the filter', () => {
const { result, fire } = setup('hi @al');
fire('keyUp', 0);
expect(result.current.option?.trigger).toBe('@');
expect(result.current.filter).toBe('al');
});

it('respects triggerLength: `:` alone does not open, `:ab` does', () => {
const closed = setup('x :');
closed.fire('keyUp', 0);
expect(closed.result.current.option).toBeUndefined();

const open = setup('x :ab');
open.fire('keyUp', 0);
expect(open.result.current.option?.trigger).toBe(':');
expect(open.result.current.filter).toBe('ab');
});

it('only triggers a start-only (`/`) option at the very start of the text', () => {
const mid = setup('hey /arch');
mid.fire('keyUp', 0);
expect(mid.result.current.option).toBeUndefined();

const start = setup('/arch');
start.fire('keyUp', 0);
expect(start.result.current.option?.trigger).toBe('/');
expect(start.result.current.filter).toBe('arch');
});

it('inserts prefix + value + suffix over the trigger range on select', async () => {
const { result, composer, fire } = setup('hi @al');
fire('keyUp', 0);
await waitFor(() => expect(result.current.focused).toBeDefined());

act(() => result.current.select?.({ _id: 'u1', username: 'alice' }));

expect(composer.replaceText).toHaveBeenCalledWith('@alice ', { start: 3, end: 6 });
});

it('moves focus with arrow keys and wraps around', async () => {
const { result, fire } = setup('hi @');
fire('keyUp', 0);
await waitFor(() => expect(result.current.focused?._id).toBe('u1'));

fire('keyDown', keys.ARROW_DOWN);
expect(result.current.focused?._id).toBe('u2');

fire('keyDown', keys.ARROW_DOWN);
expect(result.current.focused?._id).toBe('u1');

fire('keyDown', keys.ARROW_UP);
expect(result.current.focused?._id).toBe('u2');
});

it('selects the focused item on Enter', async () => {
const { result, composer, fire } = setup('hi @');
fire('keyUp', 0);
await waitFor(() => expect(result.current.focused?._id).toBe('u1'));

fire('keyDown', keys.ENTER);
expect(composer.replaceText).toHaveBeenCalledWith('@alice ', expect.objectContaining({ end: 4 }));
});

it('closes the popup on Escape', async () => {
const { result, fire } = setup('hi @al');
fire('keyUp', 0);
await waitFor(() => expect(result.current.option?.trigger).toBe('@'));

fire('keyUp', keys.ESC);
expect(result.current.option).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { MutableRefObject } from 'react';
import { useEffect, useCallback, useState, useRef } from 'react';

import { useComposerBoxPopupQueries } from './useComposerBoxPopupQueries';
import { useChat } from '../../../views/room/contexts/ChatContext';
import type { ComposerPopupOption } from '../ComposerPopupOption';
import type { EditableTextAdapter } from '../editableTextAdapter';

type ComposerBoxPopupImperativeCommands<T> = MutableRefObject<
| {
Expand All @@ -15,8 +15,6 @@ type ComposerBoxPopupImperativeCommands<T> = MutableRefObject<
| undefined
>;

type ComposerBoxPopupOptions<T extends { _id: string; sort?: number | undefined }> = ComposerPopupOption<T>;

type ComposerBoxPopupResult<T extends { _id: string; sort?: number }> =
| {
option: ComposerPopupOption<T>;
Expand Down Expand Up @@ -52,7 +50,8 @@ const keys = {
} as const;

export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(
options: ComposerBoxPopupOptions<T>[],
options: ComposerPopupOption<T>[],
editor: EditableTextAdapter | undefined,
): ComposerBoxPopupResult<T> => {
const [optionIndex, setOptionIndex] = useState<number>(-1);
const [focused, setFocused] = useState<T | undefined>(undefined);
Expand All @@ -67,8 +66,6 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(
suspended: boolean;
};

const chat = useChat();

useEffect(() => {
if (!option) {
return;
Expand All @@ -81,7 +78,7 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(
setFocused((focused) => {
const sortedItems = items
.filter((item) => item.isSuccess)
.flatMap((item) => item.data as T[])
.flatMap((item) => item.data)
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0));
return sortedItems.find((item) => item._id === focused?._id) ?? sortedItems[0];
});
Expand All @@ -95,27 +92,28 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(
if (commandsRef.current?.select) {
commandsRef.current.select(item);
} else {
const value = chat?.composer?.substring(0, chat?.composer?.selection.start);
const value = editor?.textBeforeCaret();
const selector =
option.matchSelectorRegex ??
(option.triggerAnywhere ? new RegExp(`(?:^| |\n)(${option.trigger})([^\\s]*$)`) : new RegExp(`(?:^)(${option.trigger})([^\\s]*$)`));

const result = value?.match(selector);
if (!result || !value) {
if (!result || !value || !editor) {
return;
}

chat?.composer?.replaceText((option.prefix ?? option.trigger ?? '') + option.getValue(item) + (option.suffix ?? ''), {
start: value.lastIndexOf(result[1] + result[2]),
end: chat?.composer?.selection.start,
});
editor.replaceRange(
(option.prefix ?? option.trigger ?? '') + option.getValue(item) + (option.suffix ?? ''),
value.lastIndexOf(result[1] + result[2]),
editor.caret(),
);
}
setOptionIndex(-1);
setFocused(undefined);
});

const setOptionByInput = useStableCallback((): ComposerBoxPopupOptions<T> | undefined => {
const value = chat?.composer?.substring(0, chat?.composer?.selection.start);
const setOptionByInput = useStableCallback((): ComposerPopupOption<T> | undefined => {
const value = editor?.textBeforeCaret();

if (!value) {
setOptionIndex(-1);
Expand Down Expand Up @@ -194,7 +192,7 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(
setFocused((focused) => {
const list = items
.filter((item) => item.isSuccess)
.flatMap((item) => item.data as T[])
.flatMap((item) => item.data)
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0));

if (!list) {
Expand All @@ -203,7 +201,7 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(

const focusedIndex = list.findIndex((item) => item === focused);

return (focusedIndex > 0 ? list[focusedIndex - 1] : list[list.length - 1]) as T;
return focusedIndex > 0 ? list[focusedIndex - 1] : list[list.length - 1];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
event.preventDefault();
event.stopImmediatePropagation();
Expand All @@ -213,7 +211,7 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(
setFocused((focused) => {
const list = items
.filter((item) => item.isSuccess)
.flatMap((item) => item.data as T[])
.flatMap((item) => item.data)
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0));

if (!list) {
Expand All @@ -222,12 +220,14 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(

const focusedIndex = list.findIndex((item) => item === focused);

return (focusedIndex < list.length - 1 ? list[focusedIndex + 1] : list[0]) as T;
return focusedIndex < list.length - 1 ? list[focusedIndex + 1] : list[0];
});
event.preventDefault();
event.stopImmediatePropagation();
return true;
}

return undefined;
});

const clear = useStableCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { QueriesResults } from '@tanstack/react-query';
import { keepPreviousData, useQueries } from '@tanstack/react-query';
import { useEffect, useState } from 'react';

import { useEnablePopupPreview } from './useEnablePopupPreview';
import { slashCommands } from '../../../../app/utils/client/slashCommand';
import type { ComposerPopupOption } from '../ComposerPopupOption';

export const useComposerBoxPopupQueries = <T extends { _id: string; sort?: number }>(filter: unknown, popup?: ComposerPopupOption<T>) => {
Expand All @@ -15,29 +13,24 @@ export const useComposerBoxPopupQueries = <T extends { _id: string; sort?: numbe

const shouldPopupPreview = useEnablePopupPreview(filter, popup);

const enableQuery =
!popup ||
(popup.preview &&
Boolean(slashCommands.commands[(filter as any)?.cmd]) &&
slashCommands.commands[(filter as any)?.cmd].providesPreview) ||
shouldPopupPreview;
const enableQuery = !popup || (popup.preview && (popup.enablePreviewQuery?.(filter) ?? false)) || shouldPopupPreview;

const queries = useQueries({
queries: [
{
placeholderData: keepPreviousData,
queryKey: ['message-popup', 'local', filter, popup],
queryFn: () => (popup?.getItemsFromLocal && popup.getItemsFromLocal(filter)) || [],
queryFn: () => popup?.getItemsFromLocal?.(filter) || [],
enabled: enableQuery,
},
{
placeholderData: keepPreviousData,
queryKey: ['message-popup', 'server', filter, popup],
queryFn: () => (popup?.getItemsFromServer && popup.getItemsFromServer(filter)) || [],
queryFn: () => popup?.getItemsFromServer?.(filter) || [],
enabled: counter > 0,
},
],
}) as QueriesResults<T[]>;
});

useEffect(() => {
if (Array.isArray(queries[0].data) && queries[0].data.length < 5) {
Expand Down
5 changes: 5 additions & 0 deletions apps/meteor/client/components/AutocompletePopup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { default as ComposerBoxPopup, type ComposerBoxPopupProps } from './ComposerBoxPopup';
export { type ComposerPopupOption, createMessageBoxPopupConfig } from './ComposerPopupOption';
export { type EditableTextAdapter } from './editableTextAdapter';
export { useComposerBoxPopup } from './hooks/useComposerBoxPopup';
export { useEnablePopupPreview } from './hooks/useEnablePopupPreview';
Loading
Loading