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
175 changes: 175 additions & 0 deletions app/containers/MessageComposer/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import database from '../../lib/database';
import { useMessageComposerApi } from './context';
import { sendFileMessage } from '../../lib/methods/sendFileMessage';
import { runSlashCommand } from '../../lib/services/restApi';
import * as draftMessage from '../../lib/methods/draftMessage';

jest.useFakeTimers();

Expand Down Expand Up @@ -190,6 +191,7 @@ beforeEach(() => {
showEmojiKeyboard = false;
showEmojiSearchbar = false;
(runSlashCommand as jest.Mock).mockClear();
(searchRemote as unknown as jest.Mock).mockReset().mockImplementation(() => [{ _id: 'u1', username: 'john', name: 'John' }]);
// Default DB mocks used by autocomplete
(database.active.get as unknown as jest.Mock).mockImplementation(() => ({
query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) }))
Expand Down Expand Up @@ -572,6 +574,179 @@ describe('MessageComposer', () => {
expect(onSendMessage).toHaveBeenCalledWith('#general', false);
});

test('select # discussion shows fname in composer but sends the room name so it resolves as a mention', async () => {
const onSendMessage = jest.fn();
const saveDraftMessage = jest.spyOn(draftMessage, 'saveDraftMessage').mockResolvedValue(undefined);
(searchRemote as unknown as jest.Mock).mockImplementationOnce(() => [
{ rid: 'r1', name: 'aBcD123xyz', fname: 'My Discussion', t: 'p' }
]);
render(<Render context={{ onSendMessage }} />);

await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 1, end: 1 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getByTestId('autocomplete-item-My Discussion')).toBeOnTheScreen());

await user.press(screen.getByTestId('autocomplete-item-My Discussion'));
await waitFor(() => expect(screen.queryByTestId('autocomplete')).not.toBeOnTheScreen());

await advanceComposerTimers(3000);
await waitFor(() =>
expect(saveDraftMessage).toHaveBeenCalledWith(expect.objectContaining({ draftMessage: '#My Discussion' }))
);

await user.press(screen.getByTestId('message-composer-send'));
expect(onSendMessage).toHaveBeenCalledTimes(1);
expect(onSendMessage).toHaveBeenCalledWith('#aBcD123xyz', false);
saveDraftMessage.mockRestore();
});

test('a discussion token is consumed on send and does not rewrite a later message', async () => {
const onSendMessage = jest.fn();
(searchRemote as unknown as jest.Mock).mockImplementationOnce(() => [
{ rid: 'r1', name: 'aBcD123xyz', fname: 'My Discussion', t: 'p' }
]);
render(<Render context={{ onSendMessage }} />);

await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 1, end: 1 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getByTestId('autocomplete-item-My Discussion')).toBeOnTheScreen());

await user.press(screen.getByTestId('autocomplete-item-My Discussion'));
await user.press(screen.getByTestId('message-composer-send'));
expect(onSendMessage).toHaveBeenLastCalledWith('#aBcD123xyz', false);

await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#My Discussion');
await user.press(screen.getByTestId('message-composer-send'));
expect(onSendMessage).toHaveBeenLastCalledWith('#My Discussion', false);
});

test('a token is dropped once its mention is deleted from the composer', async () => {
const onSendMessage = jest.fn();
(searchRemote as unknown as jest.Mock).mockImplementationOnce(() => [
{ rid: 'r1', name: 'aBcD123xyz', fname: 'My Discussion', t: 'p' }
]);
render(<Render context={{ onSendMessage }} />);

await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 1, end: 1 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getByTestId('autocomplete-item-My Discussion')).toBeOnTheScreen());

await user.press(screen.getByTestId('autocomplete-item-My Discussion'));

await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'hello');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'see #My Discussion later');
await user.press(screen.getByTestId('message-composer-send'));
expect(onSendMessage).toHaveBeenCalledWith('see #My Discussion later', false);
});

test('a room whose name is its title clears a token left by a same named discussion', async () => {
const onSendMessage = jest.fn();
const rooms = [
{ rid: 'r1', name: 'general-xyz', fname: 'general', t: 'p' },
{ rid: 'r2', name: 'general', t: 'c' }
];
(searchRemote as unknown as jest.Mock).mockImplementation(() => rooms);
render(<Render context={{ onSendMessage }} />);

await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 1, end: 1 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getAllByTestId('autocomplete-item-general')).toHaveLength(2));

await user.press(screen.getAllByTestId('autocomplete-item-general')[0]);
await advanceComposerTimers(100);
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#general #');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 10, end: 10 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getAllByTestId('autocomplete-item-general')).toHaveLength(2));
await user.press(screen.getAllByTestId('autocomplete-item-general')[1]);

await user.press(screen.getByTestId('message-composer-send'));
expect(onSendMessage).toHaveBeenCalledWith('#general #general', false);
});

test('a longer discussion title is resolved before a shorter one that prefixes it', async () => {
const onSendMessage = jest.fn();
const rooms = [
{ rid: 'r1', name: 'foo-1', fname: 'foo', t: 'p' },
{ rid: 'r2', name: 'foobar-2', fname: 'foo bar', t: 'p' }
];
(searchRemote as unknown as jest.Mock).mockImplementation(() => rooms);
render(<Render context={{ onSendMessage }} />);

await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 1, end: 1 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getByTestId('autocomplete-item-foo')).toBeOnTheScreen());

await user.press(screen.getByTestId('autocomplete-item-foo'));
await advanceComposerTimers(100);
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#foo #');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 6, end: 6 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getByTestId('autocomplete-item-foo bar')).toBeOnTheScreen());
await user.press(screen.getByTestId('autocomplete-item-foo bar'));

await user.press(screen.getByTestId('message-composer-send'));
expect(onSendMessage).toHaveBeenCalledWith('#foo-1 #foobar-2', false);
});

test('a second room sharing a display name is inserted by its real name so neither mention is ambiguous', async () => {
const onSendMessage = jest.fn();
const rooms = [
{ rid: 'r1', name: 'standup-1', fname: 'Standup', t: 'p' },
{ rid: 'r2', name: 'standup-2', fname: 'Standup', t: 'p' }
];
(searchRemote as unknown as jest.Mock).mockImplementation(() => rooms);
render(<Render context={{ onSendMessage }} />);

await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 1, end: 1 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getAllByTestId('autocomplete-item-Standup')).toHaveLength(2));

await user.press(screen.getAllByTestId('autocomplete-item-Standup')[0]);
await advanceComposerTimers(100);
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
await fireEvent.changeText(screen.getByTestId('message-composer-input'), '#Standup #');
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
nativeEvent: { selection: { start: 10, end: 10 } }
});
await advanceComposerTimers();
await waitFor(() => expect(screen.getAllByTestId('autocomplete-item-Standup')).toHaveLength(2));
await user.press(screen.getAllByTestId('autocomplete-item-Standup')[1]);

await user.press(screen.getByTestId('message-composer-send'));
expect(onSendMessage).toHaveBeenCalledWith('#standup-1 #standup-2', false);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('select : emoji inserts emoji and sends, autocomplete hides', async () => {
const onSendMessage = jest.fn();
render(<Render context={{ onSendMessage }} />);
Expand Down
3 changes: 2 additions & 1 deletion app/containers/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const MessageComposer = ({
getSelection: () => ({ start: 0, end: 0 }),
setInput: () => {},
onAutocompleteItemSelected: () => {},
resolveMentionRoomTokens: (text: string) => text,
focus: () => {}
});
const contentHeight = useSharedValue(MIN_HEIGHT);
Expand Down Expand Up @@ -173,7 +174,7 @@ export const MessageComposer = ({
}

// Text message
onSendMessage?.(textFromInput, alsoSendThreadToChannel);
onSendMessage?.(composerInputComponentRef.current.resolveMentionRoomTokens(textFromInput), alsoSendThreadToChannel);
};

const onKeyboardItemSelected = (eventType: EventTypes, emoji?: IEmoji) => {
Expand Down
38 changes: 36 additions & 2 deletions app/containers/MessageComposer/components/ComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import { isExternalKeyboardConnected } from '../../../lib/methods/helpers/extern

const defaultSelection: IInputSelection = { start: 0, end: 0 };

const getMentionRoomTokenRegexp = (title: string) => new RegExp(`#${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|$)`, 'g');

export const ComposerInput = memo(
forwardRef<IComposerInput, IComposerInputProps>(({ inputRef }, ref) => {
const { colors, theme } = useTheme();
Expand All @@ -59,6 +61,7 @@ export const ComposerInput = memo(
const textRef = useRef('');
const firstRender = useRef(true);
const selectionRef = useRef<IInputSelection>(defaultSelection);
const mentionRoomTokensRef = useRef<Record<string, string>>({});
const dispatch = useDispatch();
const isMasterDetail = useMasterDetail();
const altTextSupported = useAltTextSupported();
Expand Down Expand Up @@ -169,12 +172,32 @@ export const ComposerInput = memo(
getSelection: () => selectionRef.current,
setInput,
onAutocompleteItemSelected,
resolveMentionRoomTokens: text => {
let resolved = text;
// longest title first, otherwise a token for `#foo` would consume the `#foo` in `#foo bar`
const tokens = Object.entries(mentionRoomTokensRef.current).sort(([a], [b]) => b.length - a.length);
for (const [title, name] of tokens) {
resolved = resolved.replace(getMentionRoomTokenRegexp(title), `#${name}`);
}
mentionRoomTokensRef.current = {};
return resolved;
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
focus
}));

const pruneMentionRoomTokens = (text: string) => {
if (!text) return;
for (const title of Object.keys(mentionRoomTokensRef.current)) {
if (!getMentionRoomTokenRegexp(title).test(text)) {
delete mentionRoomTokensRef.current[title];
}
}
};

const setInput: TSetInput = (text, selection, forceUpdateDraftMessage) => {
const message = text.trim();
textRef.current = message;
pruneMentionRoomTokens(message);

if (forceUpdateDraftMessage) {
saveMessageDraft('');
Expand Down Expand Up @@ -277,9 +300,20 @@ export const ComposerInput = memo(
case '@':
mention = fetchIsAllOrHere(item) ? item.title : item.subtitle || item.title;
break;
case '#':
mention = item.subtitle ? item.subtitle : '';
case '#': {
const title = item.title || item.subtitle || '';
const claimed = mentionRoomTokensRef.current[title];
if (!item.subtitle || item.subtitle === title) {
mention = title;
delete mentionRoomTokensRef.current[title];
} else if (claimed && claimed !== item.subtitle) {
mention = item.subtitle;
} else {
mention = title;
mentionRoomTokensRef.current[title] = item.subtitle;
}
break;
}
case ':':
mention = `${typeof item.emoji === 'string' ? item.emoji : item.emoji.name}:`;
break;
Expand Down
1 change: 1 addition & 0 deletions app/containers/MessageComposer/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface IComposerInput {
getSelection: () => IInputSelection;
setInput: TSetInput;
onAutocompleteItemSelected: (item: TAutocompleteItem) => void;
resolveMentionRoomTokens: (text: string) => string;
focus: () => void;
}

Expand Down
Loading