Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show short mentions and current user mention in MarkdownTextInput with styling #54037

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 39 additions & 7 deletions src/components/RNMarkdownTextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,59 @@
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
const AnimatedMarkdownTextInput = Animated.createAnimatedComponent(MarkdownTextInput);

type AnimatedMarkdownTextInputRef = typeof AnimatedMarkdownTextInput & MarkdownTextInput & HTMLInputElement;

type RNMarkdownTextInputProps = Omit<MarkdownTextInputProps, 'parser'>;
// Make the parser prop optional for this component because we are always defaulting to `parseExpensiMark`
type RNMarkdownTextInputWithRefProps = Omit<MarkdownTextInputProps, 'parser'> & {
parser?: MarkdownTextInputProps['parser'];
};

function RNMarkdownTextInputWithRef({maxLength, ...props}: RNMarkdownTextInputProps, ref: ForwardedRef<AnimatedMarkdownTextInputRef>) {
function RNMarkdownTextInputWithRef({maxLength, parser, ...props}: RNMarkdownTextInputWithRefProps, ref: ForwardedRef<AnimatedMarkdownTextInputRef>) {
const theme = useTheme();

const {mentionsList, currentUserMention} = useShortMentionsList();
const mentionsSharedVal = useSharedValue<string[]>([]);

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 (
<AnimatedMarkdownTextInput
allowFontScaling={false}
textBreakStrategy="simple"
keyboardAppearance={theme.colorScheme}
parser={parseExpensiMark}
parser={parserWorklet}
ref={(refHandle) => {
if (typeof ref !== 'function') {
return;
Expand All @@ -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}
/>
Expand All @@ -40,5 +72,5 @@ function RNMarkdownTextInputWithRef({maxLength, ...props}: RNMarkdownTextInputPr

RNMarkdownTextInputWithRef.displayName = 'RNTextInputWithRef';

export default React.forwardRef(RNMarkdownTextInputWithRef);
export default forwardRef(RNMarkdownTextInputWithRef);
export type {AnimatedMarkdownTextInputRef};
44 changes: 44 additions & 0 deletions src/hooks/useShortMentionsList.ts
Original file line number Diff line number Diff line change
@@ -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};
}
39 changes: 39 additions & 0 deletions src/libs/ParsingUtils.ts
Original file line number Diff line number Diff line change
@@ -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};
142 changes: 142 additions & 0 deletions tests/unit/libs/ParsingUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -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,
},
]);
});
});
Loading