diff --git a/front/.eslintrc.json b/front/.eslintrc.json index c49a8ef..501a0c8 100644 --- a/front/.eslintrc.json +++ b/front/.eslintrc.json @@ -19,6 +19,14 @@ "@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-empty-function": "off" + "@typescript-eslint/no-empty-function": "off", + "no-restricted-imports": [ + "error", + { + "name": "react-query", + "importNames": ["useQuery", "useInfiniteQuery", "useQueries"], + "message": "Use wrapper functions provided by Queries.ts" + } + ] } } diff --git a/front/API.ts b/front/API.ts index 05b8e7a..b75cc0a 100644 --- a/front/API.ts +++ b/front/API.ts @@ -1,6 +1,5 @@ import Artist from './models/Artist'; import Album from './models/Album'; -import AuthToken from './models/AuthToken'; import Chapter from './models/Chapter'; import Lesson from './models/Lesson'; import Genre from './models/Genre'; @@ -12,10 +11,11 @@ import Constants from 'expo-constants'; import store from './state/Store'; import { Platform } from 'react-native'; import { en } from './i18n/Translations'; -import { QueryClient } from 'react-query'; import UserSettings from './models/UserSettings'; import { PartialDeep } from 'type-fest'; import SearchHistory from './models/SearchHistory'; +import { Query } from './Queries'; +import CompetenciesTable from './components/CompetenciesTable'; type AuthenticationInput = { username: string; password: string }; type RegistrationInput = AuthenticationInput & { email: string }; @@ -72,7 +72,7 @@ export default class API { try { const jsonResponse = body.length != 0 ? JSON.parse(body) : {}; if (!response.ok) { - throw new APIError(jsonResponse ?? response.statusText, response.status); + throw new APIError(response.statusText ?? body, response.status); } return jsonResponse; } catch (e) { @@ -137,43 +137,53 @@ export default class API { /*** * Retrieve information of the currently authentified user */ - public static async getUserInfo(): Promise { - const user = await API.fetch({ - route: '/auth/me', - }); - - // this a dummy settings object, we will need to fetch the real one from the API + public static getUserInfo(): Query { return { - id: user.id as number, - name: (user.username ?? user.name) as string, - email: user.email as string, - premium: false, - isGuest: user.isGuest as boolean, - data: { - gamesPlayed: user.partyPlayed as number, - xp: 0, - createdAt: new Date('2023-04-09T00:00:00.000Z'), - avatar: 'https://imgs.search.brave.com/RnQpFhmAFvuQsN_xTw7V-CN61VeHDBg2tkEXnKRYHAE/rs:fit:768:512:1/g:ce/aHR0cHM6Ly96b29h/c3Ryby5jb20vd3At/Y29udGVudC91cGxv/YWRzLzIwMjEvMDIv/Q2FzdG9yLTc2OHg1/MTIuanBn', + key: 'user', + exec: async () => { + const user = await API.fetch({ + route: '/auth/me', + }); + + // this a dummy settings object, we will need to fetch the real one from the API + return { + id: user.id as number, + name: (user.username ?? user.name) as string, + email: user.email as string, + premium: false, + isGuest: user.isGuest as boolean, + data: { + gamesPlayed: user.partyPlayed as number, + xp: 0, + createdAt: new Date('2023-04-09T00:00:00.000Z'), + avatar: 'https://imgs.search.brave.com/RnQpFhmAFvuQsN_xTw7V-CN61VeHDBg2tkEXnKRYHAE/rs:fit:768:512:1/g:ce/aHR0cHM6Ly96b29h/c3Ryby5jb20vd3At/Y29udGVudC91cGxv/YWRzLzIwMjEvMDIv/Q2FzdG9yLTc2OHg1/MTIuanBn', + }, + } as User; }, - } as User; + }; } - public static async getUserSettings(): Promise { - const settings = await API.fetch({ - route: '/auth/me/settings', - }); - + public static getUserSettings(): Query { return { - notifications: { - pushNotif: settings.pushNotification, - emailNotif: settings.emailNotification, - trainNotif: settings.trainingNotification, - newSongNotif: settings.newSongNotification, + key: 'settings', + exec: async () => { + const settings = await API.fetch({ + route: '/auth/me/settings', + }); + + return { + notifications: { + pushNotif: settings.pushNotification, + emailNotif: settings.emailNotification, + trainNotif: settings.trainingNotification, + newSongNotif: settings.newSongNotification, + }, + recommendations: settings.recommendations, + weeklyReport: settings.weeklyReport, + leaderBoard: settings.leaderBoard, + showActivity: settings.showActivity, + }; }, - recommendations: settings.recommendations, - weeklyReport: settings.weeklyReport, - leaderBoard: settings.leaderBoard, - showActivity: settings.showActivity, }; } @@ -195,36 +205,62 @@ export default class API { }); } - public static async getUserSkills() { + public static getUserSkills(): Query[0]> { return { - pedalsCompetency: Math.random() * 100, - rightHandCompetency: Math.random() * 100, - leftHandCompetency: Math.random() * 100, - accuracyCompetency: Math.random() * 100, - arpegeCompetency: Math.random() * 100, - chordsCompetency: Math.random() * 100, + key: 'skills', + exec: async () => ({ + pedalsCompetency: Math.random() * 100, + rightHandCompetency: Math.random() * 100, + leftHandCompetency: Math.random() * 100, + accuracyCompetency: Math.random() * 100, + arpegeCompetency: Math.random() * 100, + chordsCompetency: Math.random() * 100, + }), + }; + } + + public static getAllSongs(): Query { + return { + key: 'songs', + exec: async () => { + const songs = await API.fetch({ + route: '/song', + }); + + // this is a dummy illustration, we will need to fetch the real one from the API + return songs.data.map( + // To be fixed with #168 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (song: any) => + ({ + id: song.id as number, + name: song.name as string, + artistId: song.artistId as number, + albumId: song.albumId as number, + genreId: song.genreId as number, + details: song.difficulties, + cover: `${baseAPIUrl}/song/${song.id}/illustration`, + metrics: {}, + } as Song) + ); + }, }; } /** - * Authentify a new user through Google + * Retrieve a song + * @param songId the id to find the song */ - public static async authWithGoogle(): Promise { - //TODO - return '11111'; - } + public static getSong(songId: number): Query { + return { + key: ['song', songId], + exec: async () => { + const song = await API.fetch({ + route: `/song/${songId}`, + }); - public static async getAllSongs(): Promise { - const songs = await API.fetch({ - route: '/song', - }); - - // this is a dummy illustration, we will need to fetch the real one from the API - return songs.data.map( - // To be fixed with #168 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (song: any) => - ({ + // this is a dummy illustration, we will need to fetch the real one from the API + return { id: song.id as number, name: song.name as string, artistId: song.artistId as number, @@ -232,65 +268,35 @@ export default class API { genreId: song.genreId as number, details: song.difficulties, cover: `${baseAPIUrl}/song/${song.id}/illustration`, - metrics: {}, - } as Song) - ); + } as Song; + }, + }; } - /** + /** * @description retrieves songs from a specific artist * @param artistId is the id of the artist that composed the songs aimed - * @param skip is how much songs do we skip before returning the list - * @param take is how much songs should be returned * @returns a Promise of Songs type array */ public static async getSongsByArtist(artistId: number): Promise { - // let queryString = `/song?artisId=${artistId}`; - - // if (skip) { - // queryString = `${queryString}&skip=${skip}`; - // } - // if (take) { - // queryString = `${queryString}&take=${take}`; - // } - // return await API.fetch({ - // route: queryString, - // }); - return API.fetch({ route: `/song?artistId=${artistId}`, }); } - /** - * Retrieve a song - * @param songId the id to find the song - */ - public static async getSong(songId: number): Promise { - const song = await API.fetch({ - route: `/song/${songId}`, - }); - - // this is a dummy illustration, we will need to fetch the real one from the API - return { - id: song.id as number, - name: song.name as string, - artistId: song.artistId as number, - albumId: song.albumId as number, - genreId: song.genreId as number, - details: song.difficulties, - cover: `${baseAPIUrl}/song/${song.id}/illustration`, - } as Song; - } /** * Retrive a song's midi partition * @param songId the id to find the song */ - public static async getSongMidi(songId: number): Promise { - return API.fetch({ - route: `/song/${songId}/midi`, - raw: true, - }); + public static getSongMidi(songId: number): Query { + return { + key: ['midi', songId], + exec: () => + API.fetch({ + route: `/song/${songId}/midi`, + raw: true, + }), + }; } /** @@ -313,119 +319,152 @@ export default class API { * Retrive a song's musicXML partition * @param songId the id to find the song */ - public static async getSongMusicXML(songId: number): Promise { - return API.fetch({ - route: `/song/${songId}/musicXml`, - raw: true, - }); + public static getSongMusicXML(songId: number): Query { + return { + key: ['musixml', songId], + exec: () => + API.fetch({ + route: `/song/${songId}/musicXml`, + raw: true, + }), + }; } /** * Retrive an artist */ - public static async getArtist(artistId: number): Promise { - return API.fetch({ - route: `/artist/${artistId}`, - }); + public static getArtist(artistId: number): Query { + return { + key: ['artist', artistId], + exec: () => + API.fetch({ + route: `/artist/${artistId}`, + }), + }; } /** * Retrive a song's chapters * @param songId the id to find the song */ - public static async getSongChapters(songId: number): Promise { - return [1, 2, 3, 4, 5].map((value) => ({ - start: 100 * (value - 1), - end: 100 * value, - songId: songId, - name: `Chapter ${value}`, - type: 'chorus', - key_aspect: 'rhythm', - difficulty: value, - id: value * 10, - })); + public static getSongChapters(songId: number): Query { + return { + key: ['chapters', songId], + exec: async () => + [1, 2, 3, 4, 5].map((value) => ({ + start: 100 * (value - 1), + end: 100 * value, + songId: songId, + name: `Chapter ${value}`, + type: 'chorus', + key_aspect: 'rhythm', + difficulty: value, + id: value * 10, + })), + }; } /** * Retrieve a song's play history * @param songId the id to find the song */ - public static async getSongHistory( - songId: number - ): Promise<{ best: number; history: SongHistory[] }> { - return API.fetch({ - route: `/song/${songId}/history`, - }); + public static getSongHistory(songId: number): Query<{ best: number; history: SongHistory[] }> { + return { + key: ['song', 'history', songId], + exec: () => + API.fetch({ + route: `/song/${songId}/history`, + }), + }; } /** * Search a song by its name * @param query the string used to find the songs */ - public static async searchSongs(query: string): Promise { - return API.fetch({ - route: `/search/songs/${query}`, - }); + public static searchSongs(query: string): Query { + return { + key: ['search', 'song', query], + exec: () => + API.fetch({ + route: `/search/songs/${query}`, + }), + }; } /** * Search artists by name * @param query the string used to find the artists */ - public static async searchArtists(query?: string): Promise { - return API.fetch({ - route: `/search/artists/${query}`, - }); + public static searchArtists(query: string): Query { + return { + key: ['search', 'artist', query], + exec: () => + API.fetch({ + route: `/search/artists/${query}`, + }), + }; } /** * Search Album by name * @param query the string used to find the album */ - public static async searchAlbum( + public static searchAlbum( // eslint-disable-next-line @typescript-eslint/no-unused-vars - query?: string - ): Promise { - return [ - { - id: 1, - name: 'Super Trooper', - }, - { - id: 2, - name: 'Kingdom Heart 365/2 OST', - }, - { - id: 3, - name: 'The Legend Of Zelda Ocarina Of Time OST', - }, - { - id: 4, - name: 'Random Access Memories', - }, - ] as Album[]; + query: string + ): Query { + return { + key: ['search', 'album', query], + exec: async () => + [ + { + id: 1, + name: 'Super Trooper', + }, + { + id: 2, + name: 'Kingdom Heart 365/2 OST', + }, + { + id: 3, + name: 'The Legend Of Zelda Ocarina Of Time OST', + }, + { + id: 4, + name: 'Random Access Memories', + }, + ] as Album[], + }; } /** * Retrieve music genres */ - public static async searchGenres(query?: string): Promise { - return API.fetch({ - route: `/search/genres/${query}`, - }); + public static searchGenres(query: string): Query { + return { + key: ['search', 'genre', query], + exec: () => + API.fetch({ + route: `/search/genres/${query}`, + }), + }; } /** * Retrieve a lesson * @param lessonId the id to find the lesson */ - public static async getLesson(lessonId: number): Promise { + public static getLesson(lessonId: number): Query { return { - title: 'Song', - description: 'A song', - requiredLevel: 1, - mainSkill: 'lead-head-change', - id: lessonId, + key: ['lesson', lessonId], + exec: async () => ({ + title: 'Song', + description: 'A song', + requiredLevel: 1, + mainSkill: 'lead-head-change', + id: lessonId, + }), }; } @@ -435,26 +474,28 @@ export default class API { * @param take how much do we take to return * @returns Returns an array of history entries (temporary type any) */ - public static async getSearchHistory(skip?: number, take?: number): Promise { - return ( - ( - await API.fetch({ + public static getSearchHistory(skip?: number, take?: number): Query { + return { + key: ['search', 'history', 'skip', skip, 'take', take], + exec: () => + API.fetch({ route: `/history/search?skip=${skip ?? 0}&take=${take ?? 5}`, method: 'GET', - }) - ) - // To be fixed with #168 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((e: any) => { - return { - id: e.id, - query: e.query, - type: e.type, - userId: e.userId, - timestamp: new Date(e.searchDate), - } as SearchHistory; - }) - ); + }).then((value) => + value.map( + // To be fixed with #168 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: any) => + ({ + id: e.id, + query: e.query, + type: e.type, + userId: e.userId, + timestamp: new Date(e.searchDate), + } as SearchHistory) + ) + ), + }; } /** @@ -479,85 +520,38 @@ export default class API { * Retrieve the authenticated user's recommendations * @returns an array of songs */ - public static async getSongSuggestions(): Promise { - const queryClient = new QueryClient(); - return await queryClient.fetchQuery(['API', 'allsongs'], API.getAllSongs); + public static getSongSuggestions(): Query { + return API.getAllSongs(); } /** * Retrieve the authenticated user's play history * * @returns an array of songs */ - public static async getUserPlayHistory(): Promise { - return this.fetch({ - route: '/history', - }); + public static getUserPlayHistory(): Query { + return { + key: ['history'], + exec: () => + API.fetch({ + route: '/history', + }), + }; } /** * Retrieve a lesson's history * @param lessonId the id to find the lesson */ - public static async getLessonHistory(lessonId: number): Promise { - return [ - { - lessonId, - userId: 1, - }, - ]; - } - - /** - * Retrieve a partition images - * @param _songId the id of the song - * This API may be merged with the fetch song in the future - */ - public static async getPartitionRessources( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - songId: number - ): Promise<[string, number, number][]> { - return [ - [ - 'https://media.discordapp.net/attachments/717080637038788731/1067469560426545222/vivaldi_split_1.png', - 1868, - 400, + public static getLessonHistory(lessonId: number): Query { + return { + key: ['lesson', 'history', lessonId], + exec: async () => [ + { + lessonId, + userId: 1, + }, ], - [ - 'https://media.discordapp.net/attachments/717080637038788731/1067469560900505660/vivaldi_split_2.png', - 1868, - 400, - ], - [ - 'https://media.discordapp.net/attachments/717080637038788731/1067469561261203506/vivaldi_split_3.png', - 1868, - 400, - ], - [ - 'https://media.discordapp.net/attachments/717080637038788731/1067469561546424381/vivaldi_split_4.png', - 1868, - 400, - ], - [ - 'https://media.discordapp.net/attachments/717080637038788731/1067469562058133564/vivaldi_split_5.png', - 1868, - 400, - ], - [ - 'https://media.discordapp.net/attachments/717080637038788731/1067469562347528202/vivaldi_split_6.png', - 1868, - 400, - ], - [ - 'https://media.discordapp.net/attachments/717080637038788731/1067469562792136815/vivaldi_split_7.png', - 1868, - 400, - ], - [ - 'https://media.discordapp.net/attachments/717080637038788731/1067469563073142804/vivaldi_split_8.png', - 1868, - 400, - ], - ]; + }; } public static async updateUserEmail(newEmail: string): Promise { diff --git a/front/App.tsx b/front/App.tsx index e66dade..243702a 100644 --- a/front/App.tsx +++ b/front/App.tsx @@ -9,14 +9,9 @@ import { PersistGate } from 'redux-persist/integration/react'; import LanguageGate from './i18n/LanguageGate'; import ThemeProvider, { ColorSchemeProvider } from './Theme'; import 'react-native-url-polyfill/auto'; +import { QueryRules } from './Queries'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - }, - }, -}); +const queryClient = new QueryClient(QueryRules); export default function App() { SplashScreen.preventAutoHideAsync(); diff --git a/front/Navigation.tsx b/front/Navigation.tsx index 35ba673..011967b 100644 --- a/front/Navigation.tsx +++ b/front/Navigation.tsx @@ -5,7 +5,7 @@ import { ParamListBase, useNavigation as navigationHook, } from '@react-navigation/native'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native'; import { RootState, useSelector } from './state/Store'; import { useDispatch } from 'react-redux'; @@ -16,7 +16,7 @@ import StartPageView from './views/StartPageView'; import HomeView from './views/HomeView'; import SearchView from './views/SearchView'; import SetttingsNavigator from './views/settings/SettingsView'; -import { useQuery } from 'react-query'; +import { useQuery } from './Queries'; import API, { APIError } from './API'; import PlayView from './views/PlayView'; import ScoreView from './views/ScoreView'; @@ -27,24 +27,79 @@ 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'; + +// Util function to hide route props in URL +const removeMe = () => ''; const protectedRoutes = () => ({ - Home: { component: HomeView, options: { title: translate('welcome'), headerLeft: null } }, - Play: { component: PlayView, options: { title: translate('play') } }, - Settings: { component: SetttingsNavigator, options: { title: 'Settings' } }, - Song: { component: SongLobbyView, options: { title: translate('play') } }, - Artist: { component: ArtistDetailsView, options: { title: translate('artistFilter') } }, - Score: { component: ScoreView, options: { title: translate('score'), headerLeft: null } }, - Search: { component: SearchView, options: { title: translate('search') } }, - User: { component: ProfileView, options: { title: translate('user') } }, + Home: { + component: HomeView, + options: { title: translate('welcome'), headerLeft: null }, + link: '/', + }, + Play: { component: PlayView, options: { title: translate('play') }, link: '/play/:songId' }, + Settings: { + component: SetttingsNavigator, + options: { title: 'Settings' }, + link: '/settings/:screen?', + stringify: { + screen: removeMe, + }, + }, + Song: { + component: SongLobbyView, + options: { title: translate('play') }, + link: '/song/:songId', + }, + Artist: { + component: ArtistDetailsView, + options: { title: translate('artistFilter') }, + link: '/artist/:artistId', + }, + Score: { + component: ScoreView, + options: { title: translate('score'), headerLeft: null }, + link: undefined, + }, + Search: { + component: SearchView, + options: { title: translate('search') }, + link: '/search/:query?', + }, + Error: { + component: ErrorView, + options: { title: translate('error'), headerLeft: null }, + link: undefined, + }, + User: { component: ProfileView, options: { title: translate('user') }, link: '/user' }, } as const); const publicRoutes = () => ({ - Start: { component: StartPageView, options: { title: 'Chromacase', headerShown: false } }, - Login: { component: AuthenticationView, options: { title: translate('signInBtn') } }, - Oops: { component: ProfileErrorView, options: { title: 'Oops', headerShown: false } }, + Start: { + component: StartPageView, + options: { title: 'Chromacase', headerShown: false }, + link: '/', + }, + Login: { + component: (params: RouteProps<{}>) => + AuthenticationView({ isSignup: false, ...params }), + options: { title: translate('signInBtn') }, + link: '/login', + }, + Signup: { + component: (params: RouteProps<{}>) => + AuthenticationView({ isSignup: true, ...params }), + options: { title: translate('signUpBtn') }, + link: '/signup', + }, + Oops: { + component: ProfileErrorView, + options: { title: 'Oops', headerShown: false }, + link: undefined, + }, } as const); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -90,6 +145,29 @@ const routesToScreens = (routes: Partial>) = /> )); +const routesToLinkingConfig = ( + routes: Partial< + Record string> }> + > +) => { + // Too lazy to (find the) type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pagesToRoute = {} as Record; + Object.keys(routes).forEach((route) => { + const index = route as keyof AppRouteParams; + if (routes[index]?.link) { + pagesToRoute[index] = { + path: routes[index]!.link!, + stringify: routes[index]!.stringify, + }; + } + }); + return { + prefixes: [], + config: { screens: pagesToRoute }, + }; +}; + const ProfileErrorView = (props: { onTryAgain: () => void }) => { const dispatch = useDispatch(); return ( @@ -113,7 +191,7 @@ const ProfileErrorView = (props: { onTryAgain: () => void }) => { export const Router = () => { const dispatch = useDispatch(); const accessToken = useSelector((state: RootState) => state.user.accessToken); - const userProfile = useQuery(['user', 'me', accessToken], () => API.getUserInfo(), { + const userProfile = useQuery(API.getUserInfo, { retry: 1, refetchOnWindowFocus: false, onError: (err) => { @@ -123,6 +201,24 @@ export const Router = () => { }, }); 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]); + const routes = useMemo(() => { + if (authStatus == 'authed') { + return protectedRoutes(); + } + return publicRoutes(); + }, [authStatus]); useEffect(() => { if (accessToken) { @@ -130,22 +226,27 @@ export const Router = () => { } }, [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 ; + } + return ( - + } + theme={colorScheme == 'light' ? DefaultTheme : DarkTheme} + > - {userProfile.isError && accessToken && !userProfile.isLoading ? ( + {authStatus == 'error' ? ( ( userProfile.refetch()} /> ))} /> - ) : userProfile.isLoading && !userProfile.data ? ( - ) : ( - routesToScreens( - userProfile.isSuccess && accessToken ? protectedRoutes() : publicRoutes() - ) + routesToScreens(routes) )} diff --git a/front/Queries.ts b/front/Queries.ts new file mode 100644 index 0000000..d72b777 --- /dev/null +++ b/front/Queries.ts @@ -0,0 +1,80 @@ +/* eslint-disable no-restricted-imports */ +// Disabled for obvious reasons +import * as RQ from 'react-query'; + +const QueryRules: RQ.QueryClientConfig = { + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + // This is needed explicitly, otherwise will refetch **all** the time + staleTime: Infinity, + }, + }, +}; + +// The options of the query. +// E.g. enabled. +type QueryOptions = RQ.UseQueryOptions; + +// What a query *is* +export type Query = { + key: RQ.QueryKey; + exec: () => Promise; +}; + +// We want `useQuery`/`ies` to accept either a function or a `Query` directly +type QueryOrQueryFn = Query | (() => Query); +// A simple util function to avoid conditions everywhere +const queryToFn = (q: QueryOrQueryFn) => { + if (typeof q === 'function') { + return q; + } + return () => q; +}; + +// This also allows lazy laoding of query function. +// I.e. not call the function before it is enabled; +const buildRQuery = >(q: QueryOrQueryFn, opts?: Opts) => { + const laziedQuery = queryToFn(q); + if (opts?.enabled === false) { + return { + queryKey: [], + // This will not be called because the query is disabled. + // However, this is done for type-safety + queryFn: () => laziedQuery().exec(), + ...opts, + }; + } + const resolvedQuery = laziedQuery(); + return { + queryKey: resolvedQuery.key, + queryFn: resolvedQuery.exec, + ...opts, + }; +}; + +const useQuery = >( + query: QueryOrQueryFn, + options?: Opts +) => { + return RQ.useQuery(buildRQuery(query, options)); +}; + +const transformQuery = ( + query: Query, + fn: (res: OldReturnType) => NewReturnType +) => { + return { + key: query.key, + exec: () => query.exec().then(fn), + }; +}; + +const useQueries = ( + queries: readonly QueryOrQueryFn[], + options?: QueryOptions +) => { + return RQ.useQueries(queries.map((q) => buildRQuery(q, options))); +}; + +export { useQuery, useQueries, QueryRules, transformQuery }; diff --git a/front/components/Loading.tsx b/front/components/Loading.tsx index 7e75c5e..0fd7880 100644 --- a/front/components/Loading.tsx +++ b/front/components/Loading.tsx @@ -1,13 +1,26 @@ import { useTheme } from 'native-base'; import { Center, Spinner } from 'native-base'; +import useColorScheme from '../hooks/colorScheme'; +import { DefaultTheme, DarkTheme } from '@react-navigation/native'; +import { useMemo } from 'react'; const LoadingComponent = () => { const theme = useTheme(); return ; }; const LoadingView = () => { + const colorScheme = useColorScheme(); + const bgColor = useMemo(() => { + switch (colorScheme) { + case 'light': + return DefaultTheme.colors.background; + case 'dark': + return DarkTheme.colors.background; + } + }, [colorScheme]); + return ( -
+
); diff --git a/front/components/SearchResult.tsx b/front/components/SearchResult.tsx index 59e8035..274cf54 100644 --- a/front/components/SearchResult.tsx +++ b/front/components/SearchResult.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { HStack, VStack, @@ -16,7 +16,7 @@ import { import { SafeAreaView, useColorScheme } from 'react-native'; import { RootState, useSelector } from '../state/Store'; import { SearchContext } from '../views/SearchView'; -import { useQuery } from 'react-query'; +import { useQueries, useQuery } from '../Queries'; import { translate } from '../i18n/i18n'; import API from '../API'; import LoadingComponent from './Loading'; @@ -27,8 +27,8 @@ import CardGridCustom from './CardGridCustom'; import TextButton from './TextButton'; import SearchHistoryCard from './HistoryCard'; import Song, { SongWithArtist } from '../models/Song'; -import { getSongWArtistSuggestions } from './utils/api'; import { useNavigation } from '../Navigation'; +import Artist from '../models/Artist'; const swaToSongCardProps = (song: SongWithArtist) => ({ songId: song.id, @@ -135,18 +135,38 @@ SongRow.defaultProps = { const HomeSearchComponent = () => { const { updateStringQuery } = React.useContext(SearchContext); const { isLoading: isLoadingHistory, data: historyData = [] } = useQuery( - 'history', - () => API.getSearchHistory(0, 12), + API.getSearchHistory(0, 12), { enabled: true } ); - - const { isLoading: isLoadingSuggestions, data: suggestionsData = [] } = useQuery( - 'suggestions', - () => getSongWArtistSuggestions(), - { - enabled: true, - } + const songSuggestions = useQuery(API.getSongSuggestions); + const songArtistSuggestions = useQueries( + songSuggestions.data + ?.filter((song) => song.artistId !== null) + .map(({ artistId }) => API.getArtist(artistId)) ?? [] ); + const isLoadingSuggestions = useMemo( + () => songSuggestions.isLoading || songArtistSuggestions.some((q) => q.isLoading), + [songSuggestions, songArtistSuggestions] + ); + const suggestionsData = useMemo(() => { + if (isLoadingSuggestions) { + return []; + } + return ( + songSuggestions.data + ?.map((song): [Song, Artist | undefined] => [ + song, + songArtistSuggestions + .map((q) => q.data) + .filter((d) => d !== undefined) + .find((data) => data?.id === song.artistId), + ]) + // We do not need the song + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([song, artist]) => artist !== undefined) + .map(([song, artist]) => ({ ...song, artist: artist! })) ?? [] + ); + }, [songSuggestions, songArtistSuggestions]); return ( diff --git a/front/components/utils/api.tsx b/front/components/utils/api.tsx deleted file mode 100644 index 12d98e5..0000000 --- a/front/components/utils/api.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import API from '../../API'; -import { SongWithArtist } from '../../models/Song'; - -export const getSongWArtistSuggestions = async () => { - const nextStepQuery = await API.getSongSuggestions(); - - const songWartist = await Promise.all( - nextStepQuery.map(async (song) => { - if (!song.artistId) throw new Error('Song has no artistId'); - const artist = await API.getArtist(song.artistId); - return { ...song, artist } as SongWithArtist; - }) - ); - return songWartist; -}; diff --git a/front/hooks/userSettings.ts b/front/hooks/userSettings.ts index 12fb577..cc3c325 100644 --- a/front/hooks/userSettings.ts +++ b/front/hooks/userSettings.ts @@ -1,9 +1,8 @@ -import { useQuery } from 'react-query'; +import { useQuery } from '../Queries'; import API from '../API'; const useUserSettings = () => { - const queryKey = ['settings']; - const settings = useQuery(queryKey, () => API.getUserSettings()); + const settings = useQuery(API.getUserSettings); const updateSettings = (...params: Parameters) => API.updateUserSettings(...params).then(() => settings.refetch()); return { settings, updateSettings }; diff --git a/front/i18n/Translations.ts b/front/i18n/Translations.ts index ab83f94..014ed86 100644 --- a/front/i18n/Translations.ts +++ b/front/i18n/Translations.ts @@ -1,4 +1,7 @@ export const en = { + error: 'Error', + goBackHome: 'Go Back Home', + anErrorOccured: 'An Error Occured', welcome: 'Welcome', welcomeMessage: 'Welcome back ', signOutBtn: 'Sign out', @@ -179,6 +182,9 @@ export const en = { }; export const fr: typeof en = { + error: 'Erreur', + goBackHome: "Retourner à l'accueil", + anErrorOccured: 'Une erreur est survenue', welcome: 'Bienvenue', welcomeMessage: 'Re-Bonjour ', signOutBtn: 'Se déconnecter', @@ -357,6 +363,9 @@ export const fr: typeof en = { }; export const sp: typeof en = { + error: 'Error', + anErrorOccured: 'ocurrió un error', + goBackHome: 'regresar a casa', welcomeMessage: 'Benvenido', signOutBtn: 'Desconectarse', signInBtn: 'Connectarse', diff --git a/front/models/AuthToken.ts b/front/models/AuthToken.ts deleted file mode 100644 index 2f3c3b4..0000000 --- a/front/models/AuthToken.ts +++ /dev/null @@ -1,3 +0,0 @@ -type AuthToken = string; - -export default AuthToken; diff --git a/front/views/ArtistDetailsView.tsx b/front/views/ArtistDetailsView.tsx index e7ef854..2e5e088 100644 --- a/front/views/ArtistDetailsView.tsx +++ b/front/views/ArtistDetailsView.tsx @@ -1,64 +1,67 @@ import { VStack, Text, Box, Image, Heading, IconButton, Icon, Container, Center, useBreakpointValue } from 'native-base'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native'; -import { useQuery } from 'react-query'; -import LoadingComponent from '../components/Loading'; +import { useQuery } from '../Queries'; +import { LoadingView } from '../components/Loading'; import API from '../API'; -import Song from '../models/Song'; +import Song, { SongWithArtist } from '../models/Song'; import SongRow from '../components/SongRow'; -import { useNavigation } from '@react-navigation/native'; -import { useEffect, useState } from 'react'; +import { Key, useEffect, useState } from 'react'; +import { useNavigation } from '../Navigation'; const ArtistDetailsView = ({ artistId }: any) => { - const { isLoading: isLoadingArtist, data: artistData, error: errorArtist } = useQuery(['artist', artistId], () => API.getArtist(artistId)); - // const { isLoading: isLoadingSongs, data: songData = [], error: errorSongs } = useQuery(['songs', artistId], () => API.getSongsByArtist(artistId)) - const screenSize = useBreakpointValue({ base: "small", md: "big" }); + const { isLoading, data: artistData, isError } = useQuery(API.getArtist(artistId)); + // const { isLoading: isLoadingSongs, data: songData = [], error: errorSongs } = useQuery(['songs', artistId], () => API.getSongsByArtist(artistId)) + const screenSize = useBreakpointValue({ base: "small", md: "big" }); const isMobileView = screenSize == "small"; - const navigation = useNavigation(); - const [merde, setMerde] = useState(null); + const navigation = useNavigation(); + const [merde, setMerde] = useState(null); - useEffect(() => { - // Code to be executed when the component is focused - console.warn('Component focused!'); - setMerde(API.getSongsByArtist(112)); - // Call your function or perform any other actions here - }, []); + useEffect(() => { + // Code to be executed when the component is focused + console.warn('Component focused!'); + setMerde(API.getSongsByArtist(112)); + // Call your function or perform any other actions here + }, []); - if (isLoadingArtist) { - return
; - } + if (isLoading) { + return ; + } - return ( - - - {artistData?.name} - - Abba - - {merde.map((comp, index) => ( - { - API.createSearchHistoryEntry(comp.name, "song", Date.now()); - navigation.navigate("Song", { songId: comp.id }); - }} - /> - )) - } - + if (isError) { + navigation.navigate('Error'); + } - - - - ); + return ( + + + {artistData?.name} + + Abba + + {merde.map((comp: Song | SongWithArtist, index: Key | null | undefined) => ( + { + API.createSearchHistoryEntry(comp.name, "song", Date.now()); + navigation.navigate("Song", { songId: comp.id }); + }} + /> + )) + } + + + + + ); }; export default ArtistDetailsView; diff --git a/front/views/AuthenticationView.tsx b/front/views/AuthenticationView.tsx index 89dd60e..a0a463c 100644 --- a/front/views/AuthenticationView.tsx +++ b/front/views/AuthenticationView.tsx @@ -7,7 +7,7 @@ import { Center, Button, Text } from 'native-base'; import SigninForm from '../components/forms/signinform'; import SignupForm from '../components/forms/signupform'; import TextButton from '../components/TextButton'; -import { RouteProps } from '../Navigation'; +import { RouteProps, useNavigation } from '../Navigation'; const hanldeSignin = async ( username: string, @@ -48,7 +48,8 @@ type AuthenticationViewProps = { const AuthenticationView = ({ isSignup }: RouteProps) => { const dispatch = useDispatch(); - const [mode, setMode] = React.useState<'signin' | 'signup'>(isSignup ? 'signup' : 'signin'); + const navigation = useNavigation(); + const mode = isSignup ? 'signup' : 'signin'; return (
@@ -82,7 +83,7 @@ const AuthenticationView = ({ isSignup }: RouteProps) = variant="outline" marginTop={5} colorScheme="primary" - onPress={() => setMode(mode === 'signin' ? 'signup' : 'signin')} + onPress={() => navigation.navigate(mode === 'signin' ? 'Signup' : 'Login', {})} />
); diff --git a/front/views/ErrorView.tsx b/front/views/ErrorView.tsx new file mode 100644 index 0000000..0b011c5 --- /dev/null +++ b/front/views/ErrorView.tsx @@ -0,0 +1,19 @@ +import { Button, Center, VStack } from 'native-base'; +import Translate from '../components/Translate'; +import { useNavigation } from '../Navigation'; + +const ErrorView = () => { + const navigation = useNavigation(); + return ( +
+ + + + +
+ ); +}; + +export default ErrorView; diff --git a/front/views/HomeView.tsx b/front/views/HomeView.tsx index 22c6c44..131b74f 100644 --- a/front/views/HomeView.tsx +++ b/front/views/HomeView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useQueries, useQuery } from 'react-query'; +import { useQueries, useQuery } from '../Queries'; import API from '../API'; import { LoadingView } from '../components/Loading'; import { Box, ScrollView, Flex, Stack, Heading, VStack, HStack } from 'native-base'; @@ -14,26 +14,20 @@ import { FontAwesome5 } from '@expo/vector-icons'; const HomeView = () => { const navigation = useNavigation(); - const userQuery = useQuery(['user'], () => API.getUserInfo()); - const playHistoryQuery = useQuery(['history', 'play'], () => API.getUserPlayHistory()); - const searchHistoryQuery = useQuery(['history', 'search'], () => API.getSearchHistory(0, 10)); - const skillsQuery = useQuery(['skills'], () => API.getUserSkills()); - const nextStepQuery = useQuery(['user', 'recommendations'], () => API.getSongSuggestions()); + const userQuery = useQuery(API.getUserInfo); + const playHistoryQuery = useQuery(API.getUserPlayHistory); + const searchHistoryQuery = useQuery(API.getSearchHistory(0, 10)); + const skillsQuery = useQuery(API.getUserSkills); + const nextStepQuery = useQuery(API.getSongSuggestions); const songHistory = useQueries( - playHistoryQuery.data?.map(({ songID }) => ({ - queryKey: ['song', songID], - queryFn: () => API.getSong(songID), - })) ?? [] + playHistoryQuery.data?.map(({ songID }) => API.getSong(songID)) ?? [] ); const artistsQueries = useQueries( songHistory .map((entry) => entry.data) .concat(nextStepQuery.data ?? []) .filter((s): s is Song => s !== undefined) - .map((song) => ({ - queryKey: ['artist', song.id], - queryFn: () => API.getArtist(song.artistId), - })) + .map((song) => API.getArtist(song.artistId)) ); if ( diff --git a/front/views/PlayView.tsx b/front/views/PlayView.tsx index 1c8a6f8..117462d 100644 --- a/front/views/PlayView.tsx +++ b/front/views/PlayView.tsx @@ -17,7 +17,7 @@ import { import IconButton from '../components/IconButton'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import { RouteProps, useNavigation } from '../Navigation'; -import { useQuery } from 'react-query'; +import { transformQuery, useQuery } from '../Queries'; import API from '../API'; import LoadingComponent, { LoadingView } from '../components/Loading'; import Constants from 'expo-constants'; @@ -72,7 +72,7 @@ function parseMidiMessage(message: MIDIMessageEvent) { const PlayView = ({ songId, type, route }: RouteProps) => { const accessToken = useSelector((state: RootState) => state.user.accessToken); const navigation = useNavigation(); - const song = useQuery(['song', songId], () => API.getSong(songId), { staleTime: Infinity }); + const song = useQuery(API.getSong(songId), { staleTime: Infinity }); const toast = useToast(); const [lastScoreMessage, setLastScoreMessage] = useState(); const webSocket = useRef(); @@ -84,8 +84,7 @@ const PlayView = ({ songId, type, route }: RouteProps) => { const [score, setScore] = useState(0); // Between 0 and 100 const fadeAnim = useRef(new Animated.Value(0)).current; const musixml = useQuery( - ['musixml', songId], - () => API.getSongMusicXML(songId).then((data) => new TextDecoder().decode(data)), + transformQuery(API.getSongMusicXML(songId), (data) => new TextDecoder().decode(data)), { staleTime: Infinity } ); const getElapsedTime = () => stopwatch.getElapsedRunningTime() - 3000; diff --git a/front/views/ProfileView.tsx b/front/views/ProfileView.tsx index 3efcd41..892909d 100644 --- a/front/views/ProfileView.tsx +++ b/front/views/ProfileView.tsx @@ -120,7 +120,7 @@ const ProfileView = () => { navigation.navigate('Settings', { screen: 'Profile' })} + onPress={() => navigation.navigate('Settings', { screen: 'profile' })} translate={{ translationKey: 'settingsBtn' }} /> diff --git a/front/views/ScoreView.tsx b/front/views/ScoreView.tsx index 1f6c0c0..359c179 100644 --- a/front/views/ScoreView.tsx +++ b/front/views/ScoreView.tsx @@ -6,7 +6,7 @@ import TextButton from '../components/TextButton'; import API from '../API'; import CardGridCustom from '../components/CardGridCustom'; import SongCard from '../components/SongCard'; -import { useQueries, useQuery } from 'react-query'; +import { useQueries, useQuery } from '../Queries'; import { LoadingView } from '../components/Loading'; type ScoreViewProps = { @@ -25,25 +25,18 @@ type ScoreViewProps = { }; }; -const ScoreView = ({ songId, overallScore, precision, score }: RouteProps) => { +const ScoreView = (props: RouteProps) => { + const { songId, overallScore, precision, score } = props; const navigation = useNavigation(); - const songQuery = useQuery(['song', songId], () => API.getSong(songId)); - const artistQuery = useQuery( - ['song', songId, 'artist'], - () => API.getArtist(songQuery.data!.artistId!), - { - enabled: songQuery.data != undefined, - } - ); - // const perfoamnceRecommandationsQuery = useQuery(['song', props.songId, 'score', 'latest', 'recommendations'], () => API.getLastSongPerformanceScore(props.songId)); - const recommendations = useQuery(['song', 'recommendations'], () => API.getSongSuggestions()); + const songQuery = useQuery(API.getSong(songId)); + const artistQuery = useQuery(() => API.getArtist(songQuery.data!.artistId!), { + enabled: songQuery.data !== undefined, + }); + const recommendations = useQuery(API.getSongSuggestions); const artistRecommendations = useQueries( recommendations.data ?.filter(({ artistId }) => artistId !== null) - .map((song) => ({ - queryKey: ['artist', song.artistId], - queryFn: () => API.getArtist(song.artistId!), - })) ?? [] + .map((song) => API.getArtist(song.artistId)) ?? [] ); if ( @@ -54,6 +47,10 @@ const ScoreView = ({ songId, overallScore, precision, score }: RouteProps; } + if (songQuery.isError) { + navigation.navigate('Error'); + return <>; + } return ( diff --git a/front/views/SearchView.tsx b/front/views/SearchView.tsx index 32cdbb3..108ff54 100644 --- a/front/views/SearchView.tsx +++ b/front/views/SearchView.tsx @@ -4,7 +4,7 @@ import Artist from '../models/Artist'; import Song from '../models/Song'; import Genre from '../models/Genre'; import API from '../API'; -import { useQuery } from 'react-query'; +import { useQuery } from '../Queries'; import { SearchResultComponent } from '../components/SearchResult'; import { SafeAreaView } from 'react-native'; import { Filter } from '../components/SearchBar'; @@ -46,20 +46,17 @@ const SearchView = (props: RouteProps) => { const [stringQuery, setStringQuery] = useState(props?.query ?? ''); const { isLoading: isLoadingSong, data: songData = [] } = useQuery( - ['song', stringQuery], - () => API.getSongsByArtist(112), + API.searchSongs(stringQuery), { enabled: !!stringQuery } ); const { isLoading: isLoadingArtist, data: artistData = [] } = useQuery( - ['artist', stringQuery], - () => API.searchArtists(stringQuery), + API.searchArtists(stringQuery), { enabled: !!stringQuery } ); const { isLoading: isLoadingGenre, data: genreData = [] } = useQuery( - ['genre', stringQuery], - () => API.searchGenres(stringQuery), + API.searchGenres(stringQuery), { enabled: !!stringQuery } ); diff --git a/front/views/SongLobbyView.tsx b/front/views/SongLobbyView.tsx index 63324ce..7f278a9 100644 --- a/front/views/SongLobbyView.tsx +++ b/front/views/SongLobbyView.tsx @@ -1,5 +1,5 @@ import { Divider, Box, Image, Text, VStack, PresenceTransition, Icon, Stack } from 'native-base'; -import { useQuery } from 'react-query'; +import { useQuery } from '../Queries'; import LoadingComponent, { LoadingView } from '../components/Loading'; import React, { useEffect, useState } from 'react'; import { Translate, translate } from '../i18n/i18n'; @@ -16,19 +16,22 @@ interface SongLobbyProps { const SongLobbyView = (props: RouteProps) => { const navigation = useNavigation(); - const songQuery = useQuery(['song', props.songId], () => API.getSong(props.songId)); - const chaptersQuery = useQuery(['song', props.songId, 'chapters'], () => - API.getSongChapters(props.songId) - ); - const scoresQuery = useQuery(['song', props.songId, 'scores'], () => - API.getSongHistory(props.songId) - ); + // Refetch to update score when coming back from score view + const songQuery = useQuery(API.getSong(props.songId), { refetchOnWindowFocus: true }); + const chaptersQuery = useQuery(API.getSongChapters(props.songId), { + refetchOnWindowFocus: true, + }); + const scoresQuery = useQuery(API.getSongHistory(props.songId), { refetchOnWindowFocus: true }); const [chaptersOpen, setChaptersOpen] = useState(false); useEffect(() => { if (chaptersOpen && !chaptersQuery.data) chaptersQuery.refetch(); }, [chaptersOpen]); useEffect(() => {}, [songQuery.isLoading]); if (songQuery.isLoading || scoresQuery.isLoading) return ; + if (songQuery.isError || scoresQuery.isError) { + navigation.navigate('Error'); + return <>; + } return ( diff --git a/front/views/StartPageView.tsx b/front/views/StartPageView.tsx index ead1697..302ffcf 100644 --- a/front/views/StartPageView.tsx +++ b/front/views/StartPageView.tsx @@ -92,7 +92,7 @@ const StartPageView = () => { image={imgLogin} iconName="user" iconProvider={FontAwesome5} - onPress={() => navigation.navigate('Login', { isSignup: false })} + onPress={() => navigation.navigate('Login', {})} style={{ width: isSmallScreen ? '90%' : 'clamp(100px, 33.3%, 600px)', height: '300px', @@ -132,7 +132,7 @@ const StartPageView = () => { subtitle="Create an account to save your progress" iconProvider={FontAwesome5} iconName="user-plus" - onPress={() => navigation.navigate('Login', { isSignup: true })} + onPress={() => navigation.navigate('Signup', {})} style={{ height: '150px', width: isSmallScreen ? '90%' : 'clamp(150px, 50%, 600px)', diff --git a/front/views/settings/SettingsProfileView.tsx b/front/views/settings/SettingsProfileView.tsx index cbb289f..4a0b40c 100644 --- a/front/views/settings/SettingsProfileView.tsx +++ b/front/views/settings/SettingsProfileView.tsx @@ -7,7 +7,7 @@ import TextButton from '../../components/TextButton'; import { LoadingView } from '../../components/Loading'; import ElementList from '../../components/GtkUI/ElementList'; import { translate } from '../../i18n/i18n'; -import { useQuery } from 'react-query'; +import { useQuery } from '../../Queries'; const getInitials = (name: string) => { return name @@ -19,7 +19,7 @@ const getInitials = (name: string) => { // Too painful to infer the settings-only, typed navigator. Gave up // eslint-disable-next-line @typescript-eslint/no-explicit-any const ProfileSettings = ({ navigation }: { navigation: any }) => { - const userQuery = useQuery(['user'], () => API.getUserInfo()); + const userQuery = useQuery(API.getUserInfo); const dispatch = useDispatch(); if (!userQuery.data || userQuery.isLoading) { diff --git a/front/views/settings/SettingsView.tsx b/front/views/settings/SettingsView.tsx index ab25aef..a0350e1 100644 --- a/front/views/settings/SettingsView.tsx +++ b/front/views/settings/SettingsView.tsx @@ -10,8 +10,9 @@ import NotificationsView from './NotificationView'; import PrivacyView from './PrivacyView'; import PreferencesView from './PreferencesView'; import GuestToUserView from './GuestToUserView'; -import { useQuery } from 'react-query'; +import { useQuery } from '../../Queries'; import API from '../../API'; +import { RouteProps } from '../../Navigation'; const handleChangeEmail = async (newEmail: string): Promise => { await API.updateUserEmail(newEmail); @@ -65,18 +66,18 @@ const TabRow = createTabRowNavigator(); type SetttingsNavigatorProps = { screen?: - | 'Profile' - | 'Preferences' - | 'Notifications' - | 'Privacy' - | 'ChangePassword' - | 'ChangeEmail' - | 'GoogleAccount' - | 'PianoSettings'; + | 'profile' + | 'preferences' + | 'notifications' + | 'privacy' + | 'changePassword' + | 'changeEmail' + | 'googleAccount' + | 'pianoSettings'; }; -const SetttingsNavigator = (props?: SetttingsNavigatorProps) => { - const userQuery = useQuery(['user'], () => API.getUserInfo()); +const SetttingsNavigator = (props?: RouteProps) => { + const userQuery = useQuery(API.getUserInfo); const user = useMemo(() => userQuery.data, [userQuery]); if (userQuery.isLoading) { @@ -98,7 +99,7 @@ const SetttingsNavigator = (props?: SetttingsNavigatorProps) => { {user && user.isGuest && ( { /> )} { }} /> { }} /> { }} /> { }} /> { }} /> { }} /> { }} />