From 63a00c1cf6a397e5fa013f47bc55480c30001783 Mon Sep 17 00:00:00 2001 From: Shevilll Date: Mon, 22 Jun 2026 16:21:40 +0530 Subject: [PATCH 1/4] fix: validate username characters in profile before saving Validate the username field against the server's UTF8_User_Names_Validation setting (with the default pattern as a fallback) before submitting the profile. This prevents invalid characters such as spaces from reaching the server, which previously surfaced only as a generic save error, and shows an inline message explaining the allowed characters. Closes #1682 --- app/i18n/locales/en.json | 1 + app/views/ProfileView/index.test.tsx | 25 +++++++++++++++++++++- app/views/ProfileView/index.tsx | 32 ++++++++++++++++++++++------ 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index ac7f240d4d9..24b689ce196 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -994,6 +994,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 455c467a31d..c37decd216c 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() @@ -70,7 +71,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-_.]+' } }); @@ -111,6 +113,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 36e9ed8bf77..0b5c33580b2 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -45,16 +45,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) => { + 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(); @@ -66,6 +75,7 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { Accounts_AllowUserAvatarChange, Accounts_AllowUsernameChange, Accounts_CustomFields, + UTF8_User_Names_Validation, isMasterDetail, serverVersion, user @@ -78,9 +88,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 { control, handleSubmit, From 193cf2473b60ce8981dbdacdc7b4304b615c489b Mon Sep 17 00:00:00 2001 From: Shevilll Date: Wed, 24 Jun 2026 16:52:24 +0530 Subject: [PATCH 2/4] fix(profile): remove duplicate isMasterDetail binding and type guard Drop the stale isMasterDetail destructured from useAppSelector (the selector no longer returns it) so only the useMasterDetail() hook declares it, fixing the no-redeclare lint/build failure introduced by merging develop. Also annotate isValidUsername with an explicit boolean return type per the TS guideline. --- app/views/ProfileView/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx index fee2232ae29..34496096703 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -52,7 +52,7 @@ const MAX_NICKNAME_LENGTH = 120; // 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) => { +const isValidUsername = (username: string, pattern: string): boolean => { try { return new RegExp(`^(${pattern || DEFAULT_USERNAME_VALIDATION})$`).test(username); } catch { @@ -77,7 +77,6 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { Accounts_AllowUsernameChange, Accounts_CustomFields, UTF8_User_Names_Validation, - isMasterDetail, serverVersion, user } = useAppSelector(state => ({ From 7e85caf10e66a56c73f31220831f46145bc84649 Mon Sep 17 00:00:00 2001 From: Shevilll Date: Thu, 25 Jun 2026 23:59:14 +0530 Subject: [PATCH 3/4] fix(notifications): deduplicate stale video conference notifications on cold boot --- app/lib/notifications/deduplicator.ts | 46 +++++++++++++++++++ app/lib/notifications/index.ts | 25 ++++++++++ app/lib/notifications/push.ts | 43 ++++++++++++----- .../videoConf/getInitialNotification.ts | 29 ++++++++++++ 4 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 app/lib/notifications/deduplicator.ts diff --git a/app/lib/notifications/deduplicator.ts b/app/lib/notifications/deduplicator.ts new file mode 100644 index 00000000000..6e63aa58482 --- /dev/null +++ b/app/lib/notifications/deduplicator.ts @@ -0,0 +1,46 @@ +import UserPreferences from '../methods/userPreferences'; + +const NOTIFICATION_DEDUPLICATOR_KEY = 'processedNotificationIds'; +const MAX_STORED_IDS = 100; + +export const isNotificationProcessed = (id?: string | null): boolean => { + if (!id) { + return false; + } + try { + const storedJson = UserPreferences.getString(NOTIFICATION_DEDUPLICATOR_KEY); + if (!storedJson) { + return false; + } + const processedIds: string[] = JSON.parse(storedJson); + return processedIds.includes(id); + } catch (e) { + console.warn('[NotificationDeduplicator] Error reading processed notification IDs:', e); + return false; + } +}; + +export const markNotificationAsProcessed = (id?: string | null): void => { + if (!id) { + return; + } + try { + const storedJson = UserPreferences.getString(NOTIFICATION_DEDUPLICATOR_KEY); + let processedIds: string[] = []; + if (storedJson) { + processedIds = JSON.parse(storedJson); + } + + // Avoid duplicates in the array + if (!processedIds.includes(id)) { + processedIds.push(id); + // Limit size to MAX_STORED_IDS + if (processedIds.length > MAX_STORED_IDS) { + processedIds = processedIds.slice(processedIds.length - MAX_STORED_IDS); + } + UserPreferences.setString(NOTIFICATION_DEDUPLICATOR_KEY, JSON.stringify(processedIds)); + } + } catch (e) { + console.warn('[NotificationDeduplicator] Error writing processed notification IDs:', e); + } +}; diff --git a/app/lib/notifications/index.ts b/app/lib/notifications/index.ts index 47707cf964c..aa294c01a4f 100644 --- a/app/lib/notifications/index.ts +++ b/app/lib/notifications/index.ts @@ -6,6 +6,7 @@ import { deepLinkingClickCallPush, deepLinkingOpen } from '../../actions/deepLin import { type INotification, SubscriptionType } from '../../definitions'; import { store } from '../store/auxStore'; import { deviceToken, pushNotificationConfigure, removeAllNotifications, setNotificationsBadgeCount } from './push'; +import { isNotificationProcessed, markNotificationAsProcessed } from './deduplicator'; interface IEjson { rid: string; @@ -24,6 +25,18 @@ export const onNotification = (push: INotification): void => { if (push?.payload?.ejson) { try { const notification = EJSON.parse(push.payload.ejson); + const notificationId = push.identifier; + const callId = notification?.callId; + + if (isNotificationProcessed(notificationId) || (callId && isNotificationProcessed(callId))) { + return; + } + + markNotificationAsProcessed(notificationId); + if (callId) { + markNotificationAsProcessed(callId); + } + store.dispatch( deepLinkingClickCallPush({ ...notification, event: identifier === 'ACCEPT_ACTION' ? 'accept' : 'decline' }) ); @@ -40,6 +53,18 @@ export const onNotification = (push: INotification): void => { // Handle video conf notification tap (default action) - treat as accept if (notification?.notificationType === 'videoconf') { + const notificationId = push.identifier; + const callId = notification?.callId; + + if (isNotificationProcessed(notificationId) || (callId && isNotificationProcessed(callId))) { + return; + } + + markNotificationAsProcessed(notificationId); + if (callId) { + markNotificationAsProcessed(callId); + } + store.dispatch(deepLinkingClickCallPush({ ...notification, event: 'accept' })); return; } diff --git a/app/lib/notifications/push.ts b/app/lib/notifications/push.ts index 370aeea35a4..0f0842bc2d6 100644 --- a/app/lib/notifications/push.ts +++ b/app/lib/notifications/push.ts @@ -1,6 +1,7 @@ import * as Notifications from 'expo-notifications'; import * as Device from 'expo-device'; import { Platform } from 'react-native'; +import EJSON from 'ejson'; import { type INotification } from '../../definitions'; import { isIOS } from '../methods/helpers'; @@ -8,6 +9,7 @@ import { store as reduxStore } from '../store/auxStore'; import { registerPushToken } from '../services/restApi'; import I18n from '../../i18n'; import NativePushNotificationModule from '../native/NativePushNotificationAndroid'; +import { isNotificationProcessed } from './deduplicator'; export let deviceToken = ''; @@ -159,6 +161,33 @@ const registerForPushNotifications = async (): Promise => { } }; +/** + * Retrieve the last notification response, but return null if it was already processed. + */ +const getLastDeduplicatedResponse = (): INotification | null => { + try { + const lastResponse = Notifications.getLastNotificationResponse(); + if (lastResponse) { + const transformed = transformNotificationResponse(lastResponse); + if (transformed.payload?.ejson) { + const ejsonData = EJSON.parse(transformed.payload.ejson); + if (ejsonData?.notificationType === 'videoconf') { + if (transformed.identifier && isNotificationProcessed(transformed.identifier)) { + return null; + } + if (ejsonData?.callId && isNotificationProcessed(ejsonData.callId)) { + return null; + } + } + } + return transformed; + } + } catch (e) { + console.log('Error getting deduplicated last notification response:', e); + } + return null; +}; + export const pushNotificationConfigure = (onNotification: (notification: INotification) => void): Promise => { if (configured) { return Promise.resolve({ configured: true }); @@ -261,12 +290,7 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi } // Fallback to expo-notifications (for iOS or if native module doesn't have data) - const lastResponse = Notifications.getLastNotificationResponse(); - if (lastResponse) { - return transformNotificationResponse(lastResponse); - } - - return null; + return getLastDeduplicatedResponse(); }) .catch(e => { console.error('[push.ts] Error in promise chain:', e); @@ -275,10 +299,5 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi } // Fallback to expo-notifications (for iOS or if native module doesn't have data) - const lastResponse = Notifications.getLastNotificationResponse(); - if (lastResponse) { - return Promise.resolve(transformNotificationResponse(lastResponse)); - } - - return Promise.resolve(null); + return Promise.resolve(getLastDeduplicatedResponse()); }; diff --git a/app/lib/notifications/videoConf/getInitialNotification.ts b/app/lib/notifications/videoConf/getInitialNotification.ts index b70b45b0adc..0e8851e2883 100644 --- a/app/lib/notifications/videoConf/getInitialNotification.ts +++ b/app/lib/notifications/videoConf/getInitialNotification.ts @@ -5,6 +5,7 @@ import { DeviceEventEmitter, Platform } from 'react-native'; import { deepLinkingClickCallPush } from '../../../actions/deepLinking'; import { store } from '../../store/auxStore'; import NativeVideoConfModule from '../../native/NativeVideoConfAndroid'; +import { isNotificationProcessed, markNotificationAsProcessed } from '../deduplicator'; /** * Sets up listener for video conference actions from native side. @@ -16,6 +17,13 @@ export const setupVideoConfActionListener = (): (() => void) | undefined => { try { const data = JSON.parse(actionJson); if (data?.notificationType === 'videoconf') { + const callId = data?.callId; + if (callId && isNotificationProcessed(callId)) { + return; + } + if (callId) { + markNotificationAsProcessed(callId); + } store.dispatch(deepLinkingClickCallPush(data)); } } catch (error) { @@ -41,6 +49,13 @@ export const getInitialNotification = async (): Promise => { if (pendingAction) { const data = JSON.parse(pendingAction); if (data?.notificationType === 'videoconf') { + const callId = data?.callId; + if (callId && isNotificationProcessed(callId)) { + return false; + } + if (callId) { + markNotificationAsProcessed(callId); + } store.dispatch(deepLinkingClickCallPush(data)); return true; } @@ -66,6 +81,19 @@ export const getInitialNotification = async (): Promise => { if (payload.ejson) { const ejsonData = EJSON.parse(payload.ejson); if (ejsonData?.notificationType === 'videoconf') { + const notificationId = notification.request.identifier; + const callId = ejsonData?.callId; + + if (isNotificationProcessed(notificationId) || (callId && isNotificationProcessed(callId))) { + return false; + } + + // Mark as processed + markNotificationAsProcessed(notificationId); + if (callId) { + markNotificationAsProcessed(callId); + } + // Accept/Decline actions or default tap (treat as accept) let event = 'accept'; if (actionIdentifier === 'DECLINE_ACTION') { @@ -83,3 +111,4 @@ export const getInitialNotification = async (): Promise => { return false; }; + From 47aa70ae85f7884c47b10aa6a5ab107e91e21f5e Mon Sep 17 00:00:00 2001 From: Shevilll Date: Fri, 3 Jul 2026 23:57:11 +0530 Subject: [PATCH 4/4] fix(notifications): address code review comments on deduplicator storage validation and spacing --- app/lib/notifications/deduplicator.ts | 17 ++++++++++++++--- .../videoConf/getInitialNotification.ts | 1 - 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/lib/notifications/deduplicator.ts b/app/lib/notifications/deduplicator.ts index 6e63aa58482..55e0517284e 100644 --- a/app/lib/notifications/deduplicator.ts +++ b/app/lib/notifications/deduplicator.ts @@ -12,10 +12,15 @@ export const isNotificationProcessed = (id?: string | null): boolean => { if (!storedJson) { return false; } - const processedIds: string[] = JSON.parse(storedJson); - return processedIds.includes(id); + const processedIds = JSON.parse(storedJson); + if (Array.isArray(processedIds)) { + return processedIds.includes(id); + } + UserPreferences.removeItem(NOTIFICATION_DEDUPLICATOR_KEY); + return false; } catch (e) { console.warn('[NotificationDeduplicator] Error reading processed notification IDs:', e); + UserPreferences.removeItem(NOTIFICATION_DEDUPLICATOR_KEY); return false; } }; @@ -28,7 +33,12 @@ export const markNotificationAsProcessed = (id?: string | null): void => { const storedJson = UserPreferences.getString(NOTIFICATION_DEDUPLICATOR_KEY); let processedIds: string[] = []; if (storedJson) { - processedIds = JSON.parse(storedJson); + const parsed = JSON.parse(storedJson); + if (Array.isArray(parsed)) { + processedIds = parsed; + } else { + UserPreferences.removeItem(NOTIFICATION_DEDUPLICATOR_KEY); + } } // Avoid duplicates in the array @@ -42,5 +52,6 @@ export const markNotificationAsProcessed = (id?: string | null): void => { } } catch (e) { console.warn('[NotificationDeduplicator] Error writing processed notification IDs:', e); + UserPreferences.removeItem(NOTIFICATION_DEDUPLICATOR_KEY); } }; diff --git a/app/lib/notifications/videoConf/getInitialNotification.ts b/app/lib/notifications/videoConf/getInitialNotification.ts index 0e8851e2883..9ebf9bea46d 100644 --- a/app/lib/notifications/videoConf/getInitialNotification.ts +++ b/app/lib/notifications/videoConf/getInitialNotification.ts @@ -111,4 +111,3 @@ export const getInitialNotification = async (): Promise => { return false; }; -