From 43cf3f3db362f9a510eac72ca5f5da02399ed2ba Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Thu, 9 Jan 2025 13:41:02 +0100 Subject: [PATCH] Fix short mentions handling and bump live-markdown --- package-lock.json | 8 +- package.json | 2 +- src/components/RNMarkdownTextInput.tsx | 44 ++---- src/hooks/useShortMentionsList.ts | 44 ++++++ src/libs/ParsingUtils.ts | 39 +++++ .../unit/decorateRangesWithCurrentUserTest.ts | 63 -------- tests/unit/libs/ParsingUtilsTest.ts | 142 ++++++++++++++++++ 7 files changed, 245 insertions(+), 97 deletions(-) create mode 100644 src/hooks/useShortMentionsList.ts create mode 100644 src/libs/ParsingUtils.ts delete mode 100644 tests/unit/decorateRangesWithCurrentUserTest.ts create mode 100644 tests/unit/libs/ParsingUtilsTest.ts diff --git a/package-lock.json b/package-lock.json index dda4462daaa2..ffb7aad38d4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.210", + "@expensify/react-native-live-markdown": "0.1.216", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -3631,9 +3631,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.210", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.210.tgz", - "integrity": "sha512-CW9DY2yN/QJrqkD6+74s+kWQ9bhWQwd2jT+x5RCgyy5N2SdcoE8G8DGQQvmo6q94KcRkHIr/HsTVOyzACQ/nrw==", + "version": "0.1.216", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.216.tgz", + "integrity": "sha512-9BZsbCr8/87ypuYeFVjxrDpXYjPlbBRQtP+tRkqrKoh/3oFKgXwEjHx8ySdioRBm9ofFAAirYJjpXh/0wlNrlw==", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index db2a31db91d9..46a9c93edd18 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.210", + "@expensify/react-native-live-markdown": "0.1.216", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", diff --git a/src/components/RNMarkdownTextInput.tsx b/src/components/RNMarkdownTextInput.tsx index cd2769ae9367..7ee623475ba8 100644 --- a/src/components/RNMarkdownTextInput.tsx +++ b/src/components/RNMarkdownTextInput.tsx @@ -1,10 +1,11 @@ -import type {MarkdownRange, MarkdownTextInputProps} from '@expensify/react-native-live-markdown'; +import type {MarkdownTextInputProps} from '@expensify/react-native-live-markdown'; import {MarkdownTextInput, parseExpensiMark} from '@expensify/react-native-live-markdown'; import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback} from 'react'; -import Animated from 'react-native-reanimated'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import React, {forwardRef, useCallback, useEffect} from 'react'; +import Animated, {useSharedValue} from 'react-native-reanimated'; +import useShortMentionsList from '@hooks/useShortMentionsList'; import useTheme from '@hooks/useTheme'; +import {decorateRangesWithShortMentions} from '@libs/ParsingUtils'; import CONST from '@src/CONST'; // Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet @@ -17,30 +18,15 @@ type RNMarkdownTextInputWithRefProps = Omit & parser?: MarkdownTextInputProps['parser']; }; -function decorateRangesWithCurrentUser(ranges: MarkdownRange[], text: string, currentUser: string): MarkdownRange[] { - 'worklet'; - - return ranges.map((range) => { - if (range.type === 'mention-user') { - const mentionText = text.slice(range.start, range.start + range.length); - const isCurrentUser = mentionText === `@${currentUser}`; - if (isCurrentUser) { - return { - ...range, - type: 'mention-here', - }; - } - } - - return range; - }); -} - function RNMarkdownTextInputWithRef({maxLength, parser, ...props}: RNMarkdownTextInputWithRefProps, ref: ForwardedRef) { const theme = useTheme(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserLogin = currentUserPersonalDetails.login; + const {mentionsList, currentUserMention} = useShortMentionsList(); + const mentionsSharedVal = useSharedValue([]); + + useEffect(() => { + mentionsSharedVal.set(mentionsList); + }, [mentionsList, mentionsSharedVal]); // We accept parser passed down as a prop or use ExpensiMark if parser is not defined const parserWorklet = useCallback( @@ -52,13 +38,14 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ...props}: RNMarkdownTex } const parsedMentions = parseExpensiMark(text); - if (!currentUserLogin) { + const availableMentions = mentionsSharedVal.get(); + if (availableMentions.length === 0) { return parsedMentions; } - return decorateRangesWithCurrentUser(parsedMentions, text, currentUserLogin); + return decorateRangesWithShortMentions(parsedMentions, text, mentionsSharedVal.get(), currentUserMention); }, - [currentUserLogin, parser], + [currentUserMention, mentionsSharedVal, parser], ); return ( @@ -86,5 +73,4 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ...props}: RNMarkdownTex RNMarkdownTextInputWithRef.displayName = 'RNTextInputWithRef'; export default forwardRef(RNMarkdownTextInputWithRef); -export {decorateRangesWithCurrentUser}; export type {AnimatedMarkdownTextInputRef}; diff --git a/src/hooks/useShortMentionsList.ts b/src/hooks/useShortMentionsList.ts new file mode 100644 index 000000000000..9804012ab6cb --- /dev/null +++ b/src/hooks/useShortMentionsList.ts @@ -0,0 +1,44 @@ +import {useMemo} from 'react'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; + +const getMention = (mention: string) => `@${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). + * All the personal data from Onyx is formatted into short-mentions. + * In addition, currently logged-in user is returned separately since it requires special styling. + */ +export default function useShortMentionsList() { + const personalDetails = usePersonalDetails(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + + const mentionsList = useMemo(() => { + if (!personalDetails) { + return []; + } + + return Object.values(personalDetails) + .map((personalDetail) => { + if (!personalDetail?.login) { + return; + } + + const [username] = personalDetail.login.split('@'); + return username ? getMention(username) : undefined; + }) + .filter((login): login is string => !!login); + }, [personalDetails]); + + const currentUserMention = useMemo(() => { + if (!currentUserPersonalDetails.login) { + return; + } + + const [baseName] = currentUserPersonalDetails.login.split('@'); + return getMention(baseName); + }, [currentUserPersonalDetails.login]); + + return {mentionsList, currentUserMention}; +} diff --git a/src/libs/ParsingUtils.ts b/src/libs/ParsingUtils.ts new file mode 100644 index 000000000000..acdac2a378f8 --- /dev/null +++ b/src/libs/ParsingUtils.ts @@ -0,0 +1,39 @@ +import type {MarkdownRange} from '@expensify/react-native-live-markdown'; + +/** + * Handles possible short mentions inside ranges by verifying if the specific range refers to a user mention/login + * that is available in passed `availableMentions` list. If yes, then it gets the same styling as normal email mention. + * In addition, applies special styling to current user. + */ +function decorateRangesWithShortMentions(ranges: MarkdownRange[], text: string, availableMentions: string[], currentUser?: string): MarkdownRange[] { + 'worklet'; + + return ranges + .map((range) => { + if (range.type === 'mention-short') { + const mentionText = text.slice(range.start, range.start + range.length); + + if (currentUser && mentionText === currentUser) { + return { + ...range, + type: 'mention-here', + }; + } + + if (availableMentions.includes(mentionText)) { + return { + ...range, + type: 'mention-user', + }; + } + + // If it's neither, we remove the range since no styling will be needed + return; + } + return range; + }) + .filter((maybeRange): maybeRange is MarkdownRange => !!maybeRange); +} + +// eslint-disable-next-line import/prefer-default-export +export {decorateRangesWithShortMentions}; diff --git a/tests/unit/decorateRangesWithCurrentUserTest.ts b/tests/unit/decorateRangesWithCurrentUserTest.ts deleted file mode 100644 index 0d03cf1eb3f3..000000000000 --- a/tests/unit/decorateRangesWithCurrentUserTest.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type {MarkdownRange} from '@expensify/react-native-live-markdown'; -import {decorateRangesWithCurrentUser} from '@components/RNMarkdownTextInput'; - -describe('decorateRangesWithCurrentUser', () => { - test('returns empty list for empty text', () => { - const result = decorateRangesWithCurrentUser([], '', ''); - expect(result).toEqual([]); - }); - - test('returns empty list when there are no mentions', () => { - const text = 'Lorem ipsum'; - const result = decorateRangesWithCurrentUser([], text, ''); - expect(result).toEqual([]); - }); - - test('returns unchanged ranges when there are other markups than user-mentions', () => { - const text = 'Lorem ipsum'; - const ranges: MarkdownRange[] = [ - { - type: 'bold', - start: 5, - length: 3, - }, - ]; - const result = decorateRangesWithCurrentUser(ranges, text, ''); - expect(result).toEqual([ - { - type: 'bold', - start: 5, - length: 3, - }, - ]); - }); - - test('returns ranges with current user type changed to "mention-here"', () => { - const text = 'Lorem ipsum @myUser'; - const ranges: MarkdownRange[] = [ - { - type: 'bold', - start: 5, - length: 3, - }, - { - type: 'mention-user', - start: 12, - length: 8, - }, - ]; - const result = decorateRangesWithCurrentUser(ranges, text, 'myUser'); - expect(result).toEqual([ - { - type: 'bold', - start: 5, - length: 3, - }, - { - type: 'mention-here', - start: 12, - length: 8, - }, - ]); - }); -}); diff --git a/tests/unit/libs/ParsingUtilsTest.ts b/tests/unit/libs/ParsingUtilsTest.ts new file mode 100644 index 000000000000..005c246af04e --- /dev/null +++ b/tests/unit/libs/ParsingUtilsTest.ts @@ -0,0 +1,142 @@ +import type {MarkdownRange} from '@expensify/react-native-live-markdown'; +import {decorateRangesWithShortMentions} from '@libs/ParsingUtils'; + +describe('decorateRangesWithShortMentions', () => { + test('returns empty list for empty text', () => { + const result = decorateRangesWithShortMentions([], '', [], ''); + expect(result).toEqual([]); + }); + + test('returns empty list when there are no relevant mentions', () => { + const text = 'Lorem ipsum'; + const result = decorateRangesWithShortMentions([], text, [], ''); + expect(result).toEqual([]); + }); + + test('returns unchanged ranges when there are other markups than user-mentions', () => { + const text = 'Lorem ipsum'; + const ranges: MarkdownRange[] = [ + { + type: 'bold', + start: 5, + length: 3, + }, + ]; + const result = decorateRangesWithShortMentions(ranges, text, [], ''); + expect(result).toEqual([ + { + type: 'bold', + start: 5, + length: 3, + }, + ]); + }); + + test('returns ranges with current user type changed to "mention-here"', () => { + const text = 'Lorem ipsum @myUser'; + const ranges: MarkdownRange[] = [ + { + type: 'mention-short', + start: 12, + length: 8, + }, + ]; + const result = decorateRangesWithShortMentions(ranges, text, [], '@myUser'); + expect(result).toEqual([ + { + type: 'mention-here', + start: 12, + length: 8, + }, + ]); + }); + + test('returns ranges with correct short-mentions', () => { + const text = 'Lorem ipsum @steven.mock'; + const ranges: MarkdownRange[] = [ + { + type: 'mention-short', + start: 12, + length: 12, + }, + ]; + const availableMentions = ['@johnDoe', '@steven.mock']; + + const result = decorateRangesWithShortMentions(ranges, text, availableMentions, ''); + expect(result).toEqual([ + { + type: 'mention-user', + start: 12, + length: 12, + }, + ]); + }); + + test('returns ranges with removed short-mentions when they do not match', () => { + const text = 'Lorem ipsum @steven.mock'; + const ranges: MarkdownRange[] = [ + { + type: 'bold', + start: 5, + length: 3, + }, + { + type: 'mention-short', + start: 12, + length: 12, + }, + ]; + const availableMentions = ['@other.person']; + + const result = decorateRangesWithShortMentions(ranges, text, availableMentions, ''); + expect(result).toEqual([ + { + type: 'bold', + start: 5, + length: 3, + }, + ]); + }); + + test('returns ranges with both types of mentions handled', () => { + const text = 'Lorem ipsum @steven.mock @John.current @test'; + const ranges: MarkdownRange[] = [ + { + type: 'bold', + start: 5, + length: 3, + }, + { + type: 'mention-short', + start: 12, + length: 12, + }, + { + type: 'mention-short', + start: 25, + length: 13, + }, + ]; + const availableMentions = ['@johnDoe', '@steven.mock', '@John.current']; + const currentUser = '@John.current'; + + const result = decorateRangesWithShortMentions(ranges, text, availableMentions, currentUser); + expect(result).toEqual([ + { + type: 'bold', + start: 5, + length: 3, + }, + { + type: 'mention-user', + start: 12, + length: 12, + }, + { + type: 'mention-here', + start: 25, + length: 13, + }, + ]); + }); +});