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;