Newer
Older
CrypticOreWallet / src / pages / names / mgmt / NameTransferModal.tsx
@Drew Lemmy Drew Lemmy on 15 Mar 2021 9 KB feat: initial work on name transfer
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { useState, Dispatch, SetStateAction } from "react";
import { Modal, Form, notification } from "antd";

import { useTranslation, Trans } from "react-i18next";
import { TranslatedError, translateError } from "@utils/i18n";

import {
  useWallets, useMasterPasswordOnly,
  decryptAddresses, DecryptErrorGone, DecryptErrorFailed,
  ValidDecryptedAddresses
} from "@wallets";
import { NameOption, fetchNames } from "./lookupNames";
import { useNameSuffix } from "@utils/currency";

import { APIError } from "@api";
import { transferNames } from "@api/names";
import { useAuthFailedModal, AuthFailedError } from "@api/AuthFailed";

import { NamePicker } from "./NamePicker";
import { AddressPicker } from "@comp/addresses/picker/AddressPicker";
import { ContextualAddress } from "@comp/addresses/ContextualAddress";

import awaitTo from "await-to-js";
import { groupBy } from "lodash-es";

import Debug from "debug";
const debug = Debug("kristweb:name-transfer-modal");

interface FormValues {
  names: string[];
  recipient: string;
}

interface Props {
  visible: boolean;
  setVisible: Dispatch<SetStateAction<boolean>>;

  name?: string;
}

export function NameTransferModal({
  visible,
  setVisible,
  name
}: Props): JSX.Element {
  const { t } = useTranslation();

  const [form] = Form.useForm<FormValues>();
  const [submitting, setSubmitting] = useState(false);

  // Used to filter out names owned by the recipient
  const [names, setNames] = useState<string[] | undefined>();
  const [recipient, setRecipient] = useState<string | undefined>();

  // Confirmation modal used for when sending multiple names.
  // This is created here to provide a translation context for the modal.
  const [confirmModal, contextHolder] = Modal.useModal();
  // Modal used when auth fails
  const { showAuthFailed, authFailedContextHolder } = useAuthFailedModal();
  // Context for translation in the success notification
  const [notif, notifContextHolder] = notification.useNotification();

  // Used to fetch the list of available names
  const { walletAddressMap } = useWallets();
  const nameSuffix = useNameSuffix();
  // Used to decrypt the wallets for transfer
  const masterPassword = useMasterPasswordOnly();

  // Actually perform the bulk name transfer
  async function handleSubmit(names: NameOption[], recipient: string) {
    if (!masterPassword) return;
    debug("submitting with names %o", names);

    // Attempt to decrypt each wallet. Group the names by wallet to create a
    // LUT of decrypted privatekeys.
    const nameOwners = names.map(n => n.owner);
    const decryptResults = await decryptAddresses(
      masterPassword, walletAddressMap, nameOwners
    );

    // Check if there were any decryption errors
    if (Object.values(decryptResults).includes(DecryptErrorGone))
      throw new TranslatedError("nameTransfer.errorWalletGone");

    const decryptFailed = Object.entries(decryptResults)
      .find(([_, r]) => r === DecryptErrorFailed);
    if (decryptFailed) {
      throw new Error(t(
        "nameTransfer.errorWalletDecrypt",
        { address: decryptFailed[0] }
      ));
    }

    // Finally perform the transfer
    await transferNames(
      // We've already validated the names
      decryptResults as ValidDecryptedAddresses,
      names.map(n => ({ name: n.key, owner: n.owner })),
      recipient
    );


    // Success! Show notification and close modal
    const count = names.length;

    notif.success({
      message: t("nameTransfer.successMessage", { count }),
      description: <SuccessNotifContent
        count={count}
        recipient={recipient}
      />
    });

    closeModal();
  }

  // Convert API errors to friendlier errors
  function handleError(err: Error) {
    // Construct a TranslatedError pre-keyed to nameTransfer
    const tErr = (key: string) => new TranslatedError("nameTransfer." + key);
    const onError = (err: Error) => notification.error({
      message: t("nameTransfer.errorNotificationTitle"),
      description: translateError(t, err, "nameTransfer.errorUnknown")
    });

    switch (err.message) {
    case "missing_parameter":
    case "invalid_parameter":
      switch ((err as APIError).parameter) {
      case "name":
        return onError(tErr("errorParameterNames"));
      case "address":
        return onError(tErr("errorParameterRecipient"));
      }
      break;
    case "name_not_found":
      return onError(tErr("errorNameNotFound"));
    case "not_name_owner":
      return onError(tErr("errorNotNameOwner"));
    case "auth_failed":
      return showAuthFailed(walletAddressMap[(err as AuthFailedError).address!]);
    }

    // Pass through any other unknown errors
    console.error(err);
    onError(err);
  }

  // Validate the form and consolidate all the data before submission
  async function onSubmit() {
    setSubmitting(true);

    // Get the form values
    const [err, values] = await awaitTo(form.validateFields());
    if (err || !values) {
      // Validation errors are handled by the form
      setSubmitting(false);
      return;
    }

    // Convert the desired names to a lookup table
    const { names, recipient } = values;
    const namesLUT = names
      .reduce((out, name) => {
        out[name] = true;
        return out;
      }, {} as Record<string, boolean>);

    // Lookup the names list one last time, to associate the name owners
    // to the wallets for decryption, and to show the correct confirmation
    // modal.
    const nameGroups = await fetchNames(t, nameSuffix, walletAddressMap);
    if (!nameGroups) {
      // This shouldn't happen, but if the owner suddenly has no names anymore,
      // show an error.
      notification.error({
        message: t("nameTransfer.errorNotifTitle"),
        description: t("nameTransfer.errorNameRequired")
      });
      setSubmitting(false);
      return;
    }

    // Filter out names owned by the recipient, just in case.
    const filteredNameGroups = nameGroups
      .filter(g => g.wallet.address !== recipient);

    // The names from filteredNameGroups that we actually want to transfer
    const allNames = filteredNameGroups.flatMap(g => g.names);
    const filteredNames = allNames.filter(n => !!namesLUT[n.key]);

    // All the names owned by the wallets, used for the confirmation modal.
    const allNamesCount = allNames.length;
    const count = filteredNames.length;

    // If sending multiple names, prompt for confirmation
    if (count > 1) {
      confirmModal.confirm({
        title: t("nameTransfer.modalTitle"),
        content: <ConfirmModalContent
          count={count}
          allNamesCount={allNamesCount}
          recipient={recipient}
        />,

        okText: t("nameTransfer.buttonSubmit"),
        onOk: () => handleSubmit(filteredNames, recipient)
          .catch(handleError)
          .finally(() => setSubmitting(false)),

        cancelText: t("dialog.cancel"),
        onCancel: () => setSubmitting(false)
      });
    } else {
      handleSubmit(filteredNames, recipient)
        .catch(handleError)
        .finally(() => setSubmitting(false));
    }
  }

  function onValuesChange(_: unknown, values: Partial<FormValues>) {
    setNames(values.names || undefined);
    setRecipient(values.recipient || undefined);
  }

  function closeModal() {
    setVisible(false);
    form.resetFields();
    setNames(undefined);
    setRecipient(undefined);
    setSubmitting(false);
  }

  const modal = <Modal
    visible={visible}

    title={t("nameTransfer.modalTitle")}

    onOk={onSubmit}
    okText={t("nameTransfer.buttonSubmit")}
    okButtonProps={submitting ? { loading: true } : undefined}

    onCancel={closeModal}
    cancelText={t("dialog.cancel")}
    destroyOnClose
  >
    <Form
      form={form}
      layout="vertical"
      className="name-transfer-form"

      name="nameTransfer"

      initialValues={{
        names: name ? [name] : undefined
      }}

      onValuesChange={onValuesChange}
      onFinish={onSubmit}
    >
      {/* Names */}
      <NamePicker
        formName="names"
        label={t("nameTransfer.labelNames")}
        tabIndex={1}

        filterOwner={recipient}

        value={names}
        setValue={names => form.setFieldsValue({ names })}

        multiple
        allowAll
      />

      {/* Recipient */}
      <AddressPicker
        name="recipient"
        label={t("nameTransfer.labelRecipient")}
        tabIndex={2}

        value={recipient}

        noNames
        nameHint
      />
    </Form>
  </Modal>;

  return <>
    {modal}

    {/* Give the modals somewhere to find the context from. This is done
      * outside of the modal so that they don't get immediately destroyed when
      * the modal closes. */}
    {contextHolder}
    {authFailedContextHolder}
    {notifContextHolder}
  </>;
}

interface CountRecipient {
  count: number;
  recipient: string;
}

function ConfirmModalContent({
  count,
  recipient,
  allNamesCount
}: CountRecipient & { allNamesCount: number }): JSX.Element {
  const { t } = useTranslation();

  // Show the appropriate message, if this is all the owner's names
  return <Trans
    t={t}
    i18nKey={count >= allNamesCount
      ? "nameTransfer.warningAllNames"
      : "nameTransfer.warningMultipleNames"}
    count={count}
  >
    Are you sure you want to transfer <b>{{ count }}</b> names to
    <ContextualAddress address={recipient} />?
  </Trans>;
}

function SuccessNotifContent({
  count,
  recipient
}: CountRecipient): JSX.Element {
  const { t } = useTranslation();

  // Show the appropriate message, if this is all the owner's names
  return <Trans
    t={t}
    i18nKey={"nameTransfer.successDescription"}
    count={count}
  >
    Transferred <b>{{ count }}</b> names to
    <ContextualAddress address={recipient} />.
  </Trans>;
}