diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e72ecf8..2f75dab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Wallet Frontend Application CI +name: Inrupt Data Wallet CI on: pull_request: {} @@ -15,22 +15,21 @@ jobs: lint: uses: inrupt/typescript-sdk-tools/.github/workflows/reusable-lint.yml@v3 - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ ubuntu-latest, windows-latest ] - node-version: [ "20.x" ] + unit-test: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm - run: npm ci - run: npm run test:snapshot sonarqube: name: run sonarqube if: ${{ github.actor != 'dependabot[bot]' }} - needs: [build] + needs: [unit-test] runs-on: ubuntu-latest steps: - name: Checking out diff --git a/README.md b/README.md index a0fe459..4fee8b6 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,68 @@ -# Data Wallet Front-end Application +# Inrupt Data Wallet -This project produces a front-end react native application for use with the Inrupt Data Wallet. +This project produces a mobile React Native application for use with the Inrupt Data Wallet service. This README provides information on: -* [Setup](#setup) +* [Setup and configuration](#setup-and-configuration) * [Prerequisites](#prerequisites) * [Install dependencies](#install-dependencies) * [Configure build environment](#configure-build-environment) - * [Create keystore](#create-keystore) -* [Running](#running) - * [Configure test environment](#configure-test-environment) - * [Test native versions](#test-native-versions) - * [iOS app](#ios-app) - * [Android app](#android-app) + * [Create keystore for Android](#create-keystore-for-android) + * [Make the keystore available to CI](#make-the-keystore-available-to-ci) +* [Running the application locally](#running-the-application-locally) + * [On a device with Expo Go](#on-a-device-with-expo-go) + * [On an Android emulator](#on-an-android-emulator) + * [On an iOS simulator](#on-an-ios-simulator) +* [Build the app on EAS](#build-the-app-on-eas) +* [Testing with Detox](#testing-with-detox) + * [On Android](#on-android) + * [On iOS](#on-ios) * [UI overview](#ui-overview) * [Home](#home) * [Profile](#profile) * [Requests](#requests) * [Access](#access) +* [Issues & Help](#issues--help) + * [Bugs and Feature Requests](#bugs-and-feature-requests) + * [Documentation](#documentation) + * [Changelog](#changelog) + * [License](#license) - -## Build the app locally +## Setup and configuration ### Prerequisites +In order to log into the Wallet, and for it to be able to persist data, you will need a [Podspaces Account](https://start.inrupt.com). Ensure that you have the following dependencies installed and configured: -- Xcode -- iOS simulators + +##### On the mobile device + +- [Expo Go](https://expo.dev/go) - app to be installed on a real device (iOS and Android supported) + +##### On the dev machine (iOS) + +If you are only running the app in Expo Go: +- [NodeJS and NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) + +If you are building the app locally, you'll also need: +- [Xcode](https://apps.apple.com/us/app/xcode/id497799835) +- [iOS simulators](https://developer.apple.com/documentation/safari-developer-tools/installing-xcode-and-simulators) + +#### On the dev machine (Android) + +If you are only running the app in Expo Go: +- [NodeJS and NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) + +If you are building the app locally, you'll also need: +- A Java JDK - [Android emulators](https://developer.android.com/studio/install) ### Install dependencies -First, install any react native dependencies. +First, install all the project dependencies. ```bash -npm install +npm ci ``` ### Configure build environment @@ -47,7 +75,29 @@ EXPO_PUBLIC_LOGIN_URL= EXPO_PUBLIC_WALLET_API= ``` -#### Create keystore +Automated testing also requires access credentials for the Data Wallet: +```bash +TEST_ACCOUNT_USERNAME= +TEST_ACCOUNT_PASSWORD= +``` + +For testing with iOS simulators, you will need: +```bash +IOS_BINARY_PATH= +TEST_IOS_SIM= +``` + +For testing with Android emulators, you will need: +```bash +ANDROID_BINARY_PATH= +ANDROID_TEST_BINARY_PATH= +TEST_ANDROID_EMU= +``` +Ensure that the ``TEST_ANDROID_EMU`` configuration aligns with a device emulator in Android Studio. +For example: ``TEST_ANDROID_EMU=Android_34``. If the emulated device is called "Test Emulator 33", +the EMU name will be ``Test_Emulator_33``. + +#### Create keystore for Android The Android app will need to be signed for it to be installed. See: https://reactnative.dev/docs/signed-apk-android for more information on this. @@ -55,7 +105,8 @@ for more information on this. __WARNING:__ The following is only to be used for development. In production the keystore should be generated and configured in a more secure manner. -To generate a keystore with a key and cert run the following from the root of the project: +To generate a keystore with a key and cert run the following from the root of the project (this requires a Java JDK +installation): ```bash keytool -genkeypair -v -storetype PKCS12 \ @@ -70,105 +121,79 @@ KEYSTORE_PATH=/inrupt-data-wallet/android/app/wallet.keystore KEYSTORE_PASSWORD= ``` -#### Make the keystore available to CI - -In order to make the keystore available to CI, it has to be present in the repository secret. -To -- Encrypting the keystore with a GPG key to get a Base64 representation: `gpg -c --armor wallet.keystore` -- Create Github repository secrets: - - ENCRYPTED_KEYSTORE with the Base64-encoded encrypted keystore - - KEYSTORE_DECRYPTION_KEY with the GPG key - - KEYSTORE_PASSWORD with the keystore password -- In CI, decrypt the keystore back: `gpg -d --passphrase "..." --batch wallet.keystore.asc > wallet.keystore` - -## Build the app on EAS - -Both the Android and the iOS versions of the app can be built using the Expo Application Service (EAS). To do so, follow -the instructions from the [EAS documentation](https://docs.expo.dev/build/setup/). - -Note that the project ID is not hard-coded in the `app.config.ts`, but provided as an environment variable instead. -In order to trigger an EAS build, please add the appropriate project ID in your environment, e.g. - -``` -EAS_PROJECT_ID="..." eas build --platform android --local --profile preview -``` - -## Running the application - -If you are going to run the application in an emulator or simulator, you need to build the development version using -one of the following: - ```bash - npm run android - npm run ios - ``` +## Running the application locally Start the application: - ```bash - npx expo start - ``` +```bash +npx expo start +``` In the output, you'll find options to open the app in a - -- [development build](https://docs.expo.dev/develop/development-builds/introduction/) +- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo. +- [Development build](https://docs.expo.dev/develop/development-builds/introduction/) - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo -Note: When running on the android emulator, there is a special loopback IP, 10.0.2.2, which points to the host machine 127.0.0.1. You can use it if the emulator complains about cleartext communication to the local network IP of the host. - -## Running the UI-based tests +### On a device with Expo Go -### Configure test environment +After expo has started, make sure it targets the Expo Go environment (as opposed to "development build"). +This will display a QR code which you will need to scan from your device's Expo Go application. -The tests require access credentials for a Pod which will be used by this instance of the wallet. -Make a copy of the provided `.env.sample` named `.env`, and replace placeholders with actual values -specific to your setup. +The Wallet application will then build and install into your device for you to test & debug. -### Running the tests on iOS +### On an Android emulator -To build the iOS wallet app in an iOS simulator, just run the following command: +For Android, ensure that a virtual device has been added to the Android emulator by opening the new Device Manager with +the following actions: +* Go to the Android Studio Welcome screen +* Select **More Actions > Virtual Device Manager.** ```bash -npm run ios +npm run android ``` +Note: When running on the android emulator, there is a special loopback IP, 10.0.2.2, which points to the host machine 127.0.0.1. You can use it if the emulator complains about cleartext communication to the local network IP of the host. -This will install the necessary CocoaPods and compile the application. Upon completion, the iOS simulator should be open and the wallet app running. +### On an iOS simulator -If you want to generate the iOS binary as well, run the following commands. Note that this process may take around 30 minutes to complete. +To build the iOS wallet app in an iOS simulator, run the following command: ```bash -npx expo run:ios -xcodebuild -workspace ios/inruptwalletfrontend.xcworkspace -scheme inruptwalletfrontend -configuration Release -sdk iphonesimulator -derivedDataPath ios/build +npm run ios ``` -After completion, the iOS binary should be located at: - -```bash -inrupt-data-wallet/ios/build/Build/Products/inruptwalletfrontend.app -``` +This will install the necessary CocoaPods and compile the application. Upon completion, the iOS simulator should be open and the wallet app running. -You can share the .app file with others who need to run the Detox tests without building the iOS app locally. +## Build the app on the Expo Application Service (EAS) -Execute the command below to start Detox test on iOS. -```bash -npx detox test --configuration=ios.sim.release -``` +**Prerequisite**: All the following instructions are only applicable if you have an EAS account. By default, +the project is configured to be built on an Inrupt EAS project. In order to build a custom version in your +own EAS account, you'll need to create a new EAS project, and make sure that EAS project and the data (e.g. +`name`, `slug`, `project-id`...) in your copy of `app.config.ts` are aligned. -### Running the tests on Android +Both the Android and the iOS versions of the app can be built using EAS. To do so, follow the instructions +from the [EAS documentation](https://docs.expo.dev/build/setup/) to setup your environment. -Ensure that a virtual device has been added to the Android emulator. +By default, the EAS CLI will trigger a build on the EAS infrastructure. It is also possible to +[run a build locally](https://docs.expo.dev/build-reference/local-builds/), which results in the built binary +being available on the developer machine. This requires all the environment setup for being able to build +the app locally, similar to `npx expo run:`. -First, you'll need to generate the app metadata with the following command: +The project ID is not hard-coded in the `app.config.ts`, but provided as an environment variable instead. +In order to trigger an EAS build, please add the appropriate project ID in your environment variables, e.g. -```bash -npx expo prebuild --platform android +``` +EAS_PROJECT_ID="..." eas build --platform android --local --profile preview ``` +## Testing with Detox + +### On Android + Run the following command to build the Android app. This process may take up to 30 minutes to complete. ```bash -npx detox build --configuration android.emu.release +npx detox build --configuration=android.emu.release ``` For local development (a back-end server running on localhost or at an alternative location), use @@ -178,7 +203,7 @@ development server (`npx expo start`). Once built, run the detox tests for the relevant configuration: ```bash -npx detox test --configuration android.emu.release +npx detox test --configuration=android.emu.release ``` After completion, the binary apps will be located in: @@ -188,6 +213,27 @@ After completion, the binary apps will be located in: You can share the .apk files with others who need to run the Detox tests without building the Android app locally. +### On iOS + +If you want to generate the iOS binary, run the following commands. Note that this process may take around 30 minutes to complete. + +```bash +xcodebuild -workspace ios/inruptwalletfrontend.xcworkspace -scheme inruptwalletfrontend -configuration Release -sdk iphonesimulator -derivedDataPath ios/build +``` + +After completion, the iOS binary should be located at: + +```bash +inrupt-data-wallet/ios/build/Build/Products/inruptwalletfrontend.app +``` + +You can share the .app file with others who need to run the Detox tests without building the iOS app locally. + +Execute the command below to start Detox test on iOS. +```bash +npx detox test --configuration=ios.sim.release +``` + ## UI overview Upon execution, the application prompts the user to log in. After successful authentication, the wallet app presents various views, located in the `app/(tabs)` directory: @@ -219,3 +265,25 @@ From here, the user can view all the requests along with their details (requesto This page shows what access has been granted to other agents. For each authorized agent, there is a list of the wallet resources to which they have access. Users also have the option to revoke access to each resource from this page. + +## Issues & Help + +### Bugs and Feature Requests + +- For public feedback, bug reports, and feature requests please file an issue + via [Github](https://github.com/inrupt/inrupt-data-wallet/issues/). +- For non-public feedback or support inquiries please use the + [Inrupt Service Desk](https://inrupt.atlassian.net/servicedesk). + +### Documentation + +- [Inrupt Data Wallet](https://docs.inrupt.com/data-wallet/) +- [Homepage](https://docs.inrupt.com/) + +### Changelog + +See [the release notes](https://github.com/inrupt/inrupt-data-wallet/releases). + +### License + +Apache 2.0 © [Inrupt](https://inrupt.com) diff --git a/api/apiRequest.ts b/api/apiRequest.ts index 5987eb1..8667ab7 100644 --- a/api/apiRequest.ts +++ b/api/apiRequest.ts @@ -23,7 +23,8 @@ export const makeApiRequest = async ( endpoint: string, method: string = "GET", body: unknown = null, - contentType: string | null = "application/json" + contentType: string | null = "application/json", + isBlob: boolean = false ): Promise => { const session = await SecureStore.getItemAsync(SESSION_KEY); if (!session) return {} as T; @@ -49,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}`); } @@ -61,6 +62,10 @@ export const makeApiRequest = async ( ); } + if (isBlob) { + return (await response.blob()) as unknown as T; + } + const responseType = response.headers.get("content-type"); if ( responseType?.includes("application/json") || diff --git a/api/files.ts b/api/files.ts index ab12076..80a7528 100644 --- a/api/files.ts +++ b/api/files.ts @@ -17,6 +17,7 @@ import mime from "mime"; import FormData from "form-data"; import type { WalletFile } from "@/types/WalletFile"; import { handleErrorResponse } from "@inrupt/solid-client-errors"; +import { utf8EncodeResourceName } from "@/utils/fileUtils"; import { makeApiRequest } from "./apiRequest"; interface FileObject { @@ -55,13 +56,14 @@ export const postFile = async (fileMetadata: FileObject): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const formData: any = new FormData(); formData.append("file", { - name: fileMetadata.name, + name: utf8EncodeResourceName(fileMetadata.name), type: fileMetadata.type || mime.getType(fileMetadata.name) || "application/octet-stream", uri: fileMetadata.uri, }); + formData.append("fileName", fileMetadata.name); let response: Response; try { response = await fetch( @@ -99,3 +101,13 @@ export const getFile = async (fileId: string): Promise => { "GET" ); }; + +export const downloadFile = async (fileId: string): Promise => { + return makeApiRequest( + `wallet/${encodeURIComponent(fileId)}?raw=true`, + "GET", + null, + null, + true + ); +}; diff --git a/app.config.ts b/app.config.ts index d987a95..69eda2b 100644 --- a/app.config.ts +++ b/app.config.ts @@ -51,7 +51,7 @@ if (process.env.EAS_BUILD === "true") { const baseConfig: ExpoConfig = { name: "inrupt-data-wallet", - slug: "inrupt-data-wallet-frontend", + slug: "inrupt-data-wallet", version: "1.0.0", orientation: "portrait", icon: "./assets/images/logo.png", diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 9fae3cb..e9e93d6 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,27 @@ 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, + Platform, } from "react-native"; -// import { Image } from "expo-image"; 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"; +import { fetchFiles } from "@/api/files"; +import { useQueryClient } from "@tanstack/react-query"; 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 queryClient = useQueryClient(); const tabBarAddButtonRef = useRef(null); const rightHeaderAddButtonRef = useRef(null); const toggleMenu = ( @@ -120,24 +83,6 @@ export default function TabLayout() { } }; - if (!session) { - return ; - } - - if (isFetching || (userInfo!.signupRequired && isShowSplash)) { - return ( - - {!isFetching && ( - - )} - - ); - } - return ( <> toggleMenu("topMiddle", tabBarAddButtonRef)} > - - + + Add @@ -246,6 +196,12 @@ export default function TabLayout() { toggleMenu("topMiddle", rightHeaderAddButtonRef)} + onUploadSuccess={async () => { + await queryClient.fetchQuery({ + queryKey: ["files"], + queryFn: fetchFiles, + }); + }} position={menuPosition} positionType={positionType} /> diff --git a/app/(tabs)/accesses/index.test.tsx b/app/(tabs)/accesses/index.test.tsx index 5a61398..6de2c94 100644 --- a/app/(tabs)/accesses/index.test.tsx +++ b/app/(tabs)/accesses/index.test.tsx @@ -26,6 +26,10 @@ import { AccessRequestMode } from "@/types/enums"; import type { AccessGrant } from "@/types/accessGrant"; import GrantsScreen from "./index"; +const mockRefetch = jest.fn().mockImplementation(() => { + 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..43a394a 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); @@ -101,6 +110,7 @@ const HomeScreen = () => { setMenuVisible(false)} + onUploadSuccess={refetch} position={menuPosition} positionType={positionType} /> 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/access-prompt/confirmed.tsx b/app/access-prompt/confirmed.tsx index 7410bd2..6bd092f 100644 --- a/app/access-prompt/confirmed.tsx +++ b/app/access-prompt/confirmed.tsx @@ -83,7 +83,7 @@ const Page: React.FC = () => { router.replace("/")} + onPress={() => router.replace("/requests")} customStyle={styles.button} /> 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 1f89e64..b8fba33 100644 --- a/app/scan-qr.tsx +++ b/app/scan-qr.tsx @@ -13,14 +13,22 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { View, StyleSheet, Dimensions, TouchableOpacity } from "react-native"; +import { + View, + StyleSheet, + Dimensions, + Alert, + TouchableOpacity, +} from "react-native"; import { useNavigation, useRouter } from "expo-router"; import { useEffect, useState } from "react"; import { useError } from "@/hooks/useError"; -import { ThemedText } from "@/components/ThemedText"; import type { BarcodeScanningResult } from "expo-camera"; -import { Camera, CameraView } from "expo-camera"; +import { CameraView, Camera } from "expo-camera"; import { isAccessPromptQR, isDownloadQR } from "@/types/accessPrompt"; +import * as Linking from "expo-linking"; +import { PermissionStatus } from "expo-image-picker"; +import { ThemedText } from "@/components/ThemedText"; const { width } = Dimensions.get("window"); @@ -30,23 +38,42 @@ class UnrecognisedQrCodeError extends Error { } } -export default function Logout() { +export default function ScanQr() { const { goBack } = useNavigation(); const { showErrorMsg } = useError(); const { replace, navigate } = useRouter(); - const [scanned, setScanned] = useState(false); + const [showScanned, setShowScanned] = useState(false); + const handleDeniedPermissions = () => { + Alert.alert( + "Permission Denied", + "Camera access is required. Please enable it in your device settings.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open Settings", + onPress: () => Linking.openSettings(), // This will open the app settings page + }, + ], + { cancelable: false } + ); + }; + useEffect(() => { const getCameraPermissions = async () => { - await Camera.requestCameraPermissionsAsync(); + const { status } = await Camera.requestCameraPermissionsAsync(); + if (status !== PermissionStatus.GRANTED) { + handleDeniedPermissions(); + return; + } + setShowScanned(true); }; - getCameraPermissions().catch(() => - // eslint-disable-next-line no-console - console.log("Don't have camera permission") - ); + getCameraPermissions().catch(() => { + console.log("Don't have camera permission"); + }); }, []); const handleBarCodeScanned = ({ data }: BarcodeScanningResult) => { - setScanned(true); + setShowScanned(false); try { const resourceInfo = JSON.parse(data); if (isAccessPromptQR(resourceInfo)) { @@ -95,15 +122,16 @@ export default function Logout() { - + {showScanned && ( + + )} - Scan a QR code diff --git a/components/PopupMenu.tsx b/components/PopupMenu.tsx index 12766bc..0b5cdc8 100644 --- a/components/PopupMenu.tsx +++ b/components/PopupMenu.tsx @@ -14,11 +14,11 @@ // limitations under the License. // import React, { useRef } from "react"; -import { Dimensions, StyleSheet, TouchableOpacity } from "react-native"; +import { Dimensions, StyleSheet, TouchableOpacity, Alert } from "react-native"; import * as DocumentPicker from "expo-document-picker"; import * as ImagePicker from "expo-image-picker"; import { PermissionStatus } from "expo-image-picker"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { postFile } from "@/api/files"; import { useRouter } from "expo-router"; import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"; @@ -26,12 +26,15 @@ import { faImage } from "@fortawesome/free-solid-svg-icons/faImage"; import { faFile } from "@fortawesome/free-solid-svg-icons/faFile"; import { faCamera } from "@fortawesome/free-solid-svg-icons/faCamera"; import { faQrcode } from "@fortawesome/free-solid-svg-icons/faQrcode"; +import { useError } from "@/hooks/useError"; +import * as Linking from "expo-linking"; import { ThemedText } from "./ThemedText"; const { width } = Dimensions.get("window"); type PopupMenuProps = { visible: boolean; onClose: () => void; + onUploadSuccess: () => void; position: { x: number | undefined; y: number | undefined }; positionType: "topMiddle" | "bottomLeft" | "bottomMiddle"; }; @@ -48,15 +51,18 @@ const PopupMenu: React.FC = ({ onClose, position, positionType, + onUploadSuccess, }) => { const router = useRouter(); const menuRef = useRef(null); + const { showErrorMsg } = useError(); - const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: postFile, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["files"] }); + onSuccess: onUploadSuccess, + onError: (error) => { + console.debug("A non-HTTP error occurred.", error); + showErrorMsg("Unable to save the file into your Wallet."); }, mutationKey: ["filesMutation"], }); @@ -80,15 +86,35 @@ const PopupMenu: React.FC = ({ break; } + const handleDeniedPermissions = (note: string) => { + Alert.alert( + "Permission Denied", + `${note}. Please enable it in your device settings.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open Settings", + onPress: () => Linking.openSettings(), // This will open the app settings page + }, + ], + { cancelable: false } + ); + }; + 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, @@ -111,6 +137,8 @@ const PopupMenu: React.FC = ({ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== PermissionStatus.GRANTED) { + onClose(); + handleDeniedPermissions("Image Library access is required"); return; } const result = await ImagePicker.launchImageLibraryAsync(); diff --git a/components/accessRequests/AccessRequestList.tsx b/components/accessRequests/AccessRequestList.tsx index ac12d48..a6ff140 100644 --- a/components/accessRequests/AccessRequestList.tsx +++ b/components/accessRequests/AccessRequestList.tsx @@ -43,6 +43,9 @@ const AccessRequestList: React.FC = ({ {data && data.length === 0 ? ( No active requests + + Refresh to check for new requests + ) : ( = ({ onChangeSnapPoint, }) => { const [modalVisible, setModalVisible] = useState(false); + const [fileDownload, setFileDownload] = useState(null); const { data: userInfo } = useQuery({ queryKey: ["userInfo"], staleTime: Infinity, @@ -53,27 +59,46 @@ const BottomModal: React.FC = ({ onSuccess: () => queryClient.refetchQueries({ queryKey: ["files"] }), mutationKey: ["filesMutation"], }); - const { data } = useQuery({ - queryKey: ["file", file?.fileName], - queryFn: () => getFile(file?.fileName as string), - }); const queryClient = useQueryClient(); - const onFileShare = async (fileName: string) => { - if (!data) return; - const fr = new FileReader(); - fr.onload = async () => { - const fileUri = `${FileSystem.documentDirectory}/${fileName}`; - if (typeof fr.result !== "string") { - throw new Error("An error happened while reading the file."); - } - await FileSystem.writeAsStringAsync(fileUri, fr.result?.split(",")[1], { - encoding: FileSystem.EncodingType.Base64, + const onFileShare = useCallback( + async (fileName: string) => { + const data = await queryClient.fetchQuery({ + queryKey: ["downloadFile", fileName], + queryFn: () => downloadFile(fileName), }); - await Sharing.shareAsync(fileUri); - }; - fr.readAsDataURL(data); - }; + + setFileDownload(null); + + if (!data) return; + const fr = new FileReader(); + fr.onload = async () => { + const fileUri = new URL(fileName, FileSystem.documentDirectory!); + if (typeof fr.result !== "string") { + throw new Error("An error occurred while reading the file."); + } + await FileSystem.writeAsStringAsync( + fileUri.toString(), + fr.result.split(",")[1], + { + encoding: FileSystem.EncodingType.Base64, + } + ); + await Sharing.shareAsync(fileUri.toString()); + }; + fr.readAsDataURL(data); + }, + [queryClient] + ); + + useEffect(() => { + if (fileDownload) { + onFileShare(fileDownload).catch(() => + console.log("Error while sharing data") + ); + } + }, [fileDownload, onFileShare]); + const onDeleteFile = async (fileName: string) => { deleteMutation .mutateAsync(fileName) @@ -108,6 +133,8 @@ const BottomModal: React.FC = ({ )} {formatResourceName( @@ -139,12 +166,19 @@ const BottomModal: React.FC = ({ file && onFileShare(file?.fileName)} + onPress={() => file && setFileDownload(file?.fileName)} > - + Download a copy + {Boolean(fileDownload) && ( + + )} = ({ - onPressDetailedFile(item)}> + { + event.stopPropagation(); + onPressDetailedFile(item); + }} + > @@ -120,6 +125,9 @@ const FileList: React.FC = ({ enablePanDownToClose > { + if (props.onRefresh) props.onRefresh(); + }} file={selectedFile} onCloseModal={() => bottomSheetModalRef.current?.close()} onChangeSnapPoint={(snapHeight: number) => @@ -158,7 +166,7 @@ const styles = StyleSheet.create({ paddingLeft: 18, flex: 1, fontSize: 16, - paddingRight: 2, + paddingRight: 8, }, menuIconContainer: { width: 30, 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/eas.json b/eas.json index 79a0447..3ffa063 100644 --- a/eas.json +++ b/eas.json @@ -1,6 +1,6 @@ { "cli": { - "version": "12.3.0", + "version": "^12.3.0", "appVersionSource": "remote", "requireCommit": true }, @@ -18,21 +18,25 @@ "env": { "EXPO_PUBLIC_LOGIN_URL" : "https://datawallet.inrupt.com/oauth2/authorization/wallet-app", "EXPO_PUBLIC_WALLET_API": "https://datawallet.inrupt.com" - } - }, - "preview-ios": { - "distribution": "internal", - "env": { - "EXPO_PUBLIC_LOGIN_URL" : "https://datawallet.inrupt.com/oauth2/authorization/wallet-app", - "EXPO_PUBLIC_WALLET_API": "https://datawallet.inrupt.com" }, - "credentialsSource": "local" + "android": { + "image": "latest" + }, + "ios": { + "image": "latest" + } }, "production": { "autoIncrement": true, "env": { "EXPO_PUBLIC_LOGIN_URL" : "https://datawallet.inrupt.com/oauth2/authorization/wallet-app", "EXPO_PUBLIC_WALLET_API": "https://datawallet.inrupt.com" + }, + "android": { + "image": "latest" + }, + "ios": { + "image": "latest" } } }, 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} diff --git a/utils/fileUtils.ts b/utils/fileUtils.ts index 13bf314..b29d863 100644 --- a/utils/fileUtils.ts +++ b/utils/fileUtils.ts @@ -30,11 +30,6 @@ export const isImageFile = (filename: string): boolean => { return extension ? imageExtensions.includes(extension) : false; }; -export const getFileContainerByResource = (resource: string) => { - const match = resource.match(/\/([^/]+)\/?$/); - return match ? match[1] : ""; -}; - export const isDisplayDetailedPage = (file: WalletFile) => { return isImageFile(file.fileName) || file.isRDFResource; }; @@ -87,10 +82,15 @@ export const formatResourceName = ( return lastPart; }; -export const removeFileExtension = (resource: string) => { - const parts = resource.split("/"); - const lastPart = parts[parts.length - 1]; - return lastPart.split(".").slice(0, -1).join("."); +export const utf8EncodeResourceName = (input: string) => { + // encodeURIComponent() does not encode !'()*, so we manually do. This is + // required because these characters are allowed in resource names but not + // supported unencoded by the backend. For more details, see + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers + return encodeURIComponent(input).replace( + /[!'()*]/g, + (char) => `%${char.charCodeAt(0).toString(16)}` + ); }; export const isWriteMode = (modes: AccessRequestMode[]) =>