DeFiCh/wallet

View on GitHub
mobile-app/app/screens/AppNavigator/screens/Portfolio/screens/OCG/CFPDetailScreen.tsx

Summary

Maintainability
D
1 day
Test Coverage
import { useCallback, useEffect, useMemo, useState } from "react";
import { translate } from "@translations";
import { ThemedTextV2 } from "@components/themed";
import { tailwind } from "@tailwind";
import { ProposalURLInput } from "@screens/AppNavigator/screens/Portfolio/components/ProposalURLInput";
import { Platform, View } from "react-native";
import { WalletTextInputV2 } from "@components/WalletTextInputV2";
import { ButtonV2 } from "@components/ButtonV2";
import { useNetworkContext, useThemeContext } from "@waveshq/walletkit-ui";
import { BottomSheetInfoV2 } from "@components/BottomSheetInfoV2";
import { LoanAddRemoveActionButton } from "@screens/AppNavigator/screens/Loans/components/LoanActionButton";
import { useForm } from "react-hook-form";
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { PortfolioParamList } from "@screens/AppNavigator/screens/Portfolio/PortfolioNavigator";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import BigNumber from "bignumber.js";
import {
  queueConvertTransaction,
  useConversion,
} from "@hooks/wallet/Conversion";
import { useSelector } from "react-redux";
import { RootState } from "@store";
import { LocalAddress, WhitelistedAddress } from "@store/userPreferences";
import {
  DFITokenSelector,
  DFIUtxoSelector,
  hasOceanTXQueued,
  hasTxQueued,
} from "@waveshq/walletkit-ui/dist/store";
import { useLogger } from "@shared-contexts/NativeLoggingProvider";
import { useAppDispatch } from "@hooks/useAppDispatch";
import { useWhaleApiClient } from "@waveshq/walletkit-ui/dist/contexts";
import { OCGProposalType } from "@screens/AppNavigator/screens/Portfolio/screens/OCG/OCGProposalsScreen";
import { AddressRow } from "@screens/AppNavigator/screens/Portfolio/components/AddressRow";
import { ButtonGroupTabKey } from "@screens/AppNavigator/screens/Portfolio/screens/AddressBookScreen";
import { DomainType } from "@contexts/DomainContext";
import { ConvertDirection } from "@screens/enum";
import { AddressType as JellyfishAddressType } from "@waveshq/walletkit-core";

export function CFPDetailScreen(): JSX.Element {
  const logger = useLogger();
  const dispatch = useAppDispatch();
  const { isLight } = useThemeContext();
  const { networkName } = useNetworkContext();
  const client = useWhaleApiClient();

  const [fee, setFee] = useState<BigNumber>(new BigNumber(0.0001));
  const [amount, setAmount] = useState<string>("");
  const proposalFee = getCFPFee(BigNumber(amount ?? 0));
  const navigation = useNavigation<NavigationProp<PortfolioParamList>>();
  const [isUrlValid, setUrlValid] = useState<boolean>(false);
  const [matchedAddress, setMatchedAddress] = useState<
    LocalAddress | WhitelistedAddress
  >();

  const DFIToken = useSelector((state: RootState) =>
    DFITokenSelector(state.wallet),
  );
  const DFIUtxo = useSelector((state: RootState) =>
    DFIUtxoSelector(state.wallet),
  );
  const hasPendingJob = useSelector((state: RootState) =>
    hasTxQueued(state.transactionQueue),
  );
  const hasPendingBroadcastJob = useSelector((state: RootState) =>
    hasOceanTXQueued(state.ocean),
  );

  const { isConversionRequired, conversionAmount } = useConversion({
    inputToken: {
      type: "utxo",
      amount: proposalFee.plus(fee),
    },
    deps: [fee, amount, DFIUtxo],
  });

  // form
  const {
    control,
    setValue,
    getValues,
    trigger,
    formState: { isValid },
  } = useForm({
    mode: "onChange",
  });

  const [url, setUrl] = useState<string>("");
  const [title, setTitle] = useState<string>("");
  const [cycle, setCycle] = useState<string>("1");
  const [minCycle, maxCycle] = [1, 100];
  const address = getValues("address");

  useEffect(() => {
    client.fee
      .estimate()
      .then((f) => setFee(new BigNumber(f)))
      .catch(logger.error);
  }, []);

  const titleStatus = useMemo(() => {
    const isEmpty = title === undefined || title.trim() === "";
    const maxTitleLength = 128;
    return {
      isEmpty: isEmpty,
      isValidToSubmit: !isEmpty && new Blob([title]).size <= maxTitleLength,
      shouldShowError: !isEmpty && new Blob([title]).size > maxTitleLength,
    };
  }, [title]);

  const onAddressSelect = useCallback(
    async (savedAddress: string) => {
      setValue("address", savedAddress, { shouldDirty: true });
      navigation.goBack();
      await trigger("address");
    },
    [navigation],
  );

  function onContinuePress() {
    if (!isButtonEnabled() || hasPendingJob || hasPendingBroadcastJob) {
      return;
    }
    const params: PortfolioParamList["OCGConfirmScreen"] = {
      type: OCGProposalType.CFP,
      fee,
      proposalFee,
      url: url,
      title: title,
      amountRequest: BigNumber(amount),
      cycle: Number(cycle),
      receivingAddress: address,
      ...(isConversionRequired && {
        conversion: {
          isConversionRequired,
          DFIToken,
          DFIUtxo,
          conversionAmount,
        },
      }),
    };

    if (isConversionRequired) {
      queueConvertTransaction(
        {
          mode: ConvertDirection.accountToUtxos,
          amount: conversionAmount,
        },
        dispatch,
        () => {
          navigation.navigate("OCGConfirmScreen", params);
        },
        logger,
        () => {
          params.conversion = {
            DFIUtxo,
            DFIToken,
            isConversionRequired: true,
            conversionAmount,
            isConverted: true,
          };
          navigation.navigate({
            name: "OCGConfirmScreen",
            params,
            merge: true,
          });
        },
      );
    } else {
      navigation.navigate("OCGConfirmScreen", params);
    }
  }

  function isFieldEmpty(value: string | undefined): boolean {
    return value === undefined || value.trim() === "";
  }

  function isButtonEnabled(): boolean {
    return (
      !isFieldEmpty(url) &&
      titleStatus.isValidToSubmit &&
      !isFieldEmpty(amount) &&
      BigNumber(cycle).gte(minCycle) &&
      BigNumber(cycle).lte(maxCycle) &&
      isValid
    );
  }

  return (
    <KeyboardAwareScrollView
      contentContainerStyle={tailwind("flex-grow px-5 pb-6 justify-between")}
      style={tailwind(
        `${isLight ? "bg-mono-light-v2-100" : "bg-mono-dark-v2-100"}`,
      )}
    >
      <View>
        <ProposalURLInput urlValidity={setUrlValid} onChangeUrlInput={setUrl} />
        {isUrlValid && (
          <View style={tailwind("pt-6")} testID="detail_container">
            <WalletTextInputV2
              inputType="default"
              multiline
              testID="input_title"
              value={title}
              onChangeText={setTitle}
              title={translate("screens/OCGDetailScreen", "PROPOSAL TITLE")}
              placeholder={translate("screens/OCGDetailScreen", "Title")}
              style={tailwind("w-3/5 flex-grow pb-1 font-normal-v2")}
              displayClearButton={!isFieldEmpty(title)}
              onClearButtonPress={() => setTitle("")}
              inlineText={{
                type: titleStatus.shouldShowError ? "error" : "helper",
                text: translate(
                  "screens/OCGDetailScreen",
                  titleStatus.shouldShowError
                    ? "Title exceeds max character limit of 128."
                    : "Make sure that the name added here is the same as from the one posted in GitHub or Reddit.",
                ),
                style: tailwind("pl-5", {
                  "text-red-v2": titleStatus.shouldShowError,
                  "text-mono-light-v2-500":
                    !titleStatus.shouldShowError && isLight,
                  "text-mono-dark-v2-500":
                    !titleStatus.shouldShowError && !isLight,
                }),
              }}
              valid={!titleStatus.shouldShowError}
            />
            <WalletTextInputV2
              inputType="numeric"
              testID="input_amount"
              value={amount}
              onChangeText={setAmount}
              title={translate(
                "screens/OCGDetailScreen",
                "AMOUNT REQUESTED IN DFI",
              )}
              placeholder="0.00 DFI"
              inputContainerStyle={tailwind("py-4.5")}
              titleStyle={tailwind("pt-4")}
              displayClearButton={!isFieldEmpty(amount)}
              onClearButtonPress={() => setAmount("")}
            />
            <VotingCycles
              cycle={cycle}
              setCycle={setCycle}
              minCycle={minCycle}
              maxCycle={maxCycle}
            />
            <AddressRow
              control={control}
              networkName={networkName}
              title={translate("screens/OCGDetailScreen", "RECEIVING ADDRESS")}
              onContactButtonPress={() =>
                navigation.navigate({
                  name: "AddressBookScreen",
                  params: {
                    selectedAddress: getValues("address"),
                    addressDomainType: DomainType.DVM,
                    onAddressSelect,
                    disabledTab: ButtonGroupTabKey.Whitelisted,
                  },
                  merge: true,
                })
              }
              showQrButton={false}
              onClearButtonPress={async () => {
                setValue("address", "");
                await trigger("address");
              }}
              onAddressChange={async (address) => {
                setValue("address", address, { shouldDirty: true });
                await trigger("address");
              }}
              address={address}
              onMatchedAddress={setMatchedAddress}
              setMatchedAddress={setMatchedAddress}
              matchedAddress={matchedAddress}
              onlyLocalAddress
              restrictedJellyfishAddressType={[JellyfishAddressType.ETH]}
            />
          </View>
        )}
      </View>
      <View style={tailwind("justify-center pt-6")}>
        <ThemedTextV2
          style={tailwind("font-normal-v2 text-sm px-12 text-center")}
          light={tailwind("text-mono-light-v2-500")}
          dark={tailwind("text-mono-dark-v2-500")}
        >
          {translate(
            "screens/OCGDetailScreen",
            "Review full proposal details in the next screen",
          )}
        </ThemedTextV2>
        <ButtonV2
          label={translate("screens/OCGDetailScreen", "Continue")}
          styleProps="mt-5 mx-7"
          testID="cfp_continue_button"
          onPress={onContinuePress}
          disabled={
            !isButtonEnabled() || hasPendingJob || hasPendingBroadcastJob
          }
        />
      </View>
    </KeyboardAwareScrollView>
  );
}

function VotingCycles({
  cycle,
  setCycle,
  minCycle,
  maxCycle,
}: {
  cycle: string;
  setCycle: (cycle: string) => void;
  minCycle: number;
  maxCycle: number;
}): JSX.Element {
  return (
    <View>
      <View
        style={tailwind("flex-row items-center justify-start pt-4 pl-5 pb-2")}
      >
        <ThemedTextV2
          style={tailwind("text-xs font-normal-v2")}
          light={tailwind("text-mono-light-v2-500")}
          dark={tailwind("text-mono-dark-v2-500")}
        >
          {translate("screens/OCGDetailScreen", "CYCLE(S)")}
        </ThemedTextV2>
        <View
          style={tailwind("ml-1", {
            "mt-0.5": Platform.OS === "android",
          })}
        >
          <BottomSheetInfoV2
            alertInfo={{
              title: translate("screens/OCGDetailScreen", "Voting Cycle(s)"),
              message: translate(
                "screens/OCGDetailScreen",
                "Cycle(s) determine the duration for which a proposal can accept votes.",
              ),
            }}
            name="voting_cycles"
            infoIconStyle={{
              light: tailwind("text-mono-light-v2-500"),
              dark: tailwind("text-mono-dark-v2-500"),
            }}
          />
        </View>
      </View>
      <WalletTextInputV2
        inputType="number-pad"
        testID="input_cycle"
        inputContainerStyle={tailwind("pl-5 pr-4 py-2.5")}
        value={cycle.toString()}
        onChangeText={setCycle}
        valid={
          Number(cycle) >= minCycle &&
          Number(cycle) <= maxCycle &&
          Number.isInteger(Number(cycle))
        }
        inlineText={{
          type: "error",
          text: translate(
            "screens/OCGDetailScreen",
            "Cycle(s) should be 1-100 only",
          ),
          style: tailwind("pl-5"),
        }}
      >
        <LoanAddRemoveActionButton
          token="cycle"
          onAdd={() =>
            setCycle(
              Math.min(Math.floor(Number(cycle) + 1), maxCycle).toString(),
            )
          }
          onRemove={() =>
            setCycle(
              Math.max(Math.floor(Number(cycle) - 1), minCycle).toString(),
            )
          }
          leftDisabled={Number(cycle) <= minCycle}
          rightDisabled={Number(cycle) >= maxCycle}
        />
      </WalletTextInputV2>
    </View>
  );
}

function getCFPFee(requestedAmount?: BigNumber): BigNumber {
  const CFP_MIN_FEE = 10;
  const amount = requestedAmount ?? new BigNumber(0);
  return new BigNumber(
    Math.max(amount.multipliedBy(0.01).toNumber(), CFP_MIN_FEE),
  );
}