diff --git a/package-lock.json b/package-lock.json index d3820d4c582c..773f037667a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-background-task": "file:./modules/background-task", - "@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", @@ -3641,9 +3641,9 @@ "link": true }, "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 0978d03624ce..818a92550fd6 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", "@expensify/react-native-background-task": "file:./modules/background-task", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", diff --git a/src/components/RNMarkdownTextInput.tsx b/src/components/RNMarkdownTextInput.tsx index e8ed0256bf0a..7ee623475ba8 100644 --- a/src/components/RNMarkdownTextInput.tsx +++ b/src/components/RNMarkdownTextInput.tsx @@ -1,9 +1,11 @@ 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 from 'react'; -import Animated from 'react-native-reanimated'; +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 @@ -11,17 +13,47 @@ const AnimatedMarkdownTextInput = Animated.createAnimatedComponent(MarkdownTextI type AnimatedMarkdownTextInputRef = typeof AnimatedMarkdownTextInput & MarkdownTextInput & HTMLInputElement; -type RNMarkdownTextInputProps = Omit; +// Make the parser prop optional for this component because we are always defaulting to `parseExpensiMark` +type RNMarkdownTextInputWithRefProps = Omit & { + parser?: MarkdownTextInputProps['parser']; +}; -function RNMarkdownTextInputWithRef({maxLength, ...props}: RNMarkdownTextInputProps, ref: ForwardedRef) { +function RNMarkdownTextInputWithRef({maxLength, parser, ...props}: RNMarkdownTextInputWithRefProps, ref: ForwardedRef) { const theme = useTheme(); + 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( + (text: string) => { + 'worklet'; + + if (parser) { + return parser(text); + } + + const parsedMentions = parseExpensiMark(text); + const availableMentions = mentionsSharedVal.get(); + if (availableMentions.length === 0) { + return parsedMentions; + } + + return decorateRangesWithShortMentions(parsedMentions, text, mentionsSharedVal.get(), currentUserMention); + }, + [currentUserMention, mentionsSharedVal, parser], + ); + return ( { if (typeof ref !== 'function') { return; @@ -31,7 +63,7 @@ function RNMarkdownTextInputWithRef({maxLength, ...props}: RNMarkdownTextInputPr // eslint-disable-next-line {...props} /** - * If maxLength is not set, we should set the it to CONST.MAX_COMMENT_LENGTH + 1, to avoid parsing markdown for large text + * If maxLength is not set, we should set it to CONST.MAX_COMMENT_LENGTH + 1, to avoid parsing markdown for large text */ maxLength={maxLength ?? CONST.MAX_COMMENT_LENGTH + 1} /> @@ -40,5 +72,5 @@ function RNMarkdownTextInputWithRef({maxLength, ...props}: RNMarkdownTextInputPr RNMarkdownTextInputWithRef.displayName = 'RNTextInputWithRef'; -export default React.forwardRef(RNMarkdownTextInputWithRef); +export default forwardRef(RNMarkdownTextInputWithRef); 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/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, + }, + ]); + }); +});