Files
Chromacase/front/Navigation.tsx
Clément Le Bihan 5ba815590a ci fix
2024-01-09 17:34:35 +01:00

329 lines
9.5 KiB
TypeScript

/* eslint-disable @typescript-eslint/ban-types */
import {
NativeStackNavigationProp,
NativeStackScreenProps,
createNativeStackNavigator,
} from '@react-navigation/native-stack';
import { ParamListBase, useNavigation as navigationHook } from '@react-navigation/native';
import React, { ComponentProps, ComponentType, useEffect, useMemo } from 'react';
import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native';
import { RootState, useSelector } from './state/Store';
import { useDispatch } from 'react-redux';
import { Translate, translate } from './i18n/i18n';
import SearchView from './views/V2/SearchView';
import SettingsTab from './views/settings/SettingsView';
import { useQuery } from './Queries';
import API, { APIError } from './API';
import PlayView from './views/PlayView';
import { LoadingView } from './components/Loading';
import ProfileView from './views/ProfileView';
import useColorScheme from './hooks/colorScheme';
import ArtistDetailsView from './views/ArtistDetailsView';
import { Button, Center, VStack } from 'native-base';
import { unsetAccessToken } from './state/UserSlice';
import TextButton from './components/TextButton';
import ErrorView from './views/ErrorView';
import GenreDetailsView from './views/GenreDetailsView';
import GoogleView from './views/GoogleView';
import VerifiedView from './views/VerifiedView';
import SigninView from './views/SigninView';
import SignupView from './views/SignupView';
import PasswordResetView from './views/PasswordResetView';
import ForgotPasswordView from './views/ForgotPasswordView';
import DiscoveryView from './views/V2/DiscoveryView';
import MusicView from './views/MusicView';
import Leaderboardiew from './views/LeaderboardView';
import { LinearGradient } from 'expo-linear-gradient';
import { createCustomNavigator } from './utils/navigator';
import { Cup, Discover, Music, SearchNormal1, Setting2, User } from 'iconsax-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const Stack = createNativeStackNavigator<AppRouteParams>();
const Tab = createCustomNavigator<AppRouteParams>();
const Tabs = () => {
return (
<Tab.Navigator>
{Object.entries(tabRoutes).map(([name, route], routeIndex) => (
<Tab.Screen
key={'route-' + routeIndex}
name={name as keyof AppRouteParams}
options={{ ...route.options, headerTransparent: true }}
component={route.component}
/>
))}
</Tab.Navigator>
);
};
// Util function to hide route props in URL
const removeMe = () => '';
const tabRoutes = {
Home: {
component: DiscoveryView,
options: { headerShown: false, tabBarIcon: Discover },
link: '/',
},
User: {
component: ProfileView,
options: { headerShown: false, tabBarIcon: User },
link: '/user',
},
Music: {
component: MusicView,
options: { headerShown: false, tabBarIcon: Music },
link: '/music',
},
Search: {
component: SearchView,
options: { headerShown: false, tabBarIcon: SearchNormal1 },
link: '/search/:query?',
},
Leaderboard: {
component: Leaderboardiew,
options: { title: translate('leaderboardTitle'), headerShown: false, tabBarIcon: Cup },
link: '/leaderboard',
},
Settings: {
component: SettingsTab,
options: { headerShown: false, tabBarIcon: Setting2, subMenu: true },
link: '/settings/:screen?',
stringify: {
screen: removeMe,
},
},
};
const protectedRoutes = {
Tabs: {
component: Tabs,
options: { headerShown: false, path: '' },
link: '',
childRoutes: tabRoutes,
},
Play: {
component: PlayView,
options: { headerShown: false, title: translate('play') },
link: '/play/:songId',
},
Artist: {
component: ArtistDetailsView,
options: { title: translate('artistFilter') },
link: '/artist/:artistId',
},
Genre: {
component: GenreDetailsView,
options: { title: translate('genreFilter') },
link: '/genre/:genreId',
},
Error: {
component: ErrorView,
options: { title: translate('error'), headerLeft: null },
link: undefined,
},
Verified: {
component: VerifiedView,
options: { title: 'Verify email', headerShown: false },
link: '/verify',
},
};
const publicRoutes = {
Login: {
component: SigninView,
options: { title: translate('signInBtn'), headerShown: false },
link: '/login',
},
Signup: {
component: SignupView,
options: { title: translate('signUpBtn'), headerShown: false },
link: '/signup',
},
Google: {
component: GoogleView,
options: { title: 'Google signin', headerShown: false },
link: '/logged/google',
},
PasswordReset: {
component: PasswordResetView,
options: { title: 'Password reset form', headerShown: false },
link: '/password_reset',
},
ForgotPassword: {
component: ForgotPasswordView,
options: { title: 'Password reset form', headerShown: false },
link: '/forgot_password',
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Route<Props = any> = {
component: ComponentType<Props>;
options: object;
link?: string;
};
// if the component has no props, ComponentProps return unknown so we remove those
type RemoveNonObjects<T> = [T] extends [{}] ? T : undefined;
type RouteParams<Routes extends Record<string, Route>> = {
[RouteName in keyof Routes]: RemoveNonObjects<ComponentProps<Routes[RouteName]['component']>>;
};
type PrivateRoutesParams = RouteParams<typeof protectedRoutes>;
type PublicRoutesParams = RouteParams<typeof publicRoutes>;
type TabsRoutesParams = RouteParams<typeof tabRoutes>;
type AppRouteParams = Omit<PrivateRoutesParams, 'Tabs'> & {
Tabs: { screen: keyof TabsRoutesParams };
} & PublicRoutesParams &
TabsRoutesParams & { Oops: undefined };
const RouteToScreen = <T extends {}>(Component: Route<T>['component']) =>
function Route(props: NativeStackScreenProps<T & ParamListBase>) {
const colorScheme = useColorScheme();
const insets = useSafeAreaInsets();
return (
<LinearGradient
colors={colorScheme === 'dark' ? ['#101014', '#6075F9'] : ['#cdd4fd', '#cdd4fd']}
style={{
flex: 1,
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<Component {...(props.route.params as T)} route={props.route} />
</LinearGradient>
);
};
const routesToScreens = (routes: Partial<Record<keyof AppRouteParams, Route>>) =>
Object.entries(routes).map(([name, route], routeIndex) => (
<Stack.Screen
key={'route-' + routeIndex}
name={name as keyof AppRouteParams}
options={{ ...route.options, headerTransparent: true }}
component={RouteToScreen(route.component)}
/>
));
type RouteDescription = Record<
string,
{ link?: string; stringify?: Record<string, () => string>; childRoutes?: RouteDescription }
>;
const routesToLinkingConfig = (routes: RouteDescription) => {
// Too lazy to (find the) type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pagesToRoute = {} as Record<keyof AppRouteParams, any>;
Object.keys(routes).forEach((route) => {
const index = route as keyof AppRouteParams;
if (routes[index]?.link !== undefined) {
pagesToRoute[index] = {
path: routes[index]!.link!,
stringify: routes[index]!.stringify,
screens: routes[index]!.childRoutes
? routesToLinkingConfig(routes[index]!.childRoutes!).config.screens
: undefined,
};
}
});
return {
prefixes: [],
config: { screens: pagesToRoute },
};
};
const ProfileErrorView = (props: { onTryAgain: () => void }) => {
const dispatch = useDispatch();
const navigation = useNavigation();
return (
<Center style={{ flexGrow: 1 }}>
<VStack space={3}>
<Translate translationKey="userProfileFetchError" />
<Button onPress={props.onTryAgain}>
<Translate translationKey="tryAgain" />
</Button>
<TextButton
onPress={() => {
dispatch(unsetAccessToken());
navigation.navigate('Login');
}}
colorScheme="error"
variant="outline"
translate={{ translationKey: 'signOutBtn' }}
/>
</VStack>
</Center>
);
};
export const Router = () => {
const dispatch = useDispatch();
const accessToken = useSelector((state: RootState) => state.user.accessToken);
const userProfile = useQuery(API.getUserInfo, {
retry: 1,
refetchOnWindowFocus: false,
onError: (err) => {
if (err instanceof APIError && err.status === 401) {
dispatch(unsetAccessToken());
}
},
});
const colorScheme = useColorScheme();
const authStatus = useMemo(() => {
if (userProfile.isError && accessToken && !userProfile.isLoading) {
return 'error';
}
if (userProfile.isLoading && !userProfile.data) {
return 'loading';
}
if (userProfile.isSuccess && accessToken) {
return 'authed';
}
return 'noAuth';
}, [userProfile, accessToken]);
useEffect(() => {
if (accessToken) {
userProfile.refetch();
}
}, [accessToken]);
if (authStatus == 'loading') {
// We dont want this to be a screen, as this lead to a navigator without the requested route, and fallback.
return <LoadingView />;
}
const routes = authStatus == 'authed' ? { ...protectedRoutes } : publicRoutes;
return (
<NavigationContainer
linking={routesToLinkingConfig(routes)}
fallback={<LoadingView />}
theme={colorScheme == 'light' ? DefaultTheme : DarkTheme}
>
<Stack.Navigator screenOptions={{ navigationBarColor: 'transparent' }}>
{authStatus == 'error' ? (
<>
<Stack.Screen
name="Oops"
component={RouteToScreen(() => (
<ProfileErrorView onTryAgain={() => userProfile.refetch()} />
))}
/>
{routesToScreens(publicRoutes)}
</>
) : (
routesToScreens(routes)
)}
</Stack.Navigator>
</NavigationContainer>
);
};
export const useNavigation = () => navigationHook<NativeStackNavigationProp<AppRouteParams>>();