DeFiCh/wallet

View on GitHub
mobile-app/app/screens/AppNavigator/screens/Loans/components/Vaults.tsx

Summary

Maintainability
C
1 day
Test Coverage
import { tailwind } from "@tailwind";
import {
  ThemedIcon,
  ThemedScrollViewV2,
  ThemedTextV2,
  ThemedTouchableOpacityV2,
} from "@components/themed";
import { useSelector } from "react-redux";
import { RootState } from "@store";
import { useEffect, useMemo, useRef, useState } from "react";
import {
  fetchCollateralTokens,
  fetchVaults,
  LoanVault,
  vaultsSelector,
} from "@store/loans";
import { useWhaleApiClient } from "@waveshq/walletkit-ui/dist/contexts";
import { useWalletContext } from "@shared-contexts/WalletContext";
import {
  NavigationProp,
  useIsFocused,
  useNavigation,
} from "@react-navigation/native";
import { useAppDispatch } from "@hooks/useAppDispatch";
import {
  SkeletonLoader,
  SkeletonLoaderScreen,
} from "@components/SkeletonLoader";
import { SearchInput } from "@components/SearchInput";
import { translate } from "@translations";
import { Platform, TextInput, View } from "react-native";
import { useDebounce } from "@hooks/useDebounce";
import { useThemeContext } from "@waveshq/walletkit-ui";
import { LoanParamList } from "@screens/AppNavigator/screens/Loans/LoansNavigator";
import {
  BottomSheetNavScreen,
  BottomSheetWebWithNavV2,
  BottomSheetWithNavV2,
} from "@components/BottomSheetWithNavV2";
import { useBottomSheet } from "@hooks/useBottomSheet";
import { LoanToken } from "@defichain/whale-api-client/dist/api/loan";
import { BottomSheetTokenListHeader } from "@components/BottomSheetTokenListHeader";
import { EmptyVault } from "./EmptyVault";
import { PriceOracleInfo } from "./PriceOracleInfo";
import { BottomSheetModalInfo } from "../../../../../components/BottomSheetModalInfo";
import { VaultCard } from "./VaultCard";
import { BottomSheetLoanTokensList } from "./BottomSheetLoanTokensList";

interface VaultsProps {
  scrollRef?: React.Ref<any>;
}

export function Vaults(props: VaultsProps): JSX.Element {
  const dispatch = useAppDispatch();
  const client = useWhaleApiClient();
  const isFocused = useIsFocused();
  const { address } = useWalletContext();
  const { isLight } = useThemeContext();
  const navigation = useNavigation<NavigationProp<LoanParamList>>();
  const blockCount = useSelector((state: RootState) => state.block.count);
  const vaults = useSelector((state: RootState) => vaultsSelector(state.loans));
  const { hasFetchedVaultsData } = useSelector(
    (state: RootState) => state.loans
  );

  const [searchString, setSearchString] = useState("");
  const [isSearchFocus, setIsSearchFocus] = useState(false);
  const debouncedSearchTerm = useDebounce(searchString, 250);
  const searchRef = useRef<TextInput>();

  const [bottomSheetScreen, setBottomSheetScreen] = useState<
    BottomSheetNavScreen[]
  >([]);

  const {
    bottomSheetRef,
    containerRef,
    dismissModal,
    expandModal,
    isModalDisplayed,
  } = useBottomSheet();

  const BottomSheetHeader = {
    headerStatusBarHeight: 2,
    headerTitle: "",
    headerBackTitleVisible: false,
    headerStyle: tailwind("rounded-t-xl-v2 border-b-0", {
      "bg-mono-light-v2-100": isLight,
      "bg-mono-dark-v2-100": !isLight,
    }),
    headerRight: (): JSX.Element => {
      return (
        <ThemedTouchableOpacityV2
          style={tailwind("mr-5", {
            "mt-4 -mb-4": Platform.OS === "ios",
            "mt-1.5": Platform.OS === "android",
          })}
          onPress={dismissModal}
          testID="close_bottom_sheet_button"
        >
          <ThemedIcon iconType="Feather" name="x-circle" size={22} />
        </ThemedTouchableOpacityV2>
      );
    },
    headerLeft: () => <></>,
  };
  const title = "Price Oracles";
  const description =
    "Loans and vaults use aggregated market prices outside the blockchain (called price oracles)";

  const oraclePriceSheetSnapPoints = {
    ios: ["30%"],
    android: ["35%"],
  };
  const [snapPoints, setSnapPoints] = useState(oraclePriceSheetSnapPoints);
  const onBottomSheetOraclePriceSelect = (): void => {
    setSnapPoints(oraclePriceSheetSnapPoints);
    setBottomSheetScreen([
      {
        stackScreenName: "OraclePriceInfo",
        component: BottomSheetModalInfo({
          title,
          description,
        }),
        option: BottomSheetHeader,
      },
    ]);
    expandModal();
  };

  const onBottomSheetLoansTokensListSelect = ({
    onPress,
    loanTokens,
  }: {
    onPress: (item: LoanToken) => void;
    loanTokens: LoanToken[];
  }): void => {
    setSnapPoints({ ios: ["75%"], android: ["70%"] });
    setBottomSheetScreen([
      {
        stackScreenName: "LoanTokensList",
        component: BottomSheetLoanTokensList({
          onPress,
          loanTokens,
          isLight,
        }),
        option: {
          headerTitle: "",
          headerBackTitleVisible: false,
          headerStyle: tailwind("rounded-t-xl-v2 border-b-0"),
          header: () => (
            <BottomSheetTokenListHeader
              headerLabel={translate(
                "components/BottomSheetLoanTokensList",
                "Select Token"
              )}
              onCloseButtonPress={dismissModal}
            />
          ),
        },
      },
    ]);
    expandModal();
  };

  useEffect(() => {
    if (isFocused) {
      dispatch(
        fetchVaults({
          address,
          client,
        })
      );
    }
  }, [blockCount, address, isFocused]);

  useEffect(() => {
    dispatch(fetchCollateralTokens({ client }));
  }, []);

  const filteredTokensWithBalance = useMemo(() => {
    return filterVaultsBySearchTerm(vaults, debouncedSearchTerm, isSearchFocus);
  }, [vaults, debouncedSearchTerm, isSearchFocus]);

  const inSearchMode = useMemo(() => {
    return isSearchFocus || debouncedSearchTerm !== "";
  }, [isSearchFocus, debouncedSearchTerm]);

  if (!hasFetchedVaultsData) {
    return (
      <View style={tailwind("mt-1")}>
        <SkeletonLoader row={3} screen={SkeletonLoaderScreen.Vault} />
      </View>
    );
  } else if (vaults?.length === 0) {
    return <EmptyVault handleRefresh={() => {}} isLoading={false} />;
  }

  return (
    <View ref={containerRef} style={tailwind("flex-1")}>
      <ThemedScrollViewV2
        contentContainerStyle={tailwind("px-5 py-8 w-full")}
        ref={props.scrollRef}
      >
        <View style={tailwind("flex-col w-full")}>
          <View style={tailwind("flex-row flex w-full mb-4 items-center")}>
            <SearchInput
              ref={searchRef}
              value={searchString}
              showClearButton={debouncedSearchTerm !== ""}
              placeholder={translate("screens/LoansScreen", "Search vault")}
              containerStyle={tailwind("flex-1", [
                "border-0.5",
                isSearchFocus
                  ? {
                      "border-mono-light-v2-800": isLight,
                      "border-mono-dark-v2-800": !isLight,
                    }
                  : {
                      "border-mono-light-v2-00": isLight,
                      "border-mono-dark-v2-00": !isLight,
                    },
              ])}
              onClearInput={() => {
                setSearchString("");
                searchRef?.current?.focus();
              }}
              onChangeText={(text: string) => {
                setSearchString(text);
              }}
              onFocus={() => {
                setIsSearchFocus(true);
              }}
              onBlur={() => {
                setIsSearchFocus(false);
              }}
            />
            {!inSearchMode && (
              <CreateVaultButton
                onPress={() =>
                  navigation.navigate({
                    name: "CreateVaultScreen",
                    params: {},
                    merge: true,
                  })
                }
              />
            )}
          </View>
          {inSearchMode && (
            <ThemedTextV2
              style={tailwind("text-xs pl-5 my-4 font-normal-v2")}
              light={tailwind("text-mono-light-v2-700")}
              dark={tailwind("text-mono-dark-v2-700")}
              testID="empty_search_result_text"
            >
              {debouncedSearchTerm.trim() === ""
                ? translate("screens/LoansScreen", "Search with vault ID")
                : translate(
                    "screens/LoansScreen",
                    "Search results for “{{searchTerm}}”",
                    { searchTerm: debouncedSearchTerm }
                  )}
            </ThemedTextV2>
          )}
        </View>

        {filteredTokensWithBalance.map((vault, index) => {
          return (
            <VaultCard
              testID={`vault_card_${index}`}
              key={index}
              vault={vault}
              dismissModal={dismissModal}
              expandModal={expandModal}
              setBottomSheetScreen={setBottomSheetScreen}
              setSnapPoints={setSnapPoints}
              onBottomSheetLoansTokensListSelect={
                onBottomSheetLoansTokensListSelect
              }
            />
          );
        })}

        {!inSearchMode && (
          <PriceOracleInfo
            onPress={onBottomSheetOraclePriceSelect}
            text="All prices displayed are from price oracles."
          />
        )}

        {Platform.OS === "web" && (
          <BottomSheetWebWithNavV2
            modalRef={containerRef}
            screenList={bottomSheetScreen}
            isModalDisplayed={isModalDisplayed}
            // eslint-disable-next-line react-native/no-inline-styles
            modalStyle={{
              position: "absolute",
              bottom: "0",
              height: "404px",
              width: "375px",
              zIndex: 50,
              borderTopLeftRadius: 15,
              borderTopRightRadius: 15,
              overflow: "hidden",
            }}
          />
        )}

        {Platform.OS !== "web" && (
          <BottomSheetWithNavV2
            modalRef={bottomSheetRef}
            screenList={bottomSheetScreen}
            snapPoints={snapPoints}
          />
        )}
      </ThemedScrollViewV2>
    </View>
  );
}

function CreateVaultButton(props: { onPress: () => void }): JSX.Element {
  return (
    <ThemedTouchableOpacityV2
      style={tailwind(
        "w-10 h-10 ml-3 rounded-full items-center justify-center"
      )}
      light={tailwind("bg-mono-light-v2-900")}
      dark={tailwind("bg-mono-dark-v2-900")}
      onPress={props.onPress}
      testID="button_create_vault"
    >
      <ThemedIcon
        iconType="Feather"
        name="plus"
        size={24}
        light={tailwind("text-mono-light-v2-00")}
        dark={tailwind("text-mono-dark-v2-00")}
      />
    </ThemedTouchableOpacityV2>
  );
}

function filterVaultsBySearchTerm(
  vaults: LoanVault[],
  searchTerm: string,
  isFocused: boolean
): LoanVault[] {
  if (searchTerm === "") {
    return isFocused ? [] : vaults;
  }
  return vaults.filter((t) => {
    // TODO: Add tokens search in next release
    // const vault = t as LoanVaultActive;
    // const symbols =
    //   vault.collateralAmounts !== undefined
    //     ? vault.collateralAmounts.map((value) => value.displaySymbol)
    //     : [];
    return [t.vaultId].some((searchItem) =>
      searchItem.toLowerCase().includes(searchTerm.trim().toLowerCase())
    );
  });
}