DeFiCh/wallet

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

Summary

Maintainability
F
3 days
Test Coverage
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  ThemedFlashList,
  ThemedIcon,
  ThemedScrollViewV2,
  ThemedText,
  ThemedTextV2,
  ThemedTouchableOpacityV2,
  ThemedViewV2,
} from "@components/themed";
import { tailwind } from "@tailwind";
import { translate } from "@translations";
import { NumericFormat as NumberFormat } from "react-number-format";
import { Platform, TextInput, View } from "react-native";
import { getNativeIcon } from "@components/icons/assets";
import {
  LoanToken,
  LoanVaultActive,
  LoanVaultState,
} from "@defichain/whale-api-client/dist/api/loan";
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { ActivePrice } from "@defichain/whale-api-client/dist/api/prices";
import { useSelector } from "react-redux";
import { RootState } from "@store";
import BigNumber from "bignumber.js";
import { loanTokensSelector, vaultsSelector } from "@store/loans";
import { getPrecisedTokenValue } from "@screens/AppNavigator/screens/Auctions/helpers/precision-token-value";
import { BottomSheetHeader as BottonSheetHeaderSort } from "@components/BottomSheetHeader";
import { useBottomSheet } from "@hooks/useBottomSheet";
import {
  BottomSheetWebWithNavV2,
  BottomSheetWithNavV2,
} from "@components/BottomSheetWithNavV2";
import { BottomSheetModalInfo } from "@components/BottomSheetModalInfo";
import { useThemeContext } from "@waveshq/walletkit-ui";
import { SearchInput } from "@components/SearchInput";
import { debounce } from "lodash";
import {
  SkeletonLoader,
  SkeletonLoaderScreen,
} from "@components/SkeletonLoader";
import { BottomSheetTokenListHeader } from "@components/BottomSheetTokenListHeader";
import { ListRenderItemInfo } from "@shopify/flash-list";
import { getNumberFormatValue } from "@api/number-format-value";
import {
  LoansTokensSortRow,
  LoansTokensSortType,
  BottomSheetTokensLoansSortList,
} from "./LoansTokensSortRow";
import { getActivePrice } from "../../Auctions/helpers/ActivePrice";
import { LoanParamList } from "../LoansNavigator";
import { LoanActionButton } from "./LoanActionButton";
import { VaultStatus } from "../VaultStatusTypes";
import { PriceOracleInfo } from "./PriceOracleInfo";
import { VaultBanner } from "./VaultBanner";
import { BottomSheetVaultList } from "./BottomSheetVaultList";

interface LoanCardsProps {
  testID: string;
  vaultId?: string;
  scrollRef?: React.Ref<any>;
  sortRef?: React.Ref<any>;
}
export interface LoanCardOptions {
  symbol: string;
  displaySymbol: string;
  price?: ActivePrice;
  interestRate: string;
  onPress: () => void;
  testID: string;
  parentTestID: string;
  isBorrowHidden: boolean;
}

export function LoanCards(props: LoanCardsProps): JSX.Element {
  const { isLight } = useThemeContext();
  const loanTokens = useSelector((state: RootState) =>
    loanTokensSelector(state.loans)
  );

  const vaults = useSelector((state: RootState) => vaultsSelector(state.loans));
  const { hasFetchedLoansData } = useSelector(
    (state: RootState) => state.loans
  );

  // Search
  const [filteredLoanTokens, setFilteredLoanTokens] =
    useState<LoanToken[]>(loanTokens);
  const [isVaultReady, setIsVaultReady] = useState(false);
  const [searchString, setSearchString] = useState("");
  const [isSearchFocus, setIsSearchFocus] = useState(false);
  const searchRef = useRef<TextInput>();

  const [showLoader, setShowLoader] = useState(true);
  const [isFirstLoad, setIsFirstLoad] = useState(false);
  const [loansTokensSortType, setLoansTokensSortType] =
    useState<LoansTokensSortType>(LoansTokensSortType.LowestOraclePrice);
  const [isSorted, setIsSorted] = useState<boolean>(false);

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

  const handleFilter = useCallback(
    debounce((searchString: string) => {
      setFilteredLoanTokens(
        loanTokens.filter((loanToken) =>
          loanToken.token.displaySymbol
            .toLowerCase()
            .includes(searchString.trim().toLowerCase())
        )
      );
    }, 250),
    [loanTokens, hasFetchedLoansData]
  );

  const sortLoansTokensOnSort = useCallback(
    (loansTokensSortType: LoansTokensSortType): LoanToken[] => {
      let sortTokensFunc: (a: LoanToken, b: LoanToken) => number;
      function getCurrentPrice(symbol: string, price?: ActivePrice) {
        return new BigNumber(
          getPrecisedTokenValue(getActivePrice(symbol, price))
        );
      }

      switch (loansTokensSortType) {
        case LoansTokensSortType.LowestOraclePrice:
          sortTokensFunc = (a, b) =>
            getCurrentPrice(a.token.displaySymbol, a.activePrice)
              .minus(getCurrentPrice(b.token.displaySymbol, b.activePrice))
              .toNumber();
          break;
        case LoansTokensSortType.HighestOraclePrice:
          sortTokensFunc = (a, b) =>
            getCurrentPrice(b.token.displaySymbol, b.activePrice)
              .minus(getCurrentPrice(a.token.displaySymbol, a.activePrice))
              .toNumber();
          break;
        case LoansTokensSortType.LowestInterest:
          sortTokensFunc = (a, b) =>
            new BigNumber(a.interest)
              .minus(new BigNumber(b.interest))
              .toNumber();
          break;
        case LoansTokensSortType.HighestInterest:
          sortTokensFunc = (a, b) =>
            new BigNumber(b.interest)
              .minus(new BigNumber(a.interest))
              .toNumber();
          break;
        case LoansTokensSortType.AtoZ:
          sortTokensFunc = (a, b) =>
            a.token.displaySymbol.localeCompare(b.token.displaySymbol);
          break;
        case LoansTokensSortType.ZtoA:
          sortTokensFunc = (a, b) =>
            b.token.displaySymbol.localeCompare(a.token.displaySymbol);
          break;
        default:
          sortTokensFunc = (a, b) =>
            getCurrentPrice(a.token.displaySymbol, a.activePrice)
              .minus(getCurrentPrice(b.token.displaySymbol, b.activePrice))
              .toNumber();
      }

      return filteredLoanTokens.sort(sortTokensFunc);
    },
    [filteredLoanTokens, loansTokensSortType]
  );

  useEffect(() => {
    if (loanTokens.length === 0) {
      return;
    }
    handleFilter(searchString);
  }, [searchString]);

  useEffect(() => {
    setFilteredLoanTokens(loanTokens);
  }, [hasFetchedLoansData]);

  useEffect(() => {
    setIsVaultReady(
      vaults.some((vault) =>
        [
          VaultStatus.Ready,
          VaultStatus.Healthy,
          VaultStatus.AtRisk,
          VaultStatus.NearLiquidation,
        ].includes(vault.vaultState)
      )
    );
  }, [vaults]);

  useEffect(() => {
    setIsFirstLoad(true);
  }, []);

  useEffect(() => {
    let timeout: NodeJS.Timeout;
    if (isFirstLoad) {
      // extend loader for an arbitrary amount of time for flashlist to complete rendering
      const extendedLoaderTime = 1000;
      timeout = setTimeout(() => {
        setShowLoader(false);
      }, extendedLoaderTime);
    }
    return () => clearTimeout(timeout);
  }, [isFirstLoad]);

  const navigation = useNavigation<NavigationProp<LoanParamList>>();
  const activeVault = vaults.find(
    (v) =>
      v.vaultId === props.vaultId && v.state !== LoanVaultState.IN_LIQUIDATION
  ) as LoanVaultActive;

  const goToCreateVault = (): void => {
    navigation.navigate({
      name: "CreateVaultScreen",
      params: {},
      merge: true,
    });
  };

  const title = "Price Oracles";
  const description =
    "Oracles provide real time price data points from trusted sources, to reflect onto DeFiChain.";

  const {
    bottomSheetRef,
    dismissModal,
    expandModal,
    isModalDisplayed,
    bottomSheetScreen,
    setBottomSheetScreen,
    snapPoints,
    setSnapPoints,
  } = 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")}
          onPress={dismissModal}
          testID="close_bottom_sheet_button"
        >
          <ThemedIcon iconType="Feather" name="x-circle" size={22} />
        </ThemedTouchableOpacityV2>
      );
    },
    headerLeft: () => <></>,
  };

  const onBorrowPress = (loanToken: LoanToken): void => {
    setSnapPoints({
      ios: ["60%"],
      android: ["60%"],
    });
    setBottomSheetScreen([
      {
        stackScreenName: "VaultList",
        component: BottomSheetVaultList({
          onVaultPress: (vault: LoanVaultActive) => {
            dismissModal();
            navigation.navigate({
              name: "BorrowLoanTokenScreen",
              params: {
                vault: vault,
                loanToken: loanToken,
              },
              merge: true,
            });
          },
          vaults,
        }),
        option: {
          headerTitle: "",
          headerBackTitleVisible: false,
          headerStyle: tailwind("rounded-t-xl-v2 border-b-0"),
          header: () => (
            <BottomSheetTokenListHeader
              headerLabel={translate(
                "screens/BorrowLoanTokenScreen",
                "Select Vault"
              )}
              onCloseButtonPress={dismissModal}
            />
          ),
        },
      },
    ]);
    expandModal();
  };

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

  const tokenLoansSortBottomSheetScreen = (): void => {
    setSnapPoints({
      ios: ["65%"],
      android: ["65%"],
    });
    setBottomSheetScreen([
      {
        stackScreenName: "TokensLoansSortList",
        component: BottomSheetTokensLoansSortList({
          onButtonPress: (item: LoansTokensSortType) => {
            setIsSorted(true);
            sortLoansTokensOnSort(item);
            setLoansTokensSortType(item);
            dismissModal();
          },
          selectedLoansTokensSortType: loansTokensSortType,
        }),
        option: {
          headerStatusBarHeight: 1,
          headerTitle: "",
          headerBackTitleVisible: false,
          header: (): JSX.Element => {
            return (
              <BottonSheetHeaderSort
                onClose={dismissModal}
                headerText={translate("screens/LoansScreen", "Sort Tokens")}
              />
            );
          },
        },
      },
    ]);
    expandModal();
  };

  return (
    <View style={tailwind("flex-1")}>
      <ThemedScrollViewV2
        ref={props.scrollRef}
        contentContainerStyle={tailwind("py-8 w-full")}
      >
        {isVaultReady && (
          <>
            <SearchInput
              ref={searchRef}
              value={searchString}
              showClearButton={searchString !== ""}
              placeholder={translate(
                "screens/LoansScreen",
                "Search available loan tokens"
              )}
              inputStyle={{
                style: tailwind({ "py-3": Platform.OS === "web" }),
              }}
              containerStyle={tailwind("flex-1 mx-5", [
                "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);
              }}
              testID="loan_search_input"
            />
            {!isSearchFocus && searchString === "" && (
              <LoansTokensSortRow
                isSorted={isSorted}
                loansTokensSortType={loansTokensSortType}
                onPress={tokenLoansSortBottomSheetScreen}
              />
            )}
          </>
        )}

        {vaults.length === 0 && hasFetchedLoansData && (
          <ThemedViewV2 style={tailwind("mx-5 rounded-lg-v2")}>
            <VaultBanner
              buttonLabel="Create a vault"
              description="You need a vault with collaterals to borrow tokens"
              onButtonPress={goToCreateVault}
              testID="vault_banner"
            />
            <View style={tailwind("mt-2 mx-2")}>
              <PriceOracleInfo
                onPress={onBottomSheetOraclePriceSelect}
                text="Loan tokens get their prices from oracles."
              />
            </View>
          </ThemedViewV2>
        )}
        {showLoader && (
          <View style={tailwind("mt-5 mx-2")}>
            <SkeletonLoader row={6} screen={SkeletonLoaderScreen.LoanV2} />
          </View>
        )}

        {!showLoader && inSearchMode && (
          <View style={tailwind("mt-8 mx-5")}>
            <ThemedTextV2
              style={tailwind("text-xs pl-5 font-normal-v2")}
              light={tailwind("text-mono-light-v2-700")}
              dark={tailwind("text-mono-dark-v2-700")}
              testID="empty_search_result_text"
            >
              {searchString.trim() === ""
                ? translate("screens/LoansScreen", "Search with token name")
                : translate(
                    "screens/LoansScreen",
                    "Search results for “{{searchTerm}}”",
                    { searchTerm: searchString }
                  )}
            </ThemedTextV2>
          </View>
        )}
        {/* Known intermittent issue wherein the two-column layout is not followed
      in web - FlashList */}
        <ThemedFlashList
          keyExtractor={(_item, index) => index.toString()}
          testID={`${props.testID}_token_lists`}
          estimatedItemSize={116}
          contentContainerStyle={tailwind(
            "pb-2 pt-8",
            {
              "pt-0": vaults.length >= 1,
            },
            { "pt-4": searchString !== "" }
          )}
          parentContainerStyle={tailwind("mx-3", {
            hidden: isSearchFocus && searchString.trim() === "",
          })}
          data={filteredLoanTokens}
          /* This tells FlashList to rerender if any of the props below is updated */
          extraData={{
            isVaultReady,
            activeVault,
          }}
          numColumns={2}
          renderItem={({
            item,
            index,
          }: ListRenderItemInfo<LoanToken>): JSX.Element => {
            return (
              <View style={{ flexBasis: "100%" }}>
                <LoanCard
                  symbol={item.token.symbol}
                  displaySymbol={item.token.displaySymbol}
                  interestRate={item.interest}
                  price={item.activePrice}
                  onPress={() => {
                    onBorrowPress(item);
                  }}
                  testID={`loan_card_${index}`}
                  parentTestID={props.testID}
                  isBorrowHidden={!isVaultReady}
                />
              </View>
            );
          }}
        />
        {vaults.length >= 1 &&
          ((searchString.trim() !== "" &&
            isSearchFocus &&
            filteredLoanTokens.length > 0) ||
            (!isSearchFocus && filteredLoanTokens.length > 0)) && (
            <PriceOracleInfo
              onPress={onBottomSheetOraclePriceSelect}
              text="Loan tokens get their prices from oracles."
            />
          )}

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

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

function LoanCard({
  symbol,
  displaySymbol,
  price,
  interestRate,
  onPress,
  testID,
  parentTestID,
  isBorrowHidden,
}: LoanCardOptions): JSX.Element {
  const currentPrice = getPrecisedTokenValue(getActivePrice(symbol, price));

  return (
    <ThemedViewV2
      testID={`loan_card_${displaySymbol}`}
      light={tailwind("bg-mono-light-v2-00")}
      dark={tailwind("bg-mono-dark-v2-00")}
      style={tailwind("p-4 mx-2 mb-4 rounded-lg-v2")}
    >
      <View style={tailwind("flex-row items-center pb-2 justify-between")}>
        <MemoizedLoanIcon testID={testID} displaySymbol={displaySymbol} />
      </View>
      <NumberFormat
        decimalScale={2}
        thousandSeparator
        displayType="text"
        renderText={(value: string) => (
          <View style={tailwind("flex flex-row items-center")}>
            <ThemedText style={tailwind("text-sm")}>
              <ThemedTextV2
                style={tailwind("font-semibold-v2")}
                testID={`${testID}_oracle_price`}
                // eslint-disable-next-line react-native/no-raw-text
              >
                ${value}
              </ThemedTextV2>
            </ThemedText>
          </View>
        )}
        value={currentPrice}
      />
      <NumberFormat
        thousandSeparator
        displayType="text"
        renderText={(value: string) => (
          <ThemedTextV2
            light={tailwind("text-mono-light-v2-700")}
            dark={tailwind("text-mono-dark-v2-700")}
            style={tailwind("text-xs font-normal-v2")}
            testID={`${testID}_interest_rate`}
          >
            {translate("components/LoanCard", "{{interestValue}} Interest", {
              interestValue: value,
            })}
          </ThemedTextV2>
        )}
        value={getNumberFormatValue(interestRate, 2)}
        suffix="%"
      />
      {!isBorrowHidden && (
        <LoanActionButton
          label={translate("components/LoanCard", "Borrow")}
          onPress={onPress}
          style={tailwind("mt-3")}
          testID={`${displaySymbol}_borrow_button_${parentTestID}`}
        />
      )}
    </ThemedViewV2>
  );
}

/*
  Custom comparison is added because the debounced search is sluggish due to Loan Icon rendering
*/
const MemoizedLoanIcon = memo(
  ({ testID, displaySymbol }: { testID: string; displaySymbol: string }) => {
    const LoanIcon = getNativeIcon(displaySymbol);
    return (
      <View
        style={tailwind("flex flex-row justify-between items-center w-full")}
      >
        <ThemedTextV2
          light={tailwind("text-mono-light-v2-700")}
          dark={tailwind("text-mono-dark-v2-700")}
          style={tailwind("font-normal-v2")}
          testID={`${testID}_display_symbol`}
        >
          {displaySymbol}
        </ThemedTextV2>
        <LoanIcon
          width={36}
          height={36}
          style={tailwind({
            "font-normal-v2": Platform.OS === "web",
          })}
        />
      </View>
    );
  },
  comparisonFn
);

function comparisonFn(prevProps: any, nextProps: any): boolean {
  // compare objects by stringify
  return JSON.stringify(prevProps) === JSON.stringify(nextProps);
}