DeFiCh/wallet

View on GitHub
mobile-app/app/screens/AppNavigator/screens/Dex/components/PoolPairCards/PoolPairCards.tsx

Summary

Maintainability
C
1 day
Test Coverage
import BigNumber from "bignumber.js";
import { View } from "@components";
import {
  ThemedFlashList,
  ThemedTextV2,
  ThemedTouchableOpacityV2,
} from "@components/themed";
import { PoolPairData } from "@defichain/whale-api-client/dist/api/poolpairs";
import { tailwind } from "@tailwind";
import { translate } from "@translations";
import React, { useEffect, useRef, useState } from "react";
import { useScrollToTop } from "@react-navigation/native";
import { WalletToken } from "@waveshq/walletkit-ui";
import { useDebounce } from "@hooks/useDebounce";
import { AddressToken } from "@defichain/whale-api-client/dist/api/address";
import { useSelector } from "react-redux";
import { RootState } from "@store";
import { EmptyCryptoIcon } from "@screens/AppNavigator/screens/Portfolio/assets/EmptyCryptoIcon";
import { EmptyTokensScreen } from "@screens/AppNavigator/screens/Portfolio/components/EmptyTokensScreen";
import { PoolPairIconV2 } from "@screens/AppNavigator/screens/Dex/components/PoolPairCards/PoolPairIconV2";
import {
  DexActionButton,
  DexAddRemoveLiquidityButton,
} from "@screens/AppNavigator/screens/Dex/components/DexActionButton";
import { FavoriteButton } from "@screens/AppNavigator/screens/Dex/components/FavoriteButton";
import { PriceRatesSection } from "@screens/AppNavigator/screens/Dex/components/PoolPairCards/PriceRatesSection";
import { APRSection } from "@screens/AppNavigator/screens/Dex/components/PoolPairCards/APRSection";
import { PoolSharesSection } from "@screens/AppNavigator/screens/Dex/components/PoolPairCards/PoolSharesSection";
import { useTokenPrice } from "@screens/AppNavigator/screens/Portfolio/hooks/TokenPrice";
import { useToast } from "react-native-toast-notifications";
import { useFavouritePoolpairContext } from "@contexts/FavouritePoolpairContext";
import { Delayed } from "@components/Delayed";
import { DexScrollable } from "../DexScrollable";
import { TotalValueLocked } from "../TotalValueLocked";

interface DexItem<T> {
  type: "your" | "available";
  data: T;
}

export enum ButtonGroupTabKey {
  AllPairs = "ALL_PAIRS",
  DFIPairs = "DFI_PAIRS",
  DUSDPairs = "DUSD_PAIRS",
  FavouritePairs = "FAVOURITE_PAIRS",
}

interface PoolPairCardProps {
  availablePairs: Array<DexItem<PoolPairData>>;
  yourPairs: Array<DexItem<WalletToken>>;
  onAdd: (data: PoolPairData, info: WalletToken) => void;
  onRemove: (data: PoolPairData, info: WalletToken) => void;
  onSwap: (data: PoolPairData) => void;
  onPress: (id: string) => void;
  type: "your" | "available";
  searchString: string;
  showSearchInput?: boolean;
  topLiquidityPairs: Array<DexItem<PoolPairData>>;
  newPoolsPairs: Array<DexItem<PoolPairData>>;
  activeButtonGroup: ButtonGroupTabKey;
}

export function PoolPairCards({
  availablePairs,
  onAdd,
  onRemove,
  onSwap,
  onPress,
  type,
  searchString,
  yourPairs,
  showSearchInput,
  topLiquidityPairs,
  newPoolsPairs,
  activeButtonGroup,
}: PoolPairCardProps): JSX.Element {
  const { isFavouritePoolpair, setFavouritePoolpair } =
    useFavouritePoolpairContext();
  const sortedPairs = sortPoolpairsByFavourite(
    availablePairs,
    isFavouritePoolpair,
  );
  const { tvl } = useSelector((state: RootState) => state.block);
  const [filteredYourPairs, setFilteredYourPairs] =
    useState<Array<DexItem<WalletToken>>>(yourPairs);
  const debouncedSearchTerm = useDebounce(searchString, 500);
  const ref = useRef(null);
  useScrollToTop(ref);

  const pairSortingFn = (
    pairA: DexItem<WalletToken>,
    pairB: DexItem<WalletToken>,
  ): number =>
    availablePairs.findIndex((x) => x.data.id === pairA.data.id) -
    availablePairs.findIndex((x) => x.data.id === pairB.data.id);

  useEffect(() => {
    if (showSearchInput === false) {
      setFilteredYourPairs(yourPairs.sort(pairSortingFn));
      return;
    }

    if (
      debouncedSearchTerm !== undefined &&
      debouncedSearchTerm.trim().length > 0
    ) {
      setFilteredYourPairs(
        yourPairs
          .filter((pair) =>
            pair.data.displaySymbol
              .toLowerCase()
              .includes(debouncedSearchTerm.trim().toLowerCase()),
          )
          .sort(pairSortingFn),
      );
    } else {
      setFilteredYourPairs([]);
    }
  }, [yourPairs, debouncedSearchTerm, showSearchInput]);

  const renderItem = ({
    item,
    index,
  }: {
    item: DexItem<WalletToken | PoolPairData>;
    index: number;
  }): JSX.Element => (
    <PoolCard
      index={index}
      item={item}
      type={type}
      isFavouritePoolpair={isFavouritePoolpair}
      setFavouritePoolpair={setFavouritePoolpair}
      onAdd={onAdd}
      onRemove={onRemove}
      onSwap={onSwap}
      onPress={onPress}
    />
  );

  return (
    <ThemedFlashList
      light={tailwind("bg-mono-light-v2-100")}
      dark={tailwind("bg-mono-dark-v2-100")}
      contentContainerStyle={tailwind("pb-4", { "pt-8": type === "your" })}
      getItemType={(item: DexItem<WalletToken | PoolPairData>) => item.type}
      ref={ref}
      data={type === "your" ? filteredYourPairs : sortedPairs}
      numColumns={1}
      estimatedItemSize={
        type === "your" ? filteredYourPairs.length : sortedPairs.length
      }
      keyExtractor={(item) => item.data.id}
      testID={
        type === "your" ? "your_liquidity_tab" : "available_liquidity_tab"
      }
      renderItem={renderItem}
      ListEmptyComponent={
        <>
          {showSearchInput === false &&
            activeButtonGroup === ButtonGroupTabKey.FavouritePairs && (
              <EmptyTokensScreen
                icon={EmptyCryptoIcon}
                containerStyle={tailwind("pt-14")}
                testID="empty_pool_pair_screen"
                title={translate("screens/DexScreen", "No favorites added")}
                subtitle={translate(
                  "screens/DexScreen",
                  "Tap the star icon to add your favorite pools here",
                )}
              />
            )}
        </>
      }
      ListHeaderComponent={
        <>
          {type === "available" &&
          showSearchInput === false &&
          activeButtonGroup === ButtonGroupTabKey.AllPairs ? (
            <>
              <TotalValueLocked tvl={tvl ?? 0} />
              {topLiquidityPairs?.length > 0 && (
                <TopLiquiditySection
                  onPress={onPress}
                  onActionPress={onSwap}
                  pairs={topLiquidityPairs}
                />
              )}
              {newPoolsPairs?.length > 0 && (
                <NewPoolsSection
                  onPress={onPress}
                  onActionPress={onAdd}
                  pairs={newPoolsPairs}
                />
              )}
              <View>
                <ThemedTextV2
                  dark={tailwind("text-mono-dark-v2-500")}
                  light={tailwind("text-mono-light-v2-500")}
                  style={tailwind(
                    "font-normal-v2 text-xs uppercase pl-10 mb-2",
                  )}
                >
                  {translate("screens/DexScreen", "Available pairs")}
                </ThemedTextV2>
              </View>
            </>
          ) : (
            <></>
          )}
        </>
      }
    />
  );
}

interface PoolCardProps {
  item: DexItem<WalletToken | PoolPairData>;
  onAdd: (data: PoolPairData, info: WalletToken) => void;
  onRemove: (data: PoolPairData, info: WalletToken) => void;
  onSwap: (data: PoolPairData, info: WalletToken) => void;
  onPress: (id: string) => void;
  type: "your" | "available";
  index: number;
  isFavouritePoolpair: (id: string) => boolean;
  setFavouritePoolpair: (id: string) => void;
}

function PoolCard({
  item,
  isFavouritePoolpair,
  setFavouritePoolpair,
  type,
  onSwap,
  onPress,
  index,
  onAdd,
  onRemove,
}: PoolCardProps): JSX.Element {
  const { getTokenPrice } = useTokenPrice();
  const { poolpairs: pairs } = useSelector((state: RootState) => state.wallet);
  const { data: yourPair } = item;
  const isFavoritePair = isFavouritePoolpair(yourPair.id);

  const poolPairData = pairs.find(
    (pr) => pr.data.symbol === (yourPair as AddressToken).symbol,
  );
  const mappedPair = poolPairData?.data;

  const [symbolA, symbolB] =
    mappedPair?.tokenA != null && mappedPair?.tokenB != null
      ? [mappedPair.tokenA.displaySymbol, mappedPair.tokenB.displaySymbol]
      : yourPair.symbol.split("-");

  if (mappedPair === undefined) {
    return <></>;
  }
  return (
    <Delayed waitBeforeShow={0}>
      <ThemedTouchableOpacityV2
        style={tailwind("px-5 py-4 mb-2 rounded-lg-v2 mx-5")}
        dark={tailwind("bg-mono-dark-v2-00")}
        light={tailwind("bg-mono-light-v2-00")}
        testID={type === "your" ? "pool_pair_row_your" : "pool_pair_row"}
        onPress={() => onPress(item.data.id)}
      >
        <View testID={`pool_pair_row_${index}_${mappedPair.displaySymbol}`}>
          {type === "available" ? (
            <AvailablePool
              symbolA={symbolA}
              symbolB={symbolB}
              pair={mappedPair}
              onSwap={() => onSwap(mappedPair, yourPair as WalletToken)}
              aToBPrice={new BigNumber(mappedPair.priceRatio.ab)}
              bToAPrice={new BigNumber(mappedPair.priceRatio.ba)}
              isFavouritePair={isFavoritePair}
              setFavouritePoolpair={setFavouritePoolpair}
              status={mappedPair.status}
            />
          ) : (
            <YourPoolPair
              symbolA={symbolA}
              symbolB={symbolB}
              walletToken={yourPair as WalletToken}
              poolPair={mappedPair}
              onAdd={() => onAdd(mappedPair, yourPair as WalletToken)}
              onRemove={() => onRemove(mappedPair, yourPair as WalletToken)}
              walletTokenAmount={
                new BigNumber((yourPair as WalletToken).amount)
              }
              walletTokenPrice={getTokenPrice(
                yourPair.symbol,
                new BigNumber((yourPair as WalletToken).amount),
                true,
              )}
            />
          )}
        </View>
      </ThemedTouchableOpacityV2>
    </Delayed>
  );
}

interface AvailablePoolProps {
  symbolA: string;
  symbolB: string;
  onSwap: () => void;
  pair: PoolPairData;
  aToBPrice: BigNumber;
  bToAPrice: BigNumber;
  isFavouritePair: boolean;
  setFavouritePoolpair: (id: string) => void;
  status: boolean;
}

export type ActionType = "SET_FAVOURITE" | "UNSET_FAVOURITE";

function AvailablePool(props: AvailablePoolProps): JSX.Element {
  const toast = useToast();
  const TOAST_DURATION = 2000;
  const showToast = (type: ActionType): void => {
    toast.hideAll();
    const toastMessage =
      type === "SET_FAVOURITE"
        ? "Pool added as favorite"
        : "Pool removed from favorites";
    toast.show(translate("screens/PoolPairDetailsScreen", toastMessage), {
      type: "wallet_toast",
      placement: "top",
      duration: TOAST_DURATION,
    });
  };
  return (
    <>
      <View
        style={tailwind("flex flex-row justify-between items-center w-full")}
      >
        <View style={tailwind("flex flex-row items-center")}>
          <PoolPairIconV2
            symbolA={props.symbolA}
            symbolB={props.symbolB}
            customSize={36}
            iconBStyle={tailwind("-ml-4 mr-2")}
          />
          <ThemedTextV2
            style={tailwind("font-semibold-v2 text-base mr-2")}
            testID={`pair_symbol_${props.symbolA}-${props.symbolB}`}
          >
            {`${props.symbolA}-${props.symbolB}`}
          </ThemedTextV2>
          <FavoriteButton
            pairId={props.pair.id}
            isFavouritePair={props.isFavouritePair}
            onPress={() => {
              showToast(
                props.isFavouritePair ? "UNSET_FAVOURITE" : "SET_FAVOURITE",
              );
              props.setFavouritePoolpair(props.pair.id);
            }}
          />
        </View>
        <DexActionButton
          label={translate("screens/DexScreen", "Swap")}
          onPress={props.onSwap}
          testID={`composite_swap_button_${props.pair.id}`}
          style={tailwind("py-2 px-4")}
          disabled={!props.status}
        />
      </View>
      <View style={tailwind("flex flex-row justify-between mt-3")}>
        <PriceRatesSection
          {...getSortedPriceRates({
            mappedPair: props.pair,
            aToBPrice: props.aToBPrice,
            bToAPrice: props.bToAPrice,
          })}
        />
        {props.pair?.apr?.total !== undefined &&
          props.pair?.apr?.total !== null && (
            <APRSection
              label={translate("screens/DexScreen", "APR")}
              value={{
                text: new BigNumber(
                  isNaN(props.pair.apr.total) ? 0 : props.pair.apr.total,
                )
                  .times(100)
                  .toFixed(2),
                decimalScale: 2,
                testID: `apr_${props.symbolA}-${props.symbolB}`,
                suffix: "%",
              }}
            />
          )}
      </View>
    </>
  );
}

interface YourPoolPairProps {
  onAdd: () => void;
  onRemove: () => void;
  symbolA: string;
  symbolB: string;
  poolPair: PoolPairData;
  walletToken: WalletToken;
  walletTokenPrice: BigNumber;
  walletTokenAmount: BigNumber;
}

function YourPoolPair(props: YourPoolPairProps): JSX.Element {
  return (
    <>
      <View
        style={tailwind("flex flex-row justify-between items-center w-full")}
      >
        <View style={tailwind("flex flex-row items-center")}>
          <PoolPairIconV2
            symbolA={props.symbolA}
            symbolB={props.symbolB}
            customSize={36}
            iconBStyle={tailwind("-ml-4 mr-2")}
          />
          <ThemedTextV2
            style={tailwind("font-semibold-v2 text-base")}
            testID={`pair_symbol_${props.symbolA}-${props.symbolB}`}
          >
            {`${props.symbolA}-${props.symbolB}`}
          </ThemedTextV2>
        </View>
        <DexAddRemoveLiquidityButton
          onAdd={props.onAdd}
          onRemove={props.onRemove}
          pairToken={`${props.symbolA}-${props.symbolB}`}
        />
      </View>
      <View style={tailwind("flex flex-row justify-between mt-3")}>
        <PoolSharesSection
          walletTokenPrice={props.walletTokenPrice}
          walletTokenAmount={props.walletTokenAmount}
          tokenID={props.walletToken.id}
        />
        {props.poolPair?.apr?.total !== undefined &&
          props.poolPair?.apr?.total !== null && (
            <APRSection
              label={translate("screens/DexScreen", "APR")}
              value={{
                text: new BigNumber(
                  isNaN(props.poolPair.apr.total)
                    ? 0
                    : props.poolPair.apr.total,
                )
                  .times(100)
                  .toFixed(2),
                decimalScale: 2,
                testID: `apr_${props.symbolA}-${props.symbolB}`,
                suffix: "%",
              }}
            />
          )}
      </View>
    </>
  );
}

function getSortedPriceRates({
  mappedPair,
  aToBPrice,
  bToAPrice,
}: {
  mappedPair: PoolPairData;
  aToBPrice: BigNumber;
  bToAPrice: BigNumber;
}): {
  tokenA: {
    symbol: string;
    displaySymbol: string;
    priceRate: BigNumber;
  };
  tokenB: {
    symbol: string;
    displaySymbol: string;
    priceRate: BigNumber;
  };
} {
  const tokenA = {
    symbol: mappedPair.tokenA.symbol,
    displaySymbol: mappedPair.tokenA.displaySymbol,
    priceRate: bToAPrice,
  };
  const tokenB = {
    symbol: mappedPair.tokenB.symbol,
    displaySymbol: mappedPair.tokenB.displaySymbol,
    priceRate: aToBPrice,
  };

  return {
    tokenA,
    tokenB,
  };
}

function sortPoolpairsByFavourite(
  pairs: Array<DexItem<PoolPairData>>,
  isFavouritePair: (id: string) => boolean,
): Array<DexItem<PoolPairData>> {
  return pairs.slice().sort((firstPair, secondPair) => {
    if (isFavouritePair(firstPair.data.id)) {
      return -1;
    }
    if (isFavouritePair(secondPair.data.id)) {
      return 1;
    }
    return 0;
  });
}

function TopLiquiditySection({
  pairs,
  onPress,
  onActionPress,
}: {
  pairs: Array<DexItem<PoolPairData>>;
  onPress: (id: string) => void;
  onActionPress: (data: PoolPairData) => void;
}): JSX.Element {
  return (
    <DexScrollable
      testID="dex_top_liquidity"
      sectionHeading="TOP LIQUIDITY"
      sectionStyle={tailwind("mb-6")}
    >
      {pairs.map((pairItem, index) => (
        <DexScrollable.Card
          key={`${pairItem.data.id}_${index}`}
          poolpair={pairItem.data}
          style={tailwind("mr-2")}
          onActionPress={() => onActionPress(pairItem.data)}
          onPress={() => onPress(pairItem.data.id)}
          label={translate("screens/DexScreen", "Swap")}
          testID={`composite_swap_${pairItem.data.id}`}
          isSwap
        />
      ))}
    </DexScrollable>
  );
}

function NewPoolsSection({
  pairs,
  onPress,
  onActionPress,
}: {
  pairs: Array<DexItem<PoolPairData | WalletToken>>;
  onPress: (id: string) => void;
  onActionPress: (data: PoolPairData, info: WalletToken) => void;
}): JSX.Element {
  return (
    <DexScrollable
      testID="dex_new_pools"
      sectionHeading="NEW POOLS"
      sectionStyle={tailwind("mb-6")}
    >
      {pairs.map((pairItem, index) => (
        <DexScrollable.Card
          key={`${pairItem.data.id}_${index}`}
          poolpair={pairItem.data as PoolPairData}
          style={tailwind("mr-2")}
          onActionPress={() =>
            onActionPress(
              pairItem.data as PoolPairData,
              pairItem.data as WalletToken,
            )
          }
          onPress={() => onPress(pairItem.data.id)}
          label={translate("screens/DexScreen", "Add to LP")}
          testID={`add_liquidity_${pairItem.data.id}`}
        />
      ))}
    </DexScrollable>
  );
}