From 91c5c449e53d44a14ad2dc384e44f64af603f9c9 Mon Sep 17 00:00:00 2001 From: Daniel Liu Date: Mon, 21 Aug 2023 17:31:59 -0400 Subject: [PATCH] useMetamask hook --- src/app/dashboard/layout.tsx | 3 + src/app/layout.tsx | 5 +- src/app/test/page.tsx | 36 +++++++++- src/components/Wallet.tsx | 135 +++++++++++++++++++++++++++++++++++ src/hooks/useListen.tsx | 26 +++++++ src/hooks/useMetamask.tsx | 98 +++++++++++++++++++++++++ 6 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/components/Wallet.tsx create mode 100644 src/hooks/useListen.tsx create mode 100644 src/hooks/useMetamask.tsx diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index a172b8b..4e102ca 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { MetamaskStateProvider } from "use-metamask"; import "./style.css"; import Link from "next/link"; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5732299..b9eadb8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { MetamaskProvider } from "@/hooks/useMetamask"; import "./globals.css"; import type { Metadata } from "next"; import { Inter } from "next/font/google"; @@ -16,7 +17,9 @@ export default function RootLayout({ }) { return ( - {children} + + {children} + ); } diff --git a/src/app/test/page.tsx b/src/app/test/page.tsx index ca94b59..c2aa14b 100644 --- a/src/app/test/page.tsx +++ b/src/app/test/page.tsx @@ -1,7 +1,10 @@ "use client"; +import Wallet from "@/components/Wallet"; +import { useListen } from "@/hooks/useListen"; +import { useMetamask } from "@/hooks/useMetamask"; import Image from "next/image"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; export default function Page() { const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -9,6 +12,35 @@ export default function Page() { const [issues, setIssues] = useState([]); const [bounties, setBounties] = useState([]); + const { dispatch } = useMetamask(); + const listen = useListen(); + + useEffect(() => { + if (typeof window !== undefined) { + // start by checking if window.ethereum is present, indicating a wallet extension + const ethereumProviderInjected = typeof window.ethereum !== "undefined"; + // this could be other wallets so we can verify if we are dealing with metamask + // using the boolean constructor to be explecit and not let this be used as a falsy value (optional) + const isMetamaskInstalled = + ethereumProviderInjected && Boolean(window.ethereum.isMetaMask); + + const local = window.localStorage.getItem("metamaskState"); + + // user was previously connected, start listening to MM + if (local) { + listen(); + } + + // local could be null if not present in LocalStorage + const { wallet, balance } = local + ? JSON.parse(local) + : // backup if local storage is empty + { wallet: null, balance: null }; + + dispatch({ type: "pageLoaded", isMetamaskInstalled, wallet, balance }); + } + }, []); + const handleLoginSubmit = async ( e: React.FormEvent ): Promise => { @@ -152,6 +184,8 @@ export default function Page() {
+ + ); } diff --git a/src/components/Wallet.tsx b/src/components/Wallet.tsx new file mode 100644 index 0000000..4ef130f --- /dev/null +++ b/src/components/Wallet.tsx @@ -0,0 +1,135 @@ +import Link from "next/link"; +import { useListen } from "@/hooks/useListen"; +import { useMetamask } from "@/hooks/useMetamask"; + +function Loading() { + return ( +
+

Loading

+
+ ); +} + +export default function Wallet() { + const { + dispatch, + state: { status, isMetamaskInstalled, wallet, balance }, + } = useMetamask(); + const listen = useListen(); + + const showInstallMetamask = + status !== "pageNotLoaded" && !isMetamaskInstalled; + const showConnectButton = + status !== "pageNotLoaded" && isMetamaskInstalled && !wallet; + + const isConnected = status !== "pageNotLoaded" && typeof wallet === "string"; + + const handleConnect = async () => { + dispatch({ type: "loading" }); + const accounts = await window.ethereum.request({ + method: "eth_requestAccounts", + }); + + if (accounts.length > 0) { + const balance = await window.ethereum!.request({ + method: "eth_getBalance", + params: [accounts[0], "latest"], + }); + dispatch({ type: "connect", wallet: accounts[0], balance }); + + // we can register an event listener for changes to the users wallet + listen(); + } + }; + + const handleDisconnect = () => { + dispatch({ type: "disconnect" }); + }; + + const handleAddUsdc = async () => { + dispatch({ type: "loading" }); + + await window.ethereum.request({ + method: "wallet_watchAsset", + params: { + type: "ERC20", + options: { + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + symbol: "USDC", + decimals: 18, + image: "https://cryptologos.cc/logos/usd-coin-usdc-logo.svg?v=023", + }, + }, + }); + dispatch({ type: "idle" }); + }; + + return ( +
+
+

+ Metamask API intro +

+ + {wallet && balance && ( +
+
+
+
+
+

+ Address: {wallet} +

+

+ Balance:{" "} + + {(parseInt(balance) / 1000000000000000000).toFixed(4)}{" "} + ETH + +

+
+
+
+
+
+ )} + + {showConnectButton && ( + + )} + + {showInstallMetamask && ( + + Install Metamask + + )} + + {isConnected && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/hooks/useListen.tsx b/src/hooks/useListen.tsx new file mode 100644 index 0000000..f9e4a4a --- /dev/null +++ b/src/hooks/useListen.tsx @@ -0,0 +1,26 @@ +import { useMetamask } from "./useMetamask"; + +export const useListen = () => { + const { dispatch } = useMetamask(); + + return () => { + window.ethereum.on("accountsChanged", async (newAccounts: string[]) => { + if (newAccounts.length > 0) { + // uppon receiving a new wallet, we'll request again the balance to synchronize the UI. + const newBalance = await window.ethereum!.request({ + method: "eth_getBalance", + params: [newAccounts[0], "latest"], + }); + + dispatch({ + type: "connect", + wallet: newAccounts[0], + balance: newBalance, + }); + } else { + // if the length is 0, then the user has disconnected from the wallet UI + dispatch({ type: "disconnect" }); + } + }); + }; +}; diff --git a/src/hooks/useMetamask.tsx b/src/hooks/useMetamask.tsx new file mode 100644 index 0000000..36ab441 --- /dev/null +++ b/src/hooks/useMetamask.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React, { useEffect, type PropsWithChildren } from "react"; + +type ConnectAction = { type: "connect"; wallet: string; balance: string }; +type DisconnectAction = { type: "disconnect" }; +type PageLoadedAction = { + type: "pageLoaded"; + isMetamaskInstalled: boolean; + wallet: string | null; + balance: string | null; +}; +type LoadingAction = { type: "loading" }; +type IdleAction = { type: "idle" }; + +type Action = + | ConnectAction + | DisconnectAction + | PageLoadedAction + | LoadingAction + | IdleAction; + +type Dispatch = (action: Action) => void; + +type Status = "loading" | "idle" | "pageNotLoaded"; + +type State = { + wallet: string | null; + isMetamaskInstalled: boolean; + status: Status; + balance: string | null; +}; + +const initialState: State = { + wallet: null, + isMetamaskInstalled: false, + status: "loading", + balance: null, +} as const; + +function metamaskReducer(state: State, action: Action): State { + switch (action.type) { + case "connect": { + const { wallet, balance } = action; + const newState = { ...state, wallet, balance, status: "idle" } as State; + const info = JSON.stringify(newState); + window.localStorage.setItem("metamaskState", info); + + return newState; + } + case "disconnect": { + window.localStorage.removeItem("metamaskState"); + if (typeof window.ethereum !== undefined) { + window.ethereum.removeAllListeners(["accountsChanged"]); + } + return { ...state, wallet: null, balance: null }; + } + case "pageLoaded": { + const { isMetamaskInstalled, balance, wallet } = action; + return { ...state, isMetamaskInstalled, status: "idle", wallet, balance }; + } + case "loading": { + return { ...state, status: "loading" }; + } + case "idle": { + return { ...state, status: "idle" }; + } + + default: { + throw new Error("Unhandled action type"); + } + } +} + +const MetamaskContext = React.createContext< + { state: State; dispatch: Dispatch } | undefined +>(undefined); + +function MetamaskProvider({ children }: PropsWithChildren) { + const [state, dispatch] = React.useReducer(metamaskReducer, initialState); + const value = { state, dispatch }; + + return ( + + {children} + + ); +} + +function useMetamask() { + const context = React.useContext(MetamaskContext); + if (context === undefined) { + throw new Error("useMetamask must be used within a MetamaskProvider"); + } + return context; +} + +export { MetamaskProvider, useMetamask };