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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,18 @@
|
|||||||
import { Redirect, Stack } from 'expo-router';
|
import { Redirect, Stack } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { BrandBootstrapLoading } from '@/components/animated-icon';
|
||||||
|
import { useSplashReplayActive } from '@/hooks/use-splash-replay-active';
|
||||||
import { useSession } from '@/features/auth/hooks';
|
import { useSession } from '@/features/auth/hooks';
|
||||||
|
|
||||||
export default function AuthLayout() {
|
export default function AuthLayout() {
|
||||||
|
const splashReplay = useSplashReplayActive();
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
|
|
||||||
|
if (splashReplay || status === 'loading') {
|
||||||
|
return <BrandBootstrapLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'authenticated') {
|
if (status === 'authenticated') {
|
||||||
return <Redirect href="/(tabs)" />;
|
return <Redirect href="/(tabs)" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { Redirect, Stack } from 'expo-router';
|
import { Redirect, Stack } from 'expo-router';
|
||||||
import React from 'react';
|
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';
|
import { useSession } from '@/features/auth/hooks';
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
|
const splashReplay = useSplashReplayActive();
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (splashReplay || status === 'loading') {
|
||||||
return (
|
return <BrandBootstrapLoading />;
|
||||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
||||||
<ActivityIndicator size="large" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Redirect, Tabs } from 'expo-router';
|
import { Redirect, Tabs } from 'expo-router';
|
||||||
import React from 'react';
|
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -27,6 +29,7 @@ const TAB_BAR_HEIGHT = 72;
|
|||||||
const TAB_BAR_PADDING_HORIZONTAL = 12;
|
const TAB_BAR_PADDING_HORIZONTAL = 12;
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
|
const splashReplay = useSplashReplayActive();
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const typography = useTypography();
|
const typography = useTypography();
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
@@ -35,12 +38,8 @@ export default function TabsLayout() {
|
|||||||
const tabColors = TAB_BAR_COLORS[isDark ? 'dark' : 'light'];
|
const tabColors = TAB_BAR_COLORS[isDark ? 'dark' : 'light'];
|
||||||
const { t } = useTranslation('app');
|
const { t } = useTranslation('app');
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (splashReplay || status === 'loading') {
|
||||||
return (
|
return <BrandBootstrapLoading />;
|
||||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
||||||
<ActivityIndicator size="large" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { PortalHost } from '@rn-primitives/portal';
|
import { PortalHost } from '@rn-primitives/portal';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
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 { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,10 @@ import {
|
|||||||
} from 'react-native-safe-area-context';
|
} from 'react-native-safe-area-context';
|
||||||
import '@/global.css';
|
import '@/global.css';
|
||||||
import { AnimatedSplashOverlay } from '@/components/animated-icon';
|
import { AnimatedSplashOverlay } from '@/components/animated-icon';
|
||||||
|
import {
|
||||||
|
getSplashGeneration,
|
||||||
|
subscribeSplashGeneration,
|
||||||
|
} from '@/core/splash-replay';
|
||||||
import { AppProviders } from '@/core/providers';
|
import { AppProviders } from '@/core/providers';
|
||||||
import { NavigationThemeProvider } from '@/core/navigation-theme-provider';
|
import { NavigationThemeProvider } from '@/core/navigation-theme-provider';
|
||||||
import { ThemeVariablesProvider } from '@/core/theme-variables-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 { setLanguageResolver, startLocaleSync } from '@/i18n';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
|
||||||
|
/** Remount AnimatedSplashOverlay when {@link requestSplashReplay} runs (post-logout). */
|
||||||
|
function ReplayableSplashOverlay() {
|
||||||
|
const generation = useSyncExternalStore(
|
||||||
|
subscribeSplashGeneration,
|
||||||
|
getSplashGeneration,
|
||||||
|
getSplashGeneration,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View style={styles.splashHost} pointerEvents="box-none">
|
||||||
|
<AnimatedSplashOverlay key={generation} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
splashHost: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
zIndex: 9999,
|
||||||
|
elevation: 9999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { colorScheme, setColorScheme } = useColorScheme();
|
const { colorScheme, setColorScheme } = useColorScheme();
|
||||||
const resolved = colorScheme === 'dark' ? 'dark' : 'light';
|
const resolved = colorScheme === 'dark' ? 'dark' : 'light';
|
||||||
@@ -53,13 +83,15 @@ export default function RootLayout() {
|
|||||||
<ThemeVariablesProvider>
|
<ThemeVariablesProvider>
|
||||||
<NavigationThemeProvider>
|
<NavigationThemeProvider>
|
||||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||||
<AnimatedSplashOverlay />
|
<View style={styles.root}>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="(auth)" />
|
<Stack.Screen name="(auth)" />
|
||||||
<Stack.Screen name="(main)" />
|
<Stack.Screen name="(main)" />
|
||||||
<Stack.Screen name="legal" />
|
<Stack.Screen name="legal" />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<ReplayableSplashOverlay />
|
||||||
|
</View>
|
||||||
<PortalHost />
|
<PortalHost />
|
||||||
</NavigationThemeProvider>
|
</NavigationThemeProvider>
|
||||||
</ThemeVariablesProvider>
|
</ThemeVariablesProvider>
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import { Dimensions, StyleSheet, View } from 'react-native';
|
|||||||
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
|
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
|
||||||
import { scheduleOnRN } from 'react-native-worklets';
|
import { scheduleOnRN } from 'react-native-worklets';
|
||||||
|
|
||||||
|
import { completeSplashReplay } from '@/core/splash-replay';
|
||||||
|
|
||||||
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
|
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
|
||||||
const DURATION = 600;
|
const DURATION = 600;
|
||||||
|
|
||||||
|
/** Brand gate background (matches native splash / overlay). */
|
||||||
|
export const BRAND_BOOTSTRAP_BG = '#208AEF';
|
||||||
|
|
||||||
export function AnimatedSplashOverlay() {
|
export function AnimatedSplashOverlay() {
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(true);
|
||||||
|
|
||||||
@@ -37,10 +42,22 @@ export function AnimatedSplashOverlay() {
|
|||||||
'worklet';
|
'worklet';
|
||||||
if (finished) {
|
if (finished) {
|
||||||
scheduleOnRN(setVisible, false);
|
scheduleOnRN(setVisible, false);
|
||||||
|
scheduleOnRN(completeSplashReplay);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
style={styles.backgroundSolidColor}
|
style={styles.backgroundSolidColor}
|
||||||
/>
|
>
|
||||||
|
<AnimatedIcon />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-screen logo gate while session / token check is in progress (after splash). */
|
||||||
|
export function BrandBootstrapLoading() {
|
||||||
|
return (
|
||||||
|
<View style={styles.brandBootstrapRoot}>
|
||||||
|
<AnimatedIcon />
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +158,15 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
backgroundSolidColor: {
|
backgroundSolidColor: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: '#208AEF',
|
backgroundColor: BRAND_BOOTSTRAP_BG,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
brandBootstrapRoot: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: BRAND_BOOTSTRAP_BG,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,22 @@ import Animated, { Keyframe, Easing } from 'react-native-reanimated';
|
|||||||
import classes from './animated-icon.module.css';
|
import classes from './animated-icon.module.css';
|
||||||
const DURATION = 300;
|
const DURATION = 300;
|
||||||
|
|
||||||
|
/** Brand gate background (matches native splash / overlay). */
|
||||||
|
export const BRAND_BOOTSTRAP_BG = '#208AEF';
|
||||||
|
|
||||||
export function AnimatedSplashOverlay() {
|
export function AnimatedSplashOverlay() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full-screen logo gate while session / token check is in progress (after splash). */
|
||||||
|
export function BrandBootstrapLoading() {
|
||||||
|
return (
|
||||||
|
<View style={styles.brandBootstrapRoot}>
|
||||||
|
<AnimatedIcon />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const keyframe = new Keyframe({
|
const keyframe = new Keyframe({
|
||||||
0: {
|
0: {
|
||||||
transform: [{ scale: 0 }],
|
transform: [{ scale: 0 }],
|
||||||
@@ -88,6 +100,12 @@ export function AnimatedIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
brandBootstrapRoot: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: BRAND_BOOTSTRAP_BG,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MemoirReadingSettingsProvider } from '@/core/memoir-reading-settings-co
|
|||||||
import { NetworkError } from '@/core/api/types';
|
import { NetworkError } from '@/core/api/types';
|
||||||
import { tokenManager } from '@/core/auth/token-manager';
|
import { tokenManager } from '@/core/auth/token-manager';
|
||||||
import { config } from '@/core/config';
|
import { config } from '@/core/config';
|
||||||
|
import { authKeys } from '@/features/auth/auth-query-keys';
|
||||||
import { AppQueryProvider, queryClient } from '@/core/query';
|
import { AppQueryProvider, queryClient } from '@/core/query';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,8 +50,8 @@ async function refreshTokens(): Promise<boolean> {
|
|||||||
* since the cache flip is the authoritative signal.
|
* since the cache flip is the authoritative signal.
|
||||||
*/
|
*/
|
||||||
function onAuthFailure() {
|
function onAuthFailure() {
|
||||||
queryClient.setQueryData(['auth', 'token-check'], false);
|
queryClient.setQueryData(authKeys.tokenCheck, false);
|
||||||
queryClient.setQueryData(['session'], null);
|
queryClient.setQueryData(authKeys.session, null);
|
||||||
tokenManager.clearTokens();
|
tokenManager.clearTokens();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
50
app-expo/src/core/splash-replay.ts
Normal file
50
app-expo/src/core/splash-replay.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
5
app-expo/src/features/auth/auth-query-keys.ts
Normal file
5
app-expo/src/features/auth/auth-query-keys.ts
Normal file
@@ -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;
|
||||||
@@ -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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { AuthError } from '@/core/api/types';
|
import { AuthError } from '@/core/api/types';
|
||||||
import { tokenManager } from '@/core/auth/token-manager';
|
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 { getDeviceLanguage } from '@/i18n';
|
||||||
|
|
||||||
import { authApi } from './api';
|
import { authApi } from './api';
|
||||||
|
import { authKeys } from './auth-query-keys';
|
||||||
import type {
|
import type {
|
||||||
LanguagePreference,
|
LanguagePreference,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
@@ -34,10 +34,7 @@ function withDeviceLanguage<T extends { language?: LanguagePreference }>(
|
|||||||
|
|
||||||
// ─── Query keys ───
|
// ─── Query keys ───
|
||||||
|
|
||||||
export const authKeys = {
|
export { authKeys };
|
||||||
session: ['session'] as const,
|
|
||||||
tokenCheck: ['auth', 'token-check'] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const PROFILE_QUERY_PREFIX = ['profile'] as const;
|
const PROFILE_QUERY_PREFIX = ['profile'] as const;
|
||||||
|
|
||||||
@@ -251,11 +248,7 @@ export function useLogout() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
disposeAllBackgroundConversationWs();
|
await clearLocalSessionAndReplayEntry(queryClient);
|
||||||
await tokenManager.clearTokens();
|
|
||||||
queryClient.clear();
|
|
||||||
queryClient.setQueryData(authKeys.tokenCheck, false);
|
|
||||||
router.replace('/(auth)/login');
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { router } from 'expo-router';
|
|
||||||
|
|
||||||
import { tokenManager } from '@/core/auth/token-manager';
|
import { clearLocalSessionAndReplayEntry } from '@/features/auth/clear-local-session-and-replay-entry';
|
||||||
import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool';
|
|
||||||
import { authKeys } from '@/features/auth/hooks';
|
|
||||||
|
|
||||||
import { profileApi } from './api';
|
import { profileApi } from './api';
|
||||||
import type {
|
import type {
|
||||||
@@ -98,11 +95,7 @@ export function usePurgeUserData() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: PurgeUserDataRequest) => profileApi.purgeUserData(body),
|
mutationFn: (body: PurgeUserDataRequest) => profileApi.purgeUserData(body),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
disposeAllBackgroundConversationWs();
|
await clearLocalSessionAndReplayEntry(queryClient);
|
||||||
await tokenManager.clearTokens();
|
|
||||||
queryClient.clear();
|
|
||||||
queryClient.setQueryData(authKeys.tokenCheck, false);
|
|
||||||
router.replace('/(auth)/login');
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
14
app-expo/src/hooks/use-splash-replay-active.ts
Normal file
14
app-expo/src/hooks/use-splash-replay-active.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSplashReplayActive,
|
||||||
|
subscribeSplashReplayActive,
|
||||||
|
} from '@/core/splash-replay';
|
||||||
|
|
||||||
|
export function useSplashReplayActive(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeSplashReplayActive,
|
||||||
|
getSplashReplayActive,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -226,6 +226,7 @@ describe('useLogout', () => {
|
|||||||
test('reads refresh token first, calls API, then clears local state', async () => {
|
test('reads refresh token first, calls API, then clears local state', async () => {
|
||||||
mockGetRefreshToken.mockResolvedValue('my-refresh');
|
mockGetRefreshToken.mockResolvedValue('my-refresh');
|
||||||
mockLogout.mockResolvedValue({ message: 'ok' });
|
mockLogout.mockResolvedValue({ message: 'ok' });
|
||||||
|
mockHasTokens.mockResolvedValue(false);
|
||||||
|
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
@@ -251,6 +252,7 @@ describe('useLogout', () => {
|
|||||||
test('clears local state even if server logout fails', async () => {
|
test('clears local state even if server logout fails', async () => {
|
||||||
mockGetRefreshToken.mockResolvedValue('my-refresh');
|
mockGetRefreshToken.mockResolvedValue('my-refresh');
|
||||||
mockLogout.mockRejectedValue(new Error('Network down'));
|
mockLogout.mockRejectedValue(new Error('Network down'));
|
||||||
|
mockHasTokens.mockResolvedValue(false);
|
||||||
|
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user