Skip to content

Commit

Permalink
Fix short mentions handling and bump live-markdown
Browse files Browse the repository at this point in the history
  • Loading branch information
Kicu committed Jan 9, 2025
1 parent 6dc72a8 commit 43cf3f3
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 97 deletions.
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",
"@expo/metro-runtime": "^4.0.0",
"@firebase/app": "^0.10.10",
"@firebase/performance": "^0.6.8",
Expand Down
44 changes: 15 additions & 29 deletions src/components/RNMarkdownTextInput.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,30 +18,15 @@ type RNMarkdownTextInputWithRefProps = Omit<MarkdownTextInputProps, 'parser'> &
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<AnimatedMarkdownTextInputRef>) {
const theme = useTheme();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();

const currentUserLogin = currentUserPersonalDetails.login;
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(
Expand All @@ -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 (
Expand Down Expand Up @@ -86,5 +73,4 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ...props}: RNMarkdownTex
RNMarkdownTextInputWithRef.displayName = 'RNTextInputWithRef';

export default forwardRef(RNMarkdownTextInputWithRef);
export {decorateRangesWithCurrentUser};
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};
63 changes: 0 additions & 63 deletions tests/unit/decorateRangesWithCurrentUserTest.ts

This file was deleted.

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,
},
]);
});
});

0 comments on commit 43cf3f3

Please sign in to comment.