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; }