diff --git a/packages/mobile/src/AndroidBackHandlerService.ts b/packages/mobile/src/AndroidBackHandlerService.ts new file mode 100644 index 000000000..89798b87c --- /dev/null +++ b/packages/mobile/src/AndroidBackHandlerService.ts @@ -0,0 +1,21 @@ +import { AbstractService, InternalEventBus, ReactNativeToWebEvent } from '@standardnotes/snjs' +import { BackHandler, NativeEventSubscription } from 'react-native' + +export class AndroidBackHandlerService extends AbstractService { + private removeListener: NativeEventSubscription + + constructor() { + const internalEventBus = new InternalEventBus() + super(internalEventBus) + + this.removeListener = BackHandler.addEventListener('hardwareBackPress', () => { + void this.notifyEvent(ReactNativeToWebEvent.AndroidBackButtonPressed) + + return true + }) + } + + deinit() { + this.removeListener.remove() + } +} diff --git a/packages/mobile/src/Lib/Interface.ts b/packages/mobile/src/Lib/Interface.ts index 7c1151283..e59f02117 100644 --- a/packages/mobile/src/Lib/Interface.ts +++ b/packages/mobile/src/Lib/Interface.ts @@ -1,4 +1,5 @@ import AsyncStorage from '@react-native-community/async-storage' +import { AndroidBackHandlerService } from '@Root/AndroidBackHandlerService' import SNReactNative from '@standardnotes/react-native-utils' import { ApplicationIdentifier, @@ -80,13 +81,18 @@ export class MobileDevice implements MobileDeviceInterface { public isDarkMode = false private crypto: SNReactNativeCrypto - constructor(private stateObserverService?: AppStateObserverService) { + constructor( + private stateObserverService?: AppStateObserverService, + private androidBackHandlerService?: AndroidBackHandlerService, + ) { this.crypto = new SNReactNativeCrypto() } deinit() { this.stateObserverService?.deinit() ;(this.stateObserverService as unknown) = undefined + this.androidBackHandlerService?.deinit() + ;(this.androidBackHandlerService as unknown) = undefined } consoleLog(...args: any[]): void { @@ -542,4 +548,29 @@ export class MobileDevice implements MobileDeviceInterface { this.consoleLog(`${error}`) } } + + confirmAndExit() { + Alert.alert( + 'Close app', + 'Do you want to close the app?', + [ + { + text: 'Cancel', + style: 'cancel', + // eslint-disable-next-line @typescript-eslint/no-empty-function + onPress: async () => {}, + }, + { + text: 'Close', + style: 'destructive', + onPress: async () => { + SNReactNative.exitApp() + }, + }, + ], + { + cancelable: true, + }, + ) + } } diff --git a/packages/mobile/src/MobileWebAppContainer.tsx b/packages/mobile/src/MobileWebAppContainer.tsx index 83d4a17a3..94eaf0ddc 100644 --- a/packages/mobile/src/MobileWebAppContainer.tsx +++ b/packages/mobile/src/MobileWebAppContainer.tsx @@ -6,6 +6,7 @@ import { Keyboard, Platform } from 'react-native' import VersionInfo from 'react-native-version-info' import { WebView, WebViewMessageEvent } from 'react-native-webview' import pjson from '../package.json' +import { AndroidBackHandlerService } from './AndroidBackHandlerService' import { AppStateObserverService } from './AppStateObserverService' const LoggingEnabled = IsDev @@ -24,13 +25,23 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo const webViewRef = useRef(null) const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html' const stateService = useMemo(() => new AppStateObserverService(), []) - const device = useMemo(() => new MobileDevice(stateService), [stateService]) + const androidBackHandlerService = useMemo(() => new AndroidBackHandlerService(), []) + const device = useMemo( + () => new MobileDevice(stateService, androidBackHandlerService), + [androidBackHandlerService, stateService], + ) useEffect(() => { - const removeListener = stateService.addEventObserver((event: ReactNativeToWebEvent) => { + const removeStateServiceListener = stateService.addEventObserver((event: ReactNativeToWebEvent) => { webViewRef.current?.postMessage(JSON.stringify({ reactNativeEvent: event, messageType: 'event' })) }) + const removeBackHandlerServiceListener = androidBackHandlerService.addEventObserver( + (event: ReactNativeToWebEvent) => { + webViewRef.current?.postMessage(JSON.stringify({ reactNativeEvent: event, messageType: 'event' })) + }, + ) + const keyboardShowListener = Keyboard.addListener('keyboardWillShow', () => { device.reloadStatusBarStyle(false) }) @@ -40,11 +51,12 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo }) return () => { - removeListener() + removeStateServiceListener() + removeBackHandlerServiceListener() keyboardShowListener.remove() keyboardHideListener.remove() } - }, [webViewRef, stateService]) + }, [webViewRef, stateService, device, androidBackHandlerService]) useEffect(() => { const observer = device.addMobileWebEventReceiver((event) => { diff --git a/packages/services/src/Domain/Application/WebApplicationInterface.ts b/packages/services/src/Domain/Application/WebApplicationInterface.ts index 1d888c3a5..9e80b8a0e 100644 --- a/packages/services/src/Domain/Application/WebApplicationInterface.ts +++ b/packages/services/src/Domain/Application/WebApplicationInterface.ts @@ -12,4 +12,6 @@ export interface WebApplicationInterface extends ApplicationInterface { handleMobileResumingFromBackgroundEvent(): Promise isNativeMobileWeb(): boolean get mobileDevice(): MobileDeviceInterface + handleAndroidBackButtonPressed(): void + addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined } diff --git a/packages/services/src/Domain/Device/MobileDeviceInterface.ts b/packages/services/src/Domain/Device/MobileDeviceInterface.ts index 9f2d9826f..0d0f84dc8 100644 --- a/packages/services/src/Domain/Device/MobileDeviceInterface.ts +++ b/packages/services/src/Domain/Device/MobileDeviceInterface.ts @@ -15,4 +15,5 @@ export interface MobileDeviceInterface extends DeviceInterface { handleThemeSchemeChange(isDark: boolean): void shareBase64AsFile(base64: string, filename: string): Promise downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise + confirmAndExit(): void } diff --git a/packages/snjs/lib/Client/ReactNativeToWebEvent.ts b/packages/snjs/lib/Client/ReactNativeToWebEvent.ts index c7be848ee..8cb327d28 100644 --- a/packages/snjs/lib/Client/ReactNativeToWebEvent.ts +++ b/packages/snjs/lib/Client/ReactNativeToWebEvent.ts @@ -3,4 +3,5 @@ export enum ReactNativeToWebEvent { ResumingFromBackground = 'ResumingFromBackground', GainingFocus = 'GainingFocus', LosingFocus = 'LosingFocus', + AndroidBackButtonPressed = 'AndroidBackButtonPressed', } diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index 0f2583368..1507d4319 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -26,6 +26,7 @@ import { isDesktopApplication } from '@/Utils' import { DesktopManager } from './Device/DesktopManager' import { ArchiveManager, AutolockService, IOService, WebAlertService, ThemeManager } from '@standardnotes/ui-services' import { MobileWebReceiver } from './MobileWebReceiver' +import { AndroidBackHandler } from '@/NativeMobileWeb/AndroidBackHandler' type WebServices = { viewControllerManager: ViewControllerManager @@ -45,6 +46,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter public iconsController: IconsController private onVisibilityChange: () => void private mobileWebReceiver?: MobileWebReceiver + private androidBackHandler?: AndroidBackHandler constructor( deviceInterface: WebOrDesktopDevice, @@ -76,6 +78,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter if (this.isNativeMobileWeb()) { this.mobileWebReceiver = new MobileWebReceiver(this) + this.androidBackHandler = new AndroidBackHandler() // eslint-disable-next-line no-console console.log = (...args) => { @@ -264,4 +267,17 @@ export class WebApplication extends SNApplication implements WebApplicationInter await this.lock() } } + + handleAndroidBackButtonPressed(): void { + if (typeof this.androidBackHandler !== 'undefined') { + this.androidBackHandler.notifyEvent() + } + } + + addAndroidBackHandlerEventListener(listener: () => boolean) { + if (typeof this.androidBackHandler !== 'undefined') { + return this.androidBackHandler.addEventListener(listener) + } + return + } } diff --git a/packages/web/src/javascripts/Application/MobileWebReceiver.ts b/packages/web/src/javascripts/Application/MobileWebReceiver.ts index 6f5b59c61..523de9723 100644 --- a/packages/web/src/javascripts/Application/MobileWebReceiver.ts +++ b/packages/web/src/javascripts/Application/MobileWebReceiver.ts @@ -53,6 +53,9 @@ export class MobileWebReceiver { case ReactNativeToWebEvent.ResumingFromBackground: void this.application.handleMobileResumingFromBackgroundEvent() break + case ReactNativeToWebEvent.AndroidBackButtonPressed: + void this.application.handleAndroidBackButtonPressed() + break default: break diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index 47ba0deb1..b2a9921d2 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -25,6 +25,7 @@ import { PanelResizedData } from '@/Types/PanelResizedData' import TagContextMenuWrapper from '@/Components/Tags/TagContextMenuWrapper' import FileDragNDropProvider from '../FileDragNDropProvider/FileDragNDropProvider' import ResponsivePaneProvider from '../ResponsivePane/ResponsivePaneProvider' +import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler' import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal' type Props = { @@ -175,77 +176,79 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio } return ( - + -
-
- - - +
+
+ + + + + +
+ + <> +
+ + + - - + + + {renderChallenges()} + + <> + + + + + + + + + +
- - <> -
- - - - - - {renderChallenges()} - - <> - - - - - - - - - - -
+ - + ) } diff --git a/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx b/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx index a00043a5b..0f1ae4149 100644 --- a/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx +++ b/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx @@ -184,6 +184,20 @@ const ChallengeModal: FunctionComponent = ({ } }, [hasBiometricPromptValue, hasOnlyBiometricPrompt, submit]) + useEffect(() => { + const removeListener = application.addAndroidBackHandlerEventListener(() => { + if (challenge.cancelable) { + cancelChallenge() + } + return true + }) + return () => { + if (removeListener) { + removeListener() + } + } + }, [application, cancelChallenge, challenge.cancelable]) + if (!challenge.prompts) { return null } diff --git a/packages/web/src/javascripts/Components/Popover/Popover.tsx b/packages/web/src/javascripts/Components/Popover/Popover.tsx index 0dd9f1831..50279367d 100644 --- a/packages/web/src/javascripts/Components/Popover/Popover.tsx +++ b/packages/web/src/javascripts/Components/Popover/Popover.tsx @@ -1,3 +1,4 @@ +import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler' import { UuidGenerator } from '@standardnotes/snjs' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import PositionedPopoverContent from './PositionedPopoverContent' @@ -41,6 +42,8 @@ const Popover = ({ }: Props) => { const popoverId = useRef(UuidGenerator.GenerateUuid()) + const addAndroidBackHandler = useAndroidBackHandler() + useRegisterPopoverToParent(popoverId.current) const [childPopovers, setChildPopovers] = useState>(new Set()) @@ -64,6 +67,23 @@ const Popover = ({ [registerChildPopover, unregisterChildPopover], ) + useEffect(() => { + let removeListener: (() => void) | undefined + + if (open) { + removeListener = addAndroidBackHandler(() => { + togglePopover() + return true + }) + } + + return () => { + if (removeListener) { + removeListener() + } + } + }, [addAndroidBackHandler, open, togglePopover]) + return open ? ( = (props) => { +const PreferencesView: FunctionComponent = ({ + application, + viewControllerManager, + closePreferences, + userProvider, + mfaProvider, +}) => { const isDesktopScreen = useMediaQuery(MediaQueryBreakpoints.md) const menu = useMemo( - () => new PreferencesMenu(props.application, props.viewControllerManager.enableUnfinishedFeatures), - [props.viewControllerManager.enableUnfinishedFeatures, props.application], + () => new PreferencesMenu(application, viewControllerManager.enableUnfinishedFeatures), + [viewControllerManager.enableUnfinishedFeatures, application], ) useEffect(() => { - menu.selectPane(props.viewControllerManager.preferencesController.currentPane) - const removeEscKeyObserver = props.application.io.addKeyObserver({ + menu.selectPane(viewControllerManager.preferencesController.currentPane) + const removeEscKeyObserver = application.io.addKeyObserver({ key: 'Escape', onKeyDown: (event) => { event.preventDefault() - props.closePreferences() + closePreferences() }, }) return () => { removeEscKeyObserver() } - }, [props, menu]) + }, [menu, viewControllerManager.preferencesController.currentPane, application.io, closePreferences]) useDisableBodyScrollOnMobile() + const addAndroidBackHandler = useAndroidBackHandler() + + useEffect(() => { + const removeListener = addAndroidBackHandler(() => { + closePreferences() + return true + }) + return () => { + if (removeListener) { + removeListener() + } + } + }, [addAndroidBackHandler, closePreferences]) + return (
= (props) => {

Your preferences for Standard Notes

{ - props.closePreferences() + closePreferences() }} type="normal" icon="close" />
- +
) } diff --git a/packages/web/src/javascripts/Components/ResponsivePane/ResponsivePaneProvider.tsx b/packages/web/src/javascripts/Components/ResponsivePane/ResponsivePaneProvider.tsx index 4a4abe7e5..ac6c4532e 100644 --- a/packages/web/src/javascripts/Components/ResponsivePane/ResponsivePaneProvider.tsx +++ b/packages/web/src/javascripts/Components/ResponsivePane/ResponsivePaneProvider.tsx @@ -1,6 +1,19 @@ import { ElementIds } from '@/Constants/ElementIDs' +import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler' import { isMobileScreen } from '@/Utils' -import { useEffect, ReactNode, useMemo, createContext, useCallback, useContext, useState, memo } from 'react' +import { + useEffect, + ReactNode, + useMemo, + createContext, + useCallback, + useContext, + useState, + memo, + useRef, + useLayoutEffect, + MutableRefObject, +} from 'react' import { AppPaneId } from './AppPaneMetadata' type ResponsivePaneData = { @@ -20,16 +33,27 @@ export const useResponsiveAppPane = () => { return value } -type Props = { +type ChildrenProps = { children: ReactNode } -const MemoizedChildren = memo(({ children }: Props) =>
{children}
) +function useStateRef(state: State): MutableRefObject { + const ref = useRef(state) -const ResponsivePaneProvider = ({ children }: Props) => { + useLayoutEffect(() => { + ref.current = state + }, [state]) + + return ref +} + +const MemoizedChildren = memo(({ children }: ChildrenProps) =>
{children}
) + +const ResponsivePaneProvider = ({ children }: ChildrenProps) => { const [currentSelectedPane, setCurrentSelectedPane] = useState( isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor, ) + const currentSelectedPaneRef = useStateRef(currentSelectedPane) const [previousSelectedPane, setPreviousSelectedPane] = useState( isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor, ) @@ -57,6 +81,27 @@ const ResponsivePaneProvider = ({ children }: Props) => { currentPaneElement?.classList.add('selected') }, [currentSelectedPane, previousSelectedPane]) + const addAndroidBackHandler = useAndroidBackHandler() + + useEffect(() => { + const removeListener = addAndroidBackHandler(() => { + if ( + currentSelectedPaneRef.current === AppPaneId.Editor || + currentSelectedPaneRef.current === AppPaneId.Navigation + ) { + toggleAppPane(AppPaneId.Items) + return true + } else { + return false + } + }) + return () => { + if (removeListener) { + removeListener() + } + } + }, [addAndroidBackHandler, currentSelectedPaneRef, toggleAppPane]) + const contextValue = useMemo( () => ({ selectedPane: currentSelectedPane, diff --git a/packages/web/src/javascripts/Components/RevisionHistoryModal/RevisionHistoryModal.tsx b/packages/web/src/javascripts/Components/RevisionHistoryModal/RevisionHistoryModal.tsx index 378d217a8..6133f5931 100644 --- a/packages/web/src/javascripts/Components/RevisionHistoryModal/RevisionHistoryModal.tsx +++ b/packages/web/src/javascripts/Components/RevisionHistoryModal/RevisionHistoryModal.tsx @@ -1,8 +1,9 @@ import { observer } from 'mobx-react-lite' -import { FunctionComponent } from 'react' +import { FunctionComponent, useEffect } from 'react' import HistoryModalDialogContent from './HistoryModalDialogContent' import HistoryModalDialog from './HistoryModalDialog' import { RevisionHistoryModalProps } from './RevisionHistoryModalProps' +import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler' const RevisionHistoryModal: FunctionComponent = ({ application, @@ -11,6 +12,27 @@ const RevisionHistoryModal: FunctionComponent = ({ selectionController, subscriptionController, }) => { + const addAndroidBackHandler = useAndroidBackHandler() + + const isOpen = !!historyModalController.note + + useEffect(() => { + let removeListener: (() => void) | undefined + + if (isOpen) { + removeListener = addAndroidBackHandler(() => { + historyModalController.dismissModal() + return true + }) + } + + return () => { + if (removeListener) { + removeListener() + } + } + }, [addAndroidBackHandler, historyModalController, isOpen]) + if (!historyModalController.note) { return null } diff --git a/packages/web/src/javascripts/Components/Shared/ModalDialogLabel.tsx b/packages/web/src/javascripts/Components/Shared/ModalDialogLabel.tsx index 5b29e8d7b..bffbcbc79 100644 --- a/packages/web/src/javascripts/Components/Shared/ModalDialogLabel.tsx +++ b/packages/web/src/javascripts/Components/Shared/ModalDialogLabel.tsx @@ -1,7 +1,8 @@ -import { FunctionComponent, ReactNode } from 'react' +import { FunctionComponent, ReactNode, useEffect } from 'react' import { AlertDialogLabel } from '@reach/alert-dialog' import Icon from '@/Components/Icon/Icon' import { classNames } from '@/Utils/ConcatenateClassNames' +import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler' type Props = { closeDialog: () => void @@ -10,24 +11,40 @@ type Props = { children?: ReactNode } -const ModalDialogLabel: FunctionComponent = ({ children, closeDialog, className, headerButtons }) => ( - -
-
{children}
-
- {headerButtons} - +const ModalDialogLabel: FunctionComponent = ({ children, closeDialog, className, headerButtons }) => { + const addAndroidBackHandler = useAndroidBackHandler() + + useEffect(() => { + const removeListener = addAndroidBackHandler(() => { + closeDialog() + return true + }) + return () => { + if (removeListener) { + removeListener() + } + } + }, [addAndroidBackHandler, closeDialog]) + + return ( + +
+
{children}
+
+ {headerButtons} + +
-
-
- -) +
+ + ) +} export default ModalDialogLabel diff --git a/packages/web/src/javascripts/NativeMobileWeb/AndroidBackHandler.tsx b/packages/web/src/javascripts/NativeMobileWeb/AndroidBackHandler.tsx new file mode 100644 index 000000000..fcfbaf7a7 --- /dev/null +++ b/packages/web/src/javascripts/NativeMobileWeb/AndroidBackHandler.tsx @@ -0,0 +1,22 @@ +type Listener = () => boolean +type RemoveListener = () => void + +export class AndroidBackHandler { + private listeners = new Set() + + addEventListener(listener: Listener): RemoveListener { + this.listeners.add(listener) + + return () => { + this.listeners.delete(listener) + } + } + + notifyEvent() { + for (const listener of Array.from(this.listeners).reverse()) { + if (listener()) { + return + } + } + } +} diff --git a/packages/web/src/javascripts/NativeMobileWeb/useAndroidBackHandler.tsx b/packages/web/src/javascripts/NativeMobileWeb/useAndroidBackHandler.tsx new file mode 100644 index 000000000..f01bbe09e --- /dev/null +++ b/packages/web/src/javascripts/NativeMobileWeb/useAndroidBackHandler.tsx @@ -0,0 +1,54 @@ +import { WebApplication } from '@/Application/Application' +import { observer } from 'mobx-react-lite' +import { createContext, memo, ReactNode, useCallback, useContext, useEffect } from 'react' + +type BackHandlerContextData = WebApplication['addAndroidBackHandlerEventListener'] + +const BackHandlerContext = createContext(null) + +export const useAndroidBackHandler = () => { + const value = useContext(BackHandlerContext) + + if (!value) { + throw new Error('Component must be a child of ') + } + + return value +} + +type ChildrenProps = { + children: ReactNode +} + +type ProviderProps = { + application: WebApplication +} & ChildrenProps + +const MemoizedChildren = memo(({ children }: ChildrenProps) =>
{children}
) + +const AndroidBackHandlerProvider = ({ application, children }: ProviderProps) => { + const addAndroidBackHandler = useCallback( + (listener: () => boolean) => application.addAndroidBackHandlerEventListener(listener), + [application], + ) + + useEffect(() => { + const removeListener = addAndroidBackHandler(() => { + application.mobileDevice.confirmAndExit() + return true + }) + return () => { + if (removeListener) { + removeListener() + } + } + }, [addAndroidBackHandler, application.mobileDevice]) + + return ( + + + + ) +} + +export default observer(AndroidBackHandlerProvider)