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
This commit is contained in:
Yair Szarf 2025-10-01 08:20:22 -04:00 committed by GitHub
parent 3efc301da5
commit 42988fddc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 263 additions and 3 deletions

View file

@ -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),

View file

@ -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<typeof Notifications> {
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(<Notifications {...getBaseProps()}/>, {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(<Notifications {...getBaseProps()}/>, {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(<Notifications {...getBaseProps()}/>, {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(<Notifications {...getBaseProps()}/>);
await waitFor(() => {
expect(MockedNotifications.isRegisteredForRemoteNotifications).toHaveBeenCalledTimes(1);
});
appStateSpy.mockReturnValue('active');
wrapper.rerender(<Notifications {...getBaseProps()}/>);
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<boolean>((resolve) => {
resolvePromise = resolve;
});
MockedNotifications.isRegisteredForRemoteNotifications.mockReturnValue(promise);
const wrapper = renderWithEverything(<Notifications {...getBaseProps()}/>, {database});
wrapper.unmount();
resolvePromise(false);
await new Promise((r) => setTimeout(r, 10));
});
});

View file

@ -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 (
<SettingContainer testID='notification_settings'>
{!isRegistered &&
<NotificationsDisabledNotice
testID='notifications-disabled-notice'
/>}
<SettingItem
onPress={goToNotificationSettingsMentions}
optionName='mentions'

View file

@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, waitFor} from '@testing-library/react-native';
import React from 'react';
import DatabaseManager from '@database/manager/__mocks__';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import NotificationsDisabledNotice from './index';
import type Database from '@nozbe/watermelondb/Database';
const Permissions = jest.mocked(require('react-native-permissions'));
describe('Notifications Disabled Notice', () => {
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(
<NotificationsDisabledNotice testID={testId}/>, {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(
<NotificationsDisabledNotice testID={testId}/>, {database},
);
await waitFor(() => {
expect(getByTestId(testId)).toBeTruthy();
});
});
it('calls Permissions.openSettings when button is pressed', async () => {
const {queryByText} = renderWithEverything(
<NotificationsDisabledNotice testID={testId}/>, {database},
);
const button = queryByText('Enable notifications');
expect(button).toBeVisible();
fireEvent.press(button);
expect(Permissions.openSettings).toHaveBeenCalledWith('notifications');
});
afterAll(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
});

View file

@ -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 (
<View
testID={props.testID}
style={styles.wrapper}
>
<SectionNotice
text={intl.formatMessage({
id: 'user_settings.notifications.notifications_disabled_notice.body',
defaultMessage: 'You will still see mention badges within the app, but you will not receive push notifications on your device.',
})}
title={intl.formatMessage({id: 'user_settings.notifications.notifications_disabled_notice.title', defaultMessage: 'Notifications are disabled'})}
primaryButton={primaryButton}
type='danger'
location={Screens.SETTINGS_NOTIFICATION_PUSH}
/>
</View>
);
};
export default NotificationsDisabledNotice;

View file

@ -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 theyre 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",

View file

@ -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;
}),