From 42988fddc3b638b9b12728b7ce05160a63c11cdf Mon Sep 17 00:00:00 2001 From: Yair Szarf Date: Wed, 1 Oct 2025 08:20:22 -0400 Subject: [PATCH] Add a notification disabled notice to notification settings (#9145) * Adding the notification disabled notice * Change the color of the icon on the section notice to red. * Fix Linter Issues * Add new line due to CI failure * Adressing pull request comments and change requests. * i18 Strings alphabetical order * result of `npm run i18n-extract` * Add a couple more tests for notifications * Remove two unneeded styles * fix linter issue --- app/components/section_notice/index.tsx | 2 +- .../notifications/notifications.test.tsx | 103 ++++++++++++++++++ .../settings/notifications/notifications.tsx | 37 ++++++- .../index.test.tsx | 60 ++++++++++ .../notifications_disabled_notice/index.tsx | 60 ++++++++++ assets/base/i18n/en.json | 3 + test/setup.ts | 1 + 7 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 app/screens/settings/notifications/notifications.test.tsx create mode 100644 app/screens/settings/notifications/notifications_disabled_notice/index.test.tsx create mode 100644 app/screens/settings/notifications/notifications_disabled_notice/index.tsx diff --git a/app/components/section_notice/index.tsx b/app/components/section_notice/index.tsx index c07287169..fc7f92a55 100644 --- a/app/components/section_notice/index.tsx +++ b/app/components/section_notice/index.tsx @@ -103,7 +103,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { color: theme.dndIndicator, }, dangerIcon: { - color: theme.sidebarTextActiveBorder, + color: theme.errorTextColor, }, dangerContainer: { borderColor: changeOpacity(theme.dndIndicator, 0.16), diff --git a/app/screens/settings/notifications/notifications.test.tsx b/app/screens/settings/notifications/notifications.test.tsx new file mode 100644 index 000000000..96d82d0bc --- /dev/null +++ b/app/screens/settings/notifications/notifications.test.tsx @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import DatabaseManager from '@database/manager'; +import * as DeviceHooks from '@hooks/device'; +import {renderWithEverything, waitFor} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import Notifications from './notifications'; + +import type Database from '@nozbe/watermelondb/Database'; + +const MockedNotifications = jest.mocked(require('react-native-notifications').Notifications); + +function getBaseProps(): ComponentProps { + return { + componentId: 'Settings' as const, + currentUser: TestHelper.fakeUserModel({id: 'user1', username: 'username1'}), + emailInterval: '0', + enableAutoResponder: false, + enableEmailBatching: false, + isCRTEnabled: false, + sendEmailNotifications: false, + serverVersion: '10.3.0', + }; +} + +describe('Notifications disabled banner', () => { + let database: Database; + const testId = 'notifications-disabled-notice'; + const serverUrl = 'server-1'; + + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(serverUrl); + database = server.database; + jest.clearAllMocks(); + }); + + it('should be visible if notifications are disabled', async () => { + MockedNotifications.isRegisteredForRemoteNotifications.mockResolvedValue(false); + const wrapper = renderWithEverything(, {database}); + await waitFor(() => { + expect(wrapper.queryByTestId(testId)).toBeVisible(); + }); + }); + + it('should not be visible if notifications are enabled', async () => { + MockedNotifications.isRegisteredForRemoteNotifications.mockResolvedValue(true); + const wrapper = renderWithEverything(, {database}); + await waitFor(() => { + expect(wrapper.queryByTestId(testId)).toBeNull(); + }); + }); + + jest.spyOn(DeviceHooks, 'useAppState').mockReturnValue('active'); + + it('should re-check notification registration when appState changes', async () => { + MockedNotifications.isRegisteredForRemoteNotifications.mockResolvedValueOnce(false); + const appStateSpy = jest.spyOn(DeviceHooks, 'useAppState'); + appStateSpy.mockReturnValue('active'); + const wrapper = renderWithEverything(, {database}); + await waitFor(() => { + expect(MockedNotifications.isRegisteredForRemoteNotifications).toHaveBeenCalledTimes(1); + }); + + // Testing that this is not called in the background + MockedNotifications.isRegisteredForRemoteNotifications.mockResolvedValueOnce(true); + appStateSpy.mockReturnValue('background'); + wrapper.rerender(); + await waitFor(() => { + expect(MockedNotifications.isRegisteredForRemoteNotifications).toHaveBeenCalledTimes(1); + }); + + appStateSpy.mockReturnValue('active'); + wrapper.rerender(); + await waitFor(() => { + expect(MockedNotifications.isRegisteredForRemoteNotifications).toHaveBeenCalledTimes(2); + }); + }); + + afterAll(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should prevent state update after unmount (isCurrent race prevention)', async () => { + jest.spyOn(DeviceHooks, 'useAppState').mockReturnValue('active'); + + let resolvePromise!: (value: boolean) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + MockedNotifications.isRegisteredForRemoteNotifications.mockReturnValue(promise); + + const wrapper = renderWithEverything(, {database}); + wrapper.unmount(); + + resolvePromise(false); + + await new Promise((r) => setTimeout(r, 10)); + }); +}); diff --git a/app/screens/settings/notifications/notifications.tsx b/app/screens/settings/notifications/notifications.tsx index 8c1c100c5..235d302af 100644 --- a/app/screens/settings/notifications/notifications.tsx +++ b/app/screens/settings/notifications/notifications.tsx @@ -1,8 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {defineMessages, useIntl} from 'react-intl'; +import {Notifications as RNNotifications} from 'react-native-notifications'; import {getCallsConfig} from '@calls/state'; import SettingContainer from '@components/settings/container'; @@ -10,10 +11,13 @@ import SettingItem from '@components/settings/item'; import {General, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import {useAppState} from '@hooks/device'; import {popTopScreen} from '@screens/navigation'; import {gotoSettingsScreen} from '@screens/settings/config'; +import {logError} from '@utils/log'; import {getEmailInterval, getEmailIntervalTexts, getNotificationProps} from '@utils/user'; +import NotificationsDisabledNotice from './notifications_disabled_notice'; import SendTestNotificationNotice from './send_test_notification_notice'; import type UserModel from '@typings/database/models/servers/user'; @@ -38,7 +42,7 @@ const mentionTexts = defineMessages({ }, }); -type NotificationsProps = { +export type NotificationsProps = { componentId: AvailableScreens; currentUser?: UserModel; emailInterval: string; @@ -62,6 +66,31 @@ const Notifications = ({ const serverUrl = useServerUrl(); const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]); const callsRingingEnabled = useMemo(() => getCallsConfig(serverUrl).EnableRinging, [serverUrl]); + const [isRegistered, setIsRegistered] = useState(true); + + const appState = useAppState(); + + useEffect(() => { + let isCurrent = true; + if (appState === 'active') { + const checkNotificationStatus = async () => { + try { + const registered = await RNNotifications.isRegisteredForRemoteNotifications(); + if (isCurrent) { + setIsRegistered(registered); + } + } catch (error) { + if (isCurrent) { + logError('Error checking notification registration status:', error); + } + } + }; + checkNotificationStatus(); + } + return () => { + isCurrent = false; + }; + }, [appState]); const emailIntervalPref = useMemo(() => getEmailInterval( @@ -124,6 +153,10 @@ const Notifications = ({ return ( + {!isRegistered && + } { + let database: Database; + const testId = 'notifications-disabled-notice'; + const serverUrl = 'server-1'; + + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(serverUrl); + database = server.database; + jest.clearAllMocks(); + }); + + it('renders the notice with correct title and body', async () => { + const {getByText} = renderWithEverything( + , {database}, + ); + await waitFor(() => { + expect(getByText('Notifications are disabled')).toBeTruthy(); + expect(getByText(/You will still see mention badges/)).toBeTruthy(); + }); + }); + + it('sets the testID on the wrapper View', async () => { + const {getByTestId} = renderWithEverything( + , {database}, + ); + await waitFor(() => { + expect(getByTestId(testId)).toBeTruthy(); + }); + }); + + it('calls Permissions.openSettings when button is pressed', async () => { + const {queryByText} = renderWithEverything( + , {database}, + ); + const button = queryByText('Enable notifications'); + expect(button).toBeVisible(); + fireEvent.press(button); + expect(Permissions.openSettings).toHaveBeenCalledWith('notifications'); + }); + + afterAll(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); +}); diff --git a/app/screens/settings/notifications/notifications_disabled_notice/index.tsx b/app/screens/settings/notifications/notifications_disabled_notice/index.tsx new file mode 100644 index 000000000..6d06d0cb4 --- /dev/null +++ b/app/screens/settings/notifications/notifications_disabled_notice/index.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet, View} from 'react-native'; +import Permissions from 'react-native-permissions'; + +import SectionNotice from '@components/section_notice'; +import {Screens} from '@constants'; + +const styles = StyleSheet.create({ + wrapper: { + marginVertical: 16, + }, +}); + +type NotificationsDisabledNoticeProps = { + testID?: string; +} + +const NotificationsDisabledNotice = (props: NotificationsDisabledNoticeProps) => { + const intl = useIntl(); + + const onEnableNotificationClick = useCallback(() => { + Permissions.openSettings('notifications'); + }, []); + + const primaryButton = useMemo(() => { + const text = intl.formatMessage({ + id: 'user_settings.notifications.notifications_disabled_notice.button', + defaultMessage: 'Enable notifications', + }); + return { + onClick: onEnableNotificationClick, + text, + testID: 'enable-notifications-button', + }; + }, [intl, onEnableNotificationClick]); + + return ( + + + + ); +}; + +export default NotificationsDisabledNotice; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f326f9e23..41bc9b46e 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1364,6 +1364,9 @@ "user_profile.custom_status": "Custom Status", "user_profile.system_admin": "System Admin", "user_profile.team_admin": "Team Admin", + "user_settings.notifications.notifications_disabled_notice.body": "You will still see mention badges within the app, but you will not receive push notifications on your device.", + "user_settings.notifications.notifications_disabled_notice.button": "Enable notifications", + "user_settings.notifications.notifications_disabled_notice.title": "Notifications are disabled", "user_settings.notifications.test_notification.body": "Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps.", "user_settings.notifications.test_notification.go_to_docs": "Troubleshooting docs", "user_settings.notifications.test_notification.send_button.error": "Error sending test notification", diff --git a/test/setup.ts b/test/setup.ts index ed6749d70..2f73f49f9 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -380,6 +380,7 @@ jest.mock('react-native-notifications', () => { Notifications: { registerRemoteNotifications: jest.fn(), addEventListener: jest.fn(), + isRegisteredForRemoteNotifications: jest.fn(), setDeliveredNotifications: jest.fn((notifications) => { deliveredNotifications = notifications; }),