feat: handle android back button on android (#1656)

This commit is contained in:
Aman Harwara
2022-09-28 12:12:55 +05:30
committed by GitHub
parent 04245dfeeb
commit 981d8a7497
17 changed files with 413 additions and 101 deletions

View File

@@ -0,0 +1,21 @@
import { AbstractService, InternalEventBus, ReactNativeToWebEvent } from '@standardnotes/snjs'
import { BackHandler, NativeEventSubscription } from 'react-native'
export class AndroidBackHandlerService extends AbstractService<ReactNativeToWebEvent> {
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()
}
}

View File

@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-community/async-storage' import AsyncStorage from '@react-native-community/async-storage'
import { AndroidBackHandlerService } from '@Root/AndroidBackHandlerService'
import SNReactNative from '@standardnotes/react-native-utils' import SNReactNative from '@standardnotes/react-native-utils'
import { import {
ApplicationIdentifier, ApplicationIdentifier,
@@ -80,13 +81,18 @@ export class MobileDevice implements MobileDeviceInterface {
public isDarkMode = false public isDarkMode = false
private crypto: SNReactNativeCrypto private crypto: SNReactNativeCrypto
constructor(private stateObserverService?: AppStateObserverService) { constructor(
private stateObserverService?: AppStateObserverService,
private androidBackHandlerService?: AndroidBackHandlerService,
) {
this.crypto = new SNReactNativeCrypto() this.crypto = new SNReactNativeCrypto()
} }
deinit() { deinit() {
this.stateObserverService?.deinit() this.stateObserverService?.deinit()
;(this.stateObserverService as unknown) = undefined ;(this.stateObserverService as unknown) = undefined
this.androidBackHandlerService?.deinit()
;(this.androidBackHandlerService as unknown) = undefined
} }
consoleLog(...args: any[]): void { consoleLog(...args: any[]): void {
@@ -542,4 +548,29 @@ export class MobileDevice implements MobileDeviceInterface {
this.consoleLog(`${error}`) 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,
},
)
}
} }

View File

@@ -6,6 +6,7 @@ import { Keyboard, Platform } from 'react-native'
import VersionInfo from 'react-native-version-info' import VersionInfo from 'react-native-version-info'
import { WebView, WebViewMessageEvent } from 'react-native-webview' import { WebView, WebViewMessageEvent } from 'react-native-webview'
import pjson from '../package.json' import pjson from '../package.json'
import { AndroidBackHandlerService } from './AndroidBackHandlerService'
import { AppStateObserverService } from './AppStateObserverService' import { AppStateObserverService } from './AppStateObserverService'
const LoggingEnabled = IsDev const LoggingEnabled = IsDev
@@ -24,13 +25,23 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
const webViewRef = useRef<WebView>(null) const webViewRef = useRef<WebView>(null)
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html' const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html'
const stateService = useMemo(() => new AppStateObserverService(), []) 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(() => { useEffect(() => {
const removeListener = stateService.addEventObserver((event: ReactNativeToWebEvent) => { const removeStateServiceListener = stateService.addEventObserver((event: ReactNativeToWebEvent) => {
webViewRef.current?.postMessage(JSON.stringify({ reactNativeEvent: event, messageType: 'event' })) 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', () => { const keyboardShowListener = Keyboard.addListener('keyboardWillShow', () => {
device.reloadStatusBarStyle(false) device.reloadStatusBarStyle(false)
}) })
@@ -40,11 +51,12 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
}) })
return () => { return () => {
removeListener() removeStateServiceListener()
removeBackHandlerServiceListener()
keyboardShowListener.remove() keyboardShowListener.remove()
keyboardHideListener.remove() keyboardHideListener.remove()
} }
}, [webViewRef, stateService]) }, [webViewRef, stateService, device, androidBackHandlerService])
useEffect(() => { useEffect(() => {
const observer = device.addMobileWebEventReceiver((event) => { const observer = device.addMobileWebEventReceiver((event) => {

View File

@@ -12,4 +12,6 @@ export interface WebApplicationInterface extends ApplicationInterface {
handleMobileResumingFromBackgroundEvent(): Promise<void> handleMobileResumingFromBackgroundEvent(): Promise<void>
isNativeMobileWeb(): boolean isNativeMobileWeb(): boolean
get mobileDevice(): MobileDeviceInterface get mobileDevice(): MobileDeviceInterface
handleAndroidBackButtonPressed(): void
addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined
} }

View File

@@ -15,4 +15,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
handleThemeSchemeChange(isDark: boolean): void handleThemeSchemeChange(isDark: boolean): void
shareBase64AsFile(base64: string, filename: string): Promise<void> shareBase64AsFile(base64: string, filename: string): Promise<void>
downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined> downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined>
confirmAndExit(): void
} }

View File

@@ -3,4 +3,5 @@ export enum ReactNativeToWebEvent {
ResumingFromBackground = 'ResumingFromBackground', ResumingFromBackground = 'ResumingFromBackground',
GainingFocus = 'GainingFocus', GainingFocus = 'GainingFocus',
LosingFocus = 'LosingFocus', LosingFocus = 'LosingFocus',
AndroidBackButtonPressed = 'AndroidBackButtonPressed',
} }

View File

@@ -26,6 +26,7 @@ import { isDesktopApplication } from '@/Utils'
import { DesktopManager } from './Device/DesktopManager' import { DesktopManager } from './Device/DesktopManager'
import { ArchiveManager, AutolockService, IOService, WebAlertService, ThemeManager } from '@standardnotes/ui-services' import { ArchiveManager, AutolockService, IOService, WebAlertService, ThemeManager } from '@standardnotes/ui-services'
import { MobileWebReceiver } from './MobileWebReceiver' import { MobileWebReceiver } from './MobileWebReceiver'
import { AndroidBackHandler } from '@/NativeMobileWeb/AndroidBackHandler'
type WebServices = { type WebServices = {
viewControllerManager: ViewControllerManager viewControllerManager: ViewControllerManager
@@ -45,6 +46,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
public iconsController: IconsController public iconsController: IconsController
private onVisibilityChange: () => void private onVisibilityChange: () => void
private mobileWebReceiver?: MobileWebReceiver private mobileWebReceiver?: MobileWebReceiver
private androidBackHandler?: AndroidBackHandler
constructor( constructor(
deviceInterface: WebOrDesktopDevice, deviceInterface: WebOrDesktopDevice,
@@ -76,6 +78,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
if (this.isNativeMobileWeb()) { if (this.isNativeMobileWeb()) {
this.mobileWebReceiver = new MobileWebReceiver(this) this.mobileWebReceiver = new MobileWebReceiver(this)
this.androidBackHandler = new AndroidBackHandler()
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log = (...args) => { console.log = (...args) => {
@@ -264,4 +267,17 @@ export class WebApplication extends SNApplication implements WebApplicationInter
await this.lock() 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
}
} }

View File

@@ -53,6 +53,9 @@ export class MobileWebReceiver {
case ReactNativeToWebEvent.ResumingFromBackground: case ReactNativeToWebEvent.ResumingFromBackground:
void this.application.handleMobileResumingFromBackgroundEvent() void this.application.handleMobileResumingFromBackgroundEvent()
break break
case ReactNativeToWebEvent.AndroidBackButtonPressed:
void this.application.handleAndroidBackButtonPressed()
break
default: default:
break break

View File

@@ -25,6 +25,7 @@ import { PanelResizedData } from '@/Types/PanelResizedData'
import TagContextMenuWrapper from '@/Components/Tags/TagContextMenuWrapper' import TagContextMenuWrapper from '@/Components/Tags/TagContextMenuWrapper'
import FileDragNDropProvider from '../FileDragNDropProvider/FileDragNDropProvider' import FileDragNDropProvider from '../FileDragNDropProvider/FileDragNDropProvider'
import ResponsivePaneProvider from '../ResponsivePane/ResponsivePaneProvider' import ResponsivePaneProvider from '../ResponsivePane/ResponsivePaneProvider'
import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler'
import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal' import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal'
type Props = { type Props = {
@@ -175,77 +176,79 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
} }
return ( return (
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}> <AndroidBackHandlerProvider application={application}>
<ResponsivePaneProvider> <ResponsivePaneProvider>
<div className={platformString + ' main-ui-view sn-component'}> <PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
<div id="app" className={appClass + ' app app-column-container'}> <div className={platformString + ' main-ui-view sn-component'}>
<FileDragNDropProvider <div id="app" className={appClass + ' app app-column-container'}>
application={application} <FileDragNDropProvider
featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController}
>
<Navigation application={application} />
<ContentListView
application={application} application={application}
accountMenuController={viewControllerManager.accountMenuController} featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController} filesController={viewControllerManager.filesController}
itemListController={viewControllerManager.itemListController} >
navigationController={viewControllerManager.navigationController} <Navigation application={application} />
noAccountWarningController={viewControllerManager.noAccountWarningController} <ContentListView
noteTagsController={viewControllerManager.noteTagsController} application={application}
accountMenuController={viewControllerManager.accountMenuController}
filesController={viewControllerManager.filesController}
itemListController={viewControllerManager.itemListController}
navigationController={viewControllerManager.navigationController}
noAccountWarningController={viewControllerManager.noAccountWarningController}
noteTagsController={viewControllerManager.noteTagsController}
notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController}
/>
<NoteGroupView application={application} />
</FileDragNDropProvider>
</div>
<>
<Footer application={application} applicationGroup={mainApplicationGroup} />
<SessionsModal application={application} viewControllerManager={viewControllerManager} />
<PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
<RevisionHistoryModal
application={application}
historyModalController={viewControllerManager.historyModalController}
notesController={viewControllerManager.notesController} notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController} selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController} subscriptionController={viewControllerManager.subscriptionController}
/> />
<NoteGroupView application={application} /> </>
</FileDragNDropProvider>
{renderChallenges()}
<>
<NotesContextMenu
application={application}
navigationController={viewControllerManager.navigationController}
notesController={viewControllerManager.notesController}
noteTagsController={viewControllerManager.noteTagsController}
historyModalController={viewControllerManager.historyModalController}
/>
<TagContextMenuWrapper
navigationController={viewControllerManager.navigationController}
featuresController={viewControllerManager.featuresController}
/>
<FileContextMenuWrapper
filesController={viewControllerManager.filesController}
selectionController={viewControllerManager.selectionController}
/>
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
<ConfirmSignoutContainer
applicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
application={application}
/>
<ToastContainer />
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
<PermissionsModalWrapper application={application} />
<ConfirmDeleteAccountContainer application={application} viewControllerManager={viewControllerManager} />
</>
</div> </div>
</PremiumModalProvider>
<>
<Footer application={application} applicationGroup={mainApplicationGroup} />
<SessionsModal application={application} viewControllerManager={viewControllerManager} />
<PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
<RevisionHistoryModal
application={application}
historyModalController={viewControllerManager.historyModalController}
notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController}
subscriptionController={viewControllerManager.subscriptionController}
/>
</>
{renderChallenges()}
<>
<NotesContextMenu
application={application}
navigationController={viewControllerManager.navigationController}
notesController={viewControllerManager.notesController}
noteTagsController={viewControllerManager.noteTagsController}
historyModalController={viewControllerManager.historyModalController}
/>
<TagContextMenuWrapper
navigationController={viewControllerManager.navigationController}
featuresController={viewControllerManager.featuresController}
/>
<FileContextMenuWrapper
filesController={viewControllerManager.filesController}
selectionController={viewControllerManager.selectionController}
/>
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
<ConfirmSignoutContainer
applicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
application={application}
/>
<ToastContainer />
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
<PermissionsModalWrapper application={application} />
<ConfirmDeleteAccountContainer application={application} viewControllerManager={viewControllerManager} />
</>
</div>
</ResponsivePaneProvider> </ResponsivePaneProvider>
</PremiumModalProvider> </AndroidBackHandlerProvider>
) )
} }

View File

@@ -184,6 +184,20 @@ const ChallengeModal: FunctionComponent<Props> = ({
} }
}, [hasBiometricPromptValue, hasOnlyBiometricPrompt, submit]) }, [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) { if (!challenge.prompts) {
return null return null
} }

View File

@@ -1,3 +1,4 @@
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { UuidGenerator } from '@standardnotes/snjs' import { UuidGenerator } from '@standardnotes/snjs'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import PositionedPopoverContent from './PositionedPopoverContent' import PositionedPopoverContent from './PositionedPopoverContent'
@@ -41,6 +42,8 @@ const Popover = ({
}: Props) => { }: Props) => {
const popoverId = useRef(UuidGenerator.GenerateUuid()) const popoverId = useRef(UuidGenerator.GenerateUuid())
const addAndroidBackHandler = useAndroidBackHandler()
useRegisterPopoverToParent(popoverId.current) useRegisterPopoverToParent(popoverId.current)
const [childPopovers, setChildPopovers] = useState<Set<string>>(new Set()) const [childPopovers, setChildPopovers] = useState<Set<string>>(new Set())
@@ -64,6 +67,23 @@ const Popover = ({
[registerChildPopover, unregisterChildPopover], [registerChildPopover, unregisterChildPopover],
) )
useEffect(() => {
let removeListener: (() => void) | undefined
if (open) {
removeListener = addAndroidBackHandler(() => {
togglePopover()
return true
})
}
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, open, togglePopover])
return open ? ( return open ? (
<PopoverContext.Provider value={contextValue}> <PopoverContext.Provider value={contextValue}>
<PositionedPopoverContent <PositionedPopoverContent

View File

@@ -8,31 +8,52 @@ import { isIOS } from '@/Utils'
import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile' import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
const PreferencesView: FunctionComponent<PreferencesProps> = (props) => { const PreferencesView: FunctionComponent<PreferencesProps> = ({
application,
viewControllerManager,
closePreferences,
userProvider,
mfaProvider,
}) => {
const isDesktopScreen = useMediaQuery(MediaQueryBreakpoints.md) const isDesktopScreen = useMediaQuery(MediaQueryBreakpoints.md)
const menu = useMemo( const menu = useMemo(
() => new PreferencesMenu(props.application, props.viewControllerManager.enableUnfinishedFeatures), () => new PreferencesMenu(application, viewControllerManager.enableUnfinishedFeatures),
[props.viewControllerManager.enableUnfinishedFeatures, props.application], [viewControllerManager.enableUnfinishedFeatures, application],
) )
useEffect(() => { useEffect(() => {
menu.selectPane(props.viewControllerManager.preferencesController.currentPane) menu.selectPane(viewControllerManager.preferencesController.currentPane)
const removeEscKeyObserver = props.application.io.addKeyObserver({ const removeEscKeyObserver = application.io.addKeyObserver({
key: 'Escape', key: 'Escape',
onKeyDown: (event) => { onKeyDown: (event) => {
event.preventDefault() event.preventDefault()
props.closePreferences() closePreferences()
}, },
}) })
return () => { return () => {
removeEscKeyObserver() removeEscKeyObserver()
} }
}, [props, menu]) }, [menu, viewControllerManager.preferencesController.currentPane, application.io, closePreferences])
useDisableBodyScrollOnMobile() useDisableBodyScrollOnMobile()
const addAndroidBackHandler = useAndroidBackHandler()
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
closePreferences()
return true
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, closePreferences])
return ( return (
<div <div
className={classNames( className={classNames(
@@ -48,13 +69,20 @@ const PreferencesView: FunctionComponent<PreferencesProps> = (props) => {
<h1 className="text-base font-bold md:text-lg">Your preferences for Standard Notes</h1> <h1 className="text-base font-bold md:text-lg">Your preferences for Standard Notes</h1>
<RoundIconButton <RoundIconButton
onClick={() => { onClick={() => {
props.closePreferences() closePreferences()
}} }}
type="normal" type="normal"
icon="close" icon="close"
/> />
</div> </div>
<PreferencesCanvas {...props} menu={menu} /> <PreferencesCanvas
menu={menu}
application={application}
viewControllerManager={viewControllerManager}
closePreferences={closePreferences}
userProvider={userProvider}
mfaProvider={mfaProvider}
/>
</div> </div>
) )
} }

View File

@@ -1,6 +1,19 @@
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { isMobileScreen } from '@/Utils' 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' import { AppPaneId } from './AppPaneMetadata'
type ResponsivePaneData = { type ResponsivePaneData = {
@@ -20,16 +33,27 @@ export const useResponsiveAppPane = () => {
return value return value
} }
type Props = { type ChildrenProps = {
children: ReactNode children: ReactNode
} }
const MemoizedChildren = memo(({ children }: Props) => <div>{children}</div>) function useStateRef<State>(state: State): MutableRefObject<State> {
const ref = useRef<State>(state)
const ResponsivePaneProvider = ({ children }: Props) => { useLayoutEffect(() => {
ref.current = state
}, [state])
return ref
}
const MemoizedChildren = memo(({ children }: ChildrenProps) => <div>{children}</div>)
const ResponsivePaneProvider = ({ children }: ChildrenProps) => {
const [currentSelectedPane, setCurrentSelectedPane] = useState<AppPaneId>( const [currentSelectedPane, setCurrentSelectedPane] = useState<AppPaneId>(
isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor, isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor,
) )
const currentSelectedPaneRef = useStateRef<AppPaneId>(currentSelectedPane)
const [previousSelectedPane, setPreviousSelectedPane] = useState<AppPaneId>( const [previousSelectedPane, setPreviousSelectedPane] = useState<AppPaneId>(
isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor, isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor,
) )
@@ -57,6 +81,27 @@ const ResponsivePaneProvider = ({ children }: Props) => {
currentPaneElement?.classList.add('selected') currentPaneElement?.classList.add('selected')
}, [currentSelectedPane, previousSelectedPane]) }, [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( const contextValue = useMemo(
() => ({ () => ({
selectedPane: currentSelectedPane, selectedPane: currentSelectedPane,

View File

@@ -1,8 +1,9 @@
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react' import { FunctionComponent, useEffect } from 'react'
import HistoryModalDialogContent from './HistoryModalDialogContent' import HistoryModalDialogContent from './HistoryModalDialogContent'
import HistoryModalDialog from './HistoryModalDialog' import HistoryModalDialog from './HistoryModalDialog'
import { RevisionHistoryModalProps } from './RevisionHistoryModalProps' import { RevisionHistoryModalProps } from './RevisionHistoryModalProps'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({ const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({
application, application,
@@ -11,6 +12,27 @@ const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({
selectionController, selectionController,
subscriptionController, 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) { if (!historyModalController.note) {
return null return null
} }

View File

@@ -1,7 +1,8 @@
import { FunctionComponent, ReactNode } from 'react' import { FunctionComponent, ReactNode, useEffect } from 'react'
import { AlertDialogLabel } from '@reach/alert-dialog' import { AlertDialogLabel } from '@reach/alert-dialog'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
type Props = { type Props = {
closeDialog: () => void closeDialog: () => void
@@ -10,24 +11,40 @@ type Props = {
children?: ReactNode children?: ReactNode
} }
const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, className, headerButtons }) => ( const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, className, headerButtons }) => {
<AlertDialogLabel const addAndroidBackHandler = useAndroidBackHandler()
className={classNames(
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default px-4.5 py-3 text-text', useEffect(() => {
className, const removeListener = addAndroidBackHandler(() => {
)} closeDialog()
> return true
<div className="flex w-full flex-row items-center justify-between"> })
<div className="flex-grow text-lg font-semibold text-text">{children}</div> return () => {
<div className="flex items-center gap-2"> if (removeListener) {
{headerButtons} removeListener()
<button tabIndex={0} className="rounded p-1 font-bold hover:bg-contrast" onClick={closeDialog}> }
<Icon type="close" /> }
</button> }, [addAndroidBackHandler, closeDialog])
return (
<AlertDialogLabel
className={classNames(
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default px-4.5 py-3 text-text',
className,
)}
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex-grow text-lg font-semibold text-text">{children}</div>
<div className="flex items-center gap-2">
{headerButtons}
<button tabIndex={0} className="rounded p-1 font-bold hover:bg-contrast" onClick={closeDialog}>
<Icon type="close" />
</button>
</div>
</div> </div>
</div> <hr className="h-1px no-border m-0 bg-border" />
<hr className="h-1px no-border m-0 bg-border" /> </AlertDialogLabel>
</AlertDialogLabel> )
) }
export default ModalDialogLabel export default ModalDialogLabel

View File

@@ -0,0 +1,22 @@
type Listener = () => boolean
type RemoveListener = () => void
export class AndroidBackHandler {
private listeners = new Set<Listener>()
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
}
}
}
}

View File

@@ -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<BackHandlerContextData | null>(null)
export const useAndroidBackHandler = () => {
const value = useContext(BackHandlerContext)
if (!value) {
throw new Error('Component must be a child of <AndroidBackHandlerProvider />')
}
return value
}
type ChildrenProps = {
children: ReactNode
}
type ProviderProps = {
application: WebApplication
} & ChildrenProps
const MemoizedChildren = memo(({ children }: ChildrenProps) => <div>{children}</div>)
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 (
<BackHandlerContext.Provider value={addAndroidBackHandler}>
<MemoizedChildren children={children} />
</BackHandlerContext.Provider>
)
}
export default observer(AndroidBackHandlerProvider)