From 7fe0bcc224d3f8d89f7883bef91b5b1f83cb4e6b Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Mon, 24 Aug 2020 14:28:21 -0700 Subject: [PATCH 1/4] Add Browser Notifications --- src/lib/Notification/BrowserNotifications.js | 126 +++++++++++++++++++ src/lib/Notification/index.js | 9 ++ src/lib/Notification/index.native.js | 4 + src/lib/actions/Report.js | 28 ++++- 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/lib/Notification/BrowserNotifications.js create mode 100644 src/lib/Notification/index.js create mode 100644 src/lib/Notification/index.native.js diff --git a/src/lib/Notification/BrowserNotifications.js b/src/lib/Notification/BrowserNotifications.js new file mode 100644 index 0000000000000..0c0e3090f9fec --- /dev/null +++ b/src/lib/Notification/BrowserNotifications.js @@ -0,0 +1,126 @@ +// Web implementation only. Do not import for direct use. Use Notification. + +import Str from '../Str'; +import CONST from '../../CONST'; +import * as ActiveClientManager from '../ActiveClientManager'; + +const EXPENSIFY_ICON_URL = `${CONST.CLOUDFRONT_URL}/images/favicon-2019.png`; +const DEFAULT_DELAY = 4000; + +/** + * Checks if the user has granted permission to show browser notifications + * + * @return {Promise} + */ +function canUseBrowserNotifications() { + return new Promise((resolve) => { + // They have no browser notifications so we can't use this feature + if (!window.Notification) { + return resolve(false); + } + + // Check if they previously granted or denied us access to send a notification + const permissionGranted = Notification.permission === 'granted'; + + if (permissionGranted || Notification.permisson === 'denied') { + return resolve(permissionGranted); + } + + // Check their global preferences for browser notifications and ask permission if they have none + Notification.requestPermission() + .then((status) => { + resolve(status === 'granted'); + }); + }); +} + +/** + * Light abstraction around browser push notifications. + * Checks for permission before determining whether to send. + * + * @param {Object} params + * @param {String} params.title + * @param {String} params.body + * @param {String} [params.icon] Default to Expensify logo + * @param {Number} [params.delay] + * @param {Function} [params.onClick] + * @param {String} [params.tag] + * + * @return {Promise} - resolves with Notification object or undefined + */ +function push({ + title, + body, + delay = DEFAULT_DELAY, + onClick = () => {}, + tag = '', + icon = EXPENSIFY_ICON_URL, +}) { + return new Promise((resolve) => { + if (!title || !body) { + throw new Error('BrowserNotification must include title and body parameter.'); + } + + canUseBrowserNotifications().then((canUseNotifications) => { + if (!canUseNotifications) { + resolve(); + return; + } + + const notification = new Notification(title, { + body, + icon, + tag, + }); + + // If we pass in a delay param greater than 0 the notification + // will auto-close after the specified time. + if (delay > 0) { + setTimeout(notification.close.bind(notification), delay); + } + + notification.onclick = (event) => { + event.preventDefault(); + onClick(); + window.parent.focus(); + window.focus(); + notification.close(); + }; + + resolve(notification); + }); + }); +} + +/** + * BrowserNotification + * @namespace + */ +export default { + /** + * Create a report comment notification + * + * @param {Object} params + * @param {Object} params.reportAction + * @param {Function} params.onClick + */ + pushReportCommentNotification({reportAction, onClick}) { + if (!ActiveClientManager.isClientTheLeader()) { + return; + } + + const {person, message} = reportAction; + + const plainTextPerson = Str.htmlDecode(person.map(f => f.text).join()); + + // Specifically target the comment part of the message + const plainTextMessage = Str.htmlDecode((message.find(f => f.type === 'COMMENT') || {}).text); + + push({ + title: `New message from ${plainTextPerson}`, + body: plainTextMessage, + delay: 0, + onClick, + }); + }, +}; diff --git a/src/lib/Notification/index.js b/src/lib/Notification/index.js new file mode 100644 index 0000000000000..356d9749b684e --- /dev/null +++ b/src/lib/Notification/index.js @@ -0,0 +1,9 @@ +import BrowserNotifications from './BrowserNotifications'; + +function showCommentNotification({reportAction, onClick}) { + BrowserNotifications.pushReportCommentNotification({reportAction, onClick}); +} + +export default { + showCommentNotification, +}; diff --git a/src/lib/Notification/index.native.js b/src/lib/Notification/index.native.js new file mode 100644 index 0000000000000..3b52417cf02b9 --- /dev/null +++ b/src/lib/Notification/index.native.js @@ -0,0 +1,4 @@ +// Browser Notifications are not supported on mobile so we'll just noop here. +export default { + showCommentNotification: () => {}, +} diff --git a/src/lib/actions/Report.js b/src/lib/actions/Report.js index fa574525ecbb6..623618ca27318 100644 --- a/src/lib/actions/Report.js +++ b/src/lib/actions/Report.js @@ -8,6 +8,7 @@ import CONFIG from '../../CONFIG'; import * as pusher from '../Pusher/pusher'; import promiseAllSettled from '../promiseAllSettled'; import ExpensiMark from '../ExpensiMark'; +import Notification from '../Notification'; /** * Updates a report in the store with a new report action @@ -45,8 +46,31 @@ function updateReportWithNewAction(reportID, reportAction) { })) // Put the report history back into Ion - .then((reportHistory) => { - Ion.set(`${IONKEYS.REPORT_HISTORY}_${reportID}`, reportHistory); + .then(reportHistory => Ion.set(`${IONKEYS.REPORT_HISTORY}_${reportID}`, reportHistory)) + + // Check to see if we need to show a notification for this report + .then(async () => { + // If this comment is from the current user we don't want to parrot whatever they wrote back to them. + const email = await Ion.get(IONKEYS.SESSION, 'email'); + if (reportAction.actorEmail === email) { + return; + } + + const currentUrl = await Ion.get(IONKEYS.CURRENT_URL); + const currentReportID = Number(lodashGet(currentUrl.split('/'), [1], 0)); + + // If we are currently viewing this report do not show a notification. + if (reportID === currentReportID) { + return; + } + + Notification.showCommentNotification({ + reportAction, + onClick: () => { + // Navigate to this report onClick + Ion.set(IONKEYS.APP_REDIRECT_TO, `/${reportID}`); + } + }); }); } From 335c86eb55bf945eb877cff918ab98ccd3c2b789 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Mon, 24 Aug 2020 14:32:02 -0700 Subject: [PATCH 2/4] fix eslint thing --- src/lib/Notification/index.native.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/Notification/index.native.js b/src/lib/Notification/index.native.js index 3b52417cf02b9..e1b798612c610 100644 --- a/src/lib/Notification/index.native.js +++ b/src/lib/Notification/index.native.js @@ -1,4 +1,4 @@ // Browser Notifications are not supported on mobile so we'll just noop here. export default { showCommentNotification: () => {}, -} +}; From e1e7a706bc842a8c8ef464d24eaa60b2bb76e7db Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Thu, 27 Aug 2020 10:54:41 -0700 Subject: [PATCH 3/4] remove async await --- src/lib/actions/Report.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/lib/actions/Report.js b/src/lib/actions/Report.js index 66d0ec47b0e7d..1383fe1e0d015 100644 --- a/src/lib/actions/Report.js +++ b/src/lib/actions/Report.js @@ -17,6 +17,7 @@ import Notification from '../Notification'; * @param {object} reportAction */ function updateReportWithNewAction(reportID, reportAction) { + let currentUserEmail; Ion.get(`${IONKEYS.REPORT}_${reportID}`, 'reportID') .then((ionReportID) => { // This is necessary for local development because there will be pusher events from other engineers with @@ -49,14 +50,17 @@ function updateReportWithNewAction(reportID, reportAction) { .then(reportHistory => Ion.set(`${IONKEYS.REPORT_HISTORY}_${reportID}`, reportHistory)) // Check to see if we need to show a notification for this report - .then(async () => { + .then(() => Ion.get(IONKEYS.SESSION, 'email')) + .then((email) => { + currentUserEmail = email; + return Ion.get(IONKEYS.CURRENT_URL); + }) + .then((currentUrl) => { // If this comment is from the current user we don't want to parrot whatever they wrote back to them. - const email = await Ion.get(IONKEYS.SESSION, 'email'); - if (reportAction.actorEmail === email) { + if (reportAction.actorEmail === currentUserEmail) { return; } - const currentUrl = await Ion.get(IONKEYS.CURRENT_URL); const currentReportID = Number(lodashGet(currentUrl.split('/'), [1], 0)); // If we are currently viewing this report do not show a notification. From db53a89eaec26bc1d76e908ec0b78827bd8a9c1f Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Thu, 27 Aug 2020 15:06:42 -0700 Subject: [PATCH 4/4] requested changes --- src/CONST.js | 5 ++++- src/lib/Notification/BrowserNotifications.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CONST.js b/src/CONST.js index 88147aa0a7260..29bf133b1eabd 100644 --- a/src/CONST.js +++ b/src/CONST.js @@ -1,5 +1,8 @@ +const CLOUDFRONT_URL = 'https://d2k5nsl2zxldvw.cloudfront.net'; + const CONST = { - CLOUDFRONT_URL: 'https://d2k5nsl2zxldvw.cloudfront.net', + CLOUDFRONT_URL, + EXPENSIFY_ICON_URL: `${CLOUDFRONT_URL}/images/favicon-2019.png`, }; export default CONST; diff --git a/src/lib/Notification/BrowserNotifications.js b/src/lib/Notification/BrowserNotifications.js index 0c0e3090f9fec..22f3dd8f7c6ec 100644 --- a/src/lib/Notification/BrowserNotifications.js +++ b/src/lib/Notification/BrowserNotifications.js @@ -1,4 +1,4 @@ -// Web implementation only. Do not import for direct use. Use Notification. +// Web and desktop implementation only. Do not import for direct use. Use Notification. import Str from '../Str'; import CONST from '../../CONST';