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

@@ -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
}
}

View File

@@ -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

View File

@@ -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<Props> = ({ application, mainApplicatio
}
return (
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
<AndroidBackHandlerProvider application={application}>
<ResponsivePaneProvider>
<div className={platformString + ' main-ui-view sn-component'}>
<div id="app" className={appClass + ' app app-column-container'}>
<FileDragNDropProvider
application={application}
featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController}
>
<Navigation application={application} />
<ContentListView
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
<div className={platformString + ' main-ui-view sn-component'}>
<div id="app" className={appClass + ' app app-column-container'}>
<FileDragNDropProvider
application={application}
accountMenuController={viewControllerManager.accountMenuController}
featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController}
itemListController={viewControllerManager.itemListController}
navigationController={viewControllerManager.navigationController}
noAccountWarningController={viewControllerManager.noAccountWarningController}
noteTagsController={viewControllerManager.noteTagsController}
>
<Navigation application={application} />
<ContentListView
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}
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>
<>
<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>
</PremiumModalProvider>
</ResponsivePaneProvider>
</PremiumModalProvider>
</AndroidBackHandlerProvider>
)
}

View File

@@ -184,6 +184,20 @@ const ChallengeModal: FunctionComponent<Props> = ({
}
}, [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
}

View File

@@ -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<Set<string>>(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 ? (
<PopoverContext.Provider value={contextValue}>
<PositionedPopoverContent

View File

@@ -8,31 +8,52 @@ import { isIOS } from '@/Utils'
import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile'
import { classNames } from '@/Utils/ConcatenateClassNames'
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 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 (
<div
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>
<RoundIconButton
onClick={() => {
props.closePreferences()
closePreferences()
}}
type="normal"
icon="close"
/>
</div>
<PreferencesCanvas {...props} menu={menu} />
<PreferencesCanvas
menu={menu}
application={application}
viewControllerManager={viewControllerManager}
closePreferences={closePreferences}
userProvider={userProvider}
mfaProvider={mfaProvider}
/>
</div>
)
}

View File

@@ -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) => <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>(
isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor,
)
const currentSelectedPaneRef = useStateRef<AppPaneId>(currentSelectedPane)
const [previousSelectedPane, setPreviousSelectedPane] = useState<AppPaneId>(
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,

View File

@@ -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<RevisionHistoryModalProps> = ({
application,
@@ -11,6 +12,27 @@ const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({
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
}

View File

@@ -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<Props> = ({ children, closeDialog, className, headerButtons }) => (
<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>
const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, className, headerButtons }) => {
const addAndroidBackHandler = useAndroidBackHandler()
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
closeDialog()
return true
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [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>
<hr className="h-1px no-border m-0 bg-border" />
</AlertDialogLabel>
)
<hr className="h-1px no-border m-0 bg-border" />
</AlertDialogLabel>
)
}
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)