diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 8a8fd17..0ecc9ac 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -281,6 +281,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="360" value={hsl.h} onChange={handleHueChange} + aria-label="Hue" className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ background: @@ -301,6 +302,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="100" value={hsl.s} onChange={handleSaturationChange} + aria-label="Saturation" className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`, @@ -320,6 +322,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="100" value={hsl.l} onChange={handleLightnessChange} + aria-label="Lightness" className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`, diff --git a/src/components/blocking/AccountDeletedScreen.tsx b/src/components/blocking/AccountDeletedScreen.tsx index fc453c8..7b626e3 100644 --- a/src/components/blocking/AccountDeletedScreen.tsx +++ b/src/components/blocking/AccountDeletedScreen.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { usePlatform } from '@/platform'; import { useBlockingStore } from '../../store/blocking'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; /** * Full-screen block shown when the backend returns @@ -21,6 +22,7 @@ export default function AccountDeletedScreen() { const { t } = useTranslation(); const { openTelegramLink } = usePlatform(); const info = useBlockingStore((state) => state.accountDeletedInfo); + const screenRef = useFocusTrap(true, { lockScroll: false }); const deepLink = info?.telegram_deep_link?.trim() || null; // Route through the platform adapter, not raw window.open. Inside the @@ -42,7 +44,14 @@ export default function AccountDeletedScreen() { }; return ( -
+
@@ -63,7 +72,9 @@ export default function AccountDeletedScreen() {
-

{t('blocking.accountDeleted.title')}

+

+ {t('blocking.accountDeleted.title')} +

{t('blocking.accountDeleted.description')}

diff --git a/src/components/blocking/BlacklistedScreen.tsx b/src/components/blocking/BlacklistedScreen.tsx index c5051ad..065fa4c 100644 --- a/src/components/blocking/BlacklistedScreen.tsx +++ b/src/components/blocking/BlacklistedScreen.tsx @@ -1,12 +1,21 @@ import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; export default function BlacklistedScreen() { const { t } = useTranslation(); const blacklistedInfo = useBlockingStore((state) => state.blacklistedInfo); + const screenRef = useFocusTrap(true, { lockScroll: false }); return ( -
+
{/* Icon */}
@@ -28,7 +37,9 @@ export default function BlacklistedScreen() {
{/* Title */} -

{t('blocking.blacklisted.title')}

+

+ {t('blocking.blacklisted.title')} +

{/* Message */}

{t('blocking.blacklisted.defaultMessage')}

diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx index d90c600..3d3b857 100644 --- a/src/components/blocking/ChannelSubscriptionScreen.tsx +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; import { apiClient, isChannelSubscriptionError } from '../../api/client'; import { usePlatform } from '../../platform'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; const CHECK_COOLDOWN_SECONDS = 5; @@ -80,8 +81,17 @@ export default function ChannelSubscriptionScreen() { } }, [clearBlocking, t]); + const screenRef = useFocusTrap(true, { lockScroll: false }); + return ( -
+
{/* Icon */}
@@ -93,7 +103,9 @@ export default function ChannelSubscriptionScreen() {
{/* Title */} -

{t('blocking.channel.title')}

+

+ {t('blocking.channel.title')} +

{/* Message */}

diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx index 6302eed..7ff3d4b 100644 --- a/src/components/blocking/MaintenanceScreen.tsx +++ b/src/components/blocking/MaintenanceScreen.tsx @@ -1,12 +1,21 @@ import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; export default function MaintenanceScreen() { const { t } = useTranslation(); const maintenanceInfo = useBlockingStore((state) => state.maintenanceInfo); + const screenRef = useFocusTrap(true, { lockScroll: false }); return ( -

+
{/* Icon */}
@@ -28,7 +37,9 @@ export default function MaintenanceScreen() {
{/* Title */} -

{t('blocking.maintenance.title')}

+

+ {t('blocking.maintenance.title')} +

{/* Message */}

diff --git a/src/main.tsx b/src/main.tsx index 2885897..7e61769 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,7 @@ import { isFullscreen, } from '@telegram-apps/sdk-react'; import { clearStaleSessionIfNeeded } from './utils/token'; +import { useAuthStore } from './store/auth'; import { AppWithNavigator } from './AppWithNavigator'; import { ErrorBoundary } from './components/ErrorBoundary'; import { initLogoPreload } from './api/branding'; @@ -100,6 +101,11 @@ if (isTelegramEnv && !alreadyInitialized) { clearStaleSessionIfNeeded(null); } +// Bootstrap auth after the Telegram SDK is initialised so CloudStorage-backed +// refresh-token recovery can run inside initialize() (launch params + CloudStorage +// are only available post-init()). +void useAuthStore.getState().initialize(); + if ('requestIdleCallback' in window) { requestIdleCallback(() => initLogoPreload()); } else { diff --git a/src/store/auth.ts b/src/store/auth.ts index 2f56553..59b1070 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -13,7 +13,12 @@ import { consumeReferralCode, getPendingReferralCode, } from '../utils/referral'; -import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token'; +import { + tokenStorage, + isTokenValid, + tokenRefreshManager, + restoreRefreshTokenFromCloud, +} from '../utils/token'; import { usePermissionStore } from './permissions'; export interface TelegramWidgetData { @@ -162,11 +167,16 @@ export const useAuthStore = create()( tokenStorage.migrateFromLocalStorage(); const accessToken = tokenStorage.getAccessToken(); - const refreshToken = tokenStorage.getRefreshToken(); + let refreshToken = tokenStorage.getRefreshToken(); if (!refreshToken) { - set({ isLoading: false, isAuthenticated: false }); - return; + // localStorage may have been wiped by the Telegram WebView; try to + // recover the refresh token from Telegram CloudStorage before giving up. + refreshToken = await restoreRefreshTokenFromCloud(); + if (!refreshToken) { + set({ isLoading: false, isAuthenticated: false }); + return; + } } if (!isTokenValid(accessToken)) { @@ -384,4 +394,5 @@ export const useAuthStore = create()( captureCampaignFromUrl(); captureReferralFromUrl(); -useAuthStore.getState().initialize(); +// Note: initialize() is kicked off from main.tsx after the Telegram SDK is set up, +// so CloudStorage-backed token recovery can run during bootstrap. diff --git a/src/utils/token.ts b/src/utils/token.ts index 2b4b2af..ad175ed 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -1,4 +1,10 @@ import axios from 'axios'; +import { + getCloudStorageItem, + setCloudStorageItem, + deleteCloudStorageItem, +} from '@telegram-apps/sdk-react'; +import { isInTelegramWebApp } from '../hooks/useTelegramSDK'; const TOKEN_KEYS = { ACCESS: 'access_token', @@ -7,6 +13,42 @@ const TOKEN_KEYS = { TELEGRAM_INIT: 'telegram_init_data', } as const; +// --- Telegram CloudStorage backup for the refresh token --- +// The Telegram WebView can wipe localStorage between sessions. Mirroring the refresh +// token to per-user CloudStorage lets initialize() recover the session instead of +// dropping the user to the login screen. Best-effort: failures fall back to localStorage. +const CLOUD_REFRESH_KEY = TOKEN_KEYS.REFRESH; + +function mirrorRefreshTokenToCloud(refreshToken: string): void { + if (!isInTelegramWebApp()) return; + try { + void setCloudStorageItem(CLOUD_REFRESH_KEY, refreshToken).catch(() => {}); + } catch {} +} + +function removeRefreshTokenFromCloud(): void { + if (!isInTelegramWebApp()) return; + try { + void deleteCloudStorageItem(CLOUD_REFRESH_KEY).catch(() => {}); + } catch {} +} + +export async function restoreRefreshTokenFromCloud(): Promise { + if (!isInTelegramWebApp()) return null; + try { + const value = await getCloudStorageItem(CLOUD_REFRESH_KEY); + const token = value || null; + if (token) { + try { + localStorage.setItem(TOKEN_KEYS.REFRESH, token); + } catch {} + } + return token; + } catch { + return null; + } +} + interface JWTPayload { exp?: number; iat?: number; @@ -64,6 +106,7 @@ export const tokenStorage = { sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken); sessionStorage.removeItem(TOKEN_KEYS.REFRESH); + mirrorRefreshTokenToCloud(refreshToken); } catch {} }, @@ -83,6 +126,7 @@ export const tokenStorage = { localStorage.removeItem(TOKEN_KEYS.ACCESS); localStorage.removeItem(TOKEN_KEYS.REFRESH); localStorage.removeItem(TOKEN_KEYS.USER); + removeRefreshTokenFromCloud(); } catch {} },