diff --git a/api/apiRequest.ts b/api/apiRequest.ts index d75cd55..8667ab7 100644 --- a/api/apiRequest.ts +++ b/api/apiRequest.ts @@ -50,7 +50,7 @@ export const makeApiRequest = async ( ); if (response.status === 401) { - router.replace("/login?logout=true"); + router.replace(`/login?logout=${Date.now()}`); throw new Error(`Unauthorized: ${response.status}`); } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 9fae3cb..d1826f7 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -14,9 +14,9 @@ // limitations under the License. // -import { Redirect, Tabs } from "expo-router"; +import { Tabs } from "expo-router"; import type { MutableRefObject } from "react"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import HouseOutLine from "@/assets/images/house-outline.svg"; import AccessOutLine from "@/assets/images/access-outline.svg"; import AccessSolid from "@/assets/images/access-solid.svg"; @@ -25,64 +25,19 @@ import { FontAwesome6 } from "@expo/vector-icons"; import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"; import { faBell } from "@fortawesome/free-solid-svg-icons/faBell"; import { faUser } from "@fortawesome/free-solid-svg-icons/faUser"; -import { useSession } from "@/hooks/session"; -import { - Dimensions, - StyleSheet, - TouchableOpacity, - View, - Image, -} from "react-native"; -// import { Image } from "expo-image"; +import { Dimensions, StyleSheet, TouchableOpacity, View } from "react-native"; import PopupMenu from "@/components/PopupMenu"; import { ThemedText } from "@/components/ThemedText"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { getUserInfo, signup } from "@/api/user"; -import type { UserInfo } from "@/constants/user"; const { width, height } = Dimensions.get("window"); export default function TabLayout() { - const [isShowSplash, setIsShowSplash] = useState(true); - - const { session, signOut } = useSession(); - const { - isFetching, - data: userInfo, - refetch, - } = useQuery({ - queryKey: ["userInfo"], - queryFn: getUserInfo, - enabled: !!session, - }); - - const signupMutation = useMutation({ - mutationFn: signup, - onSuccess: async () => { - setIsShowSplash(false); - await refetch(); - }, - onError: () => { - setIsShowSplash(true); - }, - }); - const [menuVisible, setMenuVisible] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); const [positionType, setPositionType] = useState< "topMiddle" | "bottomLeft" | "bottomMiddle" >("bottomMiddle"); - useEffect(() => { - if (userInfo && userInfo.signupRequired && session) { - signupMutation.mutateAsync().catch((error) => { - console.error("Error while signup", error); - signOut(); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userInfo, session]); - const tabBarAddButtonRef = useRef(null); const rightHeaderAddButtonRef = useRef(null); const toggleMenu = ( @@ -120,24 +75,6 @@ export default function TabLayout() { } }; - if (!session) { - return ; - } - - if (isFetching || (userInfo!.signupRequired && isShowSplash)) { - return ( - - {!isFetching && ( - - )} - - ); - } - return ( <> { + return Promise.resolve({ data: [], status: "success" }); +}); + function mockUseQuery( data: AccessGrant[] ): ReturnType { @@ -33,7 +37,7 @@ function mockUseQuery( data, isLoading: false, isFetching: false, - refetch: jest.fn["refetch"]>(), + refetch: mockRefetch, } as unknown as ReturnType; } diff --git a/app/(tabs)/accesses/index.tsx b/app/(tabs)/accesses/index.tsx index 9efc417..e75e988 100644 --- a/app/(tabs)/accesses/index.tsx +++ b/app/(tabs)/accesses/index.tsx @@ -19,6 +19,7 @@ import { StyleSheet, View } from "react-native"; import WebIdAccessGrantList from "@/components/accessGrants/WebIdAccessGrantList"; import useRefreshOnFocus from "@/hooks/useRefreshOnFocus"; import type { AccessGrantGroup } from "@/types/accessGrant"; +import { useEffect } from "react"; export default function AccessGrantScreen() { const { @@ -29,7 +30,16 @@ export default function AccessGrantScreen() { } = useQuery({ queryKey: ["accessGrants"], queryFn: getAccessGrants, + enabled: false, }); + + useEffect(() => { + refetch().catch((err) => console.log(err)); + return () => { + console.debug("Unmount AccessGrantScreen"); + }; + }, [refetch]); + useRefreshOnFocus(refetch); return ( diff --git a/app/(tabs)/home/index.test.tsx b/app/(tabs)/home/index.test.tsx index c09a879..6bf349f 100644 --- a/app/(tabs)/home/index.test.tsx +++ b/app/(tabs)/home/index.test.tsx @@ -41,6 +41,10 @@ jest.mock("expo-router", () => { }; }); +const mockRefetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ data: [], status: "success" }); +}); + function mockUseQuery( data: WalletFile[] ): ReturnType { @@ -48,7 +52,7 @@ function mockUseQuery( data, isLoading: false, isFetching: false, - refetch: jest.fn["refetch"]>(), + refetch: mockRefetch, } as unknown as ReturnType; } diff --git a/app/(tabs)/home/index.tsx b/app/(tabs)/home/index.tsx index 3058e9c..68f11a9 100644 --- a/app/(tabs)/home/index.tsx +++ b/app/(tabs)/home/index.tsx @@ -16,7 +16,7 @@ import CustomButton from "@/components/Button"; import PopupMenu from "@/components/PopupMenu"; import { ThemedText } from "@/components/ThemedText"; -import React, { useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { View, StyleSheet, TouchableOpacity } from "react-native"; import { useQuery } from "@tanstack/react-query"; import { fetchFiles } from "@/api/files"; @@ -29,7 +29,16 @@ const HomeScreen = () => { const { data, isLoading, isFetching, refetch } = useQuery({ queryKey: ["files"], queryFn: fetchFiles, + enabled: false, }); + + useEffect(() => { + refetch().catch((err) => console.error(err)); + return () => { + console.debug("Unmount HomeScreen"); + }; + }, [refetch]); + useRefreshOnFocus(refetch); const [menuVisible, setMenuVisible] = useState(false); diff --git a/app/(tabs)/profile.test.tsx b/app/(tabs)/profile.test.tsx index 477392e..db5bd7f 100644 --- a/app/(tabs)/profile.test.tsx +++ b/app/(tabs)/profile.test.tsx @@ -28,12 +28,16 @@ import ProfileScreen from "./profile"; const { useSession } = SessionHooks; +const mockRefetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ data: {}, status: "success" }); +}); + function mockUseQuery(data: UserInfo): ReturnType { return { data, isLoading: false, isFetching: false, - refetch: jest.fn["refetch"]>(), + refetch: mockRefetch, } as unknown as ReturnType; } diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 948cffb..cb89b25 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -28,13 +28,22 @@ import { BottomSheetModal, BottomSheetView } from "@gorhom/bottom-sheet"; import type { UserInfo } from "@/constants/user"; import AccessSolid from "@/assets/images/access-solid.svg"; import { formatResourceName } from "@/utils/fileUtils"; +import { getUserInfo } from "@/api/user"; export default function Profile() { - const { data: userInfo } = useQuery({ + const { data: userInfo, refetch } = useQuery({ queryKey: ["userInfo"], + queryFn: getUserInfo, enabled: false, }); + useEffect(() => { + refetch().catch((err) => console.error(err)); + return () => { + console.debug("Unmount Profile"); + }; + }, [refetch]); + const bottomSheetModalRef = useRef(null); const navigation = useNavigation(); @@ -115,7 +124,7 @@ export default function Profile() { style={styles.logoutContainer} onPress={() => { bottomSheetModalRef.current?.close(); - router.navigate("/login?logout=true"); + router.navigate(`/login?logout=${Date.now()}`); }} > diff --git a/app/(tabs)/requests/index.test.tsx b/app/(tabs)/requests/index.test.tsx index 0c9683a..e1b6755 100644 --- a/app/(tabs)/requests/index.test.tsx +++ b/app/(tabs)/requests/index.test.tsx @@ -26,6 +26,10 @@ import type { AccessRequest } from "@/types/accessRequest"; import { AccessRequestMode } from "@/types/enums"; import RequestsScreen from "./index"; +const mockRefetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ data: [], status: "success" }); +}); + function mockUseQuery( data: AccessRequest[] ): ReturnType { @@ -33,7 +37,7 @@ function mockUseQuery( data, isLoading: false, isFetching: false, - refetch: jest.fn["refetch"]>(), + refetch: mockRefetch, } as unknown as ReturnType; } diff --git a/app/(tabs)/requests/index.tsx b/app/(tabs)/requests/index.tsx index d018857..1d2dd3c 100644 --- a/app/(tabs)/requests/index.tsx +++ b/app/(tabs)/requests/index.tsx @@ -32,7 +32,16 @@ export default function AccessRequestScreen() { } = useQuery({ queryKey: ["accessRequests"], queryFn: getAccessRequests, + enabled: false, }); + + useEffect(() => { + refetch().catch((err) => console.error(err)); + return () => { + console.debug("Unmount AccessRequestScreen"); + }; + }, [refetch]); + useRefreshOnFocus(refetch); const navigation = useNavigation("/(tabs)"); diff --git a/app/_layout.tsx b/app/_layout.tsx index 1e5f2aa..f961eae 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -18,14 +18,13 @@ import { DefaultTheme, ThemeProvider } from "@react-navigation/native"; import { useFonts } from "expo-font"; import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { QueryClientProvider, QueryClient, focusManager, } from "@tanstack/react-query"; -import { SessionProvider } from "@/hooks/session"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import type { AppStateStatus } from "react-native"; @@ -40,9 +39,13 @@ SplashScreen.preventAutoHideAsync(); const queryClient = new QueryClient(); export default function RootLayout() { + const [appStateStatus, setAppStateStatus] = + useState("active"); + function onAppStateChange(status: AppStateStatus) { if (Platform.OS !== "web") { focusManager.setFocused(status === "active"); + setAppStateStatus(status); } } useEffect(() => { @@ -80,34 +83,32 @@ export default function RootLayout() { - - - - + + + + - - - - - - + /> + + + diff --git a/app/index.tsx b/app/index.tsx index d357d6d..8885960 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -13,11 +13,80 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import React from "react"; +import React, { useEffect, useState } from "react"; import { Redirect } from "expo-router"; +import { useLoginWebView } from "@/hooks/useInruptLogin"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { UserInfo } from "@/constants/user"; +import { getUserInfo, signup } from "@/api/user"; +import { Dimensions, Image, StyleSheet, View } from "react-native"; + +const { width, height } = Dimensions.get("window"); const HomeScreen = () => { + const [isShowSplash, setIsShowSplash] = useState(true); + const { isLoggedIn } = useLoginWebView(); + const loggedIn = isLoggedIn(); + + const { + isFetching, + data: userInfo, + refetch, + } = useQuery({ + queryKey: ["userInfo"], + queryFn: getUserInfo, + enabled: loggedIn, + }); + + const signupMutation = useMutation({ + mutationFn: signup, + onSuccess: async () => { + setIsShowSplash(false); + await refetch(); + }, + onError: () => { + setIsShowSplash(true); + }, + }); + + useEffect(() => { + if (userInfo && userInfo.signupRequired && loggedIn) { + signupMutation.mutateAsync().catch((error) => { + console.error("Error while signup", error); + }); + } + }, [userInfo, loggedIn, signupMutation]); + + if (!loggedIn) { + return ; + } + + if (isFetching || !userInfo || (userInfo.signupRequired && isShowSplash)) { + return ( + + {!isFetching && ( + + )} + + ); + } + return ; }; +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + image: { + width, + height, + resizeMode: "cover", + }, +}); + export default HomeScreen; diff --git a/app/login.tsx b/app/login.tsx index f8592c5..6482d22 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -22,27 +22,21 @@ import { clearWebViewIOSCache } from "react-native-webview-ios-cache-clear"; import Logo from "@/assets/images/future_co.svg"; import { useLoginWebView } from "@/hooks/useInruptLogin"; import { useLocalSearchParams } from "expo-router"; -import { useSession } from "@/hooks/session"; const isRunningInExpoGo = Constants.appOwnership === "expo"; const LoginScreen = () => { const { showLoginPage, requestLogout } = useLoginWebView(); const { logout } = useLocalSearchParams(); - const { session } = useSession(); - useEffect(() => { - if (session && logout) { - requestLogout(); - } - }, [logout, session, requestLogout]); - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-var-requires,global-require - const RCTNetworking = require("react-native/Libraries/Network/RCTNetworking"); - RCTNetworking.default.clearCookies((result: never) => { - console.log("clearCookies", result); - }); + const clearCookies = () => { + import("react-native/Libraries/Network/RCTNetworking") + .then((RCTNetworking) => + RCTNetworking.default.clearCookies((result: never) => { + console.log("RCTNetworking:: clearCookies", result); + }) + ) + .catch((error) => console.log("Failed to clear cookies", error)); if (!isRunningInExpoGo) { clearWebViewIOSCache(); @@ -54,6 +48,17 @@ const LoginScreen = () => { ) .catch((error) => console.log("Failed to clear cookies", error)); } + }; + + useEffect(() => { + if (logout) { + clearCookies(); + requestLogout(); + } + }, [logout, requestLogout]); + + useEffect(() => { + clearCookies(); }, []); const handleLoginPress = () => { diff --git a/app/scan-qr.tsx b/app/scan-qr.tsx index 98026b0..b8fba33 100644 --- a/app/scan-qr.tsx +++ b/app/scan-qr.tsx @@ -38,7 +38,7 @@ class UnrecognisedQrCodeError extends Error { } } -export default function Logout() { +export default function ScanQr() { const { goBack } = useNavigation(); const { showErrorMsg } = useError(); const { replace, navigate } = useRouter(); diff --git a/components/PopupMenu.tsx b/components/PopupMenu.tsx index 085d2ef..658f61b 100644 --- a/components/PopupMenu.tsx +++ b/components/PopupMenu.tsx @@ -106,13 +106,17 @@ const PopupMenu: React.FC = ({ const takePicture = async () => { const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== PermissionStatus.GRANTED) { + onClose(); handleDeniedPermissions("Camera access is required to take photos"); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, }); - if (result.canceled || result.assets.length === 0) return; + if (result.canceled || result.assets.length === 0) { + onClose(); + return; + } const selectedPhoto = result.assets[0]; mutation.mutate({ uri: selectedPhoto.uri, @@ -135,6 +139,7 @@ const PopupMenu: React.FC = ({ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== PermissionStatus.GRANTED) { + onClose(); handleDeniedPermissions("Image Library access is required"); return; } diff --git a/components/login/LoginWebViewModal.tsx b/components/login/LoginWebViewModal.tsx index 6574167..50e6099 100644 --- a/components/login/LoginWebViewModal.tsx +++ b/components/login/LoginWebViewModal.tsx @@ -20,6 +20,8 @@ import { Text, StyleSheet, Modal, + ActivityIndicator, + View, } from "react-native"; import { WebView, type WebViewNavigation } from "react-native-webview"; import { DEFAULT_LOGIN_URL, DEFAULT_WALLET_API } from "@/constants/defaults"; @@ -28,23 +30,24 @@ interface LoginWebViewModalProps { onClose: () => void; onLoginSuccess: () => void; onLogoutSuccess: () => void; - requestMode: "login" | "logout" | "blank"; + requestMode: "login" | "logout" | "loginSuccess" | "blank"; } const LoginWebViewModal: React.FC = ({ - onClose = () => null, - onLoginSuccess = () => null, - onLogoutSuccess = () => null, + onClose, + onLoginSuccess, + onLogoutSuccess, requestMode = "blank", }) => { const BASE_URL = process.env.EXPO_PUBLIC_WALLET_API ?? DEFAULT_LOGIN_URL; const LOGIN_URL = process.env.EXPO_PUBLIC_LOGIN_URL ?? DEFAULT_WALLET_API; const LOGOUT_URL = `${BASE_URL}/logout`; + const LOGIN_SUCCESS_URL = `${BASE_URL}/login/success`; const webViewRef = useRef(null); const handleNavigationStateChange = ({ url }: WebViewNavigation) => { - if (url.includes("/login/success")) { + if (requestMode === "login" && url.includes("/login/success")) { onLoginSuccess(); } }; @@ -56,20 +59,22 @@ const LoginWebViewModal: React.FC = ({ }; const handleCloseModal = () => { - if (!webViewRef.current) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - webViewRef.current?.clearCache(true); + if (webViewRef.current) { + webViewRef.current!.clearCache(true); } onClose(); }; - if (requestMode === "logout") { + const isLogoutOrLoginSuccess = + requestMode === "logout" || requestMode === "loginSuccess"; + + if (isLogoutOrLoginSuccess) { + const sourceUri = requestMode === "logout" ? LOGOUT_URL : LOGIN_SUCCESS_URL; return ( = ({ domStorageEnabled={false} sharedCookiesEnabled thirdPartyCookiesEnabled - onLoadEnd={handleLoadEnd} + startInLoadingState={true} + renderLoading={() => ( + + + + )} /> Close diff --git a/hooks/loadingContext.tsx b/hooks/loadingContext.tsx deleted file mode 100644 index f32fa57..0000000 --- a/hooks/loadingContext.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// -// Copyright Inrupt Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -import type { ReactNode } from "react"; -import React, { createContext, useContext, useState } from "react"; - -// Define the context type -interface LoadingContextType { - isLoading: boolean; - setLoading: (loading: boolean) => void; -} - -// Create the context -const LoadingContext = createContext(undefined); - -// Create a provider component -export const LoadingProvider: React.FC<{ children: ReactNode }> = ({ - children, -}) => { - const [isLoading, setIsLoading] = useState(false); - - const setLoading = (loading: boolean) => { - setIsLoading(loading); - }; - - return ( - - {children} - - ); -}; - -// Custom hook to use the loading context -export const useLoading = () => { - const context = useContext(LoadingContext); - if (!context) { - throw new Error("useLoading must be used within a LoadingProvider"); - } - return context; -}; diff --git a/hooks/useInruptLogin.tsx b/hooks/useInruptLogin.tsx index 64bf874..44bc75f 100644 --- a/hooks/useInruptLogin.tsx +++ b/hooks/useInruptLogin.tsx @@ -15,37 +15,47 @@ // import React, { createContext, useCallback, useState } from "react"; import LoginWebViewModal from "@/components/login/LoginWebViewModal"; -import { useSession } from "@/hooks/session"; import { router } from "expo-router"; +import type { AppStateStatus } from "react-native"; +import { useStorageState } from "@/hooks/useStorageState"; +import { SESSION_KEY } from "@/api/apiRequest"; +import { useQueryClient } from "@tanstack/react-query"; const LoginWebViewContext = createContext<{ showLoginPage: () => void; requestLogout: () => void; + isLoggedIn: () => boolean; + isActiveScreen: () => boolean; }>({ showLoginPage: () => null, requestLogout: () => null, + isLoggedIn: () => false, + isActiveScreen: () => false, }); -export const LoginWebViewProvider = ({ children }: React.PropsWithChildren) => { +export const LoginWebViewProvider = ({ + appStateStatus, + children, +}: React.PropsWithChildren & { appStateStatus: AppStateStatus }) => { const [modalRequestMode, setModalRequestMode] = useState< - "login" | "logout" | "blank" + "login" | "logout" | "loginSuccess" | "blank" >("blank"); - const { signIn, signOut } = useSession(); - const handleLoginSuccess = () => { - signIn(); - closeModal(); - router.replace("/home"); - }; + const [session, setSession] = useStorageState(SESSION_KEY); + const queryClient = useQueryClient(); - const handleLogoutSuccess = () => { - signOut(); - closeModal(); - router.replace("/login"); + const handleLoginSuccess = async () => { + setSession("true"); + setModalRequestMode("loginSuccess"); + router.replace("/"); }; - const closeModal = () => { + const handleLogoutSuccess = () => { + setSession(null); setModalRequestMode("blank"); + queryClient.removeQueries(); + queryClient.clear(); + router.replace("/login"); }; const showLoginPage = useCallback(() => { @@ -56,11 +66,21 @@ export const LoginWebViewProvider = ({ children }: React.PropsWithChildren) => { setModalRequestMode("logout"); }, []); + const isLoggedIn = useCallback(() => { + return !!session; + }, [session]); + + const isActiveScreen = useCallback(() => { + return appStateStatus === "active"; + }, [appStateStatus]); + return ( {children}