fix(wallet): improve mobile login and logout flows

This commit is contained in:
TerryM
2026-06-02 21:05:01 +08:00
parent 0898744deb
commit f0209eb894
10 changed files with 317 additions and 288 deletions

View File

@@ -1,28 +1,17 @@
import { QRCodeSVG } from "qrcode.react";
import { LoaderCircle } from "lucide-react";
import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import {
createTokenPocketLoginRequest,
fetchTokenPocketLoginResult,
verifyWalletSignature,
type TokenPocketLoginRequest,
} from "./api";
import { openWalletDeepLink, walletDownloadUrl } from "./deepLinks";
import { getInjectedEthereum, type WalletKind } from "./injected";
import { getInjectedWallet, type WalletKind } from "./injected";
import { useWallet } from "./WalletProvider";
import { useWalletConnectLogin } from "./useWalletConnectLogin";
import { WalletBrandIcon } from "./WalletBrandIcon";
const pollIntervalMs = 1800;
type ModalState = "idle" | "signing";
type Step = "wallet" | "method";
type ModalState = "idle" | "signing" | "tpLoading" | "tpPolling";
const appWallets: { kind: WalletKind; labelKey: string }[] = [
{ kind: "tokenPocket", labelKey: "walletOpenTokenPocket" },
{ kind: "metaMask", labelKey: "walletOpenMetaMask" },
{ kind: "imToken", labelKey: "walletOpenImToken" },
];
const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"];
function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
@@ -46,80 +35,27 @@ function isMobileDevice(): boolean {
export function WalletLoginModal() {
const { t } = useI18n();
const { closeLoginModal, completeLogin, loginModalOpen, signInInjected } =
useWallet();
const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
const wc = useWalletConnectLogin();
const [step, setStep] = useState<Step>("wallet");
const [selected, setSelected] = useState<WalletKind | null>(null);
const [state, setState] = useState<ModalState>("idle");
const [error, setError] = useState("");
const [mobileDevice, setMobileDevice] = useState(false);
const [hasInjected, setHasInjected] = useState(false);
const [showOther, setShowOther] = useState(false);
const [openingWallet, setOpeningWallet] = useState<WalletKind | null>(null);
const [tpRequest, setTpRequest] = useState<TokenPocketLoginRequest | null>(
null,
);
const resetWalletConnect = wc.reset;
useEffect(() => {
if (!loginModalOpen) return;
setMobileDevice(isMobileDevice());
setHasInjected(Boolean(getInjectedEthereum()));
setShowOther(false);
setOpeningWallet(null);
setTpRequest(null);
setStep("wallet");
setSelected(null);
setState("idle");
setError("");
wc.reset();
}, [loginModalOpen, wc]);
resetWalletConnect();
}, [loginModalOpen, resetWalletConnect]);
useEffect(() => {
if (!loginModalOpen || !tpRequest || state !== "tpPolling") return;
let cancelled = false;
const abortController = new AbortController();
const poll = async () => {
try {
const result = await fetchTokenPocketLoginResult(
tpRequest.actionId,
abortController.signal,
);
if (cancelled) return;
if (result.status === "completed") {
const verified = await verifyWalletSignature({
address: result.address,
message: result.message,
signature: result.signature,
});
if (cancelled) return;
completeLogin(verified.token, verified.wallet);
setState("idle");
setTpRequest(null);
return;
}
if (result.status === "expired" || result.status === "failed") {
setState("idle");
setTpRequest(null);
setError(result.error || t("walletTpExpired"));
}
} catch (err) {
if (
!cancelled &&
!(err instanceof DOMException && err.name === "AbortError")
) {
setError(t("walletLoginFailed"));
}
}
};
void poll();
const timer = window.setInterval(() => void poll(), pollIntervalMs);
return () => {
cancelled = true;
abortController.abort();
window.clearInterval(timer);
};
}, [completeLogin, loginModalOpen, state, t, tpRequest]);
const busy = state === "signing" || state === "tpLoading";
const busy = state === "signing";
const close = () => {
if (busy) return;
@@ -129,47 +65,56 @@ export function WalletLoginModal() {
if (!loginModalOpen) return null;
const withWallet = (key: string, kind: WalletKind) =>
t(key).replace("{wallet}", t(walletNameKey(kind)));
const walletName = (kind: WalletKind) => t(walletNameKey(kind));
// A wallet's browser flow only works if THAT wallet is injected right now
// (its desktop extension, or its in-app browser on mobile).
const injectedAvailable = (kind: WalletKind) =>
Boolean(getInjectedWallet(kind));
// All scan/app login paths use RainbowKit/WalletConnect on BNB Chain.
const qrAvailable = () => wc.available;
const signInjected = async () => {
const resetFlow = () => {
setError("");
if (!getInjectedEthereum()) {
setError(t("walletNoBrowserWalletDesc"));
setState("idle");
wc.reset();
};
const pickWallet = (kind: WalletKind) => {
resetFlow();
setSelected(kind);
setStep("method");
};
const back = () => {
if (busy) return;
resetFlow();
setSelected(null);
setStep("wallet");
};
const signInjectedFor = async (kind: WalletKind) => {
setError("");
if (!getInjectedWallet(kind)) {
setError(
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
);
return;
}
setState("signing");
await signInInjected()
await signInInjected(kind)
.catch((err) => {
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
})
.finally(() => setState("idle"));
};
const openApp = (kind: WalletKind) => {
const chooseQr = (kind: WalletKind) => {
setError("");
setOpeningWallet(kind);
openWalletDeepLink(kind);
void wc.start(kind);
};
// TokenPocket login. The backend returns a `tpoutside://pull.activity` deep
// link (a one-off SIGN request, not a dApp-browser link). On mobile we open
// it directly so TokenPocket only asks for a signature and then returns to
// THIS browser — the poll below finishes login here, no in-app browser. On
// desktop we render it as a QR to scan from a phone.
const startTokenPocketLogin = async () => {
setError("");
setState("tpLoading");
try {
const req = await createTokenPocketLoginRequest();
setTpRequest(req);
setState("tpPolling");
if (mobileDevice) window.location.href = req.qrUrl;
} catch {
setState("idle");
setError(t("walletTpQrFailed"));
}
};
const iconButtonClass =
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold";
return (
<div
@@ -178,194 +123,174 @@ export function WalletLoginModal() {
aria-modal="true"
aria-labelledby="wallet-login-title"
>
<div className="max-h-[92dvh] w-full max-w-[520px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2
id="wallet-login-title"
className="text-xl font-semibold text-white"
>
{t("walletLoginTitle")}
</h2>
<p className="mt-2 text-sm leading-6 text-neutral-400">
{t("walletLoginDesc")}
</p>
<div className="max-h-[92dvh] w-full max-w-[460px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
{step === "method" ? (
<button
type="button"
onClick={back}
aria-label={t("walletBack")}
className={`${iconButtonClass} mt-0.5`}
>
<ChevronLeft size={18} />
</button>
) : null}
<div className="min-w-0">
<h2
id="wallet-login-title"
className="text-xl font-semibold text-white"
>
{t("walletLoginTitle")}
</h2>
<p className="mt-2 text-sm leading-6 text-neutral-400">
{step === "wallet"
? t("walletLoginDesc")
: t("walletChooseMethod")}
</p>
</div>
</div>
<button
type="button"
onClick={close}
className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold"
aria-label={t("close")}
className={iconButtonClass}
>
{t("close")}
<X size={18} />
</button>
</div>
<div className="mt-5 grid gap-3">
{/* Browser wallet: sign directly with the injected provider — the
reliable path for a BNB-chain extension (desktop) or a wallet's
in-app browser. No WalletConnect relay involved. */}
{!mobileDevice || hasInjected ? (
<button
type="button"
onClick={() => void signInjected()}
disabled={busy}
className="flex items-center justify-center gap-2 rounded-2xl bg-ark-gold px-4 py-4 text-base font-bold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
>
{state === "signing"
? t("walletSigning")
: mobileDevice
? t("walletUseCurrent")
: t("walletInjected")}
</button>
) : null}
{/* TokenPocket login — universal path that returns to this browser. */}
<div className="grid gap-2 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<p className="text-sm font-semibold text-neutral-100">
{t("walletTokenPocketLogin")}
</p>
<p className="text-xs leading-5 text-neutral-400">
{mobileDevice
? t("walletTpMobileDesc")
: t("walletTokenPocketQrDesc")}
</p>
{!tpRequest ? (
{/* Step 1 — choose a wallet. */}
{step === "wallet" ? (
<div className="mt-5 grid gap-2">
{wallets.map((kind) => (
<button
key={kind}
type="button"
onClick={() => void startTokenPocketLogin()}
disabled={state === "tpLoading"}
className="mt-1 inline-flex items-center justify-center gap-2 justify-self-start rounded-full bg-ark-gold px-4 py-2 text-sm font-semibold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
onClick={() => pickWallet(kind)}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-4 text-left transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
>
<WalletBrandIcon kind="tokenPocket" size={20} />
{state === "tpLoading"
? t("loading")
: mobileDevice
? t("walletTpLoginBtn")
: t("walletGenerateQr")}
<WalletBrandIcon kind={kind} size={32} />
<span className="flex-1 text-base font-semibold text-neutral-100">
{walletName(kind)}
</span>
<ChevronRight size={18} className="text-neutral-500" />
</button>
) : mobileDevice ? (
<div className="mt-1 grid gap-2 rounded-2xl border border-dashed border-white/15 bg-white/[0.03] px-4 py-3">
<p className="flex items-center gap-2 text-xs leading-5 text-neutral-300">
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
{t("walletTpWaiting")}
</p>
))}
</div>
) : selected ? (
<div className="mt-5 grid gap-3">
{/* Selected wallet header. */}
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<WalletBrandIcon kind={selected} size={28} />
<span className="text-base font-semibold text-white">
{walletName(selected)}
</span>
</div>
{!mobileDevice && !injectedAvailable(selected) ? (
<p className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm leading-6 text-amber-100">
{t("walletInstallSelected").replace(
"{wallet}",
walletName(selected),
)}
</p>
) : null}
{/* Method: browser wallet (injected). */}
{(() => {
const ok = injectedAvailable(selected);
return (
<button
type="button"
onClick={() => {
window.location.href = tpRequest.qrUrl;
}}
className="justify-self-start rounded-full border border-ark-gold/40 px-3 py-1.5 text-xs font-semibold text-ark-gold transition hover:bg-ark-gold/10"
onClick={() => void signInjectedFor(selected)}
disabled={!ok || busy}
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
ok
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
}`}
>
{t("walletTpReopen")}
</button>
</div>
) : (
<div className="mt-1 grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
<QRCodeSVG value={tpRequest.qrUrl} size={180} level="M" />
<p className="text-xs font-medium text-neutral-700">
{t("walletQrUseAnotherDevice")}
</p>
</div>
)}
</div>
{/* Other methods: open a wallet app (mobile) and WalletConnect QR. */}
<div className="rounded-2xl border border-dashed border-white/15">
<button
type="button"
onClick={() => setShowOther((value) => !value)}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-semibold text-ark-gold transition hover:text-ark-gold2"
>
<span>{t("walletOtherMethods")}</span>
<span>{showOther ? "" : "+"}</span>
</button>
{showOther ? (
<div className="grid gap-4 px-4 pb-4">
{mobileDevice && !hasInjected ? (
<div className="grid gap-2">
<p className="text-sm font-semibold text-neutral-200">
{t("walletOpenWalletApp")}
</p>
{appWallets.map((option) => (
<button
key={option.kind}
type="button"
onClick={() => openApp(option.kind)}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-left text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
>
<WalletBrandIcon kind={option.kind} />
<span>{t(option.labelKey)}</span>
</button>
))}
{openingWallet ? (
<div className="grid gap-2 rounded-2xl border border-dashed border-white/15 bg-white/[0.03] px-4 py-3 text-xs leading-5 text-neutral-400">
<p>{withWallet("walletOpening", openingWallet)}</p>
<p>{t("walletAppNotInstalled")}</p>
<div className="flex flex-wrap gap-2 pt-1">
<a
href={walletDownloadUrl(openingWallet)}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-ark-gold/40 px-3 py-1.5 font-semibold text-ark-gold transition hover:bg-ark-gold/10"
>
{withWallet("walletDownloadApp", openingWallet)}
</a>
<button
type="button"
onClick={() => openApp(openingWallet)}
className="rounded-full border border-white/15 px-3 py-1.5 font-semibold text-neutral-200 transition hover:border-ark-gold/40 hover:text-ark-gold"
>
{t("walletRetry")}
</button>
</div>
</div>
<span className="flex items-center gap-2 text-base font-semibold text-neutral-100">
{state === "signing" ? (
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
) : null}
</div>
) : null}
{mobileDevice ? t("walletUseCurrent") : t("walletInjected")}
</span>
<span className="text-xs leading-5 text-neutral-400">
{ok
? t("walletInjectedDesc")
: mobileDevice
? t("walletOpenWalletAppDesc")
: t("walletInstallSelected").replace(
"{wallet}",
walletName(selected),
)}
</span>
</button>
);
})()}
{/* MetaMask / imToken QR via WalletConnect — needs a real id. */}
<div
className={
mobileDevice && !hasInjected
? "grid gap-2 border-t border-white/10 pt-4"
: "grid gap-2"
}
>
<p className="text-sm font-semibold text-neutral-200">
{t("walletRainbowFallback")}
</p>
<p className="text-xs leading-5 text-neutral-400">
{t("walletRainbowFallbackDesc")}
</p>
{wc.available ? (
<>
<button
type="button"
onClick={() => wc.start()}
disabled={wc.state !== "idle"}
className="justify-self-start rounded-full border border-ark-gold/50 px-4 py-2 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10 disabled:cursor-wait disabled:opacity-70"
>
{wc.state === "connecting"
? t("walletConnecting")
: wc.state === "signing"
? t("walletSigning")
: t("walletOpenRainbow")}
</button>
<p className="text-xs leading-5 text-amber-300/80">
{t("walletNetworkWarning")}
</p>
</>
) : (
<p className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs leading-5 text-neutral-500">
{t("walletRainbowUnavailable")}
{/* Method: scan to log in. */}
{(() => {
const ok = qrAvailable();
const isTp = selected === "tokenPocket";
const qrBusy = wc.state !== "idle";
const qrLabel =
wc.state === "connecting"
? t("walletConnecting")
: wc.state === "signing"
? t("walletSigning")
: isTp && mobileDevice
? t("walletTpLoginBtn")
: t("walletQrLogin");
return (
<div className="grid gap-2">
<button
type="button"
onClick={() => chooseQr(selected)}
disabled={!ok || qrBusy || busy}
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
ok
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
}`}
>
<span className="text-base font-semibold text-neutral-100">
{qrLabel}
</span>
<span className="text-xs leading-5 text-neutral-400">
{ok
? isTp
? mobileDevice
? t("walletTpMobileDesc")
: t("walletTokenPocketQrDesc")
: t("walletRainbowFallbackDesc")
: t("walletRainbowUnavailable")}
</span>
</button>
{ok ? (
<p className="text-xs leading-5 text-amber-300/80">
{t("walletNetworkWarning")}
</p>
)}
) : null}
{wc.qrUri ? (
<div className="grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
<QRCodeSVG value={wc.qrUri} size={180} level="M" />
<p className="text-xs font-medium text-neutral-700">
{mobileDevice
? t("walletTpWaiting")
: t("walletQrUseAnotherDevice")}
</p>
</div>
) : null}
</div>
</div>
) : null}
);
})()}
</div>
</div>
) : null}
{error || wc.error ? (
<p className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">