diff --git a/App.tsx b/App.tsx index 803e828..3535948 100644 --- a/App.tsx +++ b/App.tsx @@ -10,6 +10,7 @@ import { SWRConfig } from "swr"; import AuthContext from "./src/auth"; import { getStateFromPath } from "./src/getStateFromPath"; +import useClient from "./src/lib/client"; import { TabParamList } from "./src/lib/NavigatorParamList"; import Navigator from "./src/Navigator"; import Login from "./src/pages/login"; @@ -49,29 +50,26 @@ const linking: LinkingOptions = { export default function App() { const [isLoading, setIsLoading] = useState(true); const [token, setToken] = useState(null); + const hcb = useClient(token); const scheme = useColorScheme(); const fetcher = useCallback( - (url: string) => - fetch(process.env.EXPO_PUBLIC_API_BASE + url, { - headers: { Authorization: `Bearer ${token}` }, - }).then(async (res) => { - const body = await res.json(); - - if (!res.ok) { - if (body.error === "invalid_auth") { - // OAuth token either expired or was revoked - setToken(""); - return; - } else { - throw body; - } + async (url: string) => { + try { + return await hcb(url).json(); + } catch (error) { + if ( + error.name === "HTTPError" && + (await error.response.json()).error === "invalid_auth" + ) { + setToken(""); + } else { + throw error; } - - return body; - }), - [token], + } + }, + [hcb], ); useEffect(() => { diff --git a/package-lock.json b/package-lock.json index 82bfce8..0875e27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "expo-system-ui": "~2.9.3", "expo-web-browser": "~12.8.2", "humanize-string": "^3.0.0", + "ky": "^1.2.3", "lodash": "^4.17.21", "react": "18.2.0", "react-native": "0.73.4", @@ -11742,6 +11743,17 @@ "node": ">=6" } }, + "node_modules/ky": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.2.3.tgz", + "integrity": "sha512-2IM3VssHfG2zYz2FsHRUqIp8chhLc9uxDMcK2THxgFfv8pQhnMfN8L0ul+iW4RdBl5AglF8ooPIflRm3yNH0IA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index 3f594de..86955ca 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "expo-system-ui": "~2.9.3", "expo-web-browser": "~12.8.2", "humanize-string": "^3.0.0", + "ky": "^1.2.3", "lodash": "^4.17.21", "react": "18.2.0", "react-native": "0.73.4", diff --git a/src/Navigator.tsx b/src/Navigator.tsx index 6a1da79..450aed6 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -37,9 +37,9 @@ const Tab = createBottomTabNavigator(); export default function Navigator() { const { data: missingReceiptData } = useSWR>( - `/user/transactions/missing_receipt`, + `user/transactions/missing_receipt`, ); - const { data: invitations } = useSWR(`/user/invitations`); + const { data: invitations } = useSWR(`user/invitations`); const scheme = useColorScheme(); const { colors: themeColors } = useTheme(); @@ -112,8 +112,8 @@ export default function Navigator() { dismissButtonStyle: "cancel", }, ).then(() => { - mutate("/user/organizations"); - mutate("/user/invitations"); + mutate("user/organizations"); + mutate("user/invitations"); }) } /> diff --git a/src/components/AdminTools.tsx b/src/components/AdminTools.tsx index 91f324c..31d7de4 100644 --- a/src/components/AdminTools.tsx +++ b/src/components/AdminTools.tsx @@ -16,7 +16,7 @@ export const AdminToolsStyle: ViewStyle = { export default function AdminTools( props: PropsWithChildren<{ style?: ViewStyle; onPress?: () => void }>, ) { - const { data: user } = useSWR("/user"); + const { data: user } = useSWR("user"); if (!user?.admin) return null; diff --git a/src/components/transaction/ReceiptList.tsx b/src/components/transaction/ReceiptList.tsx index 4667c61..0f94c81 100644 --- a/src/components/transaction/ReceiptList.tsx +++ b/src/components/transaction/ReceiptList.tsx @@ -41,7 +41,7 @@ export default function ReceiptList({ }) { const { params } = useRoute>(); const { data: receipts, isLoading } = useSWR( - `/organizations/${params.orgId}/transactions/${transaction.id}/receipts`, + `organizations/${params.orgId}/transactions/${transaction.id}/receipts`, ); const { colors: themeColors } = useTheme(); diff --git a/src/components/transaction/types/BankFeeTransaction.tsx b/src/components/transaction/types/BankFeeTransaction.tsx index f2a0a1c..56eb320 100644 --- a/src/components/transaction/types/BankFeeTransaction.tsx +++ b/src/components/transaction/types/BankFeeTransaction.tsx @@ -14,9 +14,7 @@ export default function BankFeeTransaction({ orgId, navigation, }: TransactionViewProps) { - const { data: organization } = useSWR( - `/organizations/${orgId}`, - ); + const { data: organization } = useSWR(`organizations/${orgId}`); return ( diff --git a/src/components/transaction/types/TransferTransaction.tsx b/src/components/transaction/types/TransferTransaction.tsx index 51de809..b7837cf 100644 --- a/src/components/transaction/types/TransferTransaction.tsx +++ b/src/components/transaction/types/TransferTransaction.tsx @@ -18,8 +18,8 @@ export default function TransferTransaction({ navigation, ...props }: TransactionViewProps) { - const { data: userOrgs } = useSWR(`/user/organizations`); - const { data: user } = useSWR("/user"); + const { data: userOrgs } = useSWR(`user/organizations`); + const { data: user } = useSWR("user"); const userInFromOrg = user?.admin || userOrgs?.some((org) => org.id == transfer.from.id); diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..e13bc76 --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,19 @@ +import ky from "ky"; +import { useContext, useMemo } from "react"; + +import AuthContext from "../auth"; + +export default function useClient(token?: string | null | undefined) { + const { token: _token } = useContext(AuthContext); + + return useMemo( + () => + ky.create({ + prefixUrl: process.env.EXPO_PUBLIC_API_BASE, + headers: { + Authorization: `Bearer ${token || _token}`, + }, + }), + [token, _token], + ); +} diff --git a/src/lib/organization/useTransactions.ts b/src/lib/organization/useTransactions.ts index 421c918..43c354c 100644 --- a/src/lib/organization/useTransactions.ts +++ b/src/lib/organization/useTransactions.ts @@ -14,9 +14,9 @@ export function getKey(orgId: string) { if (previousPageData?.has_more === false) return null; if (index === 0) - return `/organizations/${orgId}/transactions?limit=${PAGE_SIZE}`; + return `organizations/${orgId}/transactions?limit=${PAGE_SIZE}`; - return `/organizations/${orgId}/transactions?limit=${PAGE_SIZE}&after=${ + return `organizations/${orgId}/transactions?limit=${PAGE_SIZE}&after=${ previousPageData!.data[previousPageData!.data.length - 1].id }`; }; diff --git a/src/pages/Invitation.tsx b/src/pages/Invitation.tsx index 48b6a92..f5cef76 100644 --- a/src/pages/Invitation.tsx +++ b/src/pages/Invitation.tsx @@ -1,7 +1,7 @@ import { Ionicons } from "@expo/vector-icons"; import { useTheme } from "@react-navigation/native"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { useContext, useEffect } from "react"; +import { useEffect } from "react"; import { View, Text, @@ -13,8 +13,8 @@ import { import useSWR, { useSWRConfig } from "swr"; import useSWRMutation from "swr/mutation"; -import AuthContext from "../auth"; import Button from "../components/Button"; +import useClient from "../lib/client"; import { StackParamList } from "../lib/NavigatorParamList"; import Invitation from "../lib/types/Invitation"; import palette from "../palette"; @@ -28,10 +28,10 @@ export default function InvitationPage({ params: { inviteId, invitation: _invitation }, }, }: Props) { - const { token } = useContext(AuthContext); + const hcb = useClient(); const { data: invitation } = useSWR( - `/user/invitations/${inviteId}`, + `user/invitations/${inviteId}`, { fallbackData: _invitation }, ); @@ -56,18 +56,8 @@ export default function InvitationPage({ never, Invitation[] >( - `/user/invitations`, - () => - fetch( - process.env.EXPO_PUBLIC_API_BASE + - `/user/invitations/${inviteId}/accept`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ), + `user/invitations`, + () => hcb.post(`user/invitations/${inviteId}/accept`).json(), { populateCache: (_, invitations) => invitations?.filter((i) => i.id != inviteId) || [], @@ -77,7 +67,7 @@ export default function InvitationPage({ orgId: invitation!.organization.id, organization: invitation!.organization, }); - mutate(`/user/organizations`); + mutate(`user/organizations`); }, }, ); @@ -89,18 +79,8 @@ export default function InvitationPage({ never, Invitation[] >( - `/user/invitations`, - () => - fetch( - process.env.EXPO_PUBLIC_API_BASE + - `/user/invitations/${inviteId}/reject`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ), + `user/invitations`, + () => hcb.post(`user/invitations/${inviteId}/reject`).json(), { populateCache: (_, invitations) => invitations?.filter((i) => i.id != inviteId) || [], diff --git a/src/pages/Receipts.tsx b/src/pages/Receipts.tsx index a0abab3..ee57423 100644 --- a/src/pages/Receipts.tsx +++ b/src/pages/Receipts.tsx @@ -67,7 +67,7 @@ function Transaction({ try { await fetch( process.env.EXPO_PUBLIC_API_BASE + - `/organizations/${transaction.organization.id}/transactions/${transaction.id}/receipts`, + `organizations/${transaction.organization.id}/transactions/${transaction.id}/receipts`, { method: "POST", headers: { @@ -143,7 +143,7 @@ type Props = NativeStackScreenProps< export default function ReceiptsPage({ navigation: _navigation }: Props) { const { data, mutate, isLoading } = useSWR<{ data: (TransactionCardCharge & { organization: Organization })[]; - }>("/user/transactions/missing_receipt"); + }>("user/transactions/missing_receipt"); useFocusEffect(() => { mutate(); diff --git a/src/pages/RenameTransaction.tsx b/src/pages/RenameTransaction.tsx index 0b5b5b9..e4d481a 100644 --- a/src/pages/RenameTransaction.tsx +++ b/src/pages/RenameTransaction.tsx @@ -1,6 +1,6 @@ import { useTheme } from "@react-navigation/native"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { useContext, useState } from "react"; +import { useState } from "react"; import { ActivityIndicator, StatusBar, @@ -12,7 +12,7 @@ import useSWR, { useSWRConfig } from "swr"; import { unstable_serialize } from "swr/infinite"; import useSWRMutation from "swr/mutation"; -import AuthContext from "../auth"; +import useClient from "../lib/client"; import { StackParamList } from "../lib/NavigatorParamList"; import { getKey } from "../lib/organization/useTransactions"; import Transaction from "../lib/types/Transaction"; @@ -26,16 +26,16 @@ export default function RenameTransactionPage({ }, navigation, }: Props) { - const { token } = useContext(AuthContext); const { colors: themeColors } = useTheme(); const { mutate } = useSWRConfig(); + const hcb = useClient(); const { data: memoSuggestions, isLoading, isValidating, } = useSWR( - `/organizations/${orgId}/transactions/${transaction.id}/memo_suggestions`, + `organizations/${orgId}/transactions/${transaction.id}/memo_suggestions`, { revalidateOnMount: true }, ); @@ -44,22 +44,13 @@ export default function RenameTransactionPage({ ); const { trigger } = useSWRMutation( - `/organizations/${orgId}/transactions/${transaction.id}`, + `organizations/${orgId}/transactions/${transaction.id}`, () => - fetch( - process.env.EXPO_PUBLIC_API_BASE + - `/organizations/${orgId}/transactions/${transaction.id}`, - { - method: "PATCH", - body: JSON.stringify({ - memo, - }), - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }, - ).then((res) => res.json()), + hcb + .patch(`organizations/${orgId}/transactions/${transaction.id}`, { + json: { memo }, + }) + .json(), { optimisticData(currentData: Transaction) { return { ...currentData, memo }; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e97ac05..604bf49 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -159,7 +159,7 @@ export default function SettingsPage( }); }, []); - const { data: user } = useSWR("/user"); + const { data: user } = useSWR("user"); const handleClick = (iconName: string) => { AppIcon.setAppIcon(iconName.toString()); diff --git a/src/pages/Transaction.tsx b/src/pages/Transaction.tsx index 9b86a5e..78918d3 100644 --- a/src/pages/Transaction.tsx +++ b/src/pages/Transaction.tsx @@ -42,13 +42,13 @@ export default function TransactionPage({ Transaction & { organization?: Organization } >( orgId - ? `/organizations/${orgId}/transactions/${transactionId}` - : `/transactions/${transactionId}`, + ? `organizations/${orgId}/transactions/${transactionId}` + : `transactions/${transactionId}`, { fallbackData: _transaction }, ); const { data: comments } = useSWR( () => - `/organizations/${ + `organizations/${ orgId || transaction!.organization!.id }/transactions/${transactionId}/comments`, ); diff --git a/src/pages/card.tsx b/src/pages/card.tsx index 1f4c793..ecb6992 100644 --- a/src/pages/card.tsx +++ b/src/pages/card.tsx @@ -2,7 +2,6 @@ import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import { useTheme } from "@react-navigation/native"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; -import { useContext } from "react"; import { ScrollView, View, @@ -13,10 +12,10 @@ import { import useSWR, { useSWRConfig } from "swr"; import useSWRMutation from "swr/mutation"; -import AuthContext from "../auth"; import Button from "../components/Button"; import PaymentCard from "../components/PaymentCard"; import Transaction from "../components/Transaction"; +import useClient from "../lib/client"; import { CardsStackParamList } from "../lib/NavigatorParamList"; import Card from "../lib/types/Card"; import ITransaction from "../lib/types/Transaction"; @@ -31,14 +30,14 @@ export default function CardPage({ navigation, }: Props) { const { colors: themeColors } = useTheme(); - const { token } = useContext(AuthContext); + const hcb = useClient(); - const { data: card } = useSWR(`/cards/${_card.id}`, { + const { data: card } = useSWR(`cards/${_card.id}`, { fallbackData: _card, }); const { data: transactions, isLoading: transactionsLoading } = useSWR<{ data: ITransaction[]; - }>(`/cards/${_card.id}/transactions`); + }>(`cards/${_card.id}/transactions`); const { mutate } = useSWRConfig(); @@ -49,21 +48,13 @@ export default function CardPage({ "frozen" | "active", Card >( - `/cards/${_card.id}`, - (url, { arg }) => - fetch(process.env.EXPO_PUBLIC_API_BASE + url, { - body: JSON.stringify({ status: arg }), - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }).then((r) => r.json()), + `cards/${_card.id}`, + (url, { arg }) => hcb.patch(url, { json: { status: arg } }).json(), { populateCache: true, onSuccess: () => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - mutate(`/user/cards`); + mutate(`user/cards`); }, }, ); diff --git a/src/pages/cards.tsx b/src/pages/cards.tsx index f006a0d..9335e3d 100644 --- a/src/pages/cards.tsx +++ b/src/pages/cards.tsx @@ -16,7 +16,7 @@ type Props = NativeStackScreenProps; export default function CardsPage({ navigation }: Props) { const { data: cards, mutate: reloadCards } = - useSWR<(Card & Required>)[]>("/user/cards"); + useSWR<(Card & Required>)[]>("user/cards"); const tabBarHeight = useBottomTabBarHeight(); const scheme = useColorScheme(); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index fc027e2..74b0253 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -73,13 +73,11 @@ function Event({ onHold?: () => void; }) { const { data } = useSWR( - hideBalance ? null : `/organizations/${event.id}`, + hideBalance ? null : `organizations/${event.id}`, ); const { data: transactions, isLoading: transactionsIsLoading } = useSWR< PaginatedResponse - >( - showTransactions ? `/organizations/${event.id}/transactions?limit=5` : null, - ); + >(showTransactions ? `organizations/${event.id}/transactions?limit=5` : null); const { colors: themeColors } = useTheme(); @@ -216,10 +214,10 @@ export default function App({ navigation }: Props) { data: organizations, error, mutate: reloadOrganizations, - } = useSWR("/user/organizations"); + } = useSWR("user/organizations"); const [sortedOrgs, togglePinnedOrg] = usePinnedOrgs(organizations); const { data: invitations, mutate: reloadInvitations } = - useSWR("/user/invitations"); + useSWR("user/invitations"); const { fetcher, mutate } = useSWRConfig(); const tabBarHeight = useBottomTabBarHeight(); @@ -227,15 +225,15 @@ export default function App({ navigation }: Props) { useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - preload("/user", fetcher!); + preload("user", fetcher!); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - preload("/user/cards", fetcher!); + preload("user/cards", fetcher!); }, []); useFocusEffect(() => { reloadOrganizations(); reloadInvitations(); - mutate((k) => typeof k === "string" && k.startsWith("/organizations")); + mutate((k) => typeof k === "string" && k.startsWith("organizations")); }); if (error) { diff --git a/src/pages/organization/AccountNumber.tsx b/src/pages/organization/AccountNumber.tsx index a4465e6..58f3e61 100644 --- a/src/pages/organization/AccountNumber.tsx +++ b/src/pages/organization/AccountNumber.tsx @@ -69,7 +69,7 @@ export default function AccountNumberPage({ }, }: Props) { const { data: organization } = useSWR( - `/organizations/${orgId}`, + `organizations/${orgId}`, ); useEffect(() => { diff --git a/src/pages/organization/Settings.tsx b/src/pages/organization/Settings.tsx index 1934ae6..830248f 100644 --- a/src/pages/organization/Settings.tsx +++ b/src/pages/organization/Settings.tsx @@ -36,10 +36,10 @@ export default function OrganizationSettingsPage({ }: Props) { const { cache } = useSWRConfig(); const { data: organization } = useSWR( - `/organizations/${orgId}?avatar_size=50`, - { fallbackData: cache.get(`/organizations/${orgId}`)?.data }, + `organizations/${orgId}?avatar_size=50`, + { fallbackData: cache.get(`organizations/${orgId}`)?.data }, ); - const { data: currentUser } = useSWR("/user"); + const { data: currentUser } = useSWR("user"); const tabBarHeight = useBottomTabBarHeight(); const { colors: themeColors } = useTheme(); diff --git a/src/pages/organization/index.tsx b/src/pages/organization/index.tsx index 6e5ee4e..a3c3012 100644 --- a/src/pages/organization/index.tsx +++ b/src/pages/organization/index.tsx @@ -69,7 +69,7 @@ export default function OrganizationPage({ const scheme = useColorScheme(); const { data: organization, isLoading: organizationLoading } = useSWR< Organization | OrganizationExpanded - >(`/organizations/${orgId}`, { fallbackData: _organization }); + >(`organizations/${orgId}`, { fallbackData: _organization }); const { transactions: _transactions, isLoadingMore,