From b22f1cd4c4d33f7444c250df90229605ec44189d Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 19 May 2026 14:31:32 +0800 Subject: [PATCH] feat(app-expo): replay brand splash on logout and route to login After sign-out or data purge, clear session state reliably, remount the splash overlay above navigation, and navigate to login instead of tabs so users no longer briefly land on the chat home screen. Co-authored-by: Cursor --- app-expo/src/app/(auth)/_layout.tsx | 7 +++ app-expo/src/app/(main)/_layout.tsx | 12 ++--- app-expo/src/app/(tabs)/_layout.tsx | 13 +++-- app-expo/src/app/_layout.tsx | 48 +++++++++++++++--- app-expo/src/components/animated-icon.tsx | 29 ++++++++++- app-expo/src/components/animated-icon.web.tsx | 18 +++++++ app-expo/src/core/providers.tsx | 5 +- app-expo/src/core/splash-replay.ts | 50 +++++++++++++++++++ app-expo/src/features/auth/auth-query-keys.ts | 5 ++ .../clear-local-session-and-replay-entry.ts | 42 ++++++++++++++++ app-expo/src/features/auth/hooks.ts | 15 ++---- app-expo/src/features/profile/hooks.ts | 11 +--- .../src/hooks/use-splash-replay-active.ts | 14 ++++++ app-expo/tests/features/auth/hooks.test.tsx | 2 + 14 files changed, 225 insertions(+), 46 deletions(-) create mode 100644 app-expo/src/core/splash-replay.ts create mode 100644 app-expo/src/features/auth/auth-query-keys.ts create mode 100644 app-expo/src/features/auth/clear-local-session-and-replay-entry.ts create mode 100644 app-expo/src/hooks/use-splash-replay-active.ts diff --git a/app-expo/src/app/(auth)/_layout.tsx b/app-expo/src/app/(auth)/_layout.tsx index 9eb6260..383591d 100644 --- a/app-expo/src/app/(auth)/_layout.tsx +++ b/app-expo/src/app/(auth)/_layout.tsx @@ -1,11 +1,18 @@ import { Redirect, Stack } from 'expo-router'; import React from 'react'; +import { BrandBootstrapLoading } from '@/components/animated-icon'; +import { useSplashReplayActive } from '@/hooks/use-splash-replay-active'; import { useSession } from '@/features/auth/hooks'; export default function AuthLayout() { + const splashReplay = useSplashReplayActive(); const { status } = useSession(); + if (splashReplay || status === 'loading') { + return ; + } + if (status === 'authenticated') { return ; } diff --git a/app-expo/src/app/(main)/_layout.tsx b/app-expo/src/app/(main)/_layout.tsx index e02c618..9281aac 100644 --- a/app-expo/src/app/(main)/_layout.tsx +++ b/app-expo/src/app/(main)/_layout.tsx @@ -1,18 +1,16 @@ import { Redirect, Stack } from 'expo-router'; import React from 'react'; -import { ActivityIndicator, View } from 'react-native'; +import { BrandBootstrapLoading } from '@/components/animated-icon'; +import { useSplashReplayActive } from '@/hooks/use-splash-replay-active'; import { useSession } from '@/features/auth/hooks'; export default function MainLayout() { + const splashReplay = useSplashReplayActive(); const { status } = useSession(); - if (status === 'loading') { - return ( - - - - ); + if (splashReplay || status === 'loading') { + return ; } if (status === 'unauthenticated') { diff --git a/app-expo/src/app/(tabs)/_layout.tsx b/app-expo/src/app/(tabs)/_layout.tsx index a5c7d7e..6b9a0a0 100644 --- a/app-expo/src/app/(tabs)/_layout.tsx +++ b/app-expo/src/app/(tabs)/_layout.tsx @@ -1,6 +1,8 @@ import { Redirect, Tabs } from 'expo-router'; import React from 'react'; -import { ActivityIndicator, View } from 'react-native'; + +import { BrandBootstrapLoading } from '@/components/animated-icon'; +import { useSplashReplayActive } from '@/hooks/use-splash-replay-active'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { useTranslation } from 'react-i18next'; @@ -27,6 +29,7 @@ const TAB_BAR_HEIGHT = 72; const TAB_BAR_PADDING_HORIZONTAL = 12; export default function TabsLayout() { + const splashReplay = useSplashReplayActive(); const { status } = useSession(); const typography = useTypography(); const { colorScheme } = useColorScheme(); @@ -35,12 +38,8 @@ export default function TabsLayout() { const tabColors = TAB_BAR_COLORS[isDark ? 'dark' : 'light']; const { t } = useTranslation('app'); - if (status === 'loading') { - return ( - - - - ); + if (splashReplay || status === 'loading') { + return ; } if (status === 'unauthenticated') { diff --git a/app-expo/src/app/_layout.tsx b/app-expo/src/app/_layout.tsx index f1814d4..3436905 100644 --- a/app-expo/src/app/_layout.tsx +++ b/app-expo/src/app/_layout.tsx @@ -1,7 +1,8 @@ import { PortalHost } from '@rn-primitives/portal'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import React, { useEffect } from 'react'; +import React, { useEffect, useSyncExternalStore } from 'react'; +import { StyleSheet, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { KeyboardProvider } from 'react-native-keyboard-controller'; import { @@ -10,6 +11,10 @@ import { } from 'react-native-safe-area-context'; import '@/global.css'; import { AnimatedSplashOverlay } from '@/components/animated-icon'; +import { + getSplashGeneration, + subscribeSplashGeneration, +} from '@/core/splash-replay'; import { AppProviders } from '@/core/providers'; import { NavigationThemeProvider } from '@/core/navigation-theme-provider'; import { ThemeVariablesProvider } from '@/core/theme-variables-provider'; @@ -18,6 +23,31 @@ import { getAppLanguage, getDarkMode } from '@/core/settings/app-settings'; import { setLanguageResolver, startLocaleSync } from '@/i18n'; import { useColorScheme } from '@/hooks/use-color-scheme'; +/** Remount AnimatedSplashOverlay when {@link requestSplashReplay} runs (post-logout). */ +function ReplayableSplashOverlay() { + const generation = useSyncExternalStore( + subscribeSplashGeneration, + getSplashGeneration, + getSplashGeneration, + ); + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + splashHost: { + ...StyleSheet.absoluteFillObject, + zIndex: 9999, + elevation: 9999, + }, +}); + export default function RootLayout() { const { colorScheme, setColorScheme } = useColorScheme(); const resolved = colorScheme === 'dark' ? 'dark' : 'light'; @@ -53,13 +83,15 @@ export default function RootLayout() { - - - - - - - + + + + + + + + + diff --git a/app-expo/src/components/animated-icon.tsx b/app-expo/src/components/animated-icon.tsx index df1f04b..b757571 100644 --- a/app-expo/src/components/animated-icon.tsx +++ b/app-expo/src/components/animated-icon.tsx @@ -4,9 +4,14 @@ import { Dimensions, StyleSheet, View } from 'react-native'; import Animated, { Easing, Keyframe } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; +import { completeSplashReplay } from '@/core/splash-replay'; + const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90; const DURATION = 600; +/** Brand gate background (matches native splash / overlay). */ +export const BRAND_BOOTSTRAP_BG = '#208AEF'; + export function AnimatedSplashOverlay() { const [visible, setVisible] = useState(true); @@ -37,10 +42,22 @@ export function AnimatedSplashOverlay() { 'worklet'; if (finished) { scheduleOnRN(setVisible, false); + scheduleOnRN(completeSplashReplay); } })} style={styles.backgroundSolidColor} - /> + > + + + ); +} + +/** Full-screen logo gate while session / token check is in progress (after splash). */ +export function BrandBootstrapLoading() { + return ( + + + ); } @@ -141,7 +158,15 @@ const styles = StyleSheet.create({ }, backgroundSolidColor: { ...StyleSheet.absoluteFillObject, - backgroundColor: '#208AEF', + backgroundColor: BRAND_BOOTSTRAP_BG, zIndex: 1000, + justifyContent: 'center', + alignItems: 'center', + }, + brandBootstrapRoot: { + flex: 1, + backgroundColor: BRAND_BOOTSTRAP_BG, + justifyContent: 'center', + alignItems: 'center', }, }); diff --git a/app-expo/src/components/animated-icon.web.tsx b/app-expo/src/components/animated-icon.web.tsx index adaabbb..ce443a5 100644 --- a/app-expo/src/components/animated-icon.web.tsx +++ b/app-expo/src/components/animated-icon.web.tsx @@ -5,10 +5,22 @@ import Animated, { Keyframe, Easing } from 'react-native-reanimated'; import classes from './animated-icon.module.css'; const DURATION = 300; +/** Brand gate background (matches native splash / overlay). */ +export const BRAND_BOOTSTRAP_BG = '#208AEF'; + export function AnimatedSplashOverlay() { return null; } +/** Full-screen logo gate while session / token check is in progress (after splash). */ +export function BrandBootstrapLoading() { + return ( + + + + ); +} + const keyframe = new Keyframe({ 0: { transform: [{ scale: 0 }], @@ -88,6 +100,12 @@ export function AnimatedIcon() { } const styles = StyleSheet.create({ + brandBootstrapRoot: { + flex: 1, + backgroundColor: BRAND_BOOTSTRAP_BG, + justifyContent: 'center', + alignItems: 'center', + }, container: { alignItems: 'center', width: '100%', diff --git a/app-expo/src/core/providers.tsx b/app-expo/src/core/providers.tsx index 8357e04..674c7e6 100644 --- a/app-expo/src/core/providers.tsx +++ b/app-expo/src/core/providers.tsx @@ -6,6 +6,7 @@ import { MemoirReadingSettingsProvider } from '@/core/memoir-reading-settings-co import { NetworkError } from '@/core/api/types'; import { tokenManager } from '@/core/auth/token-manager'; import { config } from '@/core/config'; +import { authKeys } from '@/features/auth/auth-query-keys'; import { AppQueryProvider, queryClient } from '@/core/query'; /** @@ -49,8 +50,8 @@ async function refreshTokens(): Promise { * since the cache flip is the authoritative signal. */ function onAuthFailure() { - queryClient.setQueryData(['auth', 'token-check'], false); - queryClient.setQueryData(['session'], null); + queryClient.setQueryData(authKeys.tokenCheck, false); + queryClient.setQueryData(authKeys.session, null); tokenManager.clearTokens(); } diff --git a/app-expo/src/core/splash-replay.ts b/app-expo/src/core/splash-replay.ts new file mode 100644 index 0000000..1befbc5 --- /dev/null +++ b/app-expo/src/core/splash-replay.ts @@ -0,0 +1,50 @@ +/** + * Signals the root AnimatedSplashOverlay to remount so the cold-start splash + * animation runs again — used after explicit logout-like flows (not passive /me failures). + */ + +let splashGeneration = 0; +let splashReplayActive = false; + +const generationListeners = new Set<() => void>(); +const activeListeners = new Set<() => void>(); + +function notifyGeneration() { + generationListeners.forEach((listener) => listener()); +} + +function notifyActive() { + activeListeners.forEach((listener) => listener()); +} + +export function requestSplashReplay() { + splashGeneration += 1; + splashReplayActive = true; + notifyGeneration(); + notifyActive(); +} + +/** Called when the replay overlay animation finishes (or unmounts). */ +export function completeSplashReplay() { + if (!splashReplayActive) return; + splashReplayActive = false; + notifyActive(); +} + +export function getSplashGeneration(): number { + return splashGeneration; +} + +export function getSplashReplayActive(): boolean { + return splashReplayActive; +} + +export function subscribeSplashGeneration(onStoreChange: () => void): () => void { + generationListeners.add(onStoreChange); + return () => generationListeners.delete(onStoreChange); +} + +export function subscribeSplashReplayActive(onStoreChange: () => void): () => void { + activeListeners.add(onStoreChange); + return () => activeListeners.delete(onStoreChange); +} diff --git a/app-expo/src/features/auth/auth-query-keys.ts b/app-expo/src/features/auth/auth-query-keys.ts new file mode 100644 index 0000000..628712a --- /dev/null +++ b/app-expo/src/features/auth/auth-query-keys.ts @@ -0,0 +1,5 @@ +/** Shared TanStack keys for bootstrap session — avoids circular imports with hooks. */ +export const authKeys = { + session: ['session'] as const, + tokenCheck: ['auth', 'token-check'] as const, +} as const; diff --git a/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts b/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts new file mode 100644 index 0000000..1315e23 --- /dev/null +++ b/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts @@ -0,0 +1,42 @@ +import { type QueryClient } from '@tanstack/react-query'; +import { router } from 'expo-router'; + +import { tokenManager } from '@/core/auth/token-manager'; +import { requestSplashReplay } from '@/core/splash-replay'; +import { authKeys } from '@/features/auth/auth-query-keys'; +import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool'; + +/** Matches `useSession` token bootstrap — keep options aligned for cache coherence. */ +const TOKEN_CHECK_FETCH_OPTIONS = { + queryKey: authKeys.tokenCheck, + queryFn: () => tokenManager.hasTokens(), + staleTime: Infinity, + gcTime: Infinity, +} as const; + +/** + * Used after deliberate sign-out flows (manual logout, data purge…): + * wipes local credentials + TanStack cache, replays splash on top, then routes to login. + */ +export async function clearLocalSessionAndReplayEntry( + queryClient: QueryClient, +): Promise { + requestSplashReplay(); + + disposeAllBackgroundConversationWs(); + await tokenManager.clearTokens(); + + await queryClient.cancelQueries({ queryKey: authKeys.session }); + await queryClient.cancelQueries({ queryKey: authKeys.tokenCheck }); + + queryClient.removeQueries({ queryKey: authKeys.session }); + queryClient.clear(); + + queryClient.setQueryData(authKeys.tokenCheck, false); + + await queryClient.fetchQuery({ + ...TOKEN_CHECK_FETCH_OPTIONS, + }); + + router.replace('/(auth)/login'); +} diff --git a/app-expo/src/features/auth/hooks.ts b/app-expo/src/features/auth/hooks.ts index ca30ab9..b6cfefc 100644 --- a/app-expo/src/features/auth/hooks.ts +++ b/app-expo/src/features/auth/hooks.ts @@ -1,13 +1,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { router } from 'expo-router'; import { useCallback } from 'react'; import { AuthError } from '@/core/api/types'; import { tokenManager } from '@/core/auth/token-manager'; -import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool'; +import { clearLocalSessionAndReplayEntry } from '@/features/auth/clear-local-session-and-replay-entry'; import { getDeviceLanguage } from '@/i18n'; import { authApi } from './api'; +import { authKeys } from './auth-query-keys'; import type { LanguagePreference, LoginRequest, @@ -34,10 +34,7 @@ function withDeviceLanguage( // ─── Query keys ─── -export const authKeys = { - session: ['session'] as const, - tokenCheck: ['auth', 'token-check'] as const, -}; +export { authKeys }; const PROFILE_QUERY_PREFIX = ['profile'] as const; @@ -251,11 +248,7 @@ export function useLogout() { } }, onSettled: async () => { - disposeAllBackgroundConversationWs(); - await tokenManager.clearTokens(); - queryClient.clear(); - queryClient.setQueryData(authKeys.tokenCheck, false); - router.replace('/(auth)/login'); + await clearLocalSessionAndReplayEntry(queryClient); }, }); } diff --git a/app-expo/src/features/profile/hooks.ts b/app-expo/src/features/profile/hooks.ts index 0954b75..b7b478d 100644 --- a/app-expo/src/features/profile/hooks.ts +++ b/app-expo/src/features/profile/hooks.ts @@ -1,9 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { router } from 'expo-router'; -import { tokenManager } from '@/core/auth/token-manager'; -import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool'; -import { authKeys } from '@/features/auth/hooks'; +import { clearLocalSessionAndReplayEntry } from '@/features/auth/clear-local-session-and-replay-entry'; import { profileApi } from './api'; import type { @@ -98,11 +95,7 @@ export function usePurgeUserData() { return useMutation({ mutationFn: (body: PurgeUserDataRequest) => profileApi.purgeUserData(body), onSuccess: async () => { - disposeAllBackgroundConversationWs(); - await tokenManager.clearTokens(); - queryClient.clear(); - queryClient.setQueryData(authKeys.tokenCheck, false); - router.replace('/(auth)/login'); + await clearLocalSessionAndReplayEntry(queryClient); }, }); } diff --git a/app-expo/src/hooks/use-splash-replay-active.ts b/app-expo/src/hooks/use-splash-replay-active.ts new file mode 100644 index 0000000..5ae35f0 --- /dev/null +++ b/app-expo/src/hooks/use-splash-replay-active.ts @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from 'react'; + +import { + getSplashReplayActive, + subscribeSplashReplayActive, +} from '@/core/splash-replay'; + +export function useSplashReplayActive(): boolean { + return useSyncExternalStore( + subscribeSplashReplayActive, + getSplashReplayActive, + () => false, + ); +} diff --git a/app-expo/tests/features/auth/hooks.test.tsx b/app-expo/tests/features/auth/hooks.test.tsx index 013c51d..031c267 100644 --- a/app-expo/tests/features/auth/hooks.test.tsx +++ b/app-expo/tests/features/auth/hooks.test.tsx @@ -226,6 +226,7 @@ describe('useLogout', () => { test('reads refresh token first, calls API, then clears local state', async () => { mockGetRefreshToken.mockResolvedValue('my-refresh'); mockLogout.mockResolvedValue({ message: 'ok' }); + mockHasTokens.mockResolvedValue(false); const wrapper = createWrapper(); @@ -251,6 +252,7 @@ describe('useLogout', () => { test('clears local state even if server logout fails', async () => { mockGetRefreshToken.mockResolvedValue('my-refresh'); mockLogout.mockRejectedValue(new Error('Network down')); + mockHasTokens.mockResolvedValue(false); const wrapper = createWrapper();