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/lib/notifications/deduplicator.ts b/app/lib/notifications/deduplicator.ts new file mode 100644 index 00000000000..55e0517284e --- /dev/null +++ b/app/lib/notifications/deduplicator.ts @@ -0,0 +1,57 @@ +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 = 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; + } +}; + +export const markNotificationAsProcessed = (id?: string | null): void => { + if (!id) { + return; + } + try { + const storedJson = UserPreferences.getString(NOTIFICATION_DEDUPLICATOR_KEY); + let processedIds: string[] = []; + if (storedJson) { + const parsed = JSON.parse(storedJson); + if (Array.isArray(parsed)) { + processedIds = parsed; + } else { + UserPreferences.removeItem(NOTIFICATION_DEDUPLICATOR_KEY); + } + } + + // 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); + UserPreferences.removeItem(NOTIFICATION_DEDUPLICATOR_KEY); + } +}; 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..9ebf9bea46d 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') { 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,