From daa2a56c8eb3cc476e1a706ee22568bf4841231c Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 3 Feb 2026 11:15:31 +0430 Subject: [PATCH 01/10] Fix workspace mentions not being recognized --- .../ReportActionCompose/SuggestionMention.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index e9fa8b630ed0..ecc9e724985c 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -392,23 +392,29 @@ function SuggestionMention({ prefix = lastWord.substring(1); } + // Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". + const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); + const normalizedPrefix = hasTrailingDot ? prefix.slice(0, -1) : prefix; + // Keep the raw prefix for highlight so dots are preserved in the UI. + const mentionPrefix = prefix; + const nextState: Partial = { suggestedMentions: [], atSignIndex, - mentionPrefix: prefix, + mentionPrefix, prefixType, }; if (isMentionCode(suggestionWord) && prefixType === '@') { - const suggestions = getUserMentionOptions(weightedPersonalDetails, prefix); + const suggestions = getUserMentionOptions(weightedPersonalDetails, normalizedPrefix); nextState.suggestedMentions = suggestions; nextState.shouldShowSuggestionMenu = !!suggestions.length; } - const shouldDisplayRoomMentionsSuggestions = isGroupPolicyReport && (isValidRoomName(suggestionWord.toLowerCase()) || prefix === ''); + const shouldDisplayRoomMentionsSuggestions = isGroupPolicyReport && (isValidRoomName(suggestionWord.toLowerCase()) || normalizedPrefix === ''); if (prefixType === '#' && shouldDisplayRoomMentionsSuggestions) { // Filter reports by room name and current policy - nextState.suggestedMentions = getRoomMentionOptions(prefix, reports); + nextState.suggestedMentions = getRoomMentionOptions(normalizedPrefix, reports); // Even if there are no reports, we should show the suggestion menu - to perform live search nextState.shouldShowSuggestionMenu = true; From 0e55b749365ff98391937c92c3f7f536ae9c1e35 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 9 Feb 2026 19:35:16 +0430 Subject: [PATCH 02/10] applied ai feedback to be aligned with expected behavior. --- .../report/ReportActionCompose/SuggestionMention.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index ecc9e724985c..a1506b0da625 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -205,11 +205,19 @@ function SuggestionMention({ const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); + let trailingDots = ''; + let mentionToReplace = originalMention; + if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { + const match = originalMention.match(/\.{1,}$/); + trailingDots = match?.[0] ?? ''; + mentionToReplace = originalMention.slice(0, originalMention.length - trailingDots.length); + } + const commentAfterMention = value.slice( - suggestionValues.atSignIndex + Math.max(originalMention.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), + suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), ); - updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterMention)}`, true); + updateComment(`${commentBeforeAtSign}${mentionCode}${trailingDots}${trimLeadingSpace(commentAfterMention)}`, true); const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH; setSelection({ start: selectionPosition, From a83bb8f0454859d37214ea641af38763f25f6466 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 17 Feb 2026 18:31:22 +0430 Subject: [PATCH 03/10] Fix mention autocomplete to preserve trailing dot punctuation --- src/CONST/index.ts | 1 + .../report/ReportActionCompose/SuggestionMention.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index af948b82587f..f75bc7220ffc 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4019,6 +4019,7 @@ const CONST = { SPECIAL_CHAR_MENTION_BREAKER: /[,/?"{}[\]()&^%;`$=<>!*]/g, SPECIAL_CHAR: /[,/?"{}[\]()&^%;`$=#<>!*]/g, FIRST_SPACE: /.+?(?=\s)/, + TRAILING_DOTS: /\.$/, get SPECIAL_CHAR_OR_EMOJI() { return new RegExp(`[~\\n\\s]|(_\\b(?!$))|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu'); diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index a1506b0da625..2839f3577483 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -205,19 +205,20 @@ function SuggestionMention({ const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); - let trailingDots = ''; + // We split trailing dot from the mention token so selecting `@a.` can become `@adam.` + // (preserve sentence punctuation) instead of consuming the `.` into the replacement. + let trailingDot = ''; let mentionToReplace = originalMention; if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { - const match = originalMention.match(/\.{1,}$/); - trailingDots = match?.[0] ?? ''; - mentionToReplace = originalMention.slice(0, originalMention.length - trailingDots.length); + trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; + mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); } const commentAfterMention = value.slice( suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), ); - updateComment(`${commentBeforeAtSign}${mentionCode}${trailingDots}${trimLeadingSpace(commentAfterMention)}`, true); + updateComment(`${commentBeforeAtSign}${mentionCode}${trailingDot}${trimLeadingSpace(commentAfterMention)}`, true); const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH; setSelection({ start: selectionPosition, From c8151af6dbf37520ac204f22cdd69127fdeafe33 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 18 Feb 2026 20:28:39 +0430 Subject: [PATCH 04/10] extract the suggestion mention logic into util --- .../ReportActionCompose/SuggestionMention.tsx | 51 ++++----------- .../SuggestionMentionUtils.ts | 62 +++++++++++++++++++ 2 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index 2839f3577483..d3a6cbb68161 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -19,13 +19,14 @@ import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getPolicyEmployeeAccountIDs} from '@libs/PolicyUtils'; import {canReportBeMentionedWithinPolicy, doesReportBelongToWorkspace, isGroupChat, isReportParticipant} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; -import {getSortedPersonalDetails, trimLeadingSpace} from '@libs/SuggestionUtils'; +import {getSortedPersonalDetails} from '@libs/SuggestionUtils'; import {isValidRoomName} from '@libs/ValidationUtils'; import {searchInServer} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, Report} from '@src/types/onyx'; import type {SuggestionProps} from './Suggestions'; +import {getNormalizedMentionPrefix, getUpdatedCommentWithInsertedMention} from './SuggestionMentionUtils'; type SuggestionValues = { suggestedMentions: Mention[]; @@ -174,51 +175,27 @@ function SuggestionMention({ [formatLoginPrivateDomain], ); - function getOriginalMentionText(inputValue: string, atSignIndex: number, whiteSpacesLength = 0) { - const rest = inputValue.slice(atSignIndex); - - // If the search string contains spaces, it's not a simple login/email mention. - // In that case, we need to replace all the words the user typed that are part of the mention. - // For example, if `rest` is "@Adam Chr and" and "@Adam Chris" is a valid mention, - // then `whiteSpacesLength` will be 1, and we should return "@Adam Chr". - // The length of this substring will then be used to replace the user's input with the full mention. - if (whiteSpacesLength) { - const str = rest.split(' ', whiteSpacesLength + 1).join(' '); - return rest.slice(0, str.length); - } - - const breakerIndex = rest.search(CONST.REGEX.MENTION_BREAKER); - return breakerIndex === -1 ? rest : rest.slice(0, breakerIndex); - } - /** * Replace the code of mention and update selection */ const insertSelectedMention = useCallback( (highlightedMentionIndexInner: number) => { - const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); const mentionObject = suggestionValues.suggestedMentions.at(highlightedMentionIndexInner); if (!mentionObject || highlightedMentionIndexInner === -1) { return; } const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); - const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); - - // We split trailing dot from the mention token so selecting `@a.` can become `@adam.` - // (preserve sentence punctuation) instead of consuming the `.` into the replacement. - let trailingDot = ''; - let mentionToReplace = originalMention; - if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { - trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; - mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); - } - - const commentAfterMention = value.slice( - suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), - ); + const updatedComment = getUpdatedCommentWithInsertedMention({ + value, + atSignIndex: suggestionValues.atSignIndex, + mentionPrefix: suggestionValues.mentionPrefix, + prefixType: suggestionValues.prefixType, + mentionCode, + whiteSpacesLength: StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix), + }); - updateComment(`${commentBeforeAtSign}${mentionCode}${trailingDot}${trimLeadingSpace(commentAfterMention)}`, true); + updateComment(updatedComment, true); const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH; setSelection({ start: selectionPosition, @@ -401,11 +378,7 @@ function SuggestionMention({ prefix = lastWord.substring(1); } - // Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". - const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); - const normalizedPrefix = hasTrailingDot ? prefix.slice(0, -1) : prefix; - // Keep the raw prefix for highlight so dots are preserved in the UI. - const mentionPrefix = prefix; + const {mentionPrefix, normalizedPrefix} = getNormalizedMentionPrefix(prefixType, prefix); const nextState: Partial = { suggestedMentions: [], diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts b/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts new file mode 100644 index 000000000000..7017f2b9ead1 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts @@ -0,0 +1,62 @@ +import {trimLeadingSpace} from '@libs/SuggestionUtils'; +import CONST from '@src/CONST'; + +type NormalizedMentionPrefix = { + mentionPrefix: string; + normalizedPrefix: string; +}; + +type UpdatedCommentWithInsertedMentionParams = { + value: string; + atSignIndex: number; + mentionPrefix: string; + prefixType: string; + mentionCode: string; + whiteSpacesLength?: number; +}; + +function getOriginalMentionText(inputValue: string, atSignIndex: number, whiteSpacesLength = 0) { + const rest = inputValue.slice(atSignIndex); + + if (whiteSpacesLength) { + const str = rest.split(' ', whiteSpacesLength + 1).join(' '); + return rest.slice(0, str.length); + } + + const breakerIndex = rest.search(CONST.REGEX.MENTION_BREAKER); + return breakerIndex === -1 ? rest : rest.slice(0, breakerIndex); +} + +function getNormalizedMentionPrefix(prefixType: string, prefix: string): NormalizedMentionPrefix { + const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); + + return { + mentionPrefix: prefix, + normalizedPrefix: hasTrailingDot ? prefix.slice(0, -1) : prefix, + }; +} + +function getUpdatedCommentWithInsertedMention({ + value, + atSignIndex, + mentionPrefix, + prefixType, + mentionCode, + whiteSpacesLength = 0, +}: UpdatedCommentWithInsertedMentionParams): string { + const commentBeforeAtSign = value.slice(0, atSignIndex); + const originalMention = getOriginalMentionText(value, atSignIndex, whiteSpacesLength); + + let trailingDot = ''; + let mentionToReplace = originalMention; + if (prefixType === '@' && mentionPrefix.endsWith('.')) { + trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; + mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); + } + + const commentAfterMention = value.slice(atSignIndex + Math.max(mentionToReplace.length, mentionPrefix.length + prefixType.length)); + + return `${commentBeforeAtSign}${mentionCode}${trailingDot}${trimLeadingSpace(commentAfterMention)}`; +} + +export {getNormalizedMentionPrefix, getUpdatedCommentWithInsertedMention}; From 6022bc46b6a5a853f60401976678fb84dddf8c74 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 18 Feb 2026 20:28:48 +0430 Subject: [PATCH 05/10] added until test --- tests/unit/SuggestionMentionUtilsTest.ts | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/unit/SuggestionMentionUtilsTest.ts diff --git a/tests/unit/SuggestionMentionUtilsTest.ts b/tests/unit/SuggestionMentionUtilsTest.ts new file mode 100644 index 000000000000..d7bb394fca42 --- /dev/null +++ b/tests/unit/SuggestionMentionUtilsTest.ts @@ -0,0 +1,49 @@ +import {getNormalizedMentionPrefix, getUpdatedCommentWithInsertedMention} from '@pages/inbox/report/ReportActionCompose/SuggestionMentionUtils'; + +describe('SuggestionMentionUtils', () => { + describe('getNormalizedMentionPrefix', () => { + it('keeps mention prefix for highlighting and normalizes trailing dot for @mentions', () => { + expect(getNormalizedMentionPrefix('@', 'a.')).toEqual({ + mentionPrefix: 'a.', + normalizedPrefix: 'a', + }); + }); + + it('does not normalize room mention prefixes', () => { + expect(getNormalizedMentionPrefix('#', 'room.')).toEqual({ + mentionPrefix: 'room.', + normalizedPrefix: 'room.', + }); + }); + }); + + describe('getUpdatedCommentWithInsertedMention', () => { + it('preserves sentence punctuation when replacing a mention ending with dot', () => { + const value = 'hello @a.'; + + expect( + getUpdatedCommentWithInsertedMention({ + value, + atSignIndex: value.indexOf('@'), + mentionPrefix: 'a.', + prefixType: '@', + mentionCode: '@adam', + }), + ).toBe('hello @adam.'); + }); + + it('does not add extra dots when replacing mention ending with multiple dots', () => { + const value = 'hello @a..'; + + expect( + getUpdatedCommentWithInsertedMention({ + value, + atSignIndex: value.indexOf('@'), + mentionPrefix: 'a..', + prefixType: '@', + mentionCode: '@adam', + }), + ).toBe('hello @adam.'); + }); + }); +}); From 749dd17cab1e598a41bba71377478e69c6233152 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 18 Feb 2026 20:46:32 +0430 Subject: [PATCH 06/10] fix prettier failure --- .../report/ReportActionCompose/SuggestionMentionUtils.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts b/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts index 7017f2b9ead1..66f67c0b5416 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts @@ -36,14 +36,7 @@ function getNormalizedMentionPrefix(prefixType: string, prefix: string): Normali }; } -function getUpdatedCommentWithInsertedMention({ - value, - atSignIndex, - mentionPrefix, - prefixType, - mentionCode, - whiteSpacesLength = 0, -}: UpdatedCommentWithInsertedMentionParams): string { +function getUpdatedCommentWithInsertedMention({value, atSignIndex, mentionPrefix, prefixType, mentionCode, whiteSpacesLength = 0}: UpdatedCommentWithInsertedMentionParams): string { const commentBeforeAtSign = value.slice(0, atSignIndex); const originalMention = getOriginalMentionText(value, atSignIndex, whiteSpacesLength); From 9b57923515fdf878962a1ca43cecdcc70cdcd010 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 20 Feb 2026 14:48:26 +0430 Subject: [PATCH 07/10] refactor: inline mention utils into SuggestionMention and remove obsolete tests --- .../ReportActionCompose/SuggestionMention.tsx | 51 +++++++++++++---- .../SuggestionMentionUtils.ts | 55 ------------------- tests/unit/SuggestionMentionUtilsTest.ts | 49 ----------------- 3 files changed, 39 insertions(+), 116 deletions(-) delete mode 100644 src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts delete mode 100644 tests/unit/SuggestionMentionUtilsTest.ts diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index d3a6cbb68161..2839f3577483 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -19,14 +19,13 @@ import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getPolicyEmployeeAccountIDs} from '@libs/PolicyUtils'; import {canReportBeMentionedWithinPolicy, doesReportBelongToWorkspace, isGroupChat, isReportParticipant} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; -import {getSortedPersonalDetails} from '@libs/SuggestionUtils'; +import {getSortedPersonalDetails, trimLeadingSpace} from '@libs/SuggestionUtils'; import {isValidRoomName} from '@libs/ValidationUtils'; import {searchInServer} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, Report} from '@src/types/onyx'; import type {SuggestionProps} from './Suggestions'; -import {getNormalizedMentionPrefix, getUpdatedCommentWithInsertedMention} from './SuggestionMentionUtils'; type SuggestionValues = { suggestedMentions: Mention[]; @@ -175,27 +174,51 @@ function SuggestionMention({ [formatLoginPrivateDomain], ); + function getOriginalMentionText(inputValue: string, atSignIndex: number, whiteSpacesLength = 0) { + const rest = inputValue.slice(atSignIndex); + + // If the search string contains spaces, it's not a simple login/email mention. + // In that case, we need to replace all the words the user typed that are part of the mention. + // For example, if `rest` is "@Adam Chr and" and "@Adam Chris" is a valid mention, + // then `whiteSpacesLength` will be 1, and we should return "@Adam Chr". + // The length of this substring will then be used to replace the user's input with the full mention. + if (whiteSpacesLength) { + const str = rest.split(' ', whiteSpacesLength + 1).join(' '); + return rest.slice(0, str.length); + } + + const breakerIndex = rest.search(CONST.REGEX.MENTION_BREAKER); + return breakerIndex === -1 ? rest : rest.slice(0, breakerIndex); + } + /** * Replace the code of mention and update selection */ const insertSelectedMention = useCallback( (highlightedMentionIndexInner: number) => { + const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); const mentionObject = suggestionValues.suggestedMentions.at(highlightedMentionIndexInner); if (!mentionObject || highlightedMentionIndexInner === -1) { return; } const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); - const updatedComment = getUpdatedCommentWithInsertedMention({ - value, - atSignIndex: suggestionValues.atSignIndex, - mentionPrefix: suggestionValues.mentionPrefix, - prefixType: suggestionValues.prefixType, - mentionCode, - whiteSpacesLength: StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix), - }); + const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); + + // We split trailing dot from the mention token so selecting `@a.` can become `@adam.` + // (preserve sentence punctuation) instead of consuming the `.` into the replacement. + let trailingDot = ''; + let mentionToReplace = originalMention; + if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { + trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; + mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); + } + + const commentAfterMention = value.slice( + suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), + ); - updateComment(updatedComment, true); + updateComment(`${commentBeforeAtSign}${mentionCode}${trailingDot}${trimLeadingSpace(commentAfterMention)}`, true); const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH; setSelection({ start: selectionPosition, @@ -378,7 +401,11 @@ function SuggestionMention({ prefix = lastWord.substring(1); } - const {mentionPrefix, normalizedPrefix} = getNormalizedMentionPrefix(prefixType, prefix); + // Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". + const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); + const normalizedPrefix = hasTrailingDot ? prefix.slice(0, -1) : prefix; + // Keep the raw prefix for highlight so dots are preserved in the UI. + const mentionPrefix = prefix; const nextState: Partial = { suggestedMentions: [], diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts b/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts deleted file mode 100644 index 66f67c0b5416..000000000000 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMentionUtils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import {trimLeadingSpace} from '@libs/SuggestionUtils'; -import CONST from '@src/CONST'; - -type NormalizedMentionPrefix = { - mentionPrefix: string; - normalizedPrefix: string; -}; - -type UpdatedCommentWithInsertedMentionParams = { - value: string; - atSignIndex: number; - mentionPrefix: string; - prefixType: string; - mentionCode: string; - whiteSpacesLength?: number; -}; - -function getOriginalMentionText(inputValue: string, atSignIndex: number, whiteSpacesLength = 0) { - const rest = inputValue.slice(atSignIndex); - - if (whiteSpacesLength) { - const str = rest.split(' ', whiteSpacesLength + 1).join(' '); - return rest.slice(0, str.length); - } - - const breakerIndex = rest.search(CONST.REGEX.MENTION_BREAKER); - return breakerIndex === -1 ? rest : rest.slice(0, breakerIndex); -} - -function getNormalizedMentionPrefix(prefixType: string, prefix: string): NormalizedMentionPrefix { - const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); - - return { - mentionPrefix: prefix, - normalizedPrefix: hasTrailingDot ? prefix.slice(0, -1) : prefix, - }; -} - -function getUpdatedCommentWithInsertedMention({value, atSignIndex, mentionPrefix, prefixType, mentionCode, whiteSpacesLength = 0}: UpdatedCommentWithInsertedMentionParams): string { - const commentBeforeAtSign = value.slice(0, atSignIndex); - const originalMention = getOriginalMentionText(value, atSignIndex, whiteSpacesLength); - - let trailingDot = ''; - let mentionToReplace = originalMention; - if (prefixType === '@' && mentionPrefix.endsWith('.')) { - trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; - mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); - } - - const commentAfterMention = value.slice(atSignIndex + Math.max(mentionToReplace.length, mentionPrefix.length + prefixType.length)); - - return `${commentBeforeAtSign}${mentionCode}${trailingDot}${trimLeadingSpace(commentAfterMention)}`; -} - -export {getNormalizedMentionPrefix, getUpdatedCommentWithInsertedMention}; diff --git a/tests/unit/SuggestionMentionUtilsTest.ts b/tests/unit/SuggestionMentionUtilsTest.ts deleted file mode 100644 index d7bb394fca42..000000000000 --- a/tests/unit/SuggestionMentionUtilsTest.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {getNormalizedMentionPrefix, getUpdatedCommentWithInsertedMention} from '@pages/inbox/report/ReportActionCompose/SuggestionMentionUtils'; - -describe('SuggestionMentionUtils', () => { - describe('getNormalizedMentionPrefix', () => { - it('keeps mention prefix for highlighting and normalizes trailing dot for @mentions', () => { - expect(getNormalizedMentionPrefix('@', 'a.')).toEqual({ - mentionPrefix: 'a.', - normalizedPrefix: 'a', - }); - }); - - it('does not normalize room mention prefixes', () => { - expect(getNormalizedMentionPrefix('#', 'room.')).toEqual({ - mentionPrefix: 'room.', - normalizedPrefix: 'room.', - }); - }); - }); - - describe('getUpdatedCommentWithInsertedMention', () => { - it('preserves sentence punctuation when replacing a mention ending with dot', () => { - const value = 'hello @a.'; - - expect( - getUpdatedCommentWithInsertedMention({ - value, - atSignIndex: value.indexOf('@'), - mentionPrefix: 'a.', - prefixType: '@', - mentionCode: '@adam', - }), - ).toBe('hello @adam.'); - }); - - it('does not add extra dots when replacing mention ending with multiple dots', () => { - const value = 'hello @a..'; - - expect( - getUpdatedCommentWithInsertedMention({ - value, - atSignIndex: value.indexOf('@'), - mentionPrefix: 'a..', - prefixType: '@', - mentionCode: '@adam', - }), - ).toBe('hello @adam.'); - }); - }); -}); From cb45af5d00e0496ea8530997977a8c94ef212de2 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 22 Feb 2026 17:07:13 +0430 Subject: [PATCH 08/10] Fix mention trailing-dot append logic for selected suggestions --- .../report/ReportActionCompose/SuggestionMention.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index 2839f3577483..37fd937e69cb 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -214,11 +214,17 @@ function SuggestionMention({ mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); } + // Append a preserved trailing dot only when it is sentence punctuation, not part of the selected mention match. + const dotToAppend = + trailingDot && ![mentionObject.text, mentionObject.alternateText].some((mentionText) => mentionText.toLowerCase().includes(suggestionValues.mentionPrefix.toLowerCase())) + ? trailingDot + : ''; + const commentAfterMention = value.slice( suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), ); - updateComment(`${commentBeforeAtSign}${mentionCode}${trailingDot}${trimLeadingSpace(commentAfterMention)}`, true); + updateComment(`${commentBeforeAtSign}${mentionCode}${dotToAppend}${trimLeadingSpace(commentAfterMention)}`, true); const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH; setSelection({ start: selectionPosition, From 2f61a031174a23dd262e96a370d26dc536ded748 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 22 Feb 2026 19:18:21 +0430 Subject: [PATCH 09/10] added unit test --- tests/unit/SuggestionMentionTest.tsx | 207 +++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 tests/unit/SuggestionMentionTest.tsx diff --git a/tests/unit/SuggestionMentionTest.tsx b/tests/unit/SuggestionMentionTest.tsx new file mode 100644 index 000000000000..d6f6045540d6 --- /dev/null +++ b/tests/unit/SuggestionMentionTest.tsx @@ -0,0 +1,207 @@ +import {act, render, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import type {UseOnyxResult} from 'react-native-onyx'; +import type {TextSelection} from '@components/Composer/types'; +import type {Mention} from '@components/MentionSuggestions'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebounce from '@hooks/useDebounce'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import SuggestionMention from '@pages/inbox/report/ReportActionCompose/SuggestionMention'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +type MentionSuggestionsProps = { + mentions: Mention[]; + prefix: string; + onSelect: (index: number) => void; +}; + +const mockMentionSuggestionsSpy = jest.fn(); +const mockSetHighlightedMentionIndex = jest.fn(); +const mockIcons = {Megaphone: 'megaphone', FallbackAvatar: 'fallback'}; +const mockLocalize = { + translate: (key: string) => key, + formatPhoneNumber: (value: string) => value, + localeCompare: (first: string, second: string) => first.localeCompare(second), +}; +const mockReports = {}; + +let mockPersonalDetails: PersonalDetailsList = {}; + +function createOnyxResult(value: NonNullable | undefined): UseOnyxResult { + return [value, {status: 'loaded'}]; +} + +jest.mock('@components/MentionSuggestions', () => { + const ReactLib = jest.requireActual('react'); + const module = { + default: (props: MentionSuggestionsProps) => { + mockMentionSuggestionsSpy(props); + return ReactLib.createElement('mock-mention-suggestions', props); + }, + }; + Object.defineProperty(module, '__esModule', {value: true}); + return module; +}); + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: jest.fn(), +})); + +jest.mock('@hooks/useArrowKeyFocusManager', () => jest.fn()); +jest.mock('@hooks/useCurrentReportID', () => ({ + useCurrentReportIDState: jest.fn(), +})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); +jest.mock('@hooks/useDebounce', () => jest.fn()); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(), +})); +jest.mock('@hooks/useLocalize', () => jest.fn()); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/usePolicy', () => jest.fn()); + +const mockUsePersonalDetails = jest.mocked(usePersonalDetails); +const mockUseArrowKeyFocusManager = jest.mocked(useArrowKeyFocusManager); +const mockUseCurrentReportIDState = jest.mocked(useCurrentReportIDState); +const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); +const mockUseDebounce = jest.mocked(useDebounce); +const mockUseMemoizedLazyExpensifyIcons = jest.mocked(useMemoizedLazyExpensifyIcons); +const mockUseLocalize = jest.mocked(useLocalize); +const mockUseOnyx = jest.mocked(useOnyx); +const mockUsePolicy = jest.mocked(usePolicy); + +function renderSuggestionMention(value: string, updateComment = jest.fn(), selection: TextSelection = {start: value.length, end: value.length}) { + const setSelection = jest.fn(); + + render( + {}} + isComposerFocused + isGroupPolicyReport={false} + policyID="policyID" + />, + ); + + return {setSelection, updateComment}; +} + +function getLastMentionSuggestionsProps(): MentionSuggestionsProps { + const {calls} = mockMentionSuggestionsSpy.mock; + const props = calls.at(-1)?.[0]; + if (!props) { + throw new Error('Expected mention suggestions props to be available'); + } + return props; +} + +describe('SuggestionMention', () => { + beforeEach(() => { + mockMentionSuggestionsSpy.mockClear(); + mockSetHighlightedMentionIndex.mockClear(); + mockPersonalDetails = {}; + + mockUsePersonalDetails.mockImplementation(() => mockPersonalDetails); + mockUseArrowKeyFocusManager.mockReturnValue([0, mockSetHighlightedMentionIndex, {current: null}]); + mockUseCurrentReportIDState.mockReturnValue({currentReportID: ''}); + mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 1, login: 'current@gmail.com'}); + mockUseDebounce.mockImplementation((callback) => { + const callbackRef = React.useRef(callback); + callbackRef.current = callback; + return React.useCallback((...args: unknown[]) => callbackRef.current(...args), []) as typeof callback; + }); + mockUseMemoizedLazyExpensifyIcons.mockImplementation((() => mockIcons) as unknown as typeof useMemoizedLazyExpensifyIcons); + mockUseLocalize.mockImplementation(() => mockLocalize as ReturnType); + mockUseOnyx.mockImplementation( + ((...args: Parameters) => { + const key = args[0]; + if (key === ONYXKEYS.COLLECTION.REPORT) { + return createOnyxResult(mockReports); + } + if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { + return createOnyxResult(''); + } + return createOnyxResult(undefined); + }) as typeof useOnyx, + ); + mockUsePolicy.mockReturnValue(undefined); + }); + + it('shows user mention suggestions when prefix has a trailing dot', async () => { + mockPersonalDetails = {}; + mockPersonalDetails[2] = { + accountID: 2, + login: 'adam@example.com', + firstName: 'Adam', + lastName: 'Tester', + }; + + renderSuggestionMention('@a.'); + + await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); + const {prefix, mentions} = getLastMentionSuggestionsProps(); + + expect(prefix).toBe('a.'); + expect(mentions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handle: 'adam@example.com', + alternateText: '@adam@example.com', + }), + ]), + ); + }); + + it('preserves trailing punctuation dot when selected mention does not include dotted prefix', async () => { + mockPersonalDetails = {}; + mockPersonalDetails[2] = { + accountID: 2, + login: 'adam@example.com', + firstName: 'Adam', + lastName: 'Tester', + }; + + const updateComment = jest.fn(); + const {setSelection} = renderSuggestionMention('@a.', updateComment); + + await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); + const {onSelect} = getLastMentionSuggestionsProps(); + + act(() => onSelect(0)); + + expect(updateComment).toHaveBeenCalledWith('@adam@example.com.', true); + expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18}); + }); + + it('does not append an extra trailing dot when selected mention already matches dotted prefix', async () => { + mockPersonalDetails = {}; + mockPersonalDetails[2] = { + accountID: 2, + login: 'a.smith@example.com', + firstName: 'Alice', + lastName: 'Smith', + }; + + const updateComment = jest.fn(); + const {setSelection} = renderSuggestionMention('@a.', updateComment); + + await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); + const {onSelect} = getLastMentionSuggestionsProps(); + + act(() => onSelect(0)); + + expect(updateComment).toHaveBeenCalledWith('@a.smith@example.com', true); + expect(setSelection).toHaveBeenCalledWith({start: 21, end: 21}); + }); +}); From 009b54969b08dac468d3a94e32a90e9fed4a9954 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 22 Feb 2026 19:19:59 +0430 Subject: [PATCH 10/10] fixed prettier --- tests/unit/SuggestionMentionTest.tsx | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/unit/SuggestionMentionTest.tsx b/tests/unit/SuggestionMentionTest.tsx index d6f6045540d6..d19981e6e92e 100644 --- a/tests/unit/SuggestionMentionTest.tsx +++ b/tests/unit/SuggestionMentionTest.tsx @@ -123,18 +123,16 @@ describe('SuggestionMention', () => { }); mockUseMemoizedLazyExpensifyIcons.mockImplementation((() => mockIcons) as unknown as typeof useMemoizedLazyExpensifyIcons); mockUseLocalize.mockImplementation(() => mockLocalize as ReturnType); - mockUseOnyx.mockImplementation( - ((...args: Parameters) => { - const key = args[0]; - if (key === ONYXKEYS.COLLECTION.REPORT) { - return createOnyxResult(mockReports); - } - if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { - return createOnyxResult(''); - } - return createOnyxResult(undefined); - }) as typeof useOnyx, - ); + mockUseOnyx.mockImplementation(((...args: Parameters) => { + const key = args[0]; + if (key === ONYXKEYS.COLLECTION.REPORT) { + return createOnyxResult(mockReports); + } + if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { + return createOnyxResult(''); + } + return createOnyxResult(undefined); + }) as typeof useOnyx); mockUsePolicy.mockReturnValue(undefined); });