diff --git a/app/containers/markdown/Markdown.textStyle.test.tsx b/app/containers/markdown/Markdown.textStyle.test.tsx index ae7b1b4af12..c25835e5435 100644 --- a/app/containers/markdown/Markdown.textStyle.test.tsx +++ b/app/containers/markdown/Markdown.textStyle.test.tsx @@ -40,4 +40,30 @@ describe('Markdown textStyle integration', () => { expect(onLinkPress).toHaveBeenCalledWith('https://rocket.chat'); }); + + it('propagates custom color in textStyle to link, mention, hashtag and plain text', () => { + const onLinkPress = jest.fn(); + const textStyle = { color: 'red' }; + + const { getByLabelText, getByText } = render( + + ); + + const plainTextNode = getByLabelText('hello '); + const linkNode = getByText('my link'); + const mentionNode = getByText('@rocket.cat'); + const hashtagNode = getByText('#general'); + + expect(plainTextNode.props.style).toEqual(expect.arrayContaining([expect.objectContaining({ color: 'red' })])); + expect(linkNode.props.style).toEqual(expect.arrayContaining([expect.objectContaining({ color: 'red' })])); + expect(mentionNode.props.style).toEqual(expect.arrayContaining([expect.objectContaining({ color: 'red' })])); + expect(hashtagNode.props.style).toEqual(expect.arrayContaining([expect.objectContaining({ color: 'red' })])); + }); }); diff --git a/app/containers/markdown/components/inline/Link.tsx b/app/containers/markdown/components/inline/Link.tsx index 8bc9ea9d527..4da2c3124d8 100644 --- a/app/containers/markdown/components/inline/Link.tsx +++ b/app/containers/markdown/components/inline/Link.tsx @@ -49,7 +49,7 @@ const Link = ({ value }: ILinkProps) => { return ( {(block => { diff --git a/app/containers/markdown/components/mentions/AtMention.tsx b/app/containers/markdown/components/mentions/AtMention.tsx index bb454b7bbd2..ae3fcfdfed0 100644 --- a/app/containers/markdown/components/mentions/AtMention.tsx +++ b/app/containers/markdown/components/mentions/AtMention.tsx @@ -28,10 +28,10 @@ const AtMention = memo(({ mention, mentions, username, navToRoomInfo, useRealNam {preffix} {mention} @@ -76,7 +76,7 @@ const AtMention = memo(({ mention, mentions, username, navToRoomInfo, useRealNam return ( // not enough information on mentions to navigate to team info, so we don't handle onPress {preffix} {text} @@ -85,7 +85,7 @@ const AtMention = memo(({ mention, mentions, username, navToRoomInfo, useRealNam } return ( - {`@${mention}`} + {`@${mention}`} ); }); diff --git a/app/containers/markdown/components/mentions/Hashtag.tsx b/app/containers/markdown/components/mentions/Hashtag.tsx index 3336998369c..425779204e7 100644 --- a/app/containers/markdown/components/mentions/Hashtag.tsx +++ b/app/containers/markdown/components/mentions/Hashtag.tsx @@ -57,10 +57,10 @@ const Hashtag = memo(({ hashtag, channels, navToRoomInfo }: IHashtag) => { {`${preffix}${hashtag}`} @@ -68,7 +68,7 @@ const Hashtag = memo(({ hashtag, channels, navToRoomInfo }: IHashtag) => { ); } return ( - {`#${hashtag}`} + {`#${hashtag}`} ); }); diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 8f9606fe55d..37de79125da 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -1002,6 +1002,7 @@ "User_not_found_or": "User not found or incorrect password", "User_sent_an_attachment": "{{user}} sent an attachment", "Username": "Username", + "Username_invalid": "Use only letters, numbers, dots, hyphens and underscores", "Username_is_already_in_use": "Username is already in use.", "Username_not_available": "Username not available", "Username_or_email": "Username or email", diff --git a/app/views/ProfileView/index.test.tsx b/app/views/ProfileView/index.test.tsx index 0fe9bc0cb4b..03a99c22511 100644 --- a/app/views/ProfileView/index.test.tsx +++ b/app/views/ProfileView/index.test.tsx @@ -9,6 +9,7 @@ import { twoFactor } from '../../lib/services/twoFactor'; import handleSaveUserProfileError from '../../lib/methods/helpers/handleSaveUserProfileError'; import EventEmitter from '../../lib/methods/helpers/events'; import { setUser } from '../../actions/login'; +import I18n from '../../i18n'; jest.mock('react-redux', () => ({ useDispatch: jest.fn() @@ -69,7 +70,8 @@ const buildState = () => ({ Accounts_AllowUserAvatarChange: true, Accounts_AllowUsernameChange: true, Accounts_CustomFields: '', - Accounts_AllowDeleteOwnAccount: true + Accounts_AllowDeleteOwnAccount: true, + UTF8_User_Names_Validation: '[0-9a-zA-Z-_.]+' } }); @@ -110,6 +112,27 @@ describe('ProfileView submit', () => { expect(handleSaveUserProfileError).not.toHaveBeenCalled(); }); + it('does not save when the username contains invalid characters', async () => { + const { getByTestId, findByText } = renderProfile(); + + fireEvent.changeText(getByTestId('profile-view-username'), 'cygnus b'); + fireEvent.press(getByTestId('profile-view-submit')); + + await findByText(I18n.t('Username_invalid')); + expect(saveUserProfile).not.toHaveBeenCalled(); + }); + + it('saves when the username is corrected to a valid value', async () => { + (saveUserProfile as jest.Mock).mockResolvedValue(true); + + const { getByTestId } = renderProfile(); + + fireEvent.changeText(getByTestId('profile-view-username'), 'cygnus.b'); + fireEvent.press(getByTestId('profile-view-submit')); + + await waitFor(() => expect(saveUserProfile).toHaveBeenCalledWith({ username: 'cygnus.b' }, {})); + }); + it('asks for the current password before changing the email', async () => { const { getByTestId } = renderProfile(); diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx index 6b4366d58da..34496096703 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -46,16 +46,25 @@ import ConfirmEmailChangeActionSheetContent from './components/ConfirmEmailChang const MAX_BIO_LENGTH = 260; const MAX_NICKNAME_LENGTH = 120; +// Mirror the server's username validation, which matches the value against the +// UTF8_User_Names_Validation setting anchored as a full match (`^pattern$`), +// falling back to the default pattern when the setting is empty or invalid. +// https://github.com/RocketChat/Rocket.Chat/blob/develop/apps/meteor/app/lib/server/functions/validateUsername.ts +const DEFAULT_USERNAME_VALIDATION = '[0-9a-zA-Z-_.]+'; + +const isValidUsername = (username: string, pattern: string): boolean => { + try { + return new RegExp(`^(${pattern || DEFAULT_USERNAME_VALIDATION})$`).test(username); + } catch { + // Fall back to the default pattern if the server provides an invalid regex. + return new RegExp(`^(${DEFAULT_USERNAME_VALIDATION})$`).test(username); + } +}; + interface IProfileViewProps { navigation: NativeStackNavigationProp; } const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { - const validationSchema = yup.object().shape({ - name: yup.string().required(I18n.t('Name_required')), - email: yup.string().email(I18n.t('Email_must_be_a_valid_email')).required(I18n.t('Email_required')), - username: yup.string().required(I18n.t('Username_required')) - }); - const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); const dispatch = useDispatch(); @@ -67,6 +76,7 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { Accounts_AllowUserAvatarChange, Accounts_AllowUsernameChange, Accounts_CustomFields, + UTF8_User_Names_Validation, serverVersion, user } = useAppSelector(state => ({ @@ -77,9 +87,19 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { Accounts_AllowUserAvatarChange: state.settings.Accounts_AllowUserAvatarChange as boolean, Accounts_AllowUsernameChange: state.settings.Accounts_AllowUsernameChange as boolean, Accounts_CustomFields: state.settings.Accounts_CustomFields as string, + UTF8_User_Names_Validation: state.settings.UTF8_User_Names_Validation as string, serverVersion: state.server.version, Accounts_AllowDeleteOwnAccount: state.settings.Accounts_AllowDeleteOwnAccount as boolean })); + + const validationSchema = yup.object().shape({ + name: yup.string().required(I18n.t('Name_required')), + email: yup.string().email(I18n.t('Email_must_be_a_valid_email')).required(I18n.t('Email_required')), + username: yup + .string() + .required(I18n.t('Username_required')) + .test('valid-username', I18n.t('Username_invalid'), value => isValidUsername(value ?? '', UTF8_User_Names_Validation)) + }); const isMasterDetail = useMasterDetail(); const { control,