Newer
Older
CrypticOreWallet / src / pages / names / mgmt / NameEditModal.tsx
@Drew Lemmy Drew Lemmy on 30 Mar 2021 7 KB chore: more error handling
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { useState, useRef, Dispatch, SetStateAction } from "react";
import { Modal, notification } from "antd";

import { useTFns } from "@utils/i18n";

import {
  useWallets, useMasterPasswordOnly,
  decryptAddresses, DecryptErrorGone, DecryptErrorFailed,
  ValidDecryptedAddresses
} from "@wallets";
import { useNameSuffix } from "@utils/krist";

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

import { NameOption, fetchNames, buildLUT } from "./lookupNames";
import { handleEditError } from "./handleErrors";
import { lockNameTable, NameTableLock } from "../tableLock";

import { useNameEditForm } from "./NameEditForm";
import { useEditProgress } from "./EditProgress";
import { showConfirmModal } from "./ConfirmModal";
import { SuccessNotifContent } from "./SuccessNotifContent";

import awaitTo from "await-to-js";

export type Mode = "transfer" | "update";

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

  name?: string;
  aRecord?: string | null;
  mode: Mode;
}

export function NameEditModal({
  visible,
  setVisible,
  name,
  aRecord,
  mode
}: Props): JSX.Element {
  // Return translated strings with the correct prefix depending on the mode
  const tFns = useTFns(mode === "transfer" ? "nameTransfer." : "nameUpdate.");
  const { t, tKey, tStr, tErr } = tFns;

  const [submitting, setSubmitting] = useState(false);
  // Pause updates of the name table if it's visible when submitting
  const tableLock = useRef<NameTableLock>();

  // Confirmation modal used for when transferring 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/update
  const masterPassword = useMasterPasswordOnly();

  // Create the form. This is usually not rendered during submission.
  const { form, formInstance, resetFields }
    = useNameEditForm({ name, aRecord, mode, submitting, onSubmit, tFns });
  // Progress bar for bulk edits
  const { progressBar, onProgress, initProgress, resetProgress }
    = useEditProgress(tFns);

  // Wrap the handleError function
  const onError = handleEditError.bind(
    handleEditError,
    tFns, showAuthFailed, walletAddressMap, mode
  );

  // Actually perform the bulk name edit
  async function handleSubmit(
    names: NameOption[],
    recipient?: string,
    aRecord?: string
  ) {
    if (!masterPassword) return;

    // 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 tErr("errorWalletGone");

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

    // We've already validated the names, so these can be cast
    const finalAddresses = decryptResults as ValidDecryptedAddresses;
    const finalNames = names.map(n => ({ name: n.key, owner: n.owner }));

    // Lock the name table if present
    tableLock?.current?.release();
    tableLock.current = lockNameTable();

    if (mode === "transfer") {
      // Transfer the names
      await transferNames(finalAddresses, finalNames, recipient!, onProgress);
    } else if (mode === "update") {
      // Update the names
      await updateNames(finalAddresses, finalNames, aRecord!, onProgress);
    }

    // Success! Show notification and close modal
    const count = names.length;
    notif.success({
      message: t(tKey("successMessage"), { count }),
      description: <SuccessNotifContent
        count={count}
        recipient={recipient}
        mode={mode}
      />
    });

    setSubmitting(false);
    tableLock?.current?.release();
    closeModal();
  }

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

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

    // 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: tStr("errorNotifTitle"),
        description: tStr("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 edit
    const namesLUT = buildLUT(names);
    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;

    // Don't return this promise, so the confirm modal closes immediately
    const triggerSubmit = () => {
      initProgress(count);
      handleSubmit(filteredNames, recipient, aRecord)
        .catch(onError)
        .finally(() => {
          setSubmitting(false);
          tableLock?.current?.release();
        });
    };

    if (mode === "transfer" && count > 1) {
      // If transferring multiple names, prompt for confirmation
      showConfirmModal(
        t, confirmModal,
        count, allNamesCount, recipient!,
        triggerSubmit, setSubmitting
      );
    } else {
      // Otherwise, submit straight away
      triggerSubmit();
    }
  }

  function closeModal() {
    // Don't allow closing the modal while submitting
    if (submitting) return;
    setVisible(false);
    resetFields();
    resetProgress();
    tableLock?.current?.release();
  }

  return <>
    <Modal
      visible={visible}

      title={tStr("modalTitle")}

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

      onCancel={closeModal}
      cancelText={t("dialog.cancel")}
      destroyOnClose
    >
      {/* Only render the form if not submitting */}
      {!submitting && form}

      {submitting && progressBar}
    </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}
  </>;
}