fix(cabinet): blocking screens exit via the native Telegram back button, drop the in-page Close

On a full-screen blocking overlay in the Telegram Mini App there were two exit
affordances: the native back button — which ran SPA navigation on the hidden
route underneath, so it didn't dismiss the block and kept flip-flopping between
Back and Close as the route changed — and a redundant in-page "Close" button on
the service-unavailable screen.

Make the native back button the single, stable exit: while any blocking screen
is active, TelegramBackButton shows one Back button whose click closes the Mini
App (closeTelegramApp) instead of navigating the SPA — no route change, no
Back/Close flip-flop. The normal route-based back button is restored once the
block clears. Remove the in-page Close button.
This commit is contained in:
c0mrade
2026-06-04 13:48:33 +03:00
parent 782568e091
commit f9cc70cba3
2 changed files with 39 additions and 32 deletions

View File

@@ -15,9 +15,10 @@ import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
import { WebSocketProvider } from './providers/WebSocketProvider'; import { WebSocketProvider } from './providers/WebSocketProvider';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { TooltipProvider } from './components/primitives/Tooltip'; import { TooltipProvider } from './components/primitives/Tooltip';
import { isInTelegramWebApp } from './hooks/useTelegramSDK'; import { isInTelegramWebApp, closeTelegramApp } from './hooks/useTelegramSDK';
import { getFallbackParentPath } from './utils/navigation'; import { getFallbackParentPath } from './utils/navigation';
import { subscriptionApi } from './api/subscription'; import { subscriptionApi } from './api/subscription';
import { useBlockingStore } from './store/blocking';
const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const; const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const;
@@ -44,6 +45,13 @@ function TelegramBackButton() {
const pathnameRef = useRef(location.pathname); const pathnameRef = useRef(location.pathname);
pathnameRef.current = location.pathname; pathnameRef.current = location.pathname;
// A full-screen blocking overlay (maintenance / channel-sub / blacklist /
// account-deleted / backend-unavailable) takes over the native back button:
// there is nowhere to navigate, so it becomes a single, stable EXIT control.
const blockingType = useBlockingStore((state) => state.blockingType);
const blockingTypeRef = useRef(blockingType);
blockingTypeRef.current = blockingType;
// Reliable in-app navigation depth (the app's entry point is 0). Driven by // Reliable in-app navigation depth (the app's entry point is 0). Driven by
// React Router's navigation TYPE — NOT window.history.state.idx, which the // React Router's navigation TYPE — NOT window.history.state.idx, which the
// app's own redirects mutate unpredictably and which is the root flake behind // app's own redirects mutate unpredictably and which is the root flake behind
@@ -89,6 +97,15 @@ function TelegramBackButton() {
subsCountRef.current = subsCount; subsCountRef.current = subsCount;
useEffect(() => { useEffect(() => {
// On a blocking overlay, keep exactly one visible Back button (its click
// exits the app — see handler). Skip the route logic so it can't flip
// between Back and Close as the hidden route changes underneath.
if (blockingType) {
try {
showBackButton();
} catch {}
return;
}
const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname);
// Depth-independent on purpose: whether the user deep-linked in or navigated // Depth-independent on purpose: whether the user deep-linked in or navigated
// here in-app, a single-tariff detail whose list just bounces back has no // here in-app, a single-tariff detail whose list just bounces back has no
@@ -103,10 +120,17 @@ function TelegramBackButton() {
showBackButton(); showBackButton();
} }
} catch {} } catch {}
}, [location, listRedirectsToDetail]); }, [location, listRedirectsToDetail, blockingType]);
// Stable handler — ref prevents re-subscription on every render // Stable handler — ref prevents re-subscription on every render
const handler = useCallback(() => { const handler = useCallback(() => {
// A blocking overlay is a hard block with nowhere to navigate — the back
// button's only job is to EXIT the Mini App (no SPA navigation, so it can't
// flip-flop between Back and Close).
if (blockingTypeRef.current) {
closeTelegramApp();
return;
}
// Real in-app history (depth > 0): a normal back. Otherwise we were opened // Real in-app history (depth > 0): a normal back. Otherwise we were opened
// directly on this route via a deep-link — navigate(-1) is a no-op, so fall // directly on this route via a deep-link — navigate(-1) is a no-op, so fall
// back to a sensible parent route instead. // back to a sensible parent route instead.

View File

@@ -4,8 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { pingBackend, hasEverReachedBackend } from '../../api/health'; import { pingBackend, hasEverReachedBackend } from '../../api/health';
import { isInTelegramWebApp, closeTelegramApp } from '../../hooks/useTelegramSDK'; import { CloudWarningIcon, RestartIcon } from '@/components/icons';
import { CloudWarningIcon, RestartIcon, CloseIcon } from '@/components/icons';
import { Button } from '@/components/primitives'; import { Button } from '@/components/primitives';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import BlockingShell from './BlockingShell'; import BlockingShell from './BlockingShell';
@@ -25,7 +24,6 @@ export default function ServiceUnavailableScreen() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isChecking, setIsChecking] = useState(false); const [isChecking, setIsChecking] = useState(false);
const isCheckingRef = useRef(false); const isCheckingRef = useRef(false);
const inTelegram = isInTelegramWebApp();
const recover = useCallback(() => { const recover = useCallback(() => {
clearBlocking(); clearBlocking();
@@ -87,33 +85,18 @@ export default function ServiceUnavailableScreen() {
pulse pulse
footer={t('blocking.serviceUnavailable.hint')} footer={t('blocking.serviceUnavailable.hint')}
actions={ actions={
<> <Button
<Button variant="secondary"
variant="secondary" size="lg"
size="lg" fullWidth
fullWidth onClick={handleRetry}
onClick={handleRetry} disabled={isChecking}
disabled={isChecking} leftIcon={<RestartIcon className={cn('h-5 w-5', isChecking && 'animate-spin')} />}
leftIcon={<RestartIcon className={cn('h-5 w-5', isChecking && 'animate-spin')} />} >
> {isChecking
{isChecking ? t('blocking.serviceUnavailable.checking')
? t('blocking.serviceUnavailable.checking') : t('blocking.serviceUnavailable.retry')}
: t('blocking.serviceUnavailable.retry')} </Button>
</Button>
{/* Telegram Mini App only — a browser tab can't be closed by script.
Reliably exits the Mini App instead of routing back. */}
{inTelegram && (
<Button
variant="outline"
size="lg"
fullWidth
onClick={closeTelegramApp}
leftIcon={<CloseIcon className="h-5 w-5" />}
>
{t('blocking.serviceUnavailable.close')}
</Button>
)}
</>
} }
/> />
); );