From 6853707ef212281b6b654a97334302bbc2332701 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Mon, 16 Sep 2019 17:48:50 -0300 Subject: [PATCH] MM-18335 Keep the icon badge in sync (#3246) * MM-18335 Keep the icon badge in sync * feedback review * Update snapshot --- .../push_notifications.android.js | 8 +- .../push_notifications.ios.js | 44 +----- .../push_notifications.ios.test.js | 79 +---------- .../channel_drawer_button.test.js.snap | 102 ++++++++++++++ .../channel_drawer_button.js | 65 +++------ .../channel_drawer_button.test.js | 129 ++++++++++++++++++ .../channel_drawer_button/index.js | 48 +++++++ 7 files changed, 308 insertions(+), 167 deletions(-) create mode 100644 app/screens/channel/channel_nav_bar/channel_drawer_button/__snapshots__/channel_drawer_button.test.js.snap rename app/screens/channel/channel_nav_bar/{ => channel_drawer_button}/channel_drawer_button.js (69%) create mode 100644 app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js create mode 100644 app/screens/channel/channel_nav_bar/channel_drawer_button/index.js diff --git a/app/push_notifications/push_notifications.android.js b/app/push_notifications/push_notifications.android.js index 9b15f6116..97ac14965 100644 --- a/app/push_notifications/push_notifications.android.js +++ b/app/push_notifications/push_notifications.android.js @@ -95,7 +95,8 @@ class PushNotification { } setApplicationIconBadgeNumber(number) { - NotificationsAndroid.setBadgesCount(number); + const count = number < 0 ? 0 : number; + NotificationsAndroid.setBadgesCount(count); } getNotification() { @@ -114,14 +115,9 @@ class PushNotification { } } - clearForegroundNotifications = () => { - // TODO: Implement as part of https://mattermost.atlassian.net/browse/MM-17110 - }; - clearNotifications = () => { this.setApplicationIconBadgeNumber(0); this.cancelAllLocalNotifications(); // TODO: Only cancel the local notifications that belong to this server - this.clearForegroundNotifications(); // TODO: Only clear the foreground notifications that belong to this server } } diff --git a/app/push_notifications/push_notifications.ios.js b/app/push_notifications/push_notifications.ios.js index 2130d8582..dae936ecf 100644 --- a/app/push_notifications/push_notifications.ios.js +++ b/app/push_notifications/push_notifications.ios.js @@ -2,14 +2,12 @@ // See LICENSE.txt for license information. import {AppState} from 'react-native'; -import AsyncStorage from '@react-native-community/async-storage'; import NotificationsIOS, {NotificationAction, NotificationCategory} from 'react-native-notifications'; import ephemeralStore from 'app/store/ephemeral_store'; const CATEGORY = 'CAN_REPLY'; const REPLY_ACTION = 'REPLY_ACTION'; -export const FOREGROUND_NOTIFICATIONS_KEY = '@FOREGROUND_NOTIFICATIONS'; let replyCategory; const replies = new Set(); @@ -53,10 +51,6 @@ class PushNotification { if (this.onNotification) { this.onNotification(this.deviceNotification); } - - if (foreground) { - this.trackForegroundNotification(data.channel_id); - } }; handleReply = (action, completed) => { @@ -139,10 +133,6 @@ class PushNotification { message: notification.getMessage(), }; this.handleNotification(info, true, false); - - NotificationsIOS.getBadgesCount((count) => { - this.setApplicationIconBadgeNumber(count + 1); - }); }; onNotificationOpened = (notification) => { @@ -173,21 +163,7 @@ class PushNotification { } clearChannelNotifications(channelId) { - NotificationsIOS.getDeliveredNotifications(async (notifications) => { - let foregroundNotifications; - try { - const value = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY); - foregroundNotifications = JSON.parse(value) || {}; - } catch (e) { - foregroundNotifications = {}; - } - - Reflect.deleteProperty(foregroundNotifications, channelId); - AsyncStorage.setItem(FOREGROUND_NOTIFICATIONS_KEY, JSON.stringify(foregroundNotifications)); - - const foregroundCount = Object.values(foregroundNotifications).reduce((a, b) => a + b, 0); - let badgeCount = notifications.length + foregroundCount; - + NotificationsIOS.getDeliveredNotifications((notifications) => { const ids = []; for (let i = 0; i < notifications.length; i++) { const notification = notifications[i]; @@ -198,32 +174,14 @@ class PushNotification { } if (ids.length) { - badgeCount -= ids.length; NotificationsIOS.removeDeliveredNotifications(ids); } - - this.setApplicationIconBadgeNumber(badgeCount); }); } - trackForegroundNotification = async (channelId) => { - const value = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY); - const foregroundNotifications = value ? JSON.parse(value) : {}; - if (!foregroundNotifications.hasOwnProperty(channelId)) { - foregroundNotifications[channelId] = 0; - } - foregroundNotifications[channelId] += 1; - await AsyncStorage.setItem(FOREGROUND_NOTIFICATIONS_KEY, JSON.stringify(foregroundNotifications)); - } - - clearForegroundNotifications = () => { - AsyncStorage.removeItem(FOREGROUND_NOTIFICATIONS_KEY); - }; - clearNotifications = () => { this.setApplicationIconBadgeNumber(0); this.cancelAllLocalNotifications(); // TODO: Only cancel the local notifications that belong to this server - this.clearForegroundNotifications(); // TODO: Only clear the foreground notifications that belong to this server } } diff --git a/app/push_notifications/push_notifications.ios.test.js b/app/push_notifications/push_notifications.ios.test.js index 0dda9a7fa..51d249cb1 100644 --- a/app/push_notifications/push_notifications.ios.test.js +++ b/app/push_notifications/push_notifications.ios.test.js @@ -1,9 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import AsyncStorage from '@react-native-community/async-storage'; import NotificationsIOS from 'react-native-notifications'; -import PushNotification, {FOREGROUND_NOTIFICATIONS_KEY} from './push_notifications.ios'; +import PushNotification from './push_notifications.ios'; jest.mock('react-native-notifications', () => { let badgesCount = 0; @@ -33,58 +32,8 @@ jest.mock('react-native-notifications', () => { describe('PushNotification', () => { const channel1ID = 'channel-1-id'; const channel2ID = 'channel-2-id'; - const notification = { - getData: jest.fn(), - getMessage: jest.fn(), - }; - afterEach(() => AsyncStorage.clear()); - - it('should track foreground notifications for channel', async () => { - let item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY); - expect(item).toBe(null); - - await PushNotification.trackForegroundNotification(channel1ID); - item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY); - expect(item).not.toBe(null); - let foregroundNotifications = JSON.parse(item); - expect(foregroundNotifications[channel1ID]).toBe(1); - expect(foregroundNotifications[channel2ID]).toBe(undefined); - - await PushNotification.trackForegroundNotification(channel1ID); - item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY); - expect(item).not.toBe(null); - foregroundNotifications = JSON.parse(item); - expect(foregroundNotifications[channel1ID]).toBe(2); - expect(foregroundNotifications[channel2ID]).toBe(undefined); - }); - - it('should NOT track foreground notifications for channel when opened', async () => { - let item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY); - expect(item).toBe(null); - - PushNotification.trackForegroundNotification = jest.fn(); - PushNotification.onNotificationOpened(notification); - expect(PushNotification.trackForegroundNotification).not.toBeCalled(); - item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY); - expect(item).toBe(null); - }); - - it('should increment badge number when foreground notification is received', () => { - const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber'); - - NotificationsIOS.getBadgesCount((count) => expect(count).toBe(0)); - - PushNotification.onNotificationReceivedForeground(notification); - expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(1); - NotificationsIOS.getBadgesCount((count) => expect(count).toBe(1)); - - PushNotification.onNotificationReceivedForeground(notification); - expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(2); - NotificationsIOS.getBadgesCount((count) => expect(count).toBe(2)); - }); - - it('should clear channel notifications and set correct badge number', async () => { + it('should clear channel notifications', async () => { const deliveredNotifications = [ // Three channel1 delivered notifications @@ -113,20 +62,13 @@ describe('PushNotification', () => { ]; NotificationsIOS.setDeliveredNotifications(deliveredNotifications); - const foregroundNotifications = { - [channel1ID]: 1, - [channel2ID]: 1, - }; - await AsyncStorage.setItem(FOREGROUND_NOTIFICATIONS_KEY, JSON.stringify(foregroundNotifications)); - - const notificationCount = deliveredNotifications.length + Object.values(foregroundNotifications).reduce((a, b) => a + b); - expect(notificationCount).toBe(7); + const notificationCount = deliveredNotifications.length; + expect(notificationCount).toBe(5); NotificationsIOS.setBadgesCount(notificationCount); NotificationsIOS.getBadgesCount((count) => expect(count).toBe(notificationCount)); // Clear channel1 notifications - const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber'); await PushNotification.clearChannelNotifications(channel1ID); await NotificationsIOS.getDeliveredNotifications(async (deliveredNotifs) => { @@ -135,30 +77,17 @@ describe('PushNotification', () => { const channel2DeliveredNotifications = deliveredNotifs.filter((n) => n.userInfo.channel_id === channel2ID); expect(channel1DeliveredNotifications.length).toBe(0); expect(channel2DeliveredNotifications.length).toBe(2); - - const item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY); - const foregroundNotifs = JSON.parse(item); - - const channel1ForegroundNotifications = foregroundNotifs[channel1ID]; - const channel2ForegroundNotifications = foregroundNotifs[channel2ID]; - expect(channel1ForegroundNotifications).toBe(undefined); - expect(channel2ForegroundNotifications).toBe(1); - - const badgeNumber = channel2DeliveredNotifications.length + channel2ForegroundNotifications; - expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(badgeNumber); }); }); it('should clear all notifications', () => { const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber'); const cancelAllLocalNotifications = jest.spyOn(PushNotification, 'cancelAllLocalNotifications'); - const clearForegroundNotifications = jest.spyOn(PushNotification, 'clearForegroundNotifications'); PushNotification.clearNotifications(); expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(0); expect(NotificationsIOS.setBadgesCount).toHaveBeenCalledWith(0); expect(cancelAllLocalNotifications).toHaveBeenCalled(); expect(NotificationsIOS.cancelAllLocalNotifications).toHaveBeenCalled(); - expect(clearForegroundNotifications).toHaveBeenCalled(); }); }); diff --git a/app/screens/channel/channel_nav_bar/channel_drawer_button/__snapshots__/channel_drawer_button.test.js.snap b/app/screens/channel/channel_nav_bar/channel_drawer_button/__snapshots__/channel_drawer_button.test.js.snap new file mode 100644 index 000000000..df71b3cb8 --- /dev/null +++ b/app/screens/channel/channel_nav_bar/channel_drawer_button/__snapshots__/channel_drawer_button.test.js.snap @@ -0,0 +1,102 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChannelDrawerButton should match, full snapshot 1`] = ` + + + + + + + +`; + +exports[`ChannelDrawerButton should match, full snapshot 2`] = ` + + + + + + + + +`; diff --git a/app/screens/channel/channel_nav_bar/channel_drawer_button.js b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.js similarity index 69% rename from app/screens/channel/channel_nav_bar/channel_drawer_button.js rename to app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.js index 4111d6977..9de6e7955 100644 --- a/app/screens/channel/channel_nav_bar/channel_drawer_button.js +++ b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.js @@ -3,7 +3,6 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; import { TouchableOpacity, View, @@ -12,34 +11,44 @@ import { import Icon from 'react-native-vector-icons/Ionicons'; import Badge from 'app/components/badge'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import PushNotifications from 'app/push_notifications'; import {preventDoubleTap} from 'app/utils/tap'; import {makeStyleSheetFromTheme} from 'app/utils/theme'; import telemetry from 'app/telemetry'; -import {getUnreadsInCurrentTeam} from 'mattermost-redux/selectors/entities/channels'; -import {getCurrentTeamId, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams'; - -class ChannelDrawerButton extends PureComponent { +export default class ChannelDrawerButton extends PureComponent { static propTypes = { - currentTeamId: PropTypes.string.isRequired, openDrawer: PropTypes.func.isRequired, - messageCount: PropTypes.number, - mentionCount: PropTypes.number, - myTeamMembers: PropTypes.object, + badgeCount: PropTypes.number, theme: PropTypes.object, visible: PropTypes.bool, }; static defaultProps = { + badgeCount: 0, currentChannel: {}, theme: {}, - messageCount: 0, - mentionCount: 0, visible: true, }; + componentDidMount() { + if (this.props.badgeCount > 0) { + // Only set the icon badge number if once the component mounts we have at least one mention + // reason is to prevent the notification in the notification center to get cleared + // while the app is retrieving unread mentions from the server + PushNotifications.setApplicationIconBadgeNumber(this.props.badgeCount); + } + } + + componentDidUpdate(prevProps) { + // Once the component updates we know for sure if there are or not mentions when it mounted + // a) the app had mentions + if (prevProps.badgeCount !== this.props.badgeCount) { + PushNotifications.setApplicationIconBadgeNumber(this.props.badgeCount); + } + } + handlePress = preventDoubleTap(() => { telemetry.start(['channel:open_drawer']); this.props.openDrawer(); @@ -47,32 +56,13 @@ class ChannelDrawerButton extends PureComponent { render() { const { - currentTeamId, - mentionCount, - messageCount, - myTeamMembers, + badgeCount, theme, visible, } = this.props; const style = getStyleFromTheme(theme); - let mentions = mentionCount; - let messages = messageCount; - - const members = Object.values(myTeamMembers).filter((m) => m.team_id !== currentTeamId); - members.forEach((m) => { - mentions += (m.mention_count || 0); - messages += (m.msg_count || 0); - }); - - let badgeCount = 0; - if (mentions) { - badgeCount = mentions; - } else if (messages) { - badgeCount = -1; - } - let badge; if (badgeCount && visible) { badge = ( @@ -156,14 +146,3 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { }, }; }); - -function mapStateToProps(state) { - return { - currentTeamId: getCurrentTeamId(state), - myTeamMembers: getTeamMemberships(state), - theme: getTheme(state), - ...getUnreadsInCurrentTeam(state), - }; -} - -export default connect(mapStateToProps)(ChannelDrawerButton); diff --git a/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js new file mode 100644 index 000000000..cd9eef22a --- /dev/null +++ b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; +import NotificationsIOS from 'react-native-notifications'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +import Badge from 'app/components/badge'; +import PushNotification from 'app/push_notifications/push_notifications.ios'; + +import ChannelDrawerButton from './channel_drawer_button'; + +jest.mock('react-native-notifications', () => { + let badgesCount = 0; + let deliveredNotifications = {}; + + return { + getBadgesCount: jest.fn((callback) => callback(badgesCount)), + setBadgesCount: jest.fn((count) => { + badgesCount = count; + }), + addEventListener: jest.fn(), + setDeliveredNotifications: jest.fn((notifications) => { + deliveredNotifications = notifications; + }), + getDeliveredNotifications: jest.fn(async (callback) => { + await callback(deliveredNotifications); + }), + removeDeliveredNotifications: jest.fn((ids) => { + deliveredNotifications = deliveredNotifications.filter((n) => !ids.includes(n.identifier)); + }), + cancelAllLocalNotifications: jest.fn(), + NotificationAction: jest.fn(), + NotificationCategory: jest.fn(), + }; +}); + +describe('ChannelDrawerButton', () => { + const baseProps = { + openDrawer: jest.fn(), + badgeCount: 0, + theme: Preferences.THEMES.default, + visible: false, + }; + + afterEach(() => NotificationsIOS.setBadgesCount(0)); + + test('should match, full snapshot', () => { + const wrapper = shallow( + + ); + + // no badge to show + expect(wrapper.getElement()).toMatchSnapshot(); + expect(wrapper.find(Badge).length).toEqual(0); + + // badge should render + wrapper.setProps({badgeCount: 1, visible: true}); + expect(wrapper.getElement()).toMatchSnapshot(); + expect(wrapper.find(Badge).length).toEqual(1); + }); + + test('should not set app icon badge on mount', () => { + const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber'); + const props = { + ...baseProps, + badgeCount: 0, + }; + + shallow( + + ); + expect(setApplicationIconBadgeNumber).not.toBeCalled(); + NotificationsIOS.getBadgesCount((count) => expect(count).toBe(0)); + }); + + test('should set app icon badge on mount', () => { + const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber'); + const props = { + ...baseProps, + badgeCount: 1, + }; + + shallow( + + ); + expect(setApplicationIconBadgeNumber).toHaveBeenCalledTimes(1); + NotificationsIOS.getBadgesCount((count) => expect(count).toBe(1)); + }); + + test('should set app icon badge update', () => { + const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber'); + const props = { + ...baseProps, + badgeCount: 0, + }; + + const wrapper = shallow( + + ); + NotificationsIOS.getBadgesCount((count) => expect(count).toBe(0)); + + wrapper.setProps({badgeCount: 2}); + expect(setApplicationIconBadgeNumber).toHaveBeenCalledTimes(1); + expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(2); + NotificationsIOS.getBadgesCount((count) => expect(count).toBe(2)); + }); + + test('should set remove icon badge on update', () => { + const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber'); + const props = { + ...baseProps, + badgeCount: 0, + }; + + const wrapper = shallow( + + ); + wrapper.setProps({badgeCount: 2}); + expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(2); + NotificationsIOS.getBadgesCount((count) => expect(count).toBe(2)); + + wrapper.setProps({badgeCount: -1}); + expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(-1); + NotificationsIOS.getBadgesCount((count) => expect(count).toBe(0)); + }); +}); diff --git a/app/screens/channel/channel_nav_bar/channel_drawer_button/index.js b/app/screens/channel/channel_nav_bar/channel_drawer_button/index.js new file mode 100644 index 000000000..535664510 --- /dev/null +++ b/app/screens/channel/channel_nav_bar/channel_drawer_button/index.js @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {createSelector} from 'reselect'; + +import {getUnreadsInCurrentTeam} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import ChannelDrawerButton from './channel_drawer_button'; + +const getBadgeCount = createSelector( + getCurrentTeamId, + getTeamMemberships, + (state, mentionCount) => mentionCount, + (state, _, messageCount) => messageCount, + (currentTeamId, myTeamMembers, mentionCount, messageCount) => { + let mentions = mentionCount; + let messages = messageCount; + + const members = Object.values(myTeamMembers).filter((m) => m.team_id !== currentTeamId); + members.forEach((m) => { + mentions += (m.mention_count || 0); + messages += (m.msg_count || 0); + }); + + let badgeCount = 0; + if (mentions) { + badgeCount = mentions; + } else if (messages) { + badgeCount = -1; + } + + return badgeCount; + } +); + +function mapStateToProps(state) { + const {mentionCount, messageCount} = getUnreadsInCurrentTeam(state); + + return { + badgeCount: getBadgeCount(state, mentionCount, messageCount), + theme: getTheme(state), + }; +} + +export default connect(mapStateToProps)(ChannelDrawerButton);