feat: handle android back button on android (#1656)
This commit is contained in:
21
packages/mobile/src/AndroidBackHandlerService.ts
Normal file
21
packages/mobile/src/AndroidBackHandlerService.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export enum ReactNativeToWebEvent {
|
|||||||
ResumingFromBackground = 'ResumingFromBackground',
|
ResumingFromBackground = 'ResumingFromBackground',
|
||||||
GainingFocus = 'GainingFocus',
|
GainingFocus = 'GainingFocus',
|
||||||
LosingFocus = 'LosingFocus',
|
LosingFocus = 'LosingFocus',
|
||||||
|
AndroidBackButtonPressed = 'AndroidBackButtonPressed',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user