Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 89 additions & 3 deletions client/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React from "react";
import { StyleSheet } from "react-native";
import { NavigationContainer } from "@react-navigation/native";
import { Linking, Platform, StyleSheet } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
NavigationContainer,
type InitialState,
} from "@react-navigation/native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
Expand All @@ -12,16 +16,98 @@ import { queryClient } from "@/lib/query-client";
import RootStackNavigator from "@/navigation/RootStackNavigator";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { AuthProvider } from "@/contexts/AuthContext";
import {
AUTH_USER_CACHE_KEY,
parseCachedAuthUser,
} from "@/lib/auth-cache-core";

const NAVIGATION_STATE_KEY = "@ironlog/navigation_state_v1";

type RootNavigationState = {
index?: number;
routes?: { name?: string }[];
};

function getActiveRootRouteName(state: RootNavigationState | undefined) {
if (!state?.routes?.length) return undefined;
const index = state.index ?? 0;
return state.routes[index]?.name;
}

export default function App() {
const [navigationReady, setNavigationReady] = React.useState(
Platform.OS === "web",
);
const [initialNavigationState, setInitialNavigationState] =
React.useState<InitialState>();

React.useEffect(() => {
if (navigationReady) return;

async function restoreNavigationState() {
try {
const initialUrl = await Linking.getInitialURL();
if (initialUrl) return;

const cachedAuthUser = parseCachedAuthUser(
await AsyncStorage.getItem(AUTH_USER_CACHE_KEY),
);
if (!cachedAuthUser) {
await AsyncStorage.removeItem(NAVIGATION_STATE_KEY);
return;
}

const storedState = await AsyncStorage.getItem(NAVIGATION_STATE_KEY);
if (!storedState) return;

try {
setInitialNavigationState(JSON.parse(storedState) as InitialState);
} catch {
await AsyncStorage.removeItem(NAVIGATION_STATE_KEY);
}
} finally {
setNavigationReady(true);
}
}

restoreNavigationState();
}, [navigationReady]);

if (!navigationReady) {
return null;
}

return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<SafeAreaProvider>
<GestureHandlerRootView style={styles.root}>
<KeyboardProvider>
<NavigationContainer>
<NavigationContainer
initialState={initialNavigationState}
onStateChange={(state) => {
if (Platform.OS === "web") return;

if (getActiveRootRouteName(state) !== "Main") {
AsyncStorage.removeItem(NAVIGATION_STATE_KEY).catch(
(error) =>
console.error(
"Error clearing navigation state:",
error,
),
);
return;
}

AsyncStorage.setItem(
NAVIGATION_STATE_KEY,
JSON.stringify(state),
).catch((error) =>
console.error("Error saving navigation state:", error),
);
}}
>
<RootStackNavigator />
</NavigationContainer>
<StatusBar style="auto" />
Expand Down
107 changes: 101 additions & 6 deletions client/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import React, {
useCallback,
ReactNode,
} from "react";
import type { User } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
import {
clearCachedAuthUser,
readCachedAuthUser,
writeCachedAuthUser,
} from "@/lib/auth-cache";
import { getCurrentProfile, upsertCurrentProfile } from "@/lib/profile";
import { flushQueue } from "@/lib/write-queue";

Expand All @@ -20,6 +26,8 @@ export interface AuthUser {
interface AuthContextType {
user: AuthUser | null;
isLoading: boolean;
authHydrated: boolean;
isCheckingAuth: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
signup: (
Expand All @@ -37,42 +45,120 @@ interface AuthContextType {

const AuthContext = createContext<AuthContextType | undefined>(undefined);

function toFallbackAuthUser(user: User): AuthUser {
return {
id: user.id,
email: user.email ?? "",
displayName:
typeof user.user_metadata.displayName === "string"
? user.user_metadata.displayName
: "Athlete",
units: "lbs",
};
}

async function clearCachedAuthUserSafely() {
try {
await clearCachedAuthUser();
} catch (error) {
console.error("Error clearing cached auth user:", error);
}
}

async function writeCachedAuthUserSafely(user: AuthUser) {
try {
await writeCachedAuthUser(user);
} catch (error) {
console.error("Error caching auth user:", error);
}
}

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [authHydrated, setAuthHydrated] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(false);

const refreshUser = useCallback(async () => {
setIsCheckingAuth(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();

if (!session) {
await clearCachedAuthUserSafely();
setUser(null);
return;
}

const { data, error } = await supabase.auth.getUser();

if (error || !data.user) {
await clearCachedAuthUserSafely();
await supabase.auth.signOut();
setUser(null);
return;
}

const profile = await getCurrentProfile(data.user);
let profile: AuthUser;
try {
profile = await getCurrentProfile(data.user);
} catch (profileError) {
console.error("Error refreshing profile:", profileError);
setUser((currentUser) => currentUser ?? toFallbackAuthUser(data.user));
return;
}

await writeCachedAuthUserSafely(profile);
setUser(profile);
flushQueue().catch((e) => console.error("Queue flush error:", e));
} catch (error) {
console.error("Error refreshing user:", error);
await clearCachedAuthUserSafely();
setUser(null);
} finally {
setIsLoading(false);
setIsCheckingAuth(false);
}
}, []);

useEffect(() => {
refreshUser();
let active = true;

async function hydrateCachedUser() {
try {
const cachedUser = await readCachedAuthUser();
if (!active) return;
setUser(cachedUser);
} catch (error) {
console.error("Error hydrating cached auth user:", error);
if (!active) return;
setUser(null);
} finally {
if (active) {
setAuthHydrated(true);
refreshUser();
}
}
}

hydrateCachedUser();

const {
data: { subscription },
} = supabase.auth.onAuthStateChange(() => {
} = supabase.auth.onAuthStateChange((event, session) => {
setTimeout(() => {
if (event === "SIGNED_OUT" || !session) {
clearCachedAuthUserSafely();
setUser(null);
return;
}

refreshUser();
}, 0);
});

return () => {
active = false;
subscription.unsubscribe();
};
}, [refreshUser]);
Expand All @@ -89,7 +175,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {

if (data.user) {
const profile = await getCurrentProfile(data.user);
await writeCachedAuthUserSafely(profile);
setUser(profile);
setAuthHydrated(true);
flushQueue().catch((e) => console.error("Queue flush error:", e));
}
}, []);
Expand All @@ -116,7 +204,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
displayName: name,
units: "lbs",
});
await writeCachedAuthUserSafely(profile);
setUser(profile);
setAuthHydrated(true);
}
},
[],
Expand All @@ -127,7 +217,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (error) {
throw error;
}
await clearCachedAuthUserSafely();
setUser(null);
setAuthHydrated(true);
}, []);

const updateProfile = useCallback(
Expand All @@ -142,6 +234,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
displayName: updates.displayName ?? user?.displayName,
units: updates.units ?? user?.units,
});
await writeCachedAuthUserSafely(userData);
setUser(userData);
},
[user],
Expand All @@ -151,7 +244,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
<AuthContext.Provider
value={{
user,
isLoading,
isLoading: !authHydrated,
authHydrated,
isCheckingAuth,
isAuthenticated: !!user,
login,
signup,
Expand Down
Loading
Loading