108 lines
3.9 KiB
TypeScript
108 lines
3.9 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useLocation, useNavigate, useNavigationType } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { App } from '@capacitor/app';
|
|
import { Toast } from '@capacitor/toast';
|
|
import { isNativePlatform } from '../utils/capacitor';
|
|
import { getRouteSectionParent } from '../utils/routeParent';
|
|
|
|
const EXIT_CONFIRM_WINDOW_MS = 2000;
|
|
|
|
/**
|
|
* Maps the Android hardware back button to web-equivalent behavior, using
|
|
* an app-level back stack instead of the WebView's native history. The
|
|
* WebView's canGoBack includes redirects / replaces / tab-switch entries
|
|
* that inflate its depth, which is why we maintain our own stack keyed
|
|
* off react-router's useNavigationType.
|
|
*
|
|
* 1. Overlay/modal open → dispatch Escape (closes folds Overlay via
|
|
* focus-trap-react's escapeDeactivates).
|
|
* 2. App stack has more than one entry → optimistic pop + navigate(-1).
|
|
* The optimistic pop, combined with pendingPopsRef suppressing the
|
|
* duplicate pop in the tracking effect, makes rapid back-presses
|
|
* see the up-to-date stack length before React commits the new
|
|
* location.
|
|
* 3. Cold-start / deep-link into a nested screen → navigate to the
|
|
* section-root semantic parent (same logic UI back arrow uses), with
|
|
* replace so the stack doesn't grow in a loop.
|
|
* 4. At section root with a single-entry stack → require a confirming
|
|
* second press within EXIT_CONFIRM_WINDOW_MS to exit; first press
|
|
* shows a native Android toast. Any navigation between presses
|
|
* resets the window so the prompt can't leak across screens.
|
|
*/
|
|
export const useAndroidBackButton = (): void => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const navType = useNavigationType();
|
|
const { t } = useTranslation();
|
|
|
|
const navigateRef = useRef(navigate);
|
|
navigateRef.current = navigate;
|
|
|
|
const exitPromptRef = useRef(t('App.back_to_exit'));
|
|
exitPromptRef.current = t('App.back_to_exit');
|
|
|
|
const stackRef = useRef<string[]>([location.pathname]);
|
|
const lastKeyRef = useRef(location.key);
|
|
const pendingPopsRef = useRef(0);
|
|
const lastExitPressRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (location.key === lastKeyRef.current) return;
|
|
lastKeyRef.current = location.key;
|
|
|
|
lastExitPressRef.current = 0;
|
|
|
|
if (navType === 'PUSH') {
|
|
stackRef.current = [...stackRef.current, location.pathname];
|
|
} else if (navType === 'REPLACE') {
|
|
stackRef.current = [...stackRef.current.slice(0, -1), location.pathname];
|
|
} else if (navType === 'POP') {
|
|
if (pendingPopsRef.current > 0) {
|
|
pendingPopsRef.current -= 1;
|
|
} else {
|
|
stackRef.current = stackRef.current.slice(0, -1);
|
|
}
|
|
}
|
|
}, [location.key, location.pathname, navType]);
|
|
|
|
useEffect(() => {
|
|
if (!isNativePlatform()) return undefined;
|
|
|
|
const handlePromise = App.addListener('backButton', () => {
|
|
const portal = document.getElementById('portalContainer');
|
|
if (portal?.firstChild) {
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (stackRef.current.length > 1) {
|
|
stackRef.current = stackRef.current.slice(0, -1);
|
|
pendingPopsRef.current += 1;
|
|
navigateRef.current(-1);
|
|
return;
|
|
}
|
|
|
|
const current = stackRef.current[0] ?? '/';
|
|
const parent = getRouteSectionParent(current);
|
|
if (parent) {
|
|
navigateRef.current(parent, { replace: true });
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - lastExitPressRef.current < EXIT_CONFIRM_WINDOW_MS) {
|
|
App.exitApp();
|
|
return;
|
|
}
|
|
lastExitPressRef.current = now;
|
|
Toast.show({ text: exitPromptRef.current, duration: 'short', position: 'bottom' });
|
|
});
|
|
|
|
return () => {
|
|
handlePromise.then((handle) => handle.remove());
|
|
};
|
|
}, []);
|
|
};
|