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();