diff --git a/src/App.tsx b/src/App.tsx index 786339c..8f32be0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; +import { WalletLoginModal } from "./wallet/WalletLoginModal"; import { WalletProvider } from "./wallet/WalletProvider"; import { PublicLayout } from "./layouts/PublicLayout"; import { LocalizedHomePage } from "./pages/LocalizedHome"; @@ -37,6 +38,7 @@ export default function App() { + diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 297b46c..dfa375e 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -19,6 +19,7 @@ import { stripLangPrefix, } from "../languageRoutes"; import { useLocalizedPath } from "../useLocalizedPath"; +import { WalletButton } from "../wallet/WalletButton"; type PublicNavWhich = | "home" @@ -657,6 +658,9 @@ export function PublicLayout() { ariaLabel={t("langLabel")} className="hidden h-10 w-36 md:block lg:w-40" /> +
+ +
+ {open ? ( +
+
+ {wallet.address} +
+ +
+ ) : null} + + ); + } + + return ( + + ); +} diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx new file mode 100644 index 0000000..77521ad --- /dev/null +++ b/src/wallet/WalletLoginModal.tsx @@ -0,0 +1,279 @@ +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { QRCodeSVG } from "qrcode.react"; +import { useEffect, useRef, useState } from "react"; +import { useAccount, useSignMessage } from "wagmi"; +import { useToast } from "../components/Toast"; +import { useI18n } from "../i18n"; +import { + createTokenPocketLoginRequest, + fetchTokenPocketLoginResult, + requestWalletNonce, + verifyWalletSignature, + type TokenPocketLoginRequest, +} from "./api"; +import { openWalletDeepLink } from "./deepLinks"; +import { useWallet } from "./WalletProvider"; + +const pollIntervalMs = 1800; + +type ModalState = "idle" | "tpLoading" | "tpPolling" | "rainbowSigning"; + +export function WalletLoginModal() { + const { t } = useI18n(); + const { showToast } = useToast(); + const wallet = useWallet(); + const { openConnectModal } = useConnectModal(); + const { address, isConnected } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const [state, setState] = useState("idle"); + const [error, setError] = useState(""); + const [tpRequest, setTpRequest] = useState( + null, + ); + const [rainbowPending, setRainbowPending] = useState(false); + const rainbowSigningRef = useRef(false); + + const close = () => { + if (state === "tpLoading" || state === "rainbowSigning") return; + wallet.closeLoginModal(); + setError(""); + }; + + useEffect(() => { + if (!wallet.loginModalOpen || !tpRequest) return; + if (state !== "tpPolling") return; + + let cancelled = false; + const poll = async () => { + try { + const result = await fetchTokenPocketLoginResult(tpRequest.actionId); + if (cancelled) return; + if (result.status === "completed") { + const verified = await verifyWalletSignature({ + address: result.address, + message: result.message, + signature: result.signature, + }); + if (cancelled) return; + wallet.completeLogin(verified.token, verified.wallet); + showToast(t("walletLoginSuccess")); + setState("idle"); + setTpRequest(null); + return; + } + if (result.status === "expired" || result.status === "failed") { + setState("idle"); + setError(result.error || t("walletTpExpired")); + } + } catch { + if (!cancelled) setError(t("walletLoginFailed")); + } + }; + + void poll(); + const timer = window.setInterval(() => void poll(), pollIntervalMs); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [state, tpRequest, t, showToast, wallet]); + + useEffect(() => { + if (!rainbowPending || !isConnected || !address) return; + if (rainbowSigningRef.current) return; + rainbowSigningRef.current = true; + setState("rainbowSigning"); + setError(""); + + void (async () => { + try { + const nonce = await requestWalletNonce(address); + const signature = await signMessageAsync({ message: nonce.message }); + const verified = await verifyWalletSignature({ + address, + message: nonce.message, + signature, + }); + wallet.completeLogin(verified.token, verified.wallet); + showToast(t("walletLoginSuccess")); + } catch (err) { + const message = + err instanceof Error ? err.message : t("walletLoginFailed"); + setError(message || t("walletLoginFailed")); + showToast(t("walletLoginFailed"), "error"); + } finally { + setState("idle"); + setRainbowPending(false); + rainbowSigningRef.current = false; + } + })(); + }, [ + address, + isConnected, + rainbowPending, + showToast, + signMessageAsync, + t, + wallet, + ]); + + if (!wallet.loginModalOpen) return null; + + const startInjected = async () => { + setError(""); + setState("idle"); + await wallet.signInInjected().catch(() => undefined); + }; + + const startTokenPocketQr = async () => { + setError(""); + setState("tpLoading"); + try { + const req = await createTokenPocketLoginRequest(); + setTpRequest(req); + setState("tpPolling"); + } catch { + setState("idle"); + setError(t("walletTpQrFailed")); + } + }; + + const startRainbowFallback = () => { + setError(""); + setRainbowPending(true); + openConnectModal?.(); + if (!openConnectModal) setError(t("walletRainbowUnavailable")); + }; + + return ( +
+
+
+
+

+ {t("walletLoginTitle")} +

+

+ {t("walletLoginDesc")} +

+
+ +
+ +
+ + +
+
+
+

+ {t("walletTokenPocketQr")} +

+

+ {t("walletTokenPocketQrDesc")} +

+
+ +
+ {tpRequest ? ( +
+ +

+ {t("walletQrUseAnotherDevice")} +

+
+ ) : null} +
+ +
+ + + +
+ +
+
+
+

+ {t("walletRainbowFallback")} +

+

+ {t("walletRainbowFallbackDesc")} +

+
+ +
+

+ {t("walletNetworkWarning")} +

+
+
+ + {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/src/wallet/deepLinks.ts b/src/wallet/deepLinks.ts new file mode 100644 index 0000000..5446b08 --- /dev/null +++ b/src/wallet/deepLinks.ts @@ -0,0 +1,29 @@ +type WalletKind = "tokenPocket" | "metaMask" | "imToken"; + +function currentDappUrl(): string { + if (typeof window === "undefined") return "https://ark-library.com"; + return window.location.href; +} + +export function walletDeepLink( + kind: WalletKind, + dappUrl = currentDappUrl(), +): string { + switch (kind) { + case "tokenPocket": + return `tpdapp://open?params=${encodeURIComponent( + JSON.stringify({ url: dappUrl, chain: "ETH" }), + )}`; + case "metaMask": + return `https://metamask.app.link/dapp/${dappUrl.replace(/^https?:\/\//, "")}`; + case "imToken": + return `imtokenv2://navigate/DappView?url=${encodeURIComponent(dappUrl)}`; + default: + return dappUrl; + } +} + +export function openWalletDeepLink(kind: WalletKind): void { + if (typeof window === "undefined") return; + window.location.href = walletDeepLink(kind); +}