From e235cb98d24811aaaf6229a06e9571a2322a847c Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 28 Apr 2025 22:57:15 +0800 Subject: [PATCH] fix: intercept the Back key to dismiss popup dialogs on Android, closes #636 (#983) --- .../src/app/reader/components/Reader.tsx | 43 +++++++++++++++-- .../src/components/AboutWindow.tsx | 31 ++++++++++--- apps/readest-app/src/components/Dialog.tsx | 24 ++++++++-- .../src/components/UpdaterWindow.tsx | 43 ++++++++++------- apps/readest-app/src/store/deviceStore.ts | 46 ++++++++++++++----- apps/readest-app/src/utils/event.ts | 16 +++++-- 6 files changed, 155 insertions(+), 48 deletions(-) diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index ac278595..9dd4257a 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -10,8 +10,11 @@ import { useThemeStore } from '@/store/themeStore'; import { useReaderStore } from '@/store/readerStore'; import { useLibraryStore } from '@/store/libraryStore'; import { useSidebarStore } from '@/store/sidebarStore'; +import { useNotebookStore } from '@/store/notebookStore'; import { useSettingsStore } from '@/store/settingsStore'; +import { useDeviceControlStore } from '@/store/deviceStore'; import { useScreenWakeLock } from '@/hooks/useScreenWakeLock'; +import { eventDispatcher } from '@/utils/event'; import { setSystemUIVisibility } from '@/utils/bridge'; import { AboutWindow } from '@/components/AboutWindow'; import { UpdaterWindow } from '@/components/UpdaterWindow'; @@ -20,11 +23,13 @@ import ReaderContent from './ReaderContent'; const Reader: React.FC<{ ids?: string }> = ({ ids }) => { const { envConfig, appService } = useEnv(); - const { settings, setSettings } = useSettingsStore(); - const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore(); - const { hoveredBookKey } = useReaderStore(); - const { isSideBarVisible } = useSidebarStore(); const { setLibrary } = useLibraryStore(); + const { hoveredBookKey } = useReaderStore(); + const { settings, setSettings } = useSettingsStore(); + const { isSideBarVisible, setSideBarVisible } = useSidebarStore(); + const { isNotebookVisible, setNotebookVisible } = useNotebookStore(); + const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore(); + const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore(); const [libraryLoaded, setLibraryLoaded] = useState(false); const isInitiating = useRef(false); @@ -33,7 +38,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { useEffect(() => { const handleVisibilityChange = () => { - if (appService?.isMobile && !document.hidden) { + if (appService?.isMobileApp && !document.hidden) { dismissSystemUI(); setSystemUIVisibility({ visible: false, darkMode: isDarkMode }); } @@ -45,6 +50,34 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [appService, isDarkMode]); + const handleKeyDown = (event: CustomEvent) => { + if (event.detail.keyName === 'Back') { + setSideBarVisible(false); + setNotebookVisible(false); + return true; + } + return false; + }; + + useEffect(() => { + if (!appService?.isAndroidApp) return; + if (isSideBarVisible || isNotebookVisible) { + acquireBackKeyInterception(); + eventDispatcher.onSync('native-key-down', handleKeyDown); + } + if (!isSideBarVisible && !isNotebookVisible) { + releaseBackKeyInterception(); + eventDispatcher.offSync('native-key-down', handleKeyDown); + } + return () => { + if (appService?.isAndroidApp) { + releaseBackKeyInterception(); + eventDispatcher.offSync('native-key-down', handleKeyDown); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSideBarVisible, isNotebookVisible]); + useEffect(() => { if (isInitiating.current) return; isInitiating.current = true; diff --git a/apps/readest-app/src/components/AboutWindow.tsx b/apps/readest-app/src/components/AboutWindow.tsx index f304b5b3..cb996e3b 100644 --- a/apps/readest-app/src/components/AboutWindow.tsx +++ b/apps/readest-app/src/components/AboutWindow.tsx @@ -10,10 +10,11 @@ import Link from './Link'; export const setAboutDialogVisible = (visible: boolean) => { const dialog = document.getElementById('about_window'); - if (visible) { - (dialog as HTMLDialogElement)?.showModal(); - } else { - (dialog as HTMLDialogElement)?.close(); + if (dialog) { + const event = new CustomEvent('setDialogVisibility', { + detail: { visible }, + }); + dialog.dispatchEvent(event); } }; @@ -22,16 +23,32 @@ export const AboutWindow = () => { const { appService } = useEnv(); const [isUpdated, setIsUpdated] = useState(false); const [browserInfo, setBrowserInfo] = useState(''); + const [isOpen, setIsOpen] = useState(false); useEffect(() => { setBrowserInfo(parseWebViewVersion(appService)); + + const handleCustomEvent = (event: CustomEvent) => { + setIsOpen(event.detail.visible); + }; + + const el = document.getElementById('about_window'); + if (el) { + el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener); + } + + return () => { + if (el) { + el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener); + } + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleCheckUpdate = async () => { const update = await checkForAppUpdates(_, false); if (update) { - setAboutDialogVisible(false); + setIsOpen(false); } else { setIsUpdated(true); } @@ -40,9 +57,9 @@ export const AboutWindow = () => { return ( setAboutDialogVisible(false)} + onClose={() => setIsOpen(false)} boxClassName='sm:!w-96 sm:h-auto' >
diff --git a/apps/readest-app/src/components/Dialog.tsx b/apps/readest-app/src/components/Dialog.tsx index 763e9ba1..b5379059 100644 --- a/apps/readest-app/src/components/Dialog.tsx +++ b/apps/readest-app/src/components/Dialog.tsx @@ -3,9 +3,11 @@ import React, { ReactNode, useEffect, useState } from 'react'; import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md'; import { useEnv } from '@/context/EnvContext'; import { useDrag } from '@/hooks/useDrag'; +import { useDeviceControlStore } from '@/store/deviceStore'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { impactFeedback } from '@tauri-apps/plugin-haptics'; import { getDirFromUILanguage } from '@/utils/rtl'; +import { eventDispatcher } from '@/utils/event'; const VELOCITY_THRESHOLD = 0.5; const SNAP_THRESHOLD = 0.2; @@ -38,24 +40,40 @@ const Dialog: React.FC = ({ onClose, }) => { const { appService } = useEnv(); + const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore(); const [isFullHeightInMobile, setIsFullHeightInMobile] = React.useState(!snapHeight); const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl'); const iconSize22 = useResponsiveSize(22); const isMobile = window.innerWidth < 640; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { + const handleKeyDown = (event: KeyboardEvent | CustomEvent) => { + if (event instanceof CustomEvent) { + if (event.detail.keyName === 'Back') { + onClose(); + return true; + } + } else if (event.key === 'Escape') { onClose(); } + return false; }; useEffect(() => { + if (!isOpen) return; window.addEventListener('keydown', handleKeyDown); + if (appService?.isAndroidApp) { + acquireBackKeyInterception(); + eventDispatcher.onSync('native-key-down', handleKeyDown); + } return () => { window.removeEventListener('keydown', handleKeyDown); + if (appService?.isAndroidApp) { + releaseBackKeyInterception(); + eventDispatcher.offSync('native-key-down', handleKeyDown); + } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isOpen]); const handleDragMove = (data: { clientY: number; deltaY: number }) => { if (!isMobile) return; diff --git a/apps/readest-app/src/components/UpdaterWindow.tsx b/apps/readest-app/src/components/UpdaterWindow.tsx index 6869de40..0a9ab2a2 100644 --- a/apps/readest-app/src/components/UpdaterWindow.tsx +++ b/apps/readest-app/src/components/UpdaterWindow.tsx @@ -10,10 +10,10 @@ import { fetch } from '@tauri-apps/plugin-http'; import { useTranslation } from '@/hooks/useTranslation'; import { useSearchParams } from 'next/navigation'; import { tauriDownload } from '@/utils/transfer'; +import { installPackage } from '@/utils/bridge'; import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants'; import packageJson from '../../package.json'; import Dialog from '@/components/Dialog'; -import { installPackage } from '@/utils/bridge'; interface ReleaseNotes { releases: Record< @@ -333,36 +333,47 @@ export const UpdaterContent = ({ version }: { version?: string }) => { }; export const setUpdaterWindowVisible = (visible: boolean, newVersion?: string) => { - if (newVersion) { - localStorage.setItem('newVersion', newVersion); - window.dispatchEvent(new CustomEvent('new-version-detected')); - } const dialog = document.getElementById('updater_window'); - if (visible) { - (dialog as HTMLDialogElement)?.showModal(); - } else { - (dialog as HTMLDialogElement)?.close(); + if (dialog) { + const event = new CustomEvent('setDialogVisibility', { + detail: { visible, newVersion }, + }); + dialog.dispatchEvent(event); } }; export const UpdaterWindow = () => { const _ = useTranslation(); - const [newVersion, setNewVersion] = useState(localStorage.getItem('newVersion') || ''); + const [newVersion, setNewVersion] = useState(''); + const [isOpen, setIsOpen] = useState(false); useEffect(() => { - const handler = () => { - setNewVersion(localStorage.getItem('newVersion') || ''); + const handleCustomEvent = (event: CustomEvent) => { + const { visible, newVersion } = event.detail; + setIsOpen(visible); + if (newVersion) { + setNewVersion(newVersion); + } + }; + + const el = document.getElementById('updater_window'); + if (el) { + el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener); + } + + return () => { + if (el) { + el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener); + } }; - window.addEventListener('new-version-detected', handler); - return () => window.removeEventListener('new-version-detected', handler); }, []); return ( setUpdaterWindowVisible(false)} + onClose={() => setIsOpen(false)} boxClassName='sm:!w-[80%] sm:h-auto' > diff --git a/apps/readest-app/src/store/deviceStore.ts b/apps/readest-app/src/store/deviceStore.ts index 3ed5bd18..4ba11270 100644 --- a/apps/readest-app/src/store/deviceStore.ts +++ b/apps/readest-app/src/store/deviceStore.ts @@ -13,7 +13,7 @@ const handleNativeKeyDown = (keyName: string) => { return eventDispatcher.dispatch('native-key-down', { keyName }); } if (keyName === 'Back') { - return eventDispatcher.dispatch('native-key-down', { keyName }); + return eventDispatcher.dispatchSync('native-key-down', { keyName }); } return false; }; @@ -21,35 +21,57 @@ const handleNativeKeyDown = (keyName: string) => { type DeviceControlState = { volumeKeysIntercepted: boolean; backKeyIntercepted: boolean; + volumeKeysInterceptionCount: number; + backKeyInterceptionCount: number; acquireVolumeKeyInterception: () => void; releaseVolumeKeyInterception: () => void; acquireBackKeyInterception: () => void; releaseBackKeyInterception: () => void; }; -export const useDeviceControlStore = create((set) => ({ +export const useDeviceControlStore = create((set, get) => ({ volumeKeysIntercepted: false, backKeyIntercepted: false, + volumeKeysInterceptionCount: 0, + backKeyInterceptionCount: 0, acquireVolumeKeyInterception: () => { - window.onNativeKeyDown = handleNativeKeyDown; - interceptKeys({ volumeKeys: true }); - set({ volumeKeysIntercepted: true }); + const { volumeKeysInterceptionCount } = get(); + if (volumeKeysInterceptionCount == 0) { + window.onNativeKeyDown = handleNativeKeyDown; + interceptKeys({ volumeKeys: true }); + set({ volumeKeysIntercepted: true }); + } + set({ volumeKeysInterceptionCount: volumeKeysInterceptionCount + 1 }); }, releaseVolumeKeyInterception: () => { - interceptKeys({ volumeKeys: false }); - set({ volumeKeysIntercepted: false }); + const { volumeKeysInterceptionCount } = get(); + if (volumeKeysInterceptionCount <= 1) { + interceptKeys({ volumeKeys: false }); + set({ volumeKeysIntercepted: false, volumeKeysInterceptionCount: 0 }); + } else { + set({ volumeKeysInterceptionCount: volumeKeysInterceptionCount - 1 }); + } }, acquireBackKeyInterception: () => { - window.onNativeKeyDown = handleNativeKeyDown; - interceptKeys({ backKey: true }); - set({ backKeyIntercepted: true }); + const { backKeyInterceptionCount } = get(); + if (backKeyInterceptionCount == 0) { + window.onNativeKeyDown = handleNativeKeyDown; + interceptKeys({ backKey: true }); + set({ backKeyIntercepted: true }); + } + set({ backKeyInterceptionCount: backKeyInterceptionCount + 1 }); }, releaseBackKeyInterception: () => { - interceptKeys({ backKey: false }); - set({ backKeyIntercepted: false }); + const { backKeyInterceptionCount } = get(); + if (backKeyInterceptionCount <= 1) { + interceptKeys({ backKey: false }); + set({ backKeyIntercepted: false, backKeyInterceptionCount: 0 }); + } else { + set({ backKeyInterceptionCount: backKeyInterceptionCount - 1 }); + } }, })); diff --git a/apps/readest-app/src/utils/event.ts b/apps/readest-app/src/utils/event.ts index b82a35b4..57ca12b8 100644 --- a/apps/readest-app/src/utils/event.ts +++ b/apps/readest-app/src/utils/event.ts @@ -1,5 +1,5 @@ class EventDispatcher { - private syncListeners: Map boolean>>; + private syncListeners: Map boolean>>; private asyncListeners: Map Promise | void>>; constructor() { @@ -30,20 +30,26 @@ class EventDispatcher { onSync(event: string, callback: (event: CustomEvent) => boolean): void { if (!this.syncListeners.has(event)) { - this.syncListeners.set(event, new Set()); + this.syncListeners.set(event, []); } - this.syncListeners.get(event)!.add(callback); + this.syncListeners.get(event)!.push(callback); } offSync(event: string, callback: (event: CustomEvent) => boolean): void { - this.syncListeners.get(event)?.delete(callback); + const listeners = this.syncListeners.get(event); + if (listeners) { + this.syncListeners.set( + event, + listeners.filter((listener) => listener !== callback), + ); + } } dispatchSync(event: string, detail?: unknown): boolean { const listeners = this.syncListeners.get(event); if (listeners) { const customEvent = new CustomEvent(event, { detail }); - for (const listener of listeners) { + for (const listener of [...listeners].reverse()) { const consumed = listener(customEvent); if (consumed) { return true;