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,