From c123ac049dca63da15ce37183eb70f006b915d4b Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:58:23 +0530 Subject: [PATCH 1/3] display discussion name instead of id in composer --- .../MessageComposer/MessageComposer.test.tsx | 21 +++++++++++++++++++ .../MessageComposer/MessageComposer.tsx | 3 ++- .../components/ComposerInput.tsx | 19 ++++++++++++++++- app/containers/MessageComposer/interfaces.ts | 1 + 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx index 8a15b8a2472..e9bb7ca665a 100644 --- a/app/containers/MessageComposer/MessageComposer.test.tsx +++ b/app/containers/MessageComposer/MessageComposer.test.tsx @@ -572,6 +572,27 @@ 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(); + (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 user.press(screen.getByTestId('message-composer-send')); + expect(onSendMessage).toHaveBeenCalledTimes(1); + expect(onSendMessage).toHaveBeenCalledWith('#aBcD123xyz', 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 85055d43e45..39177a31217 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 a0547bab0a6..6986d22acb5 100644 --- a/app/containers/MessageComposer/components/ComposerInput.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.tsx @@ -59,6 +59,10 @@ export const ComposerInput = memo( const textRef = useRef(''); const firstRender = useRef(true); const selectionRef = useRef(defaultSelection); + // Displays the readable fname for discussion-style rooms, but the server only resolves + // channel mentions by the room `name`, so the composer translates the title back to it + // when sending. Keyed by the exact title text inserted into the composer. + const mentionRoomTokensRef = useRef>({}); const dispatch = useDispatch(); const isMasterDetail = useMasterDetail(); const altTextSupported = useAltTextSupported(); @@ -169,6 +173,14 @@ export const ComposerInput = memo( getSelection: () => selectionRef.current, setInput, onAutocompleteItemSelected, + resolveMentionRoomTokens: text => { + let resolved = text; + for (const [title, name] of Object.entries(mentionRoomTokensRef.current)) { + const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + resolved = resolved.replace(new RegExp(`#${escaped}(?=\\s|$)`, 'g'), `#${name}`); + } + return resolved; + }, focus })); @@ -278,7 +290,12 @@ export const ComposerInput = memo( mention = fetchIsAllOrHere(item) ? item.title : item.subtitle || item.title; break; case '#': - mention = item.subtitle ? item.subtitle : ''; + mention = item.title || item.subtitle || ''; + // Discussions carry an ID-like `name`; matching room name is in `subtitle`. + // Remember the pair so we can resolve the token on send. + if (item.subtitle && item.subtitle !== item.title) { + mentionRoomTokensRef.current[item.title] = item.subtitle; + } break; case ':': mention = `${typeof item.emoji === 'string' ? item.emoji : item.emoji.name}:`; diff --git a/app/containers/MessageComposer/interfaces.ts b/app/containers/MessageComposer/interfaces.ts index 9c4ec3c766f..cfdeaba7c4f 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; } From 58d6844e3d71c1bf3bc66dcc38370694403cb922 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:44:46 +0530 Subject: [PATCH 2/3] remove comment --- app/containers/MessageComposer/components/ComposerInput.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx index 6986d22acb5..257d0498f6b 100644 --- a/app/containers/MessageComposer/components/ComposerInput.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.tsx @@ -59,9 +59,6 @@ export const ComposerInput = memo( const textRef = useRef(''); const firstRender = useRef(true); const selectionRef = useRef(defaultSelection); - // Displays the readable fname for discussion-style rooms, but the server only resolves - // channel mentions by the room `name`, so the composer translates the title back to it - // when sending. Keyed by the exact title text inserted into the composer. const mentionRoomTokensRef = useRef>({}); const dispatch = useDispatch(); const isMasterDetail = useMasterDetail(); @@ -291,8 +288,6 @@ export const ComposerInput = memo( break; case '#': mention = item.title || item.subtitle || ''; - // Discussions carry an ID-like `name`; matching room name is in `subtitle`. - // Remember the pair so we can resolve the token on send. if (item.subtitle && item.subtitle !== item.title) { mentionRoomTokensRef.current[item.title] = item.subtitle; } From fb88be6c4bc1b22245276bd2b6bb31c864522f4a Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:21:23 +0530 Subject: [PATCH 3/3] coderabbit suggestions --- .../MessageComposer/MessageComposer.test.tsx | 156 +++++++++++++++++- .../components/ComposerInput.tsx | 36 +++- 2 files changed, 184 insertions(+), 8 deletions(-) diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx index e9bb7ca665a..b2cdcc40557 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([])) })) @@ -574,7 +576,10 @@ describe('MessageComposer', () => { test('select # discussion shows fname in composer but sends the room name so it resolves as a mention', async () => { const onSendMessage = jest.fn(); - (searchRemote as unknown as jest.Mock).mockImplementationOnce(() => [{ rid: 'r1', name: 'aBcD123xyz', fname: 'My Discussion', t: 'p' }]); + 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'); @@ -588,9 +593,158 @@ describe('MessageComposer', () => { 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 () => { diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx index 257d0498f6b..3e24411d488 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(); @@ -172,18 +174,30 @@ export const ComposerInput = memo( onAutocompleteItemSelected, resolveMentionRoomTokens: text => { let resolved = text; - for (const [title, name] of Object.entries(mentionRoomTokensRef.current)) { - const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - resolved = resolved.replace(new RegExp(`#${escaped}(?=\\s|$)`, 'g'), `#${name}`); + // 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(''); @@ -286,12 +300,20 @@ export const ComposerInput = memo( case '@': mention = fetchIsAllOrHere(item) ? item.title : item.subtitle || item.title; break; - case '#': - mention = item.title || item.subtitle || ''; - if (item.subtitle && item.subtitle !== item.title) { - mentionRoomTokensRef.current[item.title] = 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;