livepeer/livepeerjs

View on GitHub
packages/explorer-2.0/components/EditProfile/index.tsx

Summary

Maintainability
F
3 days
Test Coverage
import { useState, useEffect } from "react";
import { ThreeBoxSpace } from "../../@types";
import Box from "../Box";
import Flex from "../Flex";
import Camera from "../../public/img/camera.svg";
import Button from "../Button";
import ReactTooltip from "react-tooltip";
import Check from "../../public/img/check.svg";
import Copy from "../../public/img/copy.svg";
import { CopyToClipboard } from "react-copy-to-clipboard";
import Textfield from "../Textfield";
import useForm from "react-hook-form";
import { useMutation, gql } from "@apollo/client";
import QRCode from "qrcode.react";
import Modal from "../Modal";
import ExternalAccount from "../ExternalAccount";
import { useDebounce } from "use-debounce";
import ThreeBoxSteps from "../ThreeBoxSteps";
import Spinner from "../Spinner";
import { useWeb3React } from "@web3-react/core";
import { ethers } from "ethers";
import Textarea from "../Textarea";
interface Props {
  account: string;
  threeBoxSpace?: ThreeBoxSpace;
  refetch?: any;
}

const UPDATE_PROFILE = gql`
  mutation updateProfile(
    $name: String
    $website: String
    $description: String
    $image: String
    $proof: JSON
    $defaultProfile: String
  ) {
    updateProfile(
      name: $name
      website: $website
      description: $description
      image: $image
      proof: $proof
      defaultProfile: $defaultProfile
    ) {
      __typename
      id
      name
      website
      description
      image
      defaultProfile
    }
  }
`;

function hasExistingProfile(profile) {
  return (
    profile.name || profile.website || profile.description || profile.image
  );
}

const Index = ({ threeBoxSpace, refetch, account }: Props) => {
  const context = useWeb3React();
  const { register, handleSubmit, formState, watch } = useForm();
  const [previewImage, setPreviewImage] = useState(null);
  const [saving, setSaving] = useState(false);
  const [editProfileOpen, setEditProfileOpen] = useState(false);
  const [createProfileModalOpen, setCreateProfileModalOpen] = useState(false);
  const [existingProfileOpen, setExistingProfileOpen] = useState(false);
  const [activeStep, setActiveStep] = useState(0);
  const [verified, setVerified] = useState(false);
  const [hasProfile, setHasProfile] = useState(false);
  const [message, setMessage] = useState("");
  const [copied, setCopied] = useState(false);
  const [timestamp] = useState(Math.floor(Date.now() / 1000));
  const name = watch("name");
  const website = watch("website");
  const description = watch("description");
  const image = watch("image");
  const signature = watch("signature");
  const ethereumAccount = watch("ethereumAccount");
  const reader = new FileReader();
  const [updateProfile] = useMutation(UPDATE_PROFILE);
  const [debouncedSignature] = useDebounce(signature, 200);
  const [debouncedEthereumAccount] = useDebounce(ethereumAccount, 200);

  useEffect(() => {
    if (copied) {
      setTimeout(() => {
        setCopied(false);
      }, 2000);
    }
  }, [copied]);

  useEffect(() => {
    setMessage(
      `Create a new 3Box profile<br /><br />-<br />Your unique profile ID is ${threeBoxSpace.did}<br />Timestamp: ${timestamp}`
    );
    (async () => {
      const ThreeBox = require("3box");
      const profile = await ThreeBox.getProfile(context.account);

      if (hasExistingProfile(profile)) {
        setHasProfile(true);
      }

      if (signature && ethereumAccount) {
        try {
          const verifiedAccount = ethers.utils.verifyMessage(
            message.replace(/<br ?\/?>/g, "\n"),
            signature
          );
          if (verifiedAccount.toLowerCase() === ethereumAccount.toLowerCase()) {
            setVerified(true);
          } else {
            setVerified(false);
          }
        } catch (e) {
          setVerified(false);
        }
      }
    })();
  }, [
    debouncedSignature,
    debouncedEthereumAccount,
    message,
    context.account,
    ethereumAccount,
    signature,
    threeBoxSpace,
    timestamp,
  ]);

  reader.onload = function (e) {
    setPreviewImage(e.target.result);
  };

  if (image && image.length) {
    reader.readAsDataURL(image[0]);
  }

  const onClick = async () => {
    const ThreeBox = require("3box");

    if (threeBoxSpace.defaultProfile) {
      setEditProfileOpen(true);
    } else {
      setCreateProfileModalOpen(true);
      const box = await ThreeBox.openBox(
        account,
        context.library._web3Provider
      );
      setActiveStep(1);
      await box.syncDone;

      // Create a 3box account if a user doesn't already have one
      if (!hasProfile) {
        await box.linkAddress();
        await box.syncDone;
        setActiveStep(2);
      }

      const space = await box.openSpace("livepeer");
      await space.syncDone;

      if (hasProfile) {
        setCreateProfileModalOpen(false);
        setExistingProfileOpen(true);
      } else {
        await updateProfile({
          variables: { defaultProfile: "livepeer" },
          context: {
            box,
            address: account,
          },
        });
        await space.syncDone;
        setCreateProfileModalOpen(false);
        setEditProfileOpen(true);
      }
    }
  };

  const proof = signature
    ? {
        version: 1,
        type: "ethereum-eoa",
        message: message.replace(/<br ?\/?>/g, "\n"),
        timestamp,
        signature,
      }
    : null;

  const onSubmit = async () => {
    const ThreeBox = require("3box");

    setSaving(true);
    const box = await ThreeBox.openBox(
      context.account,
      context.library._web3Provider
    );
    let hash = null;

    if (previewImage && image.length) {
      const formData = new window.FormData();
      formData.append("path", image[0]);
      const resp = await fetch("https://ipfs.infura.io:5001/api/v0/add", {
        method: "post",
        body: formData,
      });
      const infuraResponse = await resp.json();
      hash = infuraResponse["Hash"];
    }

    const variables = {
      ...threeBoxSpace,
      ...(name && { name }),
      ...(website && { website }),
      ...(description && { description }),
      ...(hash && { image: hash }),
      ...(proof && { proof }),
      defaultProfile: threeBoxSpace.defaultProfile
        ? threeBoxSpace.defaultProfile
        : "livepeer",
    };

    const optimisticResponse = {
      __typename: "Mutation",
      updateProfile: {
        __typename: "ThreeBoxSpace",
        id: account.toLowerCase(),
        name: name ? name : threeBoxSpace.name,
        website: website ? website : threeBoxSpace.website,
        description: description ? description : threeBoxSpace.description,
        image: hash ? hash : threeBoxSpace.image,
        defaultProfile: threeBoxSpace.defaultProfile,
      },
    };

    const result = updateProfile({
      variables,
      optimisticResponse: !proof ? optimisticResponse : null,
      context: {
        box,
        address: account,
      },
    });

    // We don't use an optimistic response if user is linking external account
    if (proof) {
      await result;
      await refetch({
        variables: {
          account,
        },
      });
    }

    setSaving(false);
    setEditProfileOpen(false);
  };

  return (
    <>
      <Button
        onClick={() => onClick()}
        css={{ mt: "3px", ml: "$3", fontWeight: 600 }}
        outline
        size="small">
        {threeBoxSpace.defaultProfile ? "Edit Profile" : "Set up my profile"}
      </Button>
      <Modal isOpen={createProfileModalOpen} title="Profile Setup">
        <>
          <Box css={{ mb: "$3" }}>
            Approve the signing prompts in your web3 wallet to continue setting
            up your profile.
          </Box>
          <Box
            css={{
              border: "1px solid",
              borderColor: "$border",
              borderRadius: "$4",
              p: "$4",
              alignItems: "center",
              justifyContent: "center",
              mb: "$4",
            }}>
            <Flex
              css={{ justifyContent: "space-between", alignItems: "center" }}>
              <ThreeBoxSteps hasProfile={hasProfile} activeStep={activeStep} />
            </Flex>
          </Box>
          <Flex css={{ justifyContent: "flex-end" }}>
            <Button outline onClick={() => setCreateProfileModalOpen(false)}>
              Close
            </Button>
          </Flex>
        </>
      </Modal>

      <Modal isOpen={existingProfileOpen} title="Use Existing Profile?">
        <>
          <Box
            css={{
              lineHeight: 1.5,
              border: "1px solid",
              borderColor: "$border",
              borderRadius: 6,
              p: "$4",
              alignItems: "center",
              justifyContent: "center",
              mb: "$4",
            }}>
            We recognized that you already have a 3box profile. Would you like
            to use it in Livepeer?
          </Box>
          <Flex css={{ justifyContent: "flex-end" }}>
            <Button
              css={{ mr: "$3" }}
              outline
              onClick={async () => {
                const ThreeBox = require("3box");
                const box = await ThreeBox.openBox(
                  context.account,
                  context.library._web3Provider
                );
                await updateProfile({
                  variables: {
                    defaultProfile: "livepeer",
                  },
                  context: {
                    box,
                    address: account.toLowerCase(),
                  },
                });
                await refetch({
                  variables: {
                    account: account.toLowerCase(),
                  },
                });
                setExistingProfileOpen(false);
                setEditProfileOpen(true);
              }}>
              Create New
            </Button>
            <Button
              onClick={async () => {
                const ThreeBox = require("3box");
                const box = await ThreeBox.openBox(
                  context.account,
                  context.library._web3Provider
                );
                await updateProfile({
                  variables: {
                    defaultProfile: "3box",
                  },
                  context: {
                    box,
                    address: account.toLowerCase(),
                  },
                });
                setExistingProfileOpen(false);
                setEditProfileOpen(true);
              }}>
              Use Existing
            </Button>
          </Flex>
        </>
      </Modal>
      <Modal
        isOpen={editProfileOpen}
        onDismiss={() => setEditProfileOpen(false)}
        title="Edit Profile">
        <Box as="form" onSubmit={handleSubmit(onSubmit)}>
          <Box css={{ mb: "$3" }}>
            {threeBoxSpace.defaultProfile === "3box" ? (
              <Box
                css={{
                  alignItems: "center",
                  justifyContent: "center",
                  mb: "$4",
                }}>
                <Box
                  as="a"
                  css={{ color: "$primary" }}
                  href={`https://3box.io/${context.account}`}
                  target="__blank">
                  Edit profile on 3box.io
                </Box>
              </Box>
            ) : (
              <>
                <Box
                  as="label"
                  htmlFor="threeBoxImage"
                  css={{
                    display: "inline-flex",
                    alignItems: "center",
                    justifyContent: "center",
                    position: "relative",
                    cursor: "pointer",
                    marginBottom: 24,
                  }}>
                  <Box
                    css={{
                      width: "100px",
                      height: "100px",
                      borderRadius: "100%",
                      position: "absolute",
                      zIndex: 0,
                      left: 0,
                      bg: "rgba(0,0,0, .5)",
                    }}
                  />
                  {previewImage && (
                    <Box
                      as="img"
                      css={{
                        objectFit: "cover",
                        borderRadius: 1000,
                        width: 100,
                        height: 100,
                      }}
                      src={previewImage}
                    />
                  )}
                  {!previewImage && threeBoxSpace?.image && (
                    <Box
                      as="img"
                      css={{
                        objectFit: "cover",
                        borderRadius: 1000,
                        width: 100,
                        height: 100,
                      }}
                      src={`https://ipfs.infura.io/ipfs/${threeBoxSpace.image}`}
                    />
                  )}
                  {!previewImage && !threeBoxSpace?.image && (
                    <QRCode
                      style={{
                        borderRadius: 1000,
                        width: 100,
                        height: 100,
                      }}
                      bgColor="#9326E9"
                      fgColor={`#${account.substr(2, 6)}`}
                      value={account}
                    />
                  )}
                  <Box css={{ position: "absolute" }}>
                    <Camera />
                  </Box>
                  <Box
                    as="input"
                    ref={register}
                    id="threeBoxImage"
                    name="image"
                    css={{
                      width: 0.1,
                      height: 0.1,
                      opacity: 0,
                      overflow: "hidden",
                      position: "absolute",
                      zIndex: -1,
                    }}
                    accept="image/jpeg,image/png,image/webp"
                    type="file"
                  />
                </Box>
                <Textfield
                  ref={register}
                  defaultValue={threeBoxSpace ? threeBoxSpace.name : ""}
                  name="name"
                  placeholder="Name"
                  css={{ mb: "$3", width: "100%" }}
                />
                <Textfield
                  ref={register}
                  defaultValue={threeBoxSpace ? threeBoxSpace.website : ""}
                  placeholder="Website"
                  type="url"
                  name="website"
                  css={{ mb: "$3", width: "100%" }}
                />
                <Textarea
                  ref={register}
                  defaultValue={threeBoxSpace ? threeBoxSpace.description : ""}
                  name="description"
                  placeholder="Description"
                  size={3}
                  css={{ mb: "$3", width: "100%" }}
                />
              </>
            )}
            <ExternalAccount refetch={refetch} threeBoxSpace={threeBoxSpace}>
              <Box css={{ pt: "$3", mb: "$2", fontSize: "$5" }}>
                Instructions
              </Box>
              <Box as="ol" css={{ pl: 15 }}>
                <Box as="li" css={{ mb: "$5" }}>
                  <Box css={{ mb: "$3" }}>
                    Run the Livepeer CLI and select the option to "Sign a
                    message". When prompted for a message to sign, copy and
                    paste the following message:
                  </Box>
                  <Box
                    css={{
                      p: "$3",
                      mb: "$2",
                      position: "relative",
                      color: "$primary",
                      bg: "background",
                      borderRadius: 4,
                      fontFamily: "$monospace",
                      whiteSpace: "pre-wrap",
                      overflowWrap: "break-word",
                    }}>
                    <Box
                      dangerouslySetInnerHTML={{
                        __html: message,
                      }}
                    />
                    <CopyToClipboard
                      text={message.replace(/<br ?\/?>/g, "\n")}
                      onCopy={() => setCopied(true)}>
                      <Flex
                        data-for="copyMessage"
                        data-tip={`${
                          copied ? "Copied" : "Copy message to clipboard"
                        }`}
                        css={{
                          ml: "$2",
                          mt: "3px",
                          position: "absolute",
                          right: 12,
                          top: 10,
                          cursor: "pointer",
                          borderRadius: 1000,
                          bg: "surface",
                          width: 26,
                          height: 26,
                          alignItems: "center",
                          justifyContent: "center",
                        }}>
                        <ReactTooltip
                          id="copyMessage"
                          className="tooltip"
                          place="left"
                          type="dark"
                          effect="solid"
                        />
                        {copied ? (
                          <Check
                            css={{
                              width: 12,
                              height: 12,
                              color: "$muted",
                            }}
                          />
                        ) : (
                          <Copy
                            css={{
                              width: 12,
                              height: 12,
                              color: "$muted",
                            }}
                          />
                        )}
                      </Flex>
                    </CopyToClipboard>
                  </Box>
                </Box>
                <Box as="li" css={{ mb: "$4" }}>
                  <Box css={{ mb: "$3" }}>
                    The Livepeer CLI will copy the Ethereum signed message
                    signature to your clipboard. It should begin with "0x".
                    Paste it here.
                  </Box>
                  <Textfield
                    ref={register}
                    name="signature"
                    placeholder="Signature"
                    css={{ width: "100%" }}
                  />
                </Box>
                <Box as="li" css={{ mb: 0 }}>
                  <Box css={{ mb: "$3" }}>
                    Verify the message was signed correctly by pasting your
                    Livepeer Node Ethereum account used to sign the message in
                    the Livpeeer CLI.
                  </Box>
                  <Textfield
                    ref={register}
                    name="ethereumAccount"
                    placeholder="Ethereum Account"
                    css={{
                      width: "100%",
                      "&:invalid": {
                        borderColor: "$red",
                      },
                    }}
                  />
                  {signature &&
                    ethereumAccount &&
                    (verified ? (
                      <Box
                        as="span"
                        css={{ fontSize: "$1", color: "$primary" }}>
                        Signature message verification successful.
                      </Box>
                    ) : (
                      <Box as="span" css={{ fontSize: "$1", color: "red" }}>
                        Signature message verification failed.
                      </Box>
                    ))}
                </Box>
              </Box>
            </ExternalAccount>
          </Box>

          <Box>
            <Flex css={{ justifyContent: "flex-end" }}>
              <Button
                outline
                onClick={() => setEditProfileOpen(false)}
                css={{ mr: "$3" }}>
                Cancel
              </Button>
              <Button
                disabled={
                  !formState.dirty ||
                  saving ||
                  (threeBoxSpace.defaultProfile === "3box" && !verified) ||
                  (threeBoxSpace.defaultProfile === "livepeer" &&
                    (signature || ethereumAccount) &&
                    !verified)
                }
                type="submit">
                <Flex css={{ alignItems: "center" }}>
                  {saving && (
                    <Spinner css={{ width: 16, height: 16, mr: "$2" }} />
                  )}
                  Save
                </Flex>
              </Button>
            </Flex>
          </Box>
        </Box>
      </Modal>
    </>
  );
};

export default Index;