feature with back button on native app
This commit is contained in:
parent
46981791a3
commit
3717adb52e
13 changed files with 215 additions and 86 deletions
|
|
@ -9,8 +9,10 @@ android {
|
|||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-toast')
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,14 @@
|
|||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-browser'
|
||||
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-toast'
|
||||
project(':capacitor-toast').projectDir = new File('../node_modules/@capacitor/toast/android')
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Any agent (Claude Code, Cursor, Codex, Windsurf, Cline, Copilot, Aider, …) wor
|
|||
| [i18n.md](i18n.md) | i18next setup, translation patterns, Russian-language quality standards, localization progress |
|
||||
| [android.md](android.md) | Capacitor wrapper, Android build chain, edge-to-edge, Service Worker invariants, ADB workflow |
|
||||
| [bugs.md](bugs.md) | Known bugs & regressions |
|
||||
| [server-side.md](server-side.md) | Some configs that deployd on server |
|
||||
|
||||
## Rules for updating
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
**Fix options**: Remove `AuthType.Sso` from `SUPPORTED_IN_APP_UIA_STAGES` in `ActionUIA.tsx`, or rewrite the SSO callback to use a Capacitor App URL listener instead of `postMessage`.
|
||||
|
||||
---
|
||||
### Баг связанный с кривым отображением боксов в окне с авторизацией для ника и пароля: на хроме они светятся белым почему-то
|
||||
|
||||
## Resolved
|
||||
|
||||
|
|
|
|||
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -13,10 +13,12 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@capacitor/android": "8.3.0",
|
||||
"@capacitor/app": "8.1.0",
|
||||
"@capacitor/browser": "8.0.3",
|
||||
"@capacitor/cli": "8.3.0",
|
||||
"@capacitor/core": "8.3.0",
|
||||
"@capacitor/push-notifications": "8.0.3",
|
||||
"@capacitor/toast": "8.0.1",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
|
|
@ -1720,6 +1722,15 @@
|
|||
"@capacitor/core": "^8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/app": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz",
|
||||
"integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/browser": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.3.tgz",
|
||||
|
|
@ -1915,6 +1926,15 @@
|
|||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/toast": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/toast/-/toast-8.0.1.tgz",
|
||||
"integrity": "sha512-0uoyftoAeFjtOMiozBo7YXEo+zcpQCAxtBONSvrljJ05SKdlb1A8OFHaV/DZfIpGt+19kzUGaEaUemLdll4wUw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
|
|
|
|||
|
|
@ -74,10 +74,12 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@capacitor/android": "8.3.0",
|
||||
"@capacitor/app": "8.1.0",
|
||||
"@capacitor/browser": "8.0.3",
|
||||
"@capacitor/cli": "8.3.0",
|
||||
"@capacitor/core": "8.3.0",
|
||||
"@capacitor/push-notifications": "8.0.3",
|
||||
"@capacitor/toast": "8.0.1",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
"changed_room_name": " changed room name"
|
||||
}
|
||||
},
|
||||
"App": {
|
||||
"back_to_exit": "Press back again to exit"
|
||||
},
|
||||
"Auth": {
|
||||
"title_login": "Log In",
|
||||
"title_register": "Register",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
"changed_room_name": " изменил(а) название комнаты"
|
||||
}
|
||||
},
|
||||
"App": {
|
||||
"back_to_exit": "Нажмите «назад» ещё раз, чтобы выйти"
|
||||
},
|
||||
"Auth": {
|
||||
"title_login": "Войти",
|
||||
"title_register": "Регистрация",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import { ReactNode, useCallback } from 'react';
|
||||
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getDirectPath,
|
||||
getExplorePath,
|
||||
getHomePath,
|
||||
getInboxPath,
|
||||
getSpacePath,
|
||||
} from '../pages/pathUtils';
|
||||
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { getRouteSectionParent } from '../utils/routeParent';
|
||||
|
||||
type BackRouteHandlerProps = {
|
||||
children: (onBack: () => void) => ReactNode;
|
||||
|
|
@ -17,74 +10,9 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
|
|||
const location = useLocation();
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (
|
||||
matchPath(
|
||||
{
|
||||
path: HOME_PATH,
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
},
|
||||
location.pathname
|
||||
)
|
||||
) {
|
||||
navigate(getHomePath());
|
||||
return;
|
||||
}
|
||||
if (
|
||||
matchPath(
|
||||
{
|
||||
path: DIRECT_PATH,
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
},
|
||||
location.pathname
|
||||
)
|
||||
) {
|
||||
navigate(getDirectPath());
|
||||
return;
|
||||
}
|
||||
const spaceMatch = matchPath(
|
||||
{
|
||||
path: SPACE_PATH,
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
},
|
||||
location.pathname
|
||||
);
|
||||
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
||||
const decodedSpaceIdOrAlias =
|
||||
encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias);
|
||||
|
||||
if (decodedSpaceIdOrAlias) {
|
||||
navigate(getSpacePath(decodedSpaceIdOrAlias));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
matchPath(
|
||||
{
|
||||
path: EXPLORE_PATH,
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
},
|
||||
location.pathname
|
||||
)
|
||||
) {
|
||||
navigate(getExplorePath());
|
||||
return;
|
||||
}
|
||||
if (
|
||||
matchPath(
|
||||
{
|
||||
path: INBOX_PATH,
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
},
|
||||
location.pathname
|
||||
)
|
||||
) {
|
||||
navigate(getInboxPath());
|
||||
}
|
||||
}, [navigate, location]);
|
||||
const parent = getRouteSectionParent(location.pathname);
|
||||
if (parent) navigate(parent);
|
||||
}, [navigate, location.pathname]);
|
||||
|
||||
return children(goBack);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,9 +76,6 @@ export const SidebarItem = recipe({
|
|||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
transform: `translateX(${toRem(PUSH_X)})`,
|
||||
},
|
||||
'&::before': {
|
||||
content: '',
|
||||
display: 'none',
|
||||
|
|
@ -90,9 +87,18 @@ export const SidebarItem = recipe({
|
|||
background: 'CurrentColor',
|
||||
transition: 'height 200ms linear',
|
||||
},
|
||||
'&:hover::before': {
|
||||
display: 'block',
|
||||
width: toRem(3),
|
||||
},
|
||||
'@media': {
|
||||
'(hover: hover) and (pointer: fine)': {
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
transform: `translateX(${toRem(PUSH_X)})`,
|
||||
},
|
||||
'&:hover::before': {
|
||||
display: 'block',
|
||||
width: toRem(3),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -107,8 +113,14 @@ export const SidebarItem = recipe({
|
|||
display: 'block',
|
||||
height: toRem(24),
|
||||
},
|
||||
'&:hover::before': {
|
||||
width: toRem(3 + PUSH_X),
|
||||
},
|
||||
'@media': {
|
||||
'(hover: hover) and (pointer: fine)': {
|
||||
selectors: {
|
||||
'&:hover::before': {
|
||||
width: toRem(3 + PUSH_X),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
108
src/app/hooks/useAndroidBackButton.ts
Normal file
108
src/app/hooks/useAndroidBackButton.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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());
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
|
@ -28,6 +28,7 @@ import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
|||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
|
||||
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
|
||||
import { useAndroidBackButton } from '../../hooks/useAndroidBackButton';
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
|
|
@ -264,6 +265,11 @@ function PushNotificationsFeature() {
|
|||
return null;
|
||||
}
|
||||
|
||||
function AndroidBackButtonFeature() {
|
||||
useAndroidBackButton();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -274,6 +280,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||
<MessageNotifications />
|
||||
<PushNotificationsFeature />
|
||||
<PushPermissionPrompt />
|
||||
<AndroidBackButtonFeature />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
37
src/app/utils/routeParent.ts
Normal file
37
src/app/utils/routeParent.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { matchPath } from 'react-router-dom';
|
||||
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
|
||||
import {
|
||||
getDirectPath,
|
||||
getExplorePath,
|
||||
getHomePath,
|
||||
getInboxPath,
|
||||
getSpacePath,
|
||||
} from '../pages/pathUtils';
|
||||
|
||||
/**
|
||||
* Returns the section-root parent path for the given pathname, or null
|
||||
* when pathname is not under a known section or is already at the root
|
||||
* of its section. Shared by the UI back arrow (BackRouteHandler) and the
|
||||
* Android hardware back button (useAndroidBackButton) so they can't drift.
|
||||
*/
|
||||
export const getRouteSectionParent = (pathname: string): string | null => {
|
||||
const atRoot = (path: string) =>
|
||||
matchPath({ path, caseSensitive: true, end: true }, pathname) !== null;
|
||||
const under = (path: string) =>
|
||||
matchPath({ path, caseSensitive: true, end: false }, pathname) !== null;
|
||||
|
||||
if (under(HOME_PATH)) return atRoot(HOME_PATH) ? null : getHomePath();
|
||||
if (under(DIRECT_PATH)) return atRoot(DIRECT_PATH) ? null : getDirectPath();
|
||||
|
||||
const spaceMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: false }, pathname);
|
||||
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
||||
if (encodedSpaceIdOrAlias) {
|
||||
const spacePath = getSpacePath(decodeURIComponent(encodedSpaceIdOrAlias));
|
||||
return pathname === spacePath ? null : spacePath;
|
||||
}
|
||||
|
||||
if (under(EXPLORE_PATH)) return atRoot(EXPLORE_PATH) ? null : getExplorePath();
|
||||
if (under(INBOX_PATH)) return atRoot(INBOX_PATH) ? null : getInboxPath();
|
||||
|
||||
return null;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue