From 3717adb52eef733a8a99e8e8d3c7f6dc42c2d021 Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 18 Apr 2026 01:30:48 +0300 Subject: [PATCH] feature with back button on native app --- android/app/capacitor.build.gradle | 2 + android/capacitor.settings.gradle | 6 ++ docs/ai/README.md | 1 + docs/ai/bugs.md | 2 +- package-lock.json | 20 ++++ package.json | 2 + public/locales/en.json | 3 + public/locales/ru.json | 3 + src/app/components/BackRouteHandler.tsx | 82 +------------- src/app/components/sidebar/Sidebar.css.ts | 28 +++-- src/app/hooks/useAndroidBackButton.ts | 108 +++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++ src/app/utils/routeParent.ts | 37 +++++++ 13 files changed, 215 insertions(+), 86 deletions(-) create mode 100644 src/app/hooks/useAndroidBackButton.ts create mode 100644 src/app/utils/routeParent.ts diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 71b0ca40..bfc8b5fd 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -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') } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 821d963d..94394d66 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -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') diff --git a/docs/ai/README.md b/docs/ai/README.md index ccf09cfe..ea365fb8 100644 --- a/docs/ai/README.md +++ b/docs/ai/README.md @@ -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 diff --git a/docs/ai/bugs.md b/docs/ai/bugs.md index 86ace26a..f05574ad 100644 --- a/docs/ai/bugs.md +++ b/docs/ai/bugs.md @@ -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 diff --git a/package-lock.json b/package-lock.json index eee54f39..d760a9e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5a502398..a2e54fd2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en.json b/public/locales/en.json index 6a10eaa2..0f328455 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index f278571c..b84b490e 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -4,6 +4,9 @@ "changed_room_name": " изменил(а) название комнаты" } }, + "App": { + "back_to_exit": "Нажмите «назад» ещё раз, чтобы выйти" + }, "Auth": { "title_login": "Войти", "title_register": "Регистрация", diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index 3b13e487..5ff67ddf 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -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); } diff --git a/src/app/components/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts index c3686223..41197b3f 100644 --- a/src/app/components/sidebar/Sidebar.css.ts +++ b/src/app/components/sidebar/Sidebar.css.ts @@ -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), + }, + }, }, }, }, diff --git a/src/app/hooks/useAndroidBackButton.ts b/src/app/hooks/useAndroidBackButton.ts new file mode 100644 index 00000000..e4cdb275 --- /dev/null +++ b/src/app/hooks/useAndroidBackButton.ts @@ -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([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()); + }; + }, []); +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 28f73101..6b8b38ea 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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) { + {children} ); diff --git a/src/app/utils/routeParent.ts b/src/app/utils/routeParent.ts new file mode 100644 index 00000000..c5bf77ab --- /dev/null +++ b/src/app/utils/routeParent.ts @@ -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; +};