329 lines
9.5 KiB
TypeScript
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>>();
|