MM-18335 Keep the icon badge in sync (#3246)

* MM-18335 Keep the icon badge in sync

* feedback review

* Update snapshot
This commit is contained in:
Elias Nahum 2019-09-16 17:48:50 -03:00 committed by GitHub
parent 0ef2156b3f
commit 6853707ef2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 308 additions and 167 deletions

View file

@ -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
}
}

View file

@ -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
}
}

View file

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

View file

@ -0,0 +1,102 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ChannelDrawerButton should match, full snapshot 1`] = `
<TouchableOpacity
activeOpacity={0.2}
onPress={[Function]}
style={
Object {
"width": 10,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "column",
"justifyContent": "center",
"paddingHorizontal": 10,
},
]
}
>
<View>
<View
style={
Object {
"height": 25,
}
}
/>
</View>
</View>
</TouchableOpacity>
`;
exports[`ChannelDrawerButton should match, full snapshot 2`] = `
<TouchableOpacity
activeOpacity={0.2}
onPress={[Function]}
style={
Object {
"width": 55,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "column",
"justifyContent": "center",
"paddingHorizontal": 10,
},
]
}
>
<View>
<Icon
allowFontScaling={false}
color="#ffffff"
name="md-menu"
size={25}
/>
<Badge
containerStyle={
Object {
"borderColor": "#1153ab",
"borderRadius": 14,
"borderWidth": 2,
"position": "absolute",
"right": -14,
"top": -7,
}
}
count={1}
countStyle={
Object {
"color": "#145dbf",
"fontSize": 10,
}
}
extraPaddingHorizontal={10}
minHeight={20}
minWidth={19}
onPress={[Function]}
style={
Object {
"backgroundColor": undefined,
"height": 19,
"padding": 3,
}
}
/>
</View>
</View>
</TouchableOpacity>
`;

View file

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

View file

@ -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(
<ChannelDrawerButton {...baseProps}/>
);
// 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(
<ChannelDrawerButton {...props}/>
);
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(
<ChannelDrawerButton {...props}/>
);
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(
<ChannelDrawerButton {...props}/>
);
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(
<ChannelDrawerButton {...props}/>
);
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));
});
});

View file

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