diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx
index 8a15b8a247..b2cdcc4055 100644
--- a/app/containers/MessageComposer/MessageComposer.test.tsx
+++ b/app/containers/MessageComposer/MessageComposer.test.tsx
@@ -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();
@@ -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([])) }))
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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);
+ });
+
test('select : emoji inserts emoji and sends, autocomplete hides', async () => {
const onSendMessage = jest.fn();
render();
diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx
index 85055d43e4..39177a3121 100644
--- a/app/containers/MessageComposer/MessageComposer.tsx
+++ b/app/containers/MessageComposer/MessageComposer.tsx
@@ -49,6 +49,7 @@ export const MessageComposer = ({
getSelection: () => ({ start: 0, end: 0 }),
setInput: () => {},
onAutocompleteItemSelected: () => {},
+ resolveMentionRoomTokens: (text: string) => text,
focus: () => {}
});
const contentHeight = useSharedValue(MIN_HEIGHT);
@@ -173,7 +174,7 @@ export const MessageComposer = ({
}
// Text message
- onSendMessage?.(textFromInput, alsoSendThreadToChannel);
+ onSendMessage?.(composerInputComponentRef.current.resolveMentionRoomTokens(textFromInput), alsoSendThreadToChannel);
};
const onKeyboardItemSelected = (eventType: EventTypes, emoji?: IEmoji) => {
diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx
index a0547bab0a..3e24411d48 100644
--- a/app/containers/MessageComposer/components/ComposerInput.tsx
+++ b/app/containers/MessageComposer/components/ComposerInput.tsx
@@ -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(({ inputRef }, ref) => {
const { colors, theme } = useTheme();
@@ -59,6 +61,7 @@ export const ComposerInput = memo(
const textRef = useRef('');
const firstRender = useRef(true);
const selectionRef = useRef(defaultSelection);
+ const mentionRoomTokensRef = useRef>({});
const dispatch = useDispatch();
const isMasterDetail = useMasterDetail();
const altTextSupported = useAltTextSupported();
@@ -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;
+ },
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('');
@@ -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;
diff --git a/app/containers/MessageComposer/interfaces.ts b/app/containers/MessageComposer/interfaces.ts
index 9c4ec3c766..cfdeaba7c4 100644
--- a/app/containers/MessageComposer/interfaces.ts
+++ b/app/containers/MessageComposer/interfaces.ts
@@ -28,6 +28,7 @@ export interface IComposerInput {
getSelection: () => IInputSelection;
setInput: TSetInput;
onAutocompleteItemSelected: (item: TAutocompleteItem) => void;
+ resolveMentionRoomTokens: (text: string) => string;
focus: () => void;
}