From 4688d4ee337074dafbb2be83dbc5de5b9fb06fe3 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Tue, 1 Jul 2025 11:22:22 +0200 Subject: [PATCH 1/7] Fix parsing short mentions when sending message to backend --- src/CONST/index.ts | 1 + .../HTMLRenderers/ShortMentionRenderer.tsx | 7 +- src/components/RNMarkdownTextInput.tsx | 8 +- src/hooks/useShortMentionsList.ts | 10 +-- src/libs/ParsingUtils.ts | 83 ++++++++++++++++++- src/libs/ReportUtils.ts | 72 +++++----------- src/libs/actions/Report.ts | 18 ++-- src/pages/home/report/ReportFooter.tsx | 10 ++- tests/unit/libs/ParsingUtilsTest.ts | 77 +++++++++++++++-- 9 files changed, 207 insertions(+), 79 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 353d210eaf7d4..ca22ec45d9578 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3471,6 +3471,7 @@ const CONST = { `(?(.*?)<\/mention-short>/g, REPORT_ID_FROM_PATH: /(?) { - const {mentionsList, currentUserMentions} = useShortMentionsList(); + const {availableLoginsList, currentUserMentions} = useShortMentionsList(); const mentionValue = 'data' in props.tnode ? props.tnode.data.replace(CONST.UNICODE.LTR, '') : ''; + const mentionLogin = mentionValue.substring(1); - if (currentUserMentions?.includes(mentionValue)) { + if (currentUserMentions?.includes(mentionLogin)) { // eslint-disable-next-line react/jsx-props-no-spreading return ; } - if (mentionsList.includes(mentionValue)) { + if (availableLoginsList.includes(mentionLogin)) { // eslint-disable-next-line react/jsx-props-no-spreading return ; } diff --git a/src/components/RNMarkdownTextInput.tsx b/src/components/RNMarkdownTextInput.tsx index 19179d4332b2d..af37a07435746 100644 --- a/src/components/RNMarkdownTextInput.tsx +++ b/src/components/RNMarkdownTextInput.tsx @@ -23,8 +23,8 @@ type RNMarkdownTextInputWithRefProps = Omit & function RNMarkdownTextInputWithRef({maxLength, parser, ...props}: RNMarkdownTextInputWithRefProps, ref: ForwardedRef) { const theme = useTheme(); - const {mentionsList, currentUserMentions} = useShortMentionsList(); - const mentionsSharedVal = useSharedValue(mentionsList); + const {availableLoginsList, currentUserMentions} = useShortMentionsList(); + const mentionsSharedVal = useSharedValue(availableLoginsList); const inputRef = useRef(null); // Expose the ref to the parent component @@ -63,9 +63,9 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ...props}: RNMarkdownTex runOnLiveMarkdownRuntime(() => { 'worklet'; - mentionsSharedVal.set(mentionsList); + mentionsSharedVal.set(availableLoginsList); })(); - }, [mentionsList, mentionsSharedVal]); + }, [availableLoginsList, mentionsSharedVal]); return ( `@${mention}`; - /** * This hook returns data to be used with short mentions in LiveMarkdown/Composer. * Short mentions have the format `@username`, where username is the first part of user's login (email). @@ -15,7 +13,7 @@ export default function useShortMentionsList() { const personalDetails = usePersonalDetails(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const mentionsList = useMemo(() => { + const availableLoginsList = useMemo(() => { if (!personalDetails) { return []; } @@ -32,7 +30,7 @@ export default function useShortMentionsList() { } const [username] = personalDetail.login.split('@'); - return username ? getMention(username) : undefined; + return username; }) .filter((login): login is string => !!login); }, [currentUserPersonalDetails.login, personalDetails]); @@ -44,8 +42,8 @@ export default function useShortMentionsList() { } const [baseName] = currentUserPersonalDetails.login.split('@'); - return [baseName, currentUserPersonalDetails.login].map(getMention); + return [baseName, currentUserPersonalDetails.login]; }, [currentUserPersonalDetails.login]); - return {mentionsList, currentUserMentions}; + return {availableLoginsList, currentUserMentions}; } diff --git a/src/libs/ParsingUtils.ts b/src/libs/ParsingUtils.ts index c85dc5cd1fb1d..59782e66da618 100644 --- a/src/libs/ParsingUtils.ts +++ b/src/libs/ParsingUtils.ts @@ -1,5 +1,11 @@ import type {MarkdownRange} from '@expensify/react-native-live-markdown'; import {parseExpensiMark} from '@expensify/react-native-live-markdown'; +import {Str} from 'expensify-common'; +import type {Extras} from 'expensify-common/dist/ExpensiMark'; +import {unescapeText} from 'expensify-common/dist/utils'; +import CONST from '@src/CONST'; +import Parser from './Parser'; +import {addSMSDomainIfPhoneNumber} from './PhoneNumber'; /** * Handles possible short mentions inside ranges by verifying if the specific range refers to a user mention/login @@ -12,7 +18,8 @@ function decorateRangesWithShortMentions(ranges: MarkdownRange[], text: string, return ranges .map((range) => { if (range.type === 'mention-short') { - const mentionValue = text.slice(range.start, range.start + range.length); + // +1 because we want to skip `@` character from the mention value - ex: @mateusz -> mateusz + const mentionValue = text.slice(range.start + 1, range.start + range.length); if (currentUserMentions?.includes(mentionValue)) { return { @@ -34,7 +41,7 @@ function decorateRangesWithShortMentions(ranges: MarkdownRange[], text: string, // Iterate over full mentions and see if any is a self mention if (range.type === 'mention-user') { - const mentionValue = text.slice(range.start, range.start + range.length); + const mentionValue = text.slice(range.start + 1, range.start + range.length); if (currentUserMentions?.includes(mentionValue)) { return { @@ -55,4 +62,74 @@ function parseExpensiMarkWithShortMentions(text: string, availableMentions: stri return decorateRangesWithShortMentions(parsedRanges, text, availableMentions, currentUserMentions); } -export {parseExpensiMarkWithShortMentions, decorateRangesWithShortMentions}; +/** + * Adds a domain to a short mention, converting it into a full mention with email or SMS domain. + * @returns The converted mention as a full mention string or undefined if conversion is not applicable. + */ +function addDomainToShortMention(mention: string, availableMentionLogins: string[], userPrivateDomain?: string): string | undefined { + if (!Str.isValidEmail(mention) && userPrivateDomain) { + const mentionWithEmailDomain = `${mention}@${userPrivateDomain}`; + if (availableMentionLogins.includes(mentionWithEmailDomain)) { + return mentionWithEmailDomain; + } + } + if (Str.isValidE164Phone(mention)) { + const mentionWithSmsDomain = addSMSDomainIfPhoneNumber(mention); + if (availableMentionLogins.includes(mentionWithSmsDomain)) { + return mentionWithSmsDomain; + } + } + return undefined; +} + +/** + * This function receives raw text of the message, parses it with ExpensiMark, then transforms short-mentions + * into full mentions by adding a user domain to them. + * It returns a message text that can be safely sent to backend, with mentions handled. + * + * Detailed info: + * The backend allows only 2 kinds of mention tags: and . + * However, ExpensiMark can also produce a special `` tag, which is just the @login part of a full user login. + * This is handled inside `react-native-live-markdown` with a special function `parseExpensiMark` and then processed with `decorateRangesWithShortMentions`. + * However, we cannot use `parseExpensiMark` for the text that is being sent to backend, as we need html mention tags. + * This function is the missing piece that will use ExpensiMark for parsing, but and then strip+transform `mention-short` into full mentions. + */ +function getParsedMessageWithShortMentions({ + text, + availableMentionLogins, + userEmailDomain, + parserOptions, +}: { + text: string; + availableMentionLogins: string[]; + userEmailDomain?: string; + parserOptions: { + disabledRules?: string[]; + extras?: Extras; + }; +}) { + const parsedText = Parser.replace(text, { + shouldEscapeText: true, + disabledRules: parserOptions.disabledRules, + extras: parserOptions.extras, + }); + + const textWithHandledMentions = parsedText.replace(CONST.REGEX.SHORT_MENTION_HTML, (fullMatch, group1) => { + // Casting here is safe since our logic guarantees that if regex matches we will get group1 as non-empty string + const shortMention = group1 as string; + if (!Str.isValidMention(shortMention)) { + return shortMention; + } + + const loginPart = shortMention.substring(1); + const mentionWithDomain = addDomainToShortMention(loginPart, availableMentionLogins, userEmailDomain); + return mentionWithDomain ? `@${mentionWithDomain}` : shortMention; + }); + + // Because in the call to `Parser.replace` we escape the text, we need to unescape it + const cleanedText = unescapeText(textWithHandledMentions); + + return cleanedText; +} + +export {parseExpensiMarkWithShortMentions, decorateRangesWithShortMentions, addDomainToShortMention, getParsedMessageWithShortMentions}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a2647a422bb4f..8b93e679b4301 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -99,6 +99,7 @@ import {linkingConfig} from './Navigation/linkingConfig'; import Navigation, {navigationRef} from './Navigation/Navigation'; import {rand64} from './NumberUtils'; import Parser from './Parser'; +import {getParsedMessageWithShortMentions} from './ParsingUtils'; import Permissions from './Permissions'; import { getAccountIDsByLogins, @@ -110,7 +111,6 @@ import { getPersonalDetailsByIDs, getShortMentionIfFound, } from './PersonalDetailsUtils'; -import {addSMSDomainIfPhoneNumber} from './PhoneNumber'; import { arePaymentsEnabled, canSendInvoiceFromWorkspace, @@ -814,6 +814,11 @@ type OutstandingChildRequest = { }; type ParsingDetails = { + /** + * this param is deprecated + * Currently because of the way we parse short mentions we shouldn't ever NOT escape text - it will affect what mentions get parsed + * This should be removed after https://github.com/Expensify/App/issues/50724 as a followup + */ shouldEscapeText?: boolean; reportID?: string; policyID?: string; @@ -5495,44 +5500,6 @@ function hasReportNameError(report: OnyxEntry): boolean { return !isEmptyObject(report?.errorFields?.reportName); } -/** - * Adds a domain to a short mention, converting it into a full mention with email or SMS domain. - * @param mention The user mention to be converted. - * @returns The converted mention as a full mention string or undefined if conversion is not applicable. - */ -function addDomainToShortMention(mention: string): string | undefined { - if (!Str.isValidEmail(mention) && currentUserPrivateDomain) { - const mentionWithEmailDomain = `${mention}@${currentUserPrivateDomain}`; - if (allPersonalDetailLogins.includes(mentionWithEmailDomain)) { - return mentionWithEmailDomain; - } - } - if (Str.isValidE164Phone(mention)) { - const mentionWithSmsDomain = addSMSDomainIfPhoneNumber(mention); - if (allPersonalDetailLogins.includes(mentionWithSmsDomain)) { - return mentionWithSmsDomain; - } - } - return undefined; -} - -/** - * Replaces all valid short mention found in a text to a full mention - * - * Example: - * "Hello \@example -> Hello \@example\@expensify.com" - */ -function completeShortMention(text: string): string { - return text.replace(CONST.REGEX.SHORT_MENTION, (match) => { - if (!Str.isValidMention(match)) { - return match; - } - const mention = match.substring(1); - const mentionWithDomain = addDomainToShortMention(mention); - return mentionWithDomain ? `@${mentionWithDomain}` : match; - }); -} - /** * For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database * For longer comments, skip parsing, but still escape the text, and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! @@ -5553,16 +5520,21 @@ function getParsedComment(text: string, parsingDetails?: ParsingDetails, mediaAt } } - const textWithMention = completeShortMention(text); const rules = disabledRules ?? []; - return text.length <= CONST.MAX_MARKUP_LENGTH - ? Parser.replace(textWithMention, { - shouldEscapeText: parsingDetails?.shouldEscapeText, - disabledRules: isGroupPolicyReport ? [...rules] : ['reportMentions', ...rules], - extras: {mediaAttributeCache: mediaAttributes}, - }) - : lodashEscape(text); + if (text.length > CONST.MAX_MARKUP_LENGTH) { + return lodashEscape(text); + } + + return getParsedMessageWithShortMentions({ + text, + availableMentionLogins: allPersonalDetailLogins, + userEmailDomain: currentUserPrivateDomain, + parserOptions: { + disabledRules: isGroupPolicyReport ? [...rules] : ['reportMentions', ...rules], + extras: {mediaAttributeCache: mediaAttributes}, + }, + }); } function getUploadingAttachmentHtml(file?: FileObject): string { @@ -5613,6 +5585,10 @@ function getPolicyDescriptionText(policy: OnyxEntry): string { return Parser.htmlToText(policy.description); } +/** + * Fixme the `shouldEscapeText` arg is never used (it's always set to undefined) + * it should be removed after https://github.com/Expensify/App/issues/50724 gets fixed as a followup + */ function buildOptimisticAddCommentReportAction( text?: string, file?: FileObject, @@ -11109,8 +11085,6 @@ function getMoneyReportPreviewName(action: ReportAction, iouReport: OnyxEntry personalDetail?.login ?? ''); - const htmlForNewComment = Parser.replace(textWithMention, { - extras: {videoAttributeCache}, + const htmlForNewComment = getParsedMessageWithShortMentions({ + text: newCommentText, + userEmailDomain, + availableMentionLogins: allPersonalDetailLogins, + parserOptions: { + extras: {videoAttributeCache}, + }, }); - const removedLinks = Parser.getRemovedMarkdownLinks(originalCommentMarkdown, textWithMention); + + const removedLinks = Parser.getRemovedMarkdownLinks(originalCommentMarkdown, newCommentText); return removeLinksFromHtml(htmlForNewComment, removedLinks); } diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 5d244dbc9ae11..11a76a6d5d641 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -16,14 +16,17 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShortMentionsList from '@hooks/useShortMentionsList'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {addComment} from '@libs/actions/Report'; import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; import Log from '@libs/Log'; +import {isEmailPublicDomain} from '@libs/LoginUtils'; +import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; +import {addDomainToShortMention} from '@libs/ParsingUtils'; import {isPolicyAdmin} from '@libs/PolicyUtils'; import { - addDomainToShortMention, canUserPerformWriteAction, canWriteInReport as canWriteInReportUtil, isAdminsOnlyPostingRoom as isAdminsOnlyPostingRoomUtil, @@ -120,6 +123,8 @@ function ReportFooter({ const isUserPolicyAdmin = isPolicyAdmin(policy); const allPersonalDetails = usePersonalDetails(); + const {availableLoginsList} = useShortMentionsList(); + const currentUserEmail = getCurrentUserEmail(); const handleCreateTask = useCallback( (text: string): boolean => { @@ -133,7 +138,8 @@ function ReportFooter({ } const mention = match[1] ? match[1].trim() : ''; - const mentionWithDomain = addDomainToShortMention(mention) ?? mention; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail ?? '') ? '' : Str.extractEmailDomain(currentUserEmail ?? ''); + const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; const isValidMention = Str.isValidEmail(mentionWithDomain); let assignee: OnyxEntry; diff --git a/tests/unit/libs/ParsingUtilsTest.ts b/tests/unit/libs/ParsingUtilsTest.ts index b4553b03cc77c..c0139af1346e2 100644 --- a/tests/unit/libs/ParsingUtilsTest.ts +++ b/tests/unit/libs/ParsingUtilsTest.ts @@ -1,5 +1,7 @@ import type {MarkdownRange} from '@expensify/react-native-live-markdown'; -import {decorateRangesWithShortMentions} from '@libs/ParsingUtils'; +import {decorateRangesWithShortMentions, getParsedMessageWithShortMentions} from '@libs/ParsingUtils'; + +const TEST_COMPANY_DOMAIN = 'mycompany.com'; describe('decorateRangesWithShortMentions', () => { test('returns empty list for empty text', () => { @@ -41,7 +43,7 @@ describe('decorateRangesWithShortMentions', () => { length: 8, }, ]; - const result = decorateRangesWithShortMentions(ranges, text, [], ['@myUser']); + const result = decorateRangesWithShortMentions(ranges, text, [], ['myUser']); expect(result).toEqual([ { type: 'mention-here', @@ -60,7 +62,7 @@ describe('decorateRangesWithShortMentions', () => { length: 17, }, ]; - const result = decorateRangesWithShortMentions(ranges, text, [], ['@myUser.email.com']); + const result = decorateRangesWithShortMentions(ranges, text, [], ['myUser.email.com']); expect(result).toEqual([ { type: 'mention-here', @@ -79,7 +81,7 @@ describe('decorateRangesWithShortMentions', () => { length: 12, }, ]; - const availableMentions = ['@johnDoe', '@steven.mock']; + const availableMentions = ['johnDoe', 'steven.mock']; const result = decorateRangesWithShortMentions(ranges, text, availableMentions, []); expect(result).toEqual([ @@ -105,7 +107,7 @@ describe('decorateRangesWithShortMentions', () => { length: 12, }, ]; - const availableMentions = ['@other.person']; + const availableMentions = ['other.person']; const result = decorateRangesWithShortMentions(ranges, text, availableMentions, []); expect(result).toEqual([ @@ -136,8 +138,8 @@ describe('decorateRangesWithShortMentions', () => { length: 13, }, ]; - const availableMentions = ['@johnDoe', '@steven.mock', '@John.current']; - const currentUsers = ['@John.current']; + const availableMentions = ['johnDoe', 'steven.mock', 'John.current']; + const currentUsers = ['John.current']; const result = decorateRangesWithShortMentions(ranges, text, availableMentions, currentUsers); expect(result).toEqual([ @@ -159,3 +161,64 @@ describe('decorateRangesWithShortMentions', () => { ]); }); }); + +describe('getParsedMessageWithShortMentions', () => { + const availableMentionLogins = ['person@mycompany.com', 'john.doe@mycompany.com', 'steven@someother.org']; + + test('returns text without any mentions unchanged', () => { + const result = getParsedMessageWithShortMentions({ + text: 'Be the change that you wish to see in the world', + availableMentionLogins, + userEmailDomain: TEST_COMPANY_DOMAIN, + parserOptions: {}, + }); + expect(result).toEqual('Be the change that you wish to see in the world'); + }); + + test('returns text with full user mentions handled', () => { + const result = getParsedMessageWithShortMentions({ + text: '@here @john.doe@org.com is a generic mention @person@mail.com', + availableMentionLogins, + userEmailDomain: TEST_COMPANY_DOMAIN, + parserOptions: {}, + }); + expect(result).toEqual('@here @john.doe@org.com is a generic mention @person@mail.com'); + }); + + test('returns text with simple short mention transformed into full mention with domain', () => { + const result = getParsedMessageWithShortMentions({ + text: '@john.doe is a correct short mention', + availableMentionLogins, + userEmailDomain: TEST_COMPANY_DOMAIN, + parserOptions: {}, + }); + expect(result).toEqual('@john.doe@mycompany.com is a correct short mention'); + }); + + test('returns text with simple short mention unchanged, when full mention was not in the available logins', () => { + const result = getParsedMessageWithShortMentions({ + text: '@john.doe2 is not a correct short mention', + availableMentionLogins, + userEmailDomain: TEST_COMPANY_DOMAIN, + parserOptions: {}, + }); + expect(result).toEqual('@john.doe2 is not a correct short mention'); + }); + + test('returns text with multiple short mentions transformed into mentions with domain', () => { + const result = getParsedMessageWithShortMentions({ + text: '@john.doe and @john.doe@othermail.com and another @person', + availableMentionLogins, + userEmailDomain: TEST_COMPANY_DOMAIN, + parserOptions: {}, + }); + expect(result).toEqual( + '@john.doe@mycompany.com and @john.doe@othermail.com and another @person@mycompany.com', + ); + }); + + test("returns text with short mention that is followed by special ' char", () => { + const result = getParsedMessageWithShortMentions({text: `this is @john.doe's mention`, availableMentionLogins, userEmailDomain: TEST_COMPANY_DOMAIN, parserOptions: {}}); + expect(result).toEqual(`this is @john.doe@mycompany.com's mention`); + }); +}); From bae52254561910b8559e584b177b1e2ec13bf576 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Wed, 2 Jul 2025 13:22:20 +0200 Subject: [PATCH 2/7] Add mentions docs --- .../MENTIONS_HIGHLIGHTING_IN_CHAT.md | 60 +++++++++++++++++++ src/pages/home/report/ReportFooter.tsx | 2 +- tests/unit/libs/ParsingUtilsTest.ts | 10 ++-- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md diff --git a/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md b/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md new file mode 100644 index 0000000000000..8f391fbacf1e4 --- /dev/null +++ b/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md @@ -0,0 +1,60 @@ +# Mentions highlighting in Composer input and chat messages + +## Glossary +**Full mention** - called more simply `userMention` is the full email of a user. In Expensify app _every_ correct full email gets highlighted in text. +When parsed via ExpensiMark into html, this mention is described with tag ``. +Examples of user mentions: `@john.doe@company.org`, `@vit@expensify.com` + +**Short mention** - is a mention containing only the login part of users email. This basically means that any phrase starting with `@` _could be considered_ a short mention. We only highlight `@mention` if it matches a login from user's personalDetails and has the same email domain. +When parsed via ExpensiMark into html, it is described with tag ``. +Examples of short mentions: + - `@vit` - would only get highlighted IF my domain is `expensify.com` and there exists a user with email `john.doe@expensify.com` + - `@mateusz` - would not get highlighted if my domain is `myCompany.org` AND there is no user with email `mateusz@myCompany.org` + +**ExpensiMark** - parser that we are using, which allows for parsing between markdown <---> html formats. Imported from `expensify-common` package. + +## tl;dr - the most important part + - there are 2 slightly different flows of handling mentions - one is inside the Composer Input and the other outside of it + - both are complex and need to support both userMentions shortMentions - See **FAQ** + +## Parsing mentions inside Composer/Input (LiveMarkdown) +Our `Composer` component uses `react-native-live-markdown` for writing and editing markdown and handling mentions. When discussing how mentions work **inside** the composer input always look for answers in ReactNativeLiveMarkdown. + +### Mention parsing flow in live-markdown +1. User types in some text +2. `RNMarkdownTextInput` will handle the text by calling `parseExpensiMark`, which is an internal function of live-markdown: https://github.com/Expensify/react-native-live-markdown/blob/main/src/parseExpensiMark.ts +3. `parseExpensiMark` will use `ExpensiMark` for parsing, then do several extra operations so that the component can work correctly +4. When `ExpensiMark` parses the text, any full email will get parsed to `...` and any `@phrase` will get parsed to `...` +5. `userMentions` are ready to use as they are so they require no further modification, however for `shortMentions` we need to check if they actually should get the highlighting +5. We use the `parser` prop of `` to pass custom parsing logic - this allows us to do some extra processing after `parseExpensiMark` runs. +6. Our custom logic will go over every `` entry and verify if this login is someone that exists in userDetails data, then transform this into a full mention which gets highlighting + +**NOTE:** this entire process takes part only "inside" Composer input. This is what happens between user typing in some text and user seeing the markdown/highlights in the Input as he is typing. + +## Parsing mentions outside of Composer/Input +When a user types in a message and wants to send it, we need to process the message text and then call the appropriate backend command. +However, backend only accepts text in html format. This means that text payload sent to backend has to be parsed via ExpensiMark. In addition api **will not** accept `` tag - it only accepts full user mention with email. Frontend needs to process every `shortMention` into a `userMention` or stripping it completely from text. + +### Mention processing flow when sending a message +1. After typing in some text user hits ENTER or pressed the send button +2. Several functions are called but ultimately `addActions(...)` is the one that will prepare backend payload and make the Api call. +3. The function solely responsible for getting the correctly parsed text is `getParsedComment()` - it should return the string that is safe to send to backend. +4. We **do not** have access to `parseExpensiMark` or any functions that worked in worklets, as we are outside of `live-markdown` but we need to process `shortMentions` regardless. +5. The processing is done in `getParsedMessageWithShortMentions`: we parse via `ExpensiMark` with options very similar to what happens inside `parseExpensiMark` in `live-markdown`. (this is similar to Step 5. from previous flow) +6. We then find every `...` and try to see if the specific mention exists in userDetails data - + +## FAQ +### Q: Why can't we simply use `parseExpensiMark` in both cases?! +We cannot call `parseExpensiMark` in both cases, because `parseExpensiMark` returns a special data structure, called `MarkdownRange` which is both created and consumed by `react-native-live-markdown`. + +Expensify API only accepts HTML and not markdown range. + +Useful graph: +``` +ExpensiMark: (raw text with markdown markers) ----> (HTML) +parseExpensiMark: (raw text with markdown markers) ----> MarkdownRange[] structure +``` +``` + accepts MarkdownRange[] +Expensify Api call accepts HTML +``` diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 11a76a6d5d641..c338b69bed8a5 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -162,7 +162,7 @@ function ReportFooter({ createTaskAndNavigate(report.reportID, title, '', assignee?.login ?? '', assignee?.accountID, assigneeChatReport, report.policyID, true); return true; }, - [allPersonalDetails, report.policyID, report.reportID], + [allPersonalDetails, availableLoginsList, currentUserEmail, report.policyID, report.reportID], ); const onSubmitComment = useCallback( diff --git a/tests/unit/libs/ParsingUtilsTest.ts b/tests/unit/libs/ParsingUtilsTest.ts index c0139af1346e2..c8e7cc3022bd7 100644 --- a/tests/unit/libs/ParsingUtilsTest.ts +++ b/tests/unit/libs/ParsingUtilsTest.ts @@ -1,7 +1,7 @@ import type {MarkdownRange} from '@expensify/react-native-live-markdown'; import {decorateRangesWithShortMentions, getParsedMessageWithShortMentions} from '@libs/ParsingUtils'; -const TEST_COMPANY_DOMAIN = 'mycompany.com'; +const TEST_COMPANY_DOMAIN = 'myCompany.com'; describe('decorateRangesWithShortMentions', () => { test('returns empty list for empty text', () => { @@ -163,7 +163,7 @@ describe('decorateRangesWithShortMentions', () => { }); describe('getParsedMessageWithShortMentions', () => { - const availableMentionLogins = ['person@mycompany.com', 'john.doe@mycompany.com', 'steven@someother.org']; + const availableMentionLogins = ['person@myCompany.com', 'john.doe@myCompany.com', 'steven@someother.org']; test('returns text without any mentions unchanged', () => { const result = getParsedMessageWithShortMentions({ @@ -192,7 +192,7 @@ describe('getParsedMessageWithShortMentions', () => { userEmailDomain: TEST_COMPANY_DOMAIN, parserOptions: {}, }); - expect(result).toEqual('@john.doe@mycompany.com is a correct short mention'); + expect(result).toEqual('@john.doe@myCompany.com is a correct short mention'); }); test('returns text with simple short mention unchanged, when full mention was not in the available logins', () => { @@ -213,12 +213,12 @@ describe('getParsedMessageWithShortMentions', () => { parserOptions: {}, }); expect(result).toEqual( - '@john.doe@mycompany.com and @john.doe@othermail.com and another @person@mycompany.com', + '@john.doe@myCompany.com and @john.doe@othermail.com and another @person@myCompany.com', ); }); test("returns text with short mention that is followed by special ' char", () => { const result = getParsedMessageWithShortMentions({text: `this is @john.doe's mention`, availableMentionLogins, userEmailDomain: TEST_COMPANY_DOMAIN, parserOptions: {}}); - expect(result).toEqual(`this is @john.doe@mycompany.com's mention`); + expect(result).toEqual(`this is @john.doe@myCompany.com's mention`); }); }); From 4f95d5a0cdb58d1e6db797a7ba695c98fd881e4a Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Fri, 4 Jul 2025 08:44:02 +0200 Subject: [PATCH 3/7] Update Mentions docs after review --- .../MENTIONS_HIGHLIGHTING_IN_CHAT.md | 29 ++++++++++--------- src/libs/ParsingUtils.ts | 27 ++++++++--------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md b/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md index 8f391fbacf1e4..50cc8c43f61cb 100644 --- a/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md +++ b/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md @@ -3,22 +3,23 @@ ## Glossary **Full mention** - called more simply `userMention` is the full email of a user. In Expensify app _every_ correct full email gets highlighted in text. When parsed via ExpensiMark into html, this mention is described with tag ``. -Examples of user mentions: `@john.doe@company.org`, `@vit@expensify.com` +#### Examples of user mentions: `@john.doe@company.org`, `@vit@expensify.com` -**Short mention** - is a mention containing only the login part of users email. This basically means that any phrase starting with `@` _could be considered_ a short mention. We only highlight `@mention` if it matches a login from user's personalDetails and has the same email domain. +**Short mention** - a special type of mention that contains only the login part of a user email **AND** the email domain has to be the same as our email domain. Any other `@mention` will not get highlighted if domains don't match When parsed via ExpensiMark into html, it is described with tag ``. -Examples of short mentions: - - `@vit` - would only get highlighted IF my domain is `expensify.com` and there exists a user with email `john.doe@expensify.com` - - `@mateusz` - would not get highlighted if my domain is `myCompany.org` AND there is no user with email `mateusz@myCompany.org` + +#### Examples of short mentions: + - `@vit` - **IF** my domain is `expensify.com` **AND** there exists a user with email `vit@expensify.com` - it will get highlighted ✅ + - `@mateusz` - **IF** my domain is `expensify.com` **AND** there is **NO** user with email `mateusz@expensify.com`, but there is for example `mateusz@company.org` - it will NOT get highlighted ❌ **ExpensiMark** - parser that we are using, which allows for parsing between markdown <---> html formats. Imported from `expensify-common` package. ## tl;dr - the most important part - - there are 2 slightly different flows of handling mentions - one is inside the Composer Input and the other outside of it - - both are complex and need to support both userMentions shortMentions - See **FAQ** + - there are 2 slightly different flows of handling mentions - one is inside the Composer Input and the other outside of it + - both are complex and need to support both userMentions and shortMentions - See **FAQ** ## Parsing mentions inside Composer/Input (LiveMarkdown) -Our `Composer` component uses `react-native-live-markdown` for writing and editing markdown and handling mentions. When discussing how mentions work **inside** the composer input always look for answers in ReactNativeLiveMarkdown. +Our `Composer` component uses `react-native-live-markdown` for writing and editing markdown and handling mentions. When discussing how mentions work **inside** the composer input always look for answers in this [library](https://github.com/Expensify/react-native-live-markdown). ### Mention parsing flow in live-markdown 1. User types in some text @@ -29,19 +30,21 @@ Our `Composer` component uses `react-native-live-markdown` for writing and editi 5. We use the `parser` prop of `` to pass custom parsing logic - this allows us to do some extra processing after `parseExpensiMark` runs. 6. Our custom logic will go over every `` entry and verify if this login is someone that exists in userDetails data, then transform this into a full mention which gets highlighting -**NOTE:** this entire process takes part only "inside" Composer input. This is what happens between user typing in some text and user seeing the markdown/highlights in the Input as he is typing. +**NOTE:** this entire process takes part only "inside" Composer input. This is what happens between user typing in some text and user seeing the markdown/highlights in real time. ## Parsing mentions outside of Composer/Input When a user types in a message and wants to send it, we need to process the message text and then call the appropriate backend command. -However, backend only accepts text in html format. This means that text payload sent to backend has to be parsed via ExpensiMark. In addition api **will not** accept `` tag - it only accepts full user mention with email. Frontend needs to process every `shortMention` into a `userMention` or stripping it completely from text. +However, backend only accepts text in html format. This means that text payload sent to backend has to be parsed via ExpensiMark. In addition api **will not** accept `` tag - it only accepts full user mention with email. Frontend needs to process every `shortMention` into a `userMention` or stripping it completely from text. + +FINISH ME ### Mention processing flow when sending a message -1. After typing in some text user hits ENTER or pressed the send button +1. After typing in some text user hits ENTER or presses the send button 2. Several functions are called but ultimately `addActions(...)` is the one that will prepare backend payload and make the Api call. 3. The function solely responsible for getting the correctly parsed text is `getParsedComment()` - it should return the string that is safe to send to backend. 4. We **do not** have access to `parseExpensiMark` or any functions that worked in worklets, as we are outside of `live-markdown` but we need to process `shortMentions` regardless. -5. The processing is done in `getParsedMessageWithShortMentions`: we parse via `ExpensiMark` with options very similar to what happens inside `parseExpensiMark` in `live-markdown`. (this is similar to Step 5. from previous flow) -6. We then find every `...` and try to see if the specific mention exists in userDetails data - +5. The processing is done in `getParsedMessageWithShortMentions`: we parse via `ExpensiMark` with options very similar to what happens inside `parseExpensiMark` in `live-markdown`. (this is similar to Step 5. from previous flow). +6. We then find every `...` and try to see if the specific mention exists in userDetails data. ## FAQ ### Q: Why can't we simply use `parseExpensiMark` in both cases?! diff --git a/src/libs/ParsingUtils.ts b/src/libs/ParsingUtils.ts index 59782e66da618..2c7da7880464e 100644 --- a/src/libs/ParsingUtils.ts +++ b/src/libs/ParsingUtils.ts @@ -82,6 +82,16 @@ function addDomainToShortMention(mention: string, availableMentionLogins: string return undefined; } +type GetParsedMessageWithShortMentionsArgs = { + text: string; + availableMentionLogins: string[]; + userEmailDomain?: string; + parserOptions: { + disabledRules?: string[]; + extras?: Extras; + }; +}; + /** * This function receives raw text of the message, parses it with ExpensiMark, then transforms short-mentions * into full mentions by adding a user domain to them. @@ -92,22 +102,9 @@ function addDomainToShortMention(mention: string, availableMentionLogins: string * However, ExpensiMark can also produce a special `` tag, which is just the @login part of a full user login. * This is handled inside `react-native-live-markdown` with a special function `parseExpensiMark` and then processed with `decorateRangesWithShortMentions`. * However, we cannot use `parseExpensiMark` for the text that is being sent to backend, as we need html mention tags. - * This function is the missing piece that will use ExpensiMark for parsing, but and then strip+transform `mention-short` into full mentions. + * This function is the missing piece that will use ExpensiMark for parsing, but will also strip+transform `mention-short` into full mentions. */ -function getParsedMessageWithShortMentions({ - text, - availableMentionLogins, - userEmailDomain, - parserOptions, -}: { - text: string; - availableMentionLogins: string[]; - userEmailDomain?: string; - parserOptions: { - disabledRules?: string[]; - extras?: Extras; - }; -}) { +function getParsedMessageWithShortMentions({text, availableMentionLogins, userEmailDomain, parserOptions}: GetParsedMessageWithShortMentionsArgs) { const parsedText = Parser.replace(text, { shouldEscapeText: true, disabledRules: parserOptions.disabledRules, From 1c98ceeea73731fd893a8d45ab86cd0cf86f57dc Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Mon, 7 Jul 2025 16:16:58 +0200 Subject: [PATCH 4/7] Update Mentions docs after review --- contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md b/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md index 50cc8c43f61cb..c6aba1fbdb10c 100644 --- a/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md +++ b/contributingGuides/MENTIONS_HIGHLIGHTING_IN_CHAT.md @@ -34,9 +34,7 @@ Our `Composer` component uses `react-native-live-markdown` for writing and editi ## Parsing mentions outside of Composer/Input When a user types in a message and wants to send it, we need to process the message text and then call the appropriate backend command. -However, backend only accepts text in html format. This means that text payload sent to backend has to be parsed via ExpensiMark. In addition api **will not** accept `` tag - it only accepts full user mention with email. Frontend needs to process every `shortMention` into a `userMention` or stripping it completely from text. - -FINISH ME +However, backend only accepts text in html format. This means that text payload sent to backend has to be parsed via ExpensiMark. In addition, api **will not** accept `` tag - it only accepts full user mention with email. Frontend needs to process every `shortMention` into a `userMention` or stripping it completely from text. ### Mention processing flow when sending a message 1. After typing in some text user hits ENTER or presses the send button From 33f879b5e25d24bb6c328d79196ed8ad7532a108 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Mon, 14 Jul 2025 12:45:19 +0200 Subject: [PATCH 5/7] Fix handling mention tags by no longer running unescapeText --- src/libs/ParsingUtils.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/libs/ParsingUtils.ts b/src/libs/ParsingUtils.ts index 2c7da7880464e..43644a75c05e9 100644 --- a/src/libs/ParsingUtils.ts +++ b/src/libs/ParsingUtils.ts @@ -2,7 +2,6 @@ import type {MarkdownRange} from '@expensify/react-native-live-markdown'; import {parseExpensiMark} from '@expensify/react-native-live-markdown'; import {Str} from 'expensify-common'; import type {Extras} from 'expensify-common/dist/ExpensiMark'; -import {unescapeText} from 'expensify-common/dist/utils'; import CONST from '@src/CONST'; import Parser from './Parser'; import {addSMSDomainIfPhoneNumber} from './PhoneNumber'; @@ -123,10 +122,7 @@ function getParsedMessageWithShortMentions({text, availableMentionLogins, userEm return mentionWithDomain ? `@${mentionWithDomain}` : shortMention; }); - // Because in the call to `Parser.replace` we escape the text, we need to unescape it - const cleanedText = unescapeText(textWithHandledMentions); - - return cleanedText; + return textWithHandledMentions; } export {parseExpensiMarkWithShortMentions, decorateRangesWithShortMentions, addDomainToShortMention, getParsedMessageWithShortMentions}; From b34ce592157ac69a1deff74a2d2b32e6028904f7 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Mon, 14 Jul 2025 13:01:59 +0200 Subject: [PATCH 6/7] Fix tests for ParsingUtils --- tests/unit/libs/ParsingUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/libs/ParsingUtilsTest.ts b/tests/unit/libs/ParsingUtilsTest.ts index c8e7cc3022bd7..f0df34783157a 100644 --- a/tests/unit/libs/ParsingUtilsTest.ts +++ b/tests/unit/libs/ParsingUtilsTest.ts @@ -219,6 +219,6 @@ describe('getParsedMessageWithShortMentions', () => { test("returns text with short mention that is followed by special ' char", () => { const result = getParsedMessageWithShortMentions({text: `this is @john.doe's mention`, availableMentionLogins, userEmailDomain: TEST_COMPANY_DOMAIN, parserOptions: {}}); - expect(result).toEqual(`this is @john.doe@myCompany.com's mention`); + expect(result).toEqual(`this is @john.doe@myCompany.com's mention`); }); }); From ba75db6b750a5adf2b9d043058f26fdaf462a851 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Mon, 14 Jul 2025 15:53:03 +0200 Subject: [PATCH 7/7] Remove no longer used SHORT_MENTION regex --- src/CONST/index.ts | 6 ------ src/libs/ReportUtils.ts | 2 +- tests/unit/ShortMentionRegexTest.ts | 30 ----------------------------- 3 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 tests/unit/ShortMentionRegexTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b5d9a46bed6ed..a02b10c9a76ac 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3494,12 +3494,6 @@ const CONST = { INVISIBLE_CHARACTERS_GROUPS: /[\p{C}\p{Z}]/gu, OTHER_INVISIBLE_CHARACTERS: /[\u3164]/g, REPORT_FIELD_TITLE: /{report:([a-zA-Z]+)}/g, - SHORT_MENTION: new RegExp( - // We are ensuring that the short mention is not inside a code block. So we check that the short mention - // is either not preceded by an open code block or not followed by a backtick on the same line. - `(?(.*?)<\/mention-short>/g, REPORT_ID_FROM_PATH: /(? { - it('Should concat the private domain to proper short mentions only', () => { - const testTexts = [ - '`sd` `` g @short\n`sd` `` g @short `\n`jkl @short-mention `jk` \n`sd` g @short\n`jkl @short-mention`', - '`jkl` ``sth @short-mention jk`\n`jkl` ``sth`@short-mention` jk`\n`jkl @short-mention jk\n`jkl @short-mention jk\nj`k`l @short-mention` jk', - // cspell:disable-next-line - '`jk`l @short-mention`sd `g @short`jk`l @short-mention`sd g @short\n`jk`l `@short-mention`sd g @short`jk`l @short-mention`sd g @short ``\njkl @short-mention`sd `g @short`jk`l @short-mention`sd g @short\n`jkl @short-mention`sd `g @short`jk`l @short-mention`sd g @short', - ]; - const expectedValues = [ - '`sd` `` g @short@test.co\n`sd` `` g @short@test.co `\n`jkl @short-mention `jk` \n`sd` g @short@test.co\n`jkl @short-mention`', - '`jkl` ``sth @short-mention@test.co jk`\n`jkl` ``sth`@short-mention` jk`\n`jkl @short-mention@test.co jk\n`jkl @short-mention@test.co jk\nj`k`l @short-mention@test.co` jk', - // cspell:disable-next-line - '`jk`l @short-mention@test.co`sd `g @short@test.co`jk`l @short-mention@test.co`sd g @short@test.co\n`jk`l `@short-mention`sd g @short@test.co`jk`l @short-mention@test.co`sd g @short ``\njkl @short-mention@test.co`sd `g @short@test.co`jk`l @short-mention@test.co`sd g @short@test.co\n`jkl @short-mention`sd `g @short`jk`l @short-mention`sd g @short@test.co', - ]; - - testTexts.forEach((text, i) => - expect( - text.replace(CONST.REGEX.SHORT_MENTION, (match) => { - if (!Str.isValidMention(match)) { - return match; - } - return `${match}@test.co`; - }), - ).toEqual(expectedValues.at(i)), - ); - }); -});