DeFiCh/wallet

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

Summary

Maintainability
D
2 days
Test Coverage
import { MnemonicStorage } from "@api/wallet/mnemonic_storage";
import {
  ThemedIcon,
  ThemedScrollViewV2,
  ThemedSectionTitleV2,
  ThemedTextV2,
  ThemedTouchableOpacityV2,
  ThemedViewV2,
} from "@components/themed";
import { WalletAddressI, useWalletAddress } from "@hooks/useWalletAddress";
import { useAppDispatch } from "@hooks/useAppDispatch";
import { StackScreenProps } from "@react-navigation/stack";
import { useLogger } from "@shared-contexts/NativeLoggingProvider";
import { useNetworkContext, useWalletNodeContext } from "@waveshq/walletkit-ui";
import { RootState } from "@store";
import { authentication, Authentication } from "@store/authentication";
import { userPreferences } from "@store/userPreferences";
import { tailwind } from "@tailwind";
import { openURL } from "@api/linking";
import { translate } from "@translations";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { useSelector } from "react-redux";
import { WalletTextInputV2 } from "@components/WalletTextInputV2";
import { Text, TouchableOpacity, View } from "react-native";
import { ButtonV2 } from "@components/ButtonV2";
import { useDeFiScanContext } from "@shared-contexts/DeFiScanContext";
import { useToast } from "react-native-toast-notifications";
import { debounce } from "lodash";
import { AddressType, getAddressType } from "@waveshq/walletkit-core";
import * as Clipboard from "expo-clipboard";
import { DomainType, useDomainContext } from "@contexts/DomainContext";
import { SettingsParamList } from "../../Settings/SettingsNavigator";

type Props = StackScreenProps<SettingsParamList, "AddOrEditAddressBookScreen">;

export function AddOrEditAddressBookScreen({
  route,
  navigation,
}: Props): JSX.Element {
  const { isEvmFeatureEnabled } = useDomainContext();
  const {
    title,
    onSaveButtonPress,
    address,
    addressDomainType,
    addressLabel,
    isAddNew,
  } = route.params;
  const [labelInput, setLabelInput] = useState(addressLabel?.label);
  const [addressInput, setAddressInput] = useState<string | undefined>(address);
  const [isEditable, setIsEditable] = useState(isAddNew);
  const { networkName } = useNetworkContext();
  const addressBook = useSelector(
    (state: RootState) => state.userPreferences.addressBook,
  );
  const [labelInputErrorMessage, setLabelInputErrorMessage] = useState("");
  const [addressInputErrorMessage, setAddressInputErrorMessage] = useState("");
  const { fetchWalletAddresses } = useWalletAddress();
  // array of all wallet addresses
  const [walletAddress, setWalletAddress] = useState<string[]>([]);

  const AddressDomains = [
    {
      label: "DeFiChain (DVM)",
      value: DomainType.DVM,
    },
    {
      label: "MetaChain (EVM)",
      value: DomainType.EVM,
    },
  ];

  const [selectedAddressDomainType, setSelectedAddressDomainType] = useState(
    addressDomainType ?? DomainType.DVM,
  );

  const validateLabelInput = (input: string): boolean => {
    if (
      input.trim() === "" ||
      (input !== undefined && input.trim().length > 40)
    ) {
      setLabelInputErrorMessage(
        "Required field. Please enter a label. Maximum of 40 characters.",
      );
      return false;
    }
    setLabelInputErrorMessage("");
    return true;
  };

  const onQrButtonPress = (): void => {
    navigation.navigate({
      name: "BarCodeScanner",
      params: {
        onQrScanned: (value: string) => {
          setAddressInput(value);
        },
      },
      merge: true,
    });
  };

  const validateAddressInput = (input: string): boolean => {
    const addressType = getAddressType(input, networkName);
    if (
      addressType === undefined ||
      (selectedAddressDomainType === DomainType.DVM &&
        addressType === AddressType.ETH) ||
      (selectedAddressDomainType === DomainType.EVM &&
        addressType !== AddressType.ETH)
    ) {
      setAddressInputErrorMessage("Please enter a valid address");
      return false;
    }

    if (
      (addressBook?.[input.trim()] !== undefined &&
        (isAddNew || (!isAddNew && input.trim() !== address))) ||
      walletAddress.includes(input.trim())
    ) {
      // check for unique address when adding new, or only when new address is different from current during edit
      // or when address exists in local address
      setAddressInputErrorMessage(
        "This address already exists in your address book, please enter a different address",
      );
      return false;
    }
    setAddressInputErrorMessage("");
    return true;
  };

  const isSaveDisabled = (): boolean => {
    if (
      !isAddNew &&
      address === addressInput &&
      addressLabel?.label === labelInput
    ) {
      return true;
    }
    if (
      addressInput === undefined ||
      labelInput === undefined ||
      labelInputErrorMessage !== "" ||
      addressInputErrorMessage !== ""
    ) {
      return true;
    }
    return false;
  };

  // Passcode prompt
  const dispatch = useAppDispatch();
  const {
    data: { type: encryptionType },
  } = useWalletNodeContext();
  const isEncrypted = encryptionType === "MNEMONIC_ENCRYPTED";
  const logger = useLogger();

  const handleSubmit = useCallback(async (): Promise<void> => {
    if (
      !isEncrypted ||
      addressInput === undefined ||
      labelInput === undefined ||
      !validateLabelInput(labelInput) ||
      !validateAddressInput(addressInput)
    ) {
      return;
    }

    const auth: Authentication<string[]> = {
      consume: async (passphrase) => await MnemonicStorage.get(passphrase),
      onAuthenticated: async () => {
        const editedAddress = {
          [addressInput]: {
            address: addressInput,
            addressDomainType: selectedAddressDomainType,
            label: labelInput,
            isFavourite: addressLabel?.isFavourite,
          },
        };

        if (
          !isAddNew &&
          address !== undefined &&
          address !== addressInput.trim()
        ) {
          // delete current address if changing to a new address during edit
          dispatch(userPreferences.actions.deleteFromAddressBook(address));
        }
        dispatch(userPreferences.actions.addToAddressBook(editedAddress));
        onSaveButtonPress(addressInput);
        navigation.pop();
      },
      onError: (e) => logger.error(e),
      title: translate(
        "screens/AddOrEditAddressBookScreen",
        isAddNew
          ? "Add address to address book?\n{{address}}"
          : "Update address label for\n{{address}}",
        { address: addressInput },
      ),
      message: translate("screens/Settings", "Enter passcode to continue"),
      loading: translate(
        "screens/AddOrEditAddressBookScreen",
        isAddNew
          ? "It may take a few seconds to save"
          : "It may take a few seconds to update",
      ),
      successMessage: translate(
        "screens/AddOrEditAddressBookScreen",
        isAddNew ? "Address saved!" : "Address label updated!",
      ),
    };
    dispatch(authentication.actions.prompt(auth));
  }, [navigation, dispatch, isEncrypted, addressInput, labelInput]);

  const onDelete = useCallback(
    async (address: string): Promise<void> => {
      if (!isEncrypted) {
        return;
      }
      const auth: Authentication<string[]> = {
        consume: async (passphrase) => await MnemonicStorage.get(passphrase),
        onAuthenticated: async () => {
          dispatch(userPreferences.actions.deleteFromAddressBook(address));
          navigation.pop();
        },
        onError: (e) => logger.error(e),
        title: translate(
          "screens/AddOrEditAddressBookScreen",
          "Are you sure you want to delete the address?",
        ),
        message: translate("screens/Settings", "Enter passcode to continue"),
        loading: translate(
          "screens/AddOrEditAddressBookScreen",
          "It may take a few seconds to delete",
        ),
        successMessage: translate(
          "screens/AddOrEditAddressBookScreen",
          "Address deleted!",
        ),
      };
      dispatch(authentication.actions.prompt(auth));
    },
    [navigation, dispatch, isEncrypted],
  );

  useLayoutEffect(() => {
    navigation.setOptions({
      headerTitle: translate("screens/AddOrEditAddressBookScreen", title),
    });
  }, [navigation]);

  useEffect(() => {
    // validate on QR scan
    if (addressInput === undefined) {
      return;
    }
    validateAddressInput(addressInput);
  }, [addressInput, selectedAddressDomainType]);

  useEffect(() => {
    let isSubscribed = true;
    fetchWalletAddresses().then((walletAddress: WalletAddressI[]) => {
      if (isSubscribed) {
        const allWalletAddresses = walletAddress.reduce(
          (allAddress: string[], each: WalletAddressI) => {
            return [...allAddress, ...Object.values(each)];
          },
          [],
        );
        setWalletAddress(allWalletAddresses);
      }
    });
    return () => {
      isSubscribed = false;
    };
  }, [fetchWalletAddresses]);

  return (
    <ThemedScrollViewV2
      contentContainerStyle={tailwind("px-5 pb-16")}
      style={tailwind("flex-1")}
    >
      {isEvmFeatureEnabled && (
        <ThemedViewV2
          light={tailwind("bg-transparent")}
          dark={tailwind("bg-transparent")}
          style={tailwind("w-full flex-col")}
        >
          <ThemedSectionTitleV2
            testID="address_book_address_type_header"
            text={translate(
              "screens/AddOrEditAddressBookScreen",
              "ADDRESS TYPE",
            )}
          />
          <ThemedViewV2
            light={tailwind("bg-mono-light-v2-00 border-mono-light-v2-00")}
            dark={tailwind("bg-mono-dark-v2-00 border-mono-dark-v2-00")}
            style={tailwind("flex-col w-full border-0.5 rounded-lg-v2")}
          >
            {AddressDomains.map((addressDomain, index) => {
              const isChecked =
                selectedAddressDomainType === addressDomain.value;
              return (
                <ThemedTouchableOpacityV2
                  key={addressDomain.value}
                  light={tailwind("border-mono-light-v2-300")}
                  dark={tailwind("border-mono-dark-v2-300")}
                  style={[
                    tailwind("flex flex-row mx-5 py-4"),
                    index !== AddressDomains.length - 1 &&
                      tailwind("border-b-0.5"),
                  ]}
                  activeOpacity={0.7}
                  disabled={!isAddNew}
                  onPress={() => {
                    setSelectedAddressDomainType(addressDomain.value);
                  }}
                  testID={`address_book_address_type_${addressDomain.value}${
                    isChecked ? "_checked" : ""
                  }`}
                >
                  <ThemedIcon
                    size={20}
                    name={isChecked ? "check-circle" : "radio-button-off"}
                    light={tailwind(
                      isChecked ? "text-green-v2 " : "text-mono-light-v2-700",
                    )}
                    dark={tailwind(
                      isChecked ? "text-green-v2" : "text-mono-dark-v2-700",
                    )}
                    iconType="MaterialIcons"
                    testID="address_book_address_type_header"
                  />
                  <ThemedTextV2 style={tailwind("pl-4 text-sm font-normal-v2")}>
                    {translate(
                      "screens/AddOrEditAddressBookScreen",
                      addressDomain.label,
                    )}
                  </ThemedTextV2>
                </ThemedTouchableOpacityV2>
              );
            })}
          </ThemedViewV2>
        </ThemedViewV2>
      )}

      {isAddNew ? (
        <WalletTextInputV2
          value={addressInput}
          autoCapitalize="none"
          multiline
          inputType="default"
          displayClearButton={
            addressInput !== "" && addressInput !== undefined && isAddNew
          }
          onChangeText={(text: string) => {
            setAddressInput(text);
            validateAddressInput(text);
          }}
          onClearButtonPress={() => {
            setAddressInput("");
            validateAddressInput("");
          }}
          title={translate("screens/AddOrEditAddressBookScreen", "ADDRESS")}
          placeholder={translate(
            "screens/AddOrEditAddressBookScreen",
            "Enter address",
          )}
          style={tailwind("font-normal-v2 py-2.5 flex-1")}
          valid={addressInputErrorMessage === ""}
          inputContainerStyle={tailwind("px-5")}
          inlineText={{
            type: "error",
            text: translate(
              "screens/AddOrEditAddressBookScreen",
              addressInputErrorMessage,
            ),
            style: tailwind("px-5"),
          }}
          testID="address_book_address_input"
        >
          {(addressInput ?? "")?.trim().length === 0 && (
            <TouchableOpacity onPress={onQrButtonPress} testID="qr_code_button">
              <ThemedIcon
                light={tailwind("text-mono-light-v2-700")}
                dark={tailwind("text-mono-dark-v2-700")}
                style={tailwind("ml-2")}
                iconType="MaterialIcons"
                name="qr-code"
                size={20}
              />
            </TouchableOpacity>
          )}
        </WalletTextInputV2>
      ) : (
        <>
          {addressInput !== undefined && addressInput?.trim().length > 0 && (
            <CopyAddressComponent address={addressInput} />
          )}
        </>
      )}
      <WalletTextInputV2
        value={labelInput}
        inputType="default"
        editable={isAddNew || isEditable}
        displayClearButton={
          labelInput !== "" && labelInput !== undefined && isEditable
        }
        onChangeText={(text: string) => {
          setLabelInput(text);
          validateLabelInput(text);
        }}
        onClearButtonPress={() => {
          setLabelInput("");
          validateLabelInput("");
        }}
        inputContainerStyle={tailwind("px-5")}
        placeholder={translate(
          "screens/AddOrEditAddressBookScreen",
          "Enter label",
        )}
        style={tailwind("font-normal-v2 flex-1 py-2.5")}
        title={translate("screens/AddOrEditAddressBookScreen", "LABEL")}
        valid={labelInputErrorMessage === ""}
        inlineText={{
          type: "error",
          text: translate(
            "screens/AddOrEditAddressBookScreen",
            labelInputErrorMessage,
          ),
          style: tailwind("px-5"),
        }}
        testID="address_book_label_input"
      >
        {!isEditable && (
          <TouchableOpacity
            onPress={() => {
              setIsEditable(true);
            }}
            style={tailwind("ml-5")}
            testID="address_book_edit_label"
          >
            <ThemedIcon
              dark={tailwind("text-mono-dark-v2-700")}
              light={tailwind("text-mono-light-v2-700")}
              iconType="Feather"
              name="edit-2"
              size={20}
            />
          </TouchableOpacity>
        )}
      </WalletTextInputV2>
      {labelInputErrorMessage === "" && (
        <ThemedTextV2
          style={tailwind("font-normal-v2 text-xs mt-2 px-5")}
          light={tailwind("text-mono-light-v2-500")}
          dark={tailwind("text-mono-dark-v2-500")}
        >
          {translate(
            "screens/AddOrEditAddressBookScreen",
            "Maximum of 40 characters.",
          )}
        </ThemedTextV2>
      )}
      {!isEditable && address !== undefined ? (
        <>
          <ThemedTouchableOpacityV2
            light={tailwind("bg-mono-light-v2-00")}
            dark={tailwind("bg-mono-dark-v2-00 ")}
            style={tailwind(
              "border-0 p-4.5 flex-row justify-center rounded-lg-v2 mt-6",
            )}
            testID="delete_address"
            onPress={async () => await onDelete(address)}
          >
            <Text style={tailwind("font-normal-v2 text-sm text-red-v2")}>
              {translate(
                "screens/AddOrEditAddressBookScreen",
                "Delete address",
              )}
            </Text>
          </ThemedTouchableOpacityV2>
          <ThemedTextV2
            light={tailwind("text-mono-light-v2-500")}
            dark={tailwind("text-mono-dark-v2-500")}
            style={tailwind("font-normal-v2 mt-2 text-xs text-center")}
          >
            {translate(
              "screens/ServiceProviderScreen",
              "This will delete the whitelisted address\nfrom your address book.",
            )}
          </ThemedTextV2>
        </>
      ) : (
        <ButtonV2
          disabled={isSaveDisabled()}
          label={translate(
            "screens/AddOrEditAddressBookScreen",
            isAddNew ? "Save address" : "Save changes",
          )}
          onPress={handleSubmit}
          testID="save_address_label"
          styleProps="mx-7 mt-12"
        />
      )}
    </ThemedScrollViewV2>
  );
}

function CopyAddressComponent(props: { address: string }): JSX.Element {
  const { getAddressUrl } = useDeFiScanContext();
  const [showToast, setShowToast] = useState(false);
  const toast = useToast();
  const TOAST_DURATION = 2000;

  const copyToClipboard = useCallback(
    debounce(() => {
      if (showToast) {
        return;
      }
      setShowToast(true);
      setTimeout(() => setShowToast(false), TOAST_DURATION);
    }, 500),
    [showToast],
  );

  useEffect(() => {
    if (showToast) {
      toast.show(translate("components/toaster", "Copied"), {
        type: "wallet_toast",
        placement: "top",
        duration: TOAST_DURATION,
      });
    } else {
      toast.hideAll();
    }
  }, [showToast, props.address]);

  return (
    <ThemedViewV2
      light={tailwind("bg-transparent")}
      dark={tailwind("bg-transparent")}
      style={tailwind("w-full flex-col")}
    >
      <ThemedSectionTitleV2
        text={translate("screens/AddOrEditAddressBookScreen", "ADDRESS")}
      />
      <ThemedViewV2
        light={tailwind("bg-mono-light-v2-00 border-mono-light-v2-00")}
        dark={tailwind("bg-mono-dark-v2-00 border-mono-dark-v2-00")}
        style={tailwind("flex flex-col border-0.5 rounded-lg-v2")}
      >
        <View
          style={tailwind(
            "flex flex-row items-center py-4.5 px-5 justify-between",
          )}
        >
          <TouchableOpacity
            onPress={async () => {
              copyToClipboard();
              await Clipboard.setStringAsync(props.address);
            }}
            style={tailwind("w-10/12")}
          >
            <ThemedTextV2 style={tailwind("font-normal-v2 text-sm")}>
              {props.address}&nbsp;&nbsp;
              <ThemedIcon
                dark={tailwind("text-mono-dark-v2-700")}
                light={tailwind("text-mono-light-v2-700")}
                style={tailwind("pt-1")}
                iconType="Feather"
                name="copy"
                size={12}
              />
            </ThemedTextV2>
          </TouchableOpacity>
          <TouchableOpacity
            onPress={async () => {
              await openURL(getAddressUrl(props.address));
            }}
            style={tailwind("items-end")}
          >
            <ThemedIcon
              dark={tailwind("text-mono-dark-v2-700")}
              light={tailwind("text-mono-light-v2-700")}
              iconType="Feather"
              name="external-link"
              size={20}
            />
          </TouchableOpacity>
        </View>
      </ThemedViewV2>
    </ThemedViewV2>
  );
}