From 9a894641fc1da3586bbfbeed1dced0bdac994532 Mon Sep 17 00:00:00 2001 From: Miguel Alatzar Date: Wed, 24 Jul 2019 07:58:15 -0700 Subject: [PATCH] [MM-14926] Include in-app notifications in iOS badge number (#3001) * Include in-app notifications in iOS badge number * Make foreground notifications key a const --- .../push_notifications.ios.js | 36 ++++- .../push_notifications.ios.test.js | 136 ++++++++++++++++++ package-lock.json | 50 ++++--- package.json | 1 + test/setup.js | 4 + 5 files changed, 202 insertions(+), 25 deletions(-) create mode 100644 app/push_notifications/push_notifications.ios.test.js diff --git a/app/push_notifications/push_notifications.ios.js b/app/push_notifications/push_notifications.ios.js index e6efa7c7e..5ec54d4ca 100644 --- a/app/push_notifications/push_notifications.ios.js +++ b/app/push_notifications/push_notifications.ios.js @@ -2,12 +2,14 @@ // 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(); @@ -51,6 +53,8 @@ class PushNotification { if (this.onNotification) { this.onNotification(this.deviceNotification); } + + this.trackForegroundNotification(data.channel_id); }; handleReply = (action, completed) => { @@ -130,6 +134,10 @@ class PushNotification { message: notification.getMessage(), }; this.handleNotification(info, true, false); + + NotificationsIOS.getBadgesCount((count) => { + this.setApplicationIconBadgeNumber(count + 1); + }); }; onNotificationOpened = (notification) => { @@ -160,10 +168,22 @@ class PushNotification { } clearChannelNotifications(channelId) { - NotificationsIOS.getDeliveredNotifications((notifications) => { - const ids = []; - let badgeCount = notifications.length; + 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; + + const ids = []; for (let i = 0; i < notifications.length; i++) { const notification = notifications[i]; @@ -180,6 +200,16 @@ class PushNotification { 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)); + } } export default new PushNotification(); diff --git a/app/push_notifications/push_notifications.ios.test.js b/app/push_notifications/push_notifications.ios.test.js new file mode 100644 index 000000000..4afa5b9ba --- /dev/null +++ b/app/push_notifications/push_notifications.ios.test.js @@ -0,0 +1,136 @@ +// 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'; + +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)); + }), + NotificationAction: jest.fn(), + NotificationCategory: jest.fn(), + }; +}); + +describe('PushNotification', () => { + const channel1ID = 'channel-1-id'; + const channel2ID = 'channel-2-id'; + const notification = { + getData: jest.fn(), + getMessage: jest.fn(), + }; + + 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 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 () => { + const deliveredNotifications = [ + + // Three channel1 delivered notifications + { + identifier: 'channel1-1', + userInfo: {channel_id: channel1ID}, + }, + { + identifier: 'channel1-2', + userInfo: {channel_id: channel1ID}, + }, + { + identifier: 'channel1-3', + userInfo: {channel_id: channel1ID}, + }, + + // Two channel2 delivered notifications + { + identifier: 'channel2-1', + userInfo: {channel_id: channel2ID}, + }, + { + identifier: 'channel2-2', + userInfo: {channel_id: channel2ID}, + }, + ]; + 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); + + NotificationsIOS.setBadgesCount(notificationCount); + NotificationsIOS.getBadgesCount((count) => expect(count).toBe(notificationCount)); + + // Clear channel1 notifications + const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber'); + await PushNotification.clearChannelNotifications(channel1ID); + + NotificationsIOS.getDeliveredNotifications(async (deliveredNotifs) => { + expect(deliveredNotifs.length).toBe(2); + const channel1DeliveredNotifications = deliveredNotifs.filter((n) => n.userInfo.channel_id === channel1ID); + 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.length).toBe(0); + expect(channel2ForegroundNotifications.length).toBe(1); + + const badgeNumber = channel2DeliveredNotifications.lenth + channel2ForegroundNotifications.length; + expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(badgeNumber); + }); + }); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2002ba012..131ed5800 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7674,8 +7674,7 @@ "ansi-regex": { "version": "2.1.1", "resolved": false, - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "optional": true + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" }, "aproba": { "version": "1.2.0", @@ -7696,8 +7695,7 @@ "balanced-match": { "version": "1.0.0", "resolved": false, - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "optional": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "brace-expansion": { "version": "1.1.11", @@ -7717,20 +7715,17 @@ "code-point-at": { "version": "1.1.0", "resolved": false, - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "optional": true + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, "concat-map": { "version": "0.0.1", "resolved": false, - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "optional": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "console-control-strings": { "version": "1.1.0", "resolved": false, - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "optional": true + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "core-util-is": { "version": "1.0.2", @@ -7847,8 +7842,7 @@ "inherits": { "version": "2.0.3", "resolved": false, - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "optional": true + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "ini": { "version": "1.3.5", @@ -7881,8 +7875,7 @@ "minimist": { "version": "0.0.8", "resolved": false, - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "optional": true + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "minipass": { "version": "2.3.5", @@ -7986,8 +7979,7 @@ "number-is-nan": { "version": "1.0.1", "resolved": false, - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "optional": true + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, "object-assign": { "version": "4.1.1", @@ -8084,8 +8076,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": false, - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "optional": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safer-buffer": { "version": "2.1.2", @@ -8183,14 +8174,12 @@ "wrappy": { "version": "1.0.2", "resolved": false, - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "optional": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "yallist": { "version": "3.0.3", "resolved": false, - "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", - "optional": true + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==" } } }, @@ -13854,6 +13843,23 @@ } } }, + "mock-async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mock-async-storage/-/mock-async-storage-2.2.0.tgz", + "integrity": "sha512-HpMe9KNKxlyiRyOXFBrEOSQ1Idcoz1k5W5J+6C8zYzjasSxFJlrA5wVVjPR4U6zJJtrbuky1c0s1o3whzgghSQ==", + "dev": true, + "requires": { + "deepmerge": "^3.3.0" + }, + "dependencies": { + "deepmerge": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz", + "integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==", + "dev": true + } + } + }, "moment": { "version": "2.22.2", "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz", diff --git a/package.json b/package.json index f92f4d70f..83de06de1 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "jsdom-global": "3.0.2", "metro-react-native-babel-preset": "0.54.0", "mmjstool": "github:mattermost/mattermost-utilities", + "mock-async-storage": "2.2.0", "nyc": "14.1.1", "react-dom": "16.8.6", "react-test-renderer": "16.8.6", diff --git a/test/setup.js b/test/setup.js index dd30a9374..f60c19e47 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,10 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import MockAsyncStorage from 'mock-async-storage'; import {configure} from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; configure({adapter: new Adapter()}); +const mockImpl = new MockAsyncStorage(); +jest.mock('@react-native-community/async-storage', () => mockImpl); + /* eslint-disable no-console */ jest.mock('NativeModules', () => {