diff --git a/Gemfile.lock b/Gemfile.lock index 93dab195ebdd..fcf4f878e2de 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,7 +81,8 @@ GEM declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20231109) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) dotenv (2.8.1) emoji_regex (3.2.3) escape (0.0.4) @@ -261,6 +262,9 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.9.1) unicode-display_width (2.5.0) webrick (1.8.1) word_wrap (1.0.0) @@ -294,4 +298,4 @@ RUBY VERSION ruby 2.6.10p210 BUNDLED WITH - 2.1.4 + 2.4.7 diff --git a/__mocks__/@ua/react-native-airship.js b/__mocks__/@ua/react-native-airship.js index 29be662e96a1..65e7c1a8b97e 100644 --- a/__mocks__/@ua/react-native-airship.js +++ b/__mocks__/@ua/react-native-airship.js @@ -28,6 +28,7 @@ const Airship = { enableUserNotifications: () => Promise.resolve(false), clearNotifications: jest.fn(), getNotificationStatus: () => Promise.resolve({airshipOptIn: false, systemEnabled: false}), + getActiveNotifications: () => Promise.resolve([]), }, contact: { identify: jest.fn(), diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 390511397b0e..0fac30a26430 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,25 +1,25 @@ PODS: - - Airship (16.11.3): - - Airship/Automation (= 16.11.3) - - Airship/Basement (= 16.11.3) - - Airship/Core (= 16.11.3) - - Airship/ExtendedActions (= 16.11.3) - - Airship/MessageCenter (= 16.11.3) - - Airship/Automation (16.11.3): + - Airship (16.12.1): + - Airship/Automation (= 16.12.1) + - Airship/Basement (= 16.12.1) + - Airship/Core (= 16.12.1) + - Airship/ExtendedActions (= 16.12.1) + - Airship/MessageCenter (= 16.12.1) + - Airship/Automation (16.12.1): - Airship/Core - - Airship/Basement (16.11.3) - - Airship/Core (16.11.3): + - Airship/Basement (16.12.1) + - Airship/Core (16.12.1): - Airship/Basement - - Airship/ExtendedActions (16.11.3): + - Airship/ExtendedActions (16.12.1): - Airship/Core - - Airship/MessageCenter (16.11.3): + - Airship/MessageCenter (16.12.1): - Airship/Core - - Airship/PreferenceCenter (16.11.3): + - Airship/PreferenceCenter (16.12.1): - Airship/Core - - AirshipFrameworkProxy (2.0.8): - - Airship (= 16.11.3) - - Airship/MessageCenter (= 16.11.3) - - Airship/PreferenceCenter (= 16.11.3) + - AirshipFrameworkProxy (2.1.1): + - Airship (= 16.12.1) + - Airship/MessageCenter (= 16.12.1) + - Airship/PreferenceCenter (= 16.12.1) - AppAuth (1.6.2): - AppAuth/Core (= 1.6.2) - AppAuth/ExternalUserAgent (= 1.6.2) @@ -558,8 +558,8 @@ PODS: - React-jsinspector (0.72.4) - React-logger (0.72.4): - glog - - react-native-airship (15.2.6): - - AirshipFrameworkProxy (= 2.0.8) + - react-native-airship (15.3.1): + - AirshipFrameworkProxy (= 2.1.1) - React-Core - react-native-blob-util (0.17.3): - React-Core @@ -1160,8 +1160,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - Airship: c70eed50e429f97f5adb285423c7291fb7a032ae - AirshipFrameworkProxy: 7bc4130c668c6c98e2d4c60fe4c9eb61a999be99 + Airship: 2f4510b497a8200780752a5e0304a9072bfffb6d + AirshipFrameworkProxy: ea1b6c665c798637b93c465b5e505be3011f1d9d AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570 boost: 57d2868c099736d80fcd648bf211b4431e51a558 BVLinearGradient: 421743791a59d259aec53f4c58793aad031da2ca @@ -1224,7 +1224,7 @@ SPEC CHECKSUMS: React-jsiexecutor: c7f826e40fa9cab5d37cab6130b1af237332b594 React-jsinspector: aaed4cf551c4a1c98092436518c2d267b13a673f React-logger: da1ebe05ae06eb6db4b162202faeafac4b435e77 - react-native-airship: 5d19f4ba303481cf4101ff9dee9249ef6a8a6b64 + react-native-airship: 6ded22e4ca54f2f80db80b7b911c2b9b696d9335 react-native-blob-util: 99f4d79189252f597fe0d810c57a3733b1b1dea6 react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151 react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e diff --git a/package-lock.json b/package-lock.json index 8b346e3a8038..072e5f6ebae3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "@rnmapbox/maps": "^10.0.11", "@shopify/flash-list": "^1.6.1", "@types/node": "^18.14.0", - "@ua/react-native-airship": "^15.2.6", + "@ua/react-native-airship": "^15.3.1", "awesome-phonenumber": "^5.4.0", "babel-polyfill": "^6.26.0", "canvas-size": "^1.2.6", @@ -20437,9 +20437,9 @@ } }, "node_modules/@ua/react-native-airship": { - "version": "15.2.6", - "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.6.tgz", - "integrity": "sha512-dVlBPPYXD/4SEshv/X7mmt3xF8WfnNqiSNzCyqJSLAZ1aJuPpP9Z5WemCYsa2iv6goRZvtJSE4P79QKlfoTwXw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.3.1.tgz", + "integrity": "sha512-g5YX4/fpBJ0ml//7ave8HE68uF4QFriCuei0xJwK+ClzbTDWYB6OldvE/wj5dMpgQ95ZFSbr5LU77muYScxXLQ==", "engines": { "node": ">= 16.0.0" }, @@ -67439,9 +67439,9 @@ } }, "@ua/react-native-airship": { - "version": "15.2.6", - "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.6.tgz", - "integrity": "sha512-dVlBPPYXD/4SEshv/X7mmt3xF8WfnNqiSNzCyqJSLAZ1aJuPpP9Z5WemCYsa2iv6goRZvtJSE4P79QKlfoTwXw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.3.1.tgz", + "integrity": "sha512-g5YX4/fpBJ0ml//7ave8HE68uF4QFriCuei0xJwK+ClzbTDWYB6OldvE/wj5dMpgQ95ZFSbr5LU77muYScxXLQ==", "requires": {} }, "@vercel/ncc": { diff --git a/package.json b/package.json index 27ae4feffcf1..de4b7f595382 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@rnmapbox/maps": "^10.0.11", "@shopify/flash-list": "^1.6.1", "@types/node": "^18.14.0", - "@ua/react-native-airship": "^15.2.6", + "@ua/react-native-airship": "^15.3.1", "awesome-phonenumber": "^5.4.0", "babel-polyfill": "^6.26.0", "canvas-size": "^1.2.6", diff --git a/src/hooks/useAppFocusEvent/index.native.ts b/src/hooks/useAppFocusEvent/index.native.ts new file mode 100644 index 000000000000..e7df6fb8054b --- /dev/null +++ b/src/hooks/useAppFocusEvent/index.native.ts @@ -0,0 +1,20 @@ +import {useEffect} from 'react'; +import {AppState} from 'react-native'; +import {UseAppFocusEvent, UseAppFocusEventCallback} from './types'; + +const useAppFocusEvent: UseAppFocusEvent = (callback: UseAppFocusEventCallback) => { + useEffect(() => { + const subscription = AppState.addEventListener('change', (appState) => { + if (appState !== 'active') { + return; + } + callback(); + }); + + return () => { + subscription.remove(); + }; + }, [callback]); +}; + +export default useAppFocusEvent; diff --git a/src/hooks/useAppFocusEvent/index.ts b/src/hooks/useAppFocusEvent/index.ts new file mode 100644 index 000000000000..d4b6772733f8 --- /dev/null +++ b/src/hooks/useAppFocusEvent/index.ts @@ -0,0 +1,19 @@ +import {useEffect} from 'react'; +import {UseAppFocusEvent, UseAppFocusEventCallback} from './types'; + +/** + * Runs the given callback when the app is focused (eg: after re-opening the app, switching tabs, or focusing the window) + * + * @param callback the function to run when the app is focused. This should be memoized with `useCallback`. + */ +const useAppFocusEvent: UseAppFocusEvent = (callback: UseAppFocusEventCallback) => { + useEffect(() => { + window.addEventListener('focus', callback); + + return () => { + window.removeEventListener('focus', callback); + }; + }, [callback]); +}; + +export default useAppFocusEvent; diff --git a/src/hooks/useAppFocusEvent/types.ts b/src/hooks/useAppFocusEvent/types.ts new file mode 100644 index 000000000000..7c8cf50dd9d9 --- /dev/null +++ b/src/hooks/useAppFocusEvent/types.ts @@ -0,0 +1,5 @@ +type UseAppFocusEventCallback = () => void; + +type UseAppFocusEvent = (callback: UseAppFocusEventCallback) => void; + +export type {UseAppFocusEventCallback, UseAppFocusEvent}; diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index dd07ddbb5e33..242248b17794 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -1,11 +1,14 @@ // Web and desktop implementation only. Do not import for direct use. Use LocalNotification. +import Str from 'expensify-common/lib/str'; +import {ImageSourcePropType} from 'react-native'; import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.png'; import * as ReportUtils from '@libs/ReportUtils'; import * as AppUpdate from '@userActions/AppUpdate'; +import {Report, ReportAction} from '@src/types/onyx'; import focusApp from './focusApp'; -import {PushParams, ReportCommentParams} from './types'; +import {LocalNotificationClickHandler, LocalNotificationData} from './types'; -const DEFAULT_DELAY = 4000; +const notificationCache: Record = {}; /** * Checks if the user has granted permission to show browser notifications @@ -34,42 +37,33 @@ function canUseBrowserNotifications(): Promise { /** * Light abstraction around browser push notifications. * Checks for permission before determining whether to send. - * @return resolves with Notification object or undefined + * + * @param icon Path to icon + * @param data extra data to attach to the notification */ -function push({title, body, delay = DEFAULT_DELAY, onClick = () => {}, tag = '', icon}: PushParams): Promise { - return new Promise((resolve) => { - if (!title || !body) { - throw new Error('BrowserNotification must include title and body parameter.'); +function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}) { + canUseBrowserNotifications().then((canUseNotifications) => { + if (!canUseNotifications) { + return; } - canUseBrowserNotifications().then((canUseNotifications) => { - if (!canUseNotifications) { - resolve(); - return; - } - - const notification = new Notification(title, { - body, - tag, - icon: String(icon), - }); - - // 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 = () => { - onClick(); - window.parent.focus(); - window.focus(); - focusApp(); - notification.close(); - }; - - resolve(notification); + // We cache these notifications so that we can clear them later + const notificationID = Str.guid(); + notificationCache[notificationID] = new Notification(title, { + body, + icon: String(icon), + data, }); + notificationCache[notificationID].onclick = () => { + onClick(); + window.parent.focus(); + window.focus(); + focusApp(); + notificationCache[notificationID].close(); + }; + notificationCache[notificationID].onclose = () => { + delete notificationCache[notificationID]; + }; }); } @@ -80,19 +74,21 @@ function push({title, body, delay = DEFAULT_DELAY, onClick = () => {}, tag = '', export default { /** * Create a report comment notification + * * @param usesIcon true if notification uses right circular icon */ - pushReportCommentNotification({report, reportAction, onClick}: ReportCommentParams, usesIcon = false) { - let title: string | undefined; - let body: string | undefined; + pushReportCommentNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) { + let title; + let body; + const icon = usesIcon ? EXPENSIFY_ICON_URL : ''; const isChatRoom = ReportUtils.isChatRoom(report); const {person, message} = reportAction; - const plainTextPerson = person?.map((f) => f.text).join(); + const plainTextPerson = person?.map((f) => f.text).join() ?? ''; // Specifically target the comment part of the message - const plainTextMessage = message?.find((f) => f.type === 'COMMENT')?.text; + const plainTextMessage = message?.find((f) => f.type === 'COMMENT')?.text ?? ''; if (isChatRoom) { const roomName = ReportUtils.getReportName(report); @@ -103,36 +99,40 @@ export default { body = plainTextMessage; } - push({ - title: title ?? '', - body, - delay: 0, - onClick, - icon: usesIcon ? EXPENSIFY_ICON_URL : '', - }); + const data = { + reportID: report.reportID, + }; + + push(title, body, icon, data, onClick); }, - pushModifiedExpenseNotification({reportAction, onClick}: ReportCommentParams, usesIcon = false) { - push({ - title: reportAction.person?.map((f) => f.text).join(', ') ?? '', - body: ReportUtils.getModifiedExpenseMessage(reportAction), - delay: 0, - onClick, - icon: usesIcon ? EXPENSIFY_ICON_URL : '', - }); + pushModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) { + const title = reportAction.person?.map((f) => f.text).join(', ') ?? ''; + const body = ReportUtils.getModifiedExpenseMessage(reportAction); + const icon = usesIcon ? EXPENSIFY_ICON_URL : ''; + const data = { + reportID: report.reportID, + }; + push(title, body, icon, data, onClick); }, /** * Create a notification to indicate that an update is available. */ pushUpdateAvailableNotification() { - push({ - title: 'Update available', - body: 'A new version of this app is available!', - delay: 0, - onClick: () => { - AppUpdate.triggerUpdateAvailable(); - }, + push('Update available', 'A new version of this app is available!', '', {}, () => { + AppUpdate.triggerUpdateAvailable(); }); }, + + /** + * Clears all open notifications where shouldClearNotification returns true + * + * @param shouldClearNotification a function that receives notification.data and returns true/false if the notification should be cleared + */ + clearNotifications(shouldClearNotification: (notificationData: LocalNotificationData) => boolean) { + Object.values(notificationCache) + .filter((notification) => shouldClearNotification(notification.data)) + .forEach((notification) => notification.close()); + }, }; diff --git a/src/libs/Notification/LocalNotification/index.desktop.ts b/src/libs/Notification/LocalNotification/index.desktop.ts index 1576fefe2118..e2aa5fcf2830 100644 --- a/src/libs/Notification/LocalNotification/index.desktop.ts +++ b/src/libs/Notification/LocalNotification/index.desktop.ts @@ -1,22 +1,28 @@ +import {Report, ReportAction} from '@src/types/onyx'; import BrowserNotifications from './BrowserNotifications'; -import {LocalNotificationModule, ReportCommentParams} from './types'; +import {LocalNotificationClickHandler, LocalNotificationModule} from './types'; -function showCommentNotification({report, reportAction, onClick}: ReportCommentParams) { - BrowserNotifications.pushReportCommentNotification({report, reportAction, onClick}); +function showCommentNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler) { + BrowserNotifications.pushReportCommentNotification(report, reportAction, onClick); } function showUpdateAvailableNotification() { BrowserNotifications.pushUpdateAvailableNotification(); } -function showModifiedExpenseNotification({report, reportAction, onClick}: ReportCommentParams) { - BrowserNotifications.pushModifiedExpenseNotification({report, reportAction, onClick}); +function showModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler) { + BrowserNotifications.pushModifiedExpenseNotification(report, reportAction, onClick); +} + +function clearReportNotifications(reportID: string) { + BrowserNotifications.clearNotifications((notificationData) => notificationData.reportID === reportID); } const LocalNotification: LocalNotificationModule = { showCommentNotification, showUpdateAvailableNotification, showModifiedExpenseNotification, + clearReportNotifications, }; export default LocalNotification; diff --git a/src/libs/Notification/LocalNotification/index.native.ts b/src/libs/Notification/LocalNotification/index.native.ts index b23378b6875a..2b4e54286c06 100644 --- a/src/libs/Notification/LocalNotification/index.native.ts +++ b/src/libs/Notification/LocalNotification/index.native.ts @@ -1,10 +1,11 @@ import {LocalNotificationModule} from './types'; -// Local Notifications are not currently supported on mobile so we'll just noop here. +// Local Notifications are not currently supported on mobile so we'll just no-op here. const LocalNotification: LocalNotificationModule = { showCommentNotification: () => {}, showUpdateAvailableNotification: () => {}, showModifiedExpenseNotification: () => {}, + clearReportNotifications: () => {}, }; export default LocalNotification; diff --git a/src/libs/Notification/LocalNotification/index.ts b/src/libs/Notification/LocalNotification/index.ts index 69c1ccaacf4e..469e5f9bc08b 100644 --- a/src/libs/Notification/LocalNotification/index.ts +++ b/src/libs/Notification/LocalNotification/index.ts @@ -1,22 +1,28 @@ +import {Report, ReportAction} from '@src/types/onyx'; import BrowserNotifications from './BrowserNotifications'; -import {LocalNotificationModule, ReportCommentParams} from './types'; +import {LocalNotificationClickHandler, LocalNotificationModule} from './types'; -function showCommentNotification({report, reportAction, onClick}: ReportCommentParams) { - BrowserNotifications.pushReportCommentNotification({report, reportAction, onClick}, true); +function showCommentNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler) { + BrowserNotifications.pushReportCommentNotification(report, reportAction, onClick, true); } function showUpdateAvailableNotification() { BrowserNotifications.pushUpdateAvailableNotification(); } -function showModifiedExpenseNotification({report, reportAction, onClick}: ReportCommentParams) { - BrowserNotifications.pushModifiedExpenseNotification({report, reportAction, onClick}, true); +function showModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler) { + BrowserNotifications.pushModifiedExpenseNotification(report, reportAction, onClick, true); +} + +function clearReportNotifications(reportID: string) { + BrowserNotifications.clearNotifications((notificationData) => notificationData.reportID === reportID); } const LocalNotification: LocalNotificationModule = { showCommentNotification, showUpdateAvailableNotification, showModifiedExpenseNotification, + clearReportNotifications, }; export default LocalNotification; diff --git a/src/libs/Notification/LocalNotification/types.ts b/src/libs/Notification/LocalNotification/types.ts index fb18c6931560..d8441fb1f4bb 100644 --- a/src/libs/Notification/LocalNotification/types.ts +++ b/src/libs/Notification/LocalNotification/types.ts @@ -1,26 +1,17 @@ -import {ImageSourcePropType} from 'react-native'; -import {OnyxEntry} from 'react-native-onyx'; +import ClearReportNotifications from '@libs/Notification/clearReportNotifications/types'; import {Report, ReportAction} from '@src/types/onyx'; -type PushParams = { - title: string; - body?: string; - icon?: string | ImageSourcePropType; - delay?: number; - onClick?: () => void; - tag?: string; -}; +type LocalNotificationClickHandler = () => void; -type ReportCommentParams = { - report: OnyxEntry; - reportAction: ReportAction; - onClick: () => void; +type LocalNotificationData = { + reportID?: string; }; type LocalNotificationModule = { - showCommentNotification: (reportCommentParams: ReportCommentParams) => void; + showCommentNotification: (report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler) => void; showUpdateAvailableNotification: () => void; - showModifiedExpenseNotification: (reportCommentParams: ReportCommentParams) => void; + showModifiedExpenseNotification: (report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler) => void; + clearReportNotifications: ClearReportNotifications; }; -export type {PushParams, ReportCommentParams, LocalNotificationModule}; +export type {LocalNotificationModule, LocalNotificationClickHandler, LocalNotificationData}; diff --git a/src/libs/Notification/PushNotification/index.native.ts b/src/libs/Notification/PushNotification/index.native.ts index 7b2571eea368..d48d56f71993 100644 --- a/src/libs/Notification/PushNotification/index.native.ts +++ b/src/libs/Notification/PushNotification/index.native.ts @@ -2,6 +2,7 @@ import Airship, {EventType, PushPayload} from '@ua/react-native-airship'; import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import * as PushNotificationActions from '@userActions/PushNotification'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ForegroundNotifications from './ForegroundNotifications'; import NotificationType, {NotificationData} from './NotificationType'; @@ -189,6 +190,40 @@ const clearNotifications: ClearNotifications = () => { Airship.push.clearNotifications(); }; +const parseNotificationAndReportIDs = (pushPayload: PushPayload) => { + let payload = pushPayload.extras.payload; + if (typeof payload === 'string') { + payload = JSON.parse(payload); + } + const data = payload as NotificationData; + return { + notificationID: pushPayload.notificationId, + reportID: String(data.reportID), + }; +}; + +const clearReportNotifications = (reportID: string) => { + Log.info('[PushNotification] clearing report notifications', false, {reportID}); + + Airship.push + .getActiveNotifications() + .then((pushPayloads) => { + const reportNotificationIDs = pushPayloads.reduce((notificationIDs, pushPayload) => { + const notification = parseNotificationAndReportIDs(pushPayload); + if (notification.notificationID && notification.reportID === reportID) { + notificationIDs.push(notification.notificationID); + } + return notificationIDs; + }, []); + + Log.info(`[PushNotification] found ${reportNotificationIDs.length} notifications to clear`, false, {reportID}); + reportNotificationIDs.forEach((notificationID) => Airship.push.clearNotification(notificationID)); + }) + .catch((error) => { + Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PushNotification] BrowserNotifications.clearReportNotifications threw an error. This should never happen.`, {reportID, error}); + }); +}; + const PushNotification: PushNotificationType = { init, register, @@ -197,6 +232,7 @@ const PushNotification: PushNotificationType = { onSelected, TYPE: NotificationType, clearNotifications, + clearReportNotifications, }; export default PushNotification; diff --git a/src/libs/Notification/PushNotification/index.ts b/src/libs/Notification/PushNotification/index.ts index 1e5499d1fe7d..925574b8976f 100644 --- a/src/libs/Notification/PushNotification/index.ts +++ b/src/libs/Notification/PushNotification/index.ts @@ -10,6 +10,7 @@ const PushNotification: PushNotificationType = { onSelected: () => {}, TYPE: NotificationType, clearNotifications: () => {}, + clearReportNotifications: () => {}, }; export default PushNotification; diff --git a/src/libs/Notification/PushNotification/types.ts b/src/libs/Notification/PushNotification/types.ts index f72ee1af887a..e39f21e0cb92 100644 --- a/src/libs/Notification/PushNotification/types.ts +++ b/src/libs/Notification/PushNotification/types.ts @@ -1,4 +1,5 @@ import {ValueOf} from 'type-fest'; +import ClearReportNotifications from '@libs/Notification/clearReportNotifications/types'; import NotificationType, {NotificationDataMap} from './NotificationType'; type Init = () => void; @@ -16,6 +17,7 @@ type PushNotification = { onSelected: OnSelected; TYPE: typeof NotificationType; clearNotifications: ClearNotifications; + clearReportNotifications: ClearReportNotifications; }; export default PushNotification; diff --git a/src/libs/Notification/clearReportNotifications/index.native.ts b/src/libs/Notification/clearReportNotifications/index.native.ts new file mode 100644 index 000000000000..5082fe492564 --- /dev/null +++ b/src/libs/Notification/clearReportNotifications/index.native.ts @@ -0,0 +1,5 @@ +import PushNotification from '@libs/Notification/PushNotification'; +import ClearReportNotifications from './types'; + +const clearReportNotifications: ClearReportNotifications = PushNotification.clearReportNotifications; +export default clearReportNotifications; diff --git a/src/libs/Notification/clearReportNotifications/index.ts b/src/libs/Notification/clearReportNotifications/index.ts new file mode 100644 index 000000000000..fa8ceac6b05d --- /dev/null +++ b/src/libs/Notification/clearReportNotifications/index.ts @@ -0,0 +1,5 @@ +import LocalNotification from '@libs/Notification/LocalNotification'; +import ClearReportNotifications from './types'; + +const clearReportNotifications: ClearReportNotifications = LocalNotification.clearReportNotifications; +export default clearReportNotifications; diff --git a/src/libs/Notification/clearReportNotifications/types.ts b/src/libs/Notification/clearReportNotifications/types.ts new file mode 100644 index 000000000000..ec01dc0aaeb3 --- /dev/null +++ b/src/libs/Notification/clearReportNotifications/types.ts @@ -0,0 +1,3 @@ +type ClearReportNotifications = (reportID: string) => void; + +export default ClearReportNotifications; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9977693896ee..135e616f7691 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -17,8 +17,8 @@ import * as Environment from '@libs/Environment/Environment'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; +import clearReportNotifications from '@libs/Notification/clearReportNotifications'; import LocalNotification from '@libs/Notification/LocalNotification'; -import {ReportCommentParams} from '@libs/Notification/LocalNotification/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as Pusher from '@libs/Pusher/pusher'; @@ -476,6 +476,8 @@ function openReport( return; } + clearReportNotifications(reportID); + const optimisticReport = reportActionsExist(reportID) ? {} : { @@ -1822,17 +1824,19 @@ function showReportActionNotification(reportID: string, reportAction: ReportActi } Log.info('[LocalNotification] Creating notification'); + const report = allReports?.[reportID] ?? null; + if (!report) { + Log.hmmm("[LocalNotification] couldn't show report action notification because the report wasn't found", {reportID, reportActionID: reportAction.reportActionID}); + return; + } + + const onClick = () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - const notificationParams: ReportCommentParams = { - report, - reportAction, - onClick: () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)), - }; if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) { - LocalNotification.showModifiedExpenseNotification(notificationParams); + LocalNotification.showModifiedExpenseNotification(report, reportAction, onClick); } else { - LocalNotification.showCommentNotification(notificationParams); + LocalNotification.showCommentNotification(report, reportAction, onClick); } notifyNewAction(reportID, reportAction.actorAccountID, reportAction.reportActionID); diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index fd5caeea24f4..161b8aa8889d 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -15,6 +15,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID'; import withViewportOffsetTop from '@components/withViewportOffsetTop'; +import useAppFocusEvent from '@hooks/useAppFocusEvent'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; @@ -23,6 +24,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import Timing from '@libs/actions/Timing'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; +import clearReportNotifications from '@libs/Notification/clearReportNotifications'; import reportWithoutHasDraftSelector from '@libs/OnyxSelectors/reportWithoutHasDraftSelector'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -289,6 +291,18 @@ function ReportScreen({ [route], ); + // Clear notifications for the current report when the app is focused + useAppFocusEvent( + useCallback(() => { + // Check if this is the top-most ReportScreen since the Navigator preserves multiple at a time + if (!isTopMostReportId) { + return; + } + + clearReportNotifications(report.reportID); + }, [report.reportID, isTopMostReportId]), + ); + useEffect(() => { Timing.end(CONST.TIMING.CHAT_RENDER); Performance.markEnd(CONST.TIMING.CHAT_RENDER); diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 46abbfc71b84..e2ae7b947fcc 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -17,6 +17,7 @@ import compose from '@libs/compose'; import DateUtils from '@libs/DateUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import Visibility from '@libs/Visibility'; import reportPropTypes from '@pages/reportPropTypes'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; @@ -190,7 +191,7 @@ function ReportActionsList({ } if (ReportUtils.isUnread(report)) { - if (scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD) { + if (Visibility.isVisible() && scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD) { Report.readNewestAction(report.reportID); } else { readActionSkipped.current = true;