DeFiCh/wallet

View on GitHub
mobile-app/app/screens/TransactionAuthorization/PasscodePrompt.tsx

Summary

Maintainability
B
4 hrs
Test Coverage
import {
  ThemedActivityIndicatorV2,
  ThemedIcon,
  ThemedTextV2,
  ThemedTouchableOpacityV2,
  ThemedViewV2,
} from "@components/themed";
import { DfTxSigner } from "@waveshq/walletkit-ui/dist/store";
import { tailwind } from "@tailwind";
import { translate } from "@translations";
import { Platform, SafeAreaView, View } from "react-native";
import {
  TransactionStatus,
  USER_CANCELED,
} from "@screens/TransactionAuthorization/api/transaction_types";
import {
  BottomSheetBackdropProps,
  BottomSheetBackgroundProps,
  BottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useReducedMotion } from "react-native-reanimated";
import { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import * as React from "react";
import Modal from "react-overlays/Modal";
import { PinTextInputV2 } from "@components/PinTextInputV2";

/* eslint-disable react/no-unused-prop-types */
interface PasscodePromptProps {
  onCancel: (err: string) => void;
  title: string;
  message: string;
  transaction: DfTxSigner;
  status: TransactionStatus;
  pinLength: number;
  onPinInput: (pin: string) => void;
  pin: string;
  loadingMessage: string;
  authorizedTransactionMessage: { title: string; description: string };
  grantedAccessMessage: { title: string; description: string };
  isRetry: boolean;
  attemptsRemaining: number;
  maxPasscodeAttempt: number;
  promptModalName: string;
  modalRef: React.RefObject<BottomSheetModalMethods>;
  onModalCancel: () => void;
  additionalMessage?: string;
  additionalMessageUrl?: string;
  successMessage?: string;
}

// Todo(suraj) Remove code duplication and figure out a way to add focus to PinInput

const PromptContent = React.memo((props: PasscodePromptProps): JSX.Element => {
  return (
    <>
      <ThemedTouchableOpacityV2
        light={tailwind("bg-mono-light-v2-100")}
        dark={tailwind("bg-mono-dark-v2-100")}
        onPress={() => props.onCancel(USER_CANCELED)}
        style={tailwind("items-end pt-5 px-5 rounded-t-xl-v2")}
        testID="cancel_authorization"
        disabled={[TransactionStatus.BLOCK, TransactionStatus.SIGNING].includes(
          props.status,
        )}
      >
        <ThemedIcon
          dark={tailwind("text-mono-dark-v2-900")}
          light={tailwind("text-mono-light-v2-900")}
          iconType="Feather"
          name="x-circle"
          size={22}
        />
      </ThemedTouchableOpacityV2>
      <ThemedViewV2 style={tailwind("w-full flex-1 flex-col px-5")}>
        <ThemedTextV2
          style={tailwind("text-center font-normal-v2 pt-1.5 px-10", {
            "mb-16 pb-3": props.status === TransactionStatus.PIN,
          })}
          testID="txn_authorization_title"
        >
          {props.transaction?.title ?? props.title}
        </ThemedTextV2>
        {props.status === TransactionStatus.SIGNING && (
          <>
            <ThemedActivityIndicatorV2
              style={[tailwind("py-2 my-5"), { transform: [{ scale: 1.5 }] }]}
            />
            <ReadOnlyPinInput pinLength={props.pinLength} pin={props.pin} />
          </>
        )}
        {props.status === TransactionStatus.AUTHORIZED && (
          <>
            <SuccessIndicator />
            <ReadOnlyPinInput pinLength={props.pinLength} pin={props.pin} />
          </>
        )}
        {props.status === TransactionStatus.PIN && (
          <PinTextInputV2
            cellCount={props.pinLength}
            onChange={(pin) => {
              props.onPinInput(pin);
            }}
            testID="pin_authorize"
            value={props.pin}
          />
        )}
        <View style={tailwind("text-sm text-center mb-14 mt-4 px-10")}>
          {([TransactionStatus.SIGNING, TransactionStatus.AUTHORIZED].includes(
            props.status,
          ) ||
            (props.status === TransactionStatus.PIN && !props.isRetry)) && (
            <ThemedTextV2
              testID="txn_authorization_message"
              dark={tailwind("text-mono-dark-v2-700")}
              light={tailwind("text-mono-light-v2-700")}
              style={tailwind("text-sm text-center font-normal-v2")}
            >
              {props.status === TransactionStatus.SIGNING &&
                translate("screens/UnlockWallet", props.loadingMessage)}
              {props.status === TransactionStatus.AUTHORIZED &&
                props.successMessage}
              {props.status === TransactionStatus.PIN &&
                translate("screens/UnlockWallet", props.message)}
            </ThemedTextV2>
          )}

          {
            // hide description when passcode is incorrect or verified.
            props.status !== TransactionStatus.AUTHORIZED &&
              !props.isRetry &&
              props.transaction?.description !== undefined && (
                <ThemedTextV2
                  testID="txn_authorization_description"
                  dark={tailwind("text-mono-dark-v2-700")}
                  light={tailwind("text-mono-light-v2-700")}
                  style={tailwind("text-sm text-center font-normal-v2")}
                >
                  {props.transaction.description}
                </ThemedTextV2>
              )
          }
          {
            // show remaining attempt allowed
            props.status !== TransactionStatus.SIGNING &&
              props.attemptsRemaining !== undefined &&
              props.attemptsRemaining !== props.maxPasscodeAttempt && (
                <ThemedTextV2
                  style={tailwind("text-center text-sm font-normal-v2")}
                  light={tailwind("text-red-v2")}
                  dark={tailwind("text-red-v2")}
                  testID="pin_attempt_error"
                >
                  {translate(
                    "screens/PinConfirmation",
                    `${
                      props.attemptsRemaining === 1
                        ? "Last attempt or your wallet will be unlinked for your security"
                        : props.isRetry
                          ? "Incorrect passcode.\n{{attemptsRemaining}} attempts remaining"
                          : "{{attemptsRemaining}} attempts remaining"
                    }`,
                    { attemptsRemaining: props.attemptsRemaining },
                  )}
                </ThemedTextV2>
              )
          }
        </View>
      </ThemedViewV2>
    </>
  );
});

export const PasscodePrompt = React.memo(
  (props: PasscodePromptProps): JSX.Element => {
    const containerRef = React.useRef(null);
    const reducedMotion = useReducedMotion();

    const getSnapPoints = (): string[] => {
      if (Platform.OS === "ios") {
        return ["70%"]; // ios measures space without keyboard
      } else if (Platform.OS === "android") {
        return ["55%"]; // android measure space by including keyboard
      }
      return [];
    };

    if (Platform.OS === "web") {
      return (
        <SafeAreaView
          style={tailwind("w-full h-full flex-col absolute z-50 top-0 left-0")}
          ref={containerRef}
        >
          <Modal
            container={containerRef}
            show
            renderBackdrop={() => (
              <View
                style={{
                  position: "absolute",
                  top: 0,
                  right: 0,
                  bottom: 0,
                  left: 0,
                  backgroundColor: "black",
                  opacity: 0.6,
                }}
              />
            )}
            style={{
              position: "absolute",
              height: "450px",
              width: "375px",
              zIndex: 50,
              bottom: "0",
            }} // array as value crashes Web Modal
          >
            <View style={tailwind("w-full h-full")}>
              <PromptContent {...props} />
            </View>
          </Modal>
        </SafeAreaView>
      );
    }

    return (
      <BottomSheetModal
        name={props.promptModalName}
        ref={props.modalRef}
        snapPoints={getSnapPoints()}
        handleComponent={EmptyHandleComponent}
        animateOnMount={!reducedMotion}
        backdropComponent={(backdropProps: BottomSheetBackdropProps) => (
          <View
            {...backdropProps}
            style={[backdropProps.style, tailwind("bg-black bg-opacity-60")]}
          />
        )}
        backgroundComponent={(backgroundProps: BottomSheetBackgroundProps) => (
          <ThemedViewV2
            {...backgroundProps}
            style={[backgroundProps.style, tailwind("rounded-t-xl-v2")]}
          />
        )}
        onChange={(index) => {
          if (index === -1) {
            props.onModalCancel();
          }
        }}
        enablePanDownToClose={false}
      >
        <SafeAreaView
          style={tailwind("w-full h-full flex-col absolute z-50 top-0 left-0")}
        >
          <PromptContent {...props} />
        </SafeAreaView>
      </BottomSheetModal>
    );
  },
);

function EmptyHandleComponent(): JSX.Element {
  return <View />;
}

function SuccessIndicator(): JSX.Element {
  return (
    <View style={tailwind("flex flex-col items-center py-4.5 mb-0.5")}>
      <ThemedIcon
        size={38}
        name="check-circle"
        iconType="MaterialIcons"
        light={tailwind("text-green-v2")}
        dark={tailwind("text-green-v2")}
        testID="passcode_success_indicator"
      />
    </View>
  );
}

function ReadOnlyPinInput({
  pinLength,
  pin,
}: {
  pinLength: number;
  pin: string;
}): JSX.Element {
  return (
    <PinTextInputV2
      cellCount={pinLength}
      onChange={() => {}}
      testID="pin_authorize"
      value={pin}
      autofocus={false} // to hide keyboard input on android
    />
  );
}