fix: intercept the Back key to dismiss popup dialogs on Android, closes #636 (#983)

This commit is contained in:
Huang Xin
2025-04-28 22:57:15 +08:00
committed by GitHub
parent 4275508ccd
commit e235cb98d2
6 changed files with 155 additions and 48 deletions
@@ -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;
@@ -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 (
<Dialog
id='about_window'
isOpen={false}
isOpen={isOpen}
title={_('About Readest')}
onClose={() => setAboutDialogVisible(false)}
onClose={() => setIsOpen(false)}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
+21 -3
View File
@@ -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<DialogProps> = ({
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;
@@ -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 (
<Dialog
id='updater_window'
isOpen={false}
isOpen={isOpen}
title={_('Software Update')}
onClose={() => setUpdaterWindowVisible(false)}
onClose={() => setIsOpen(false)}
boxClassName='sm:!w-[80%] sm:h-auto'
>
<UpdaterContent version={newVersion ?? undefined} />
+34 -12
View File
@@ -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<DeviceControlState>((set) => ({
export const useDeviceControlStore = create<DeviceControlState>((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 });
}
},
}));
+11 -5
View File
@@ -1,5 +1,5 @@
class EventDispatcher {
private syncListeners: Map<string, Set<(event: CustomEvent) => boolean>>;
private syncListeners: Map<string, Array<(event: CustomEvent) => boolean>>;
private asyncListeners: Map<string, Set<(event: CustomEvent) => Promise<void> | 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;