diff --git a/.yarn/cache/@notifee-react-native-npm-7.8.0-c618815190-800233c950.zip b/.yarn/cache/@notifee-react-native-npm-7.8.0-c618815190-800233c950.zip new file mode 100644 index 000000000..daa79a024 Binary files /dev/null and b/.yarn/cache/@notifee-react-native-npm-7.8.0-c618815190-800233c950.zip differ diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index b31e46c03..040690037 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -507,6 +507,11 @@ PODS: - React-Core - RNKeychain (8.1.2): - React-Core + - RNNotifee (7.8.0): + - React-Core + - RNNotifee/NotifeeCore (= 7.8.0) + - RNNotifee/NotifeeCore (7.8.0): + - React-Core - RNPrivacySnapshot (1.0.0): - React-Core - RNShare (9.4.1): @@ -593,6 +598,7 @@ DEPENDENCIES: - RNFS (from `../node_modules/react-native-fs`) - RNIap (from `../node_modules/react-native-iap`) - RNKeychain (from `../node_modules/react-native-keychain`) + - "RNNotifee (from `../node_modules/@notifee/react-native`)" - RNPrivacySnapshot (from `../node_modules/react-native-privacy-snapshot`) - RNShare (from `../node_modules/react-native-share`) - RNStoreReview (from `../node_modules/react-native-store-review`) @@ -716,6 +722,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-iap" RNKeychain: :path: "../node_modules/react-native-keychain" + RNNotifee: + :path: "../node_modules/@notifee/react-native" RNPrivacySnapshot: :path: "../node_modules/react-native-privacy-snapshot" RNShare: @@ -789,6 +797,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNIap: c397f49db45af3b10dca64b2325f21bb8078ad21 RNKeychain: a65256b6ca6ba6976132cc4124b238a5b13b3d9c + RNNotifee: f3c01b391dd8e98e67f539f9a35a9cbcd3bae744 RNPrivacySnapshot: 8eaf571478a353f2e5184f5c803164f22428b023 RNShare: 32e97adc8d8c97d4a26bcdd3c45516882184f8b6 RNStoreReview: 923b1c888c13469925bf0256dc2c046eab557ce5 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 5ded67aca..156cee122 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -72,6 +72,7 @@ "node": ">=16" }, "dependencies": { + "@notifee/react-native": "^7.8.0", "react-native-store-review": "^0.4.1" } } diff --git a/packages/mobile/src/Lib/MobileDevice.ts b/packages/mobile/src/Lib/MobileDevice.ts index 43a8c6dd0..2c4f49141 100644 --- a/packages/mobile/src/Lib/MobileDevice.ts +++ b/packages/mobile/src/Lib/MobileDevice.ts @@ -50,6 +50,7 @@ import { Database } from './Database/Database' import { isLegacyIdentifier } from './Database/LegacyIdentifier' import { LegacyKeyValueStore } from './Database/LegacyKeyValueStore' import Keychain from './Keychain' +import notifee, { AuthorizationStatus, Notification } from '@notifee/react-native' export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID' @@ -75,7 +76,40 @@ export class MobileDevice implements MobileDeviceInterface { private stateObserverService?: AppStateObserverService, private androidBackHandlerService?: AndroidBackHandlerService, private colorSchemeService?: ColorSchemeObserverService, - ) {} + ) { + this.initializeNotifications().catch(console.error) + } + + async initializeNotifications() { + if (Platform.OS !== 'android') { + return + } + + await notifee.createChannel({ + id: 'files', + name: 'File Upload/Download', + }) + } + + async canDisplayNotifications(): Promise { + const settings = await notifee.requestPermission() + + return settings.authorizationStatus >= AuthorizationStatus.AUTHORIZED + } + + async displayNotification(options: Notification): Promise { + return await notifee.displayNotification({ + ...options, + android: { + ...options.android, + channelId: 'files', + }, + }) + } + + async cancelNotification(notificationId: string): Promise { + await notifee.cancelNotification(notificationId) + } async removeRawStorageValuesForIdentifier(identifier: string): Promise { await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.SnjsVersion)) diff --git a/packages/mobile/src/MobileWebAppContainer.tsx b/packages/mobile/src/MobileWebAppContainer.tsx index ce2eedf1e..588016000 100644 --- a/packages/mobile/src/MobileWebAppContainer.tsx +++ b/packages/mobile/src/MobileWebAppContainer.tsx @@ -14,6 +14,7 @@ import { MobileDevice, MobileDeviceEvent } from './Lib/MobileDevice' import { IsDev } from './Lib/Utils' import { ReceivedSharedItemsHandler } from './ReceivedSharedItemsHandler' import { ReviewService } from './ReviewService' +import notifee, { EventType } from '@notifee/react-native' const LoggingEnabled = IsDev @@ -117,6 +118,34 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo } }, [webViewRef, stateService, device, androidBackHandlerService, colorSchemeService]) + useEffect(() => { + return notifee.onForegroundEvent(({ type, detail }) => { + if (type !== EventType.ACTION_PRESS) { + return + } + + const { notification, pressAction } = detail + + if (!notification || !pressAction) { + return + } + + if (pressAction.id !== 'open-file') { + return + } + + webViewRef.current?.postMessage( + JSON.stringify({ + reactNativeEvent: ReactNativeToWebEvent.OpenFilePreview, + messageType: 'event', + messageData: { + id: notification.id, + }, + }), + ) + }) + }, []) + useEffect(() => { const observer = device.addMobileDeviceEventReceiver((event) => { if (event === MobileDeviceEvent.RequestsWebViewReload) { diff --git a/packages/services/src/Domain/Device/MobileDeviceInterface.ts b/packages/services/src/Domain/Device/MobileDeviceInterface.ts index 00ab6a2c6..84b0fe8c3 100644 --- a/packages/services/src/Domain/Device/MobileDeviceInterface.ts +++ b/packages/services/src/Domain/Device/MobileDeviceInterface.ts @@ -5,6 +5,8 @@ import { DeviceInterface } from './DeviceInterface' import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt' import { ApplicationEvent } from '../Event/ApplicationEvent' +import type { Notification } from '../../../../mobile/node_modules/@notifee/react-native/dist/index' + export interface MobileDeviceInterface extends DeviceInterface { environment: Environment.Mobile platform: Platform.Ios | Platform.Android @@ -34,4 +36,8 @@ export interface MobileDeviceInterface extends DeviceInterface { purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise authenticateWithU2F(authenticationOptionsJSONString: string): Promise | null> notifyApplicationEvent(event: ApplicationEvent): void + + canDisplayNotifications(): Promise + displayNotification(options: Notification): Promise + cancelNotification(notificationId: string): Promise } diff --git a/packages/snjs/lib/Client/ReactNativeToWebEvent.ts b/packages/snjs/lib/Client/ReactNativeToWebEvent.ts index e30ffcb0d..776c4bc3f 100644 --- a/packages/snjs/lib/Client/ReactNativeToWebEvent.ts +++ b/packages/snjs/lib/Client/ReactNativeToWebEvent.ts @@ -12,4 +12,5 @@ export enum ReactNativeToWebEvent { ReceivedFile = 'ReceivedFile', ReceivedLink = 'ReceivedLink', ReceivedText = 'ReceivedText', + OpenFilePreview = 'OpenFilePreview', } diff --git a/packages/ui-services/src/WebApplication/WebApplicationInterface.ts b/packages/ui-services/src/WebApplication/WebApplicationInterface.ts index fdd79a60a..f232d13a5 100644 --- a/packages/ui-services/src/WebApplication/WebApplicationInterface.ts +++ b/packages/ui-services/src/WebApplication/WebApplicationInterface.ts @@ -24,6 +24,7 @@ export interface WebApplicationInterface extends ApplicationInterface { handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void handleReceivedTextEvent(item: { text: string; title?: string }): Promise handleReceivedLinkEvent(item: { link: string; title: string }): Promise + handleOpenFilePreviewEvent(item: { id: string }): void isNativeMobileWeb(): boolean handleAndroidBackButtonPressed(): void addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined diff --git a/packages/web/src/javascripts/Application/WebApplication.ts b/packages/web/src/javascripts/Application/WebApplication.ts index ca4a5ad48..dc8737a81 100644 --- a/packages/web/src/javascripts/Application/WebApplication.ts +++ b/packages/web/src/javascripts/Application/WebApplication.ts @@ -22,6 +22,7 @@ import { NoteContent, SNNote, DesktopManagerInterface, + FileItem, } from '@standardnotes/snjs' import { action, computed, makeObservable, observable } from 'mobx' import { startAuthentication, startRegistration } from '@simplewebauthn/browser' @@ -76,6 +77,7 @@ import { NoAccountWarningController } from '@/Controllers/NoAccountWarningContro import { SearchOptionsController } from '@/Controllers/SearchOptionsController' import { PersistenceService } from '@/Controllers/Abstract/PersistenceService' import { removeFromArray } from '@standardnotes/utils' +import { FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction' export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void @@ -353,6 +355,21 @@ export class WebApplication extends SNApplication implements WebApplicationInter this.notifyWebEvent(WebAppEvent.MobileKeyboardDidChangeFrame, frame) } + handleOpenFilePreviewEvent({ id }: { id: string }): void { + const file = this.items.findItem(id) + if (!file) { + return + } + this.filesController + .handleFileAction({ + type: FileItemActionType.PreviewFile, + payload: { + file, + }, + }) + .catch(console.error) + } + handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void { const filesController = this.filesController const blob = getBlobFromBase64(file.data, file.mimeType) diff --git a/packages/web/src/javascripts/Controllers/FilesController.ts b/packages/web/src/javascripts/Controllers/FilesController.ts index bdfcdf869..f47e6be6a 100644 --- a/packages/web/src/javascripts/Controllers/FilesController.ts +++ b/packages/web/src/javascripts/Controllers/FilesController.ts @@ -291,6 +291,11 @@ export class FilesController extends AbstractViewController { let downloadingToastId = '' + let canShowProgressNotification = false + + if (this.mobileDevice && this.platform === Platform.Android) { + canShowProgressNotification = await this.mobileDevice.canDisplayNotifications() + } try { const saver = StreamingFileSaver.available() ? new StreamingFileSaver(file.name) : new ClassicFileSaver() @@ -301,11 +306,21 @@ export class FilesController extends AbstractViewController 0) { - dismissToast(downloadingToastId) + if (downloadingToastId) { + if (this.mobileDevice && canShowProgressNotification) { + this.mobileDevice.cancelNotification(downloadingToastId).catch(console.error) + } else { + dismissToast(downloadingToastId) + } } } @@ -412,6 +449,11 @@ export class FilesController extends AbstractViewController { @@ -461,10 +513,21 @@ export class FilesController extends AbstractViewController { - void this.handleFileAction({ - type: FileItemActionType.PreviewFile, - payload: { file: uploadedFile }, - }) - dismissToast(toastId) + if (this.mobileDevice && canShowProgressNotification) { + this.mobileDevice + .displayNotification({ + id: uploadedFile.uuid, + title: `Uploaded file "${uploadedFile.name}"`, + android: { + actions: [ + { + title: 'Open', + pressAction: { + id: 'open-file', + }, + }, + ], }, - }, - ], - autoClose: true, - }) + }) + .catch(console.error) + } else { + addToast({ + type: ToastType.Success, + message: `Uploaded file "${uploadedFile.name}"`, + actions: [ + { + label: 'Open', + handler: (toastId) => { + void this.handleFileAction({ + type: FileItemActionType.PreviewFile, + payload: { file: uploadedFile }, + }) + dismissToast(toastId) + }, + }, + ], + autoClose: true, + }) + } } return uploadedFile @@ -513,12 +598,23 @@ export class FilesController extends AbstractViewController