vojo/src/app/hooks/useAndroidBackButton.ts
2026-04-18 01:30:48 +03:00

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());
};
}, []);
};