536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
import toast from "react-hot-toast";
|
||
import { useSnapshot } from "valtio";
|
||
import { ArrowSquareOut, Copy, Trash, Wallet, X } from "phosphor-react";
|
||
import React, { useEffect, useState, FC } from "react";
|
||
import Dinero from "dinero.js";
|
||
|
||
import Button from "./Button";
|
||
import { addFaucetAccount, importAccount } from "../state/actions";
|
||
import state from "../state";
|
||
import Box from "./Box";
|
||
import { Container, Heading, Stack, Text, Flex } from ".";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
DialogClose,
|
||
DialogTrigger,
|
||
} from "./Dialog";
|
||
import { css } from "../stitches.config";
|
||
import { Input } from "./Input";
|
||
import truncate from "../utils/truncate";
|
||
|
||
const labelStyle = css({
|
||
color: "$mauve10",
|
||
textTransform: "uppercase",
|
||
fontSize: "10px",
|
||
mb: "$0.5",
|
||
});
|
||
import transactionsData from "../content/transactions.json";
|
||
import { SetHookDialog } from "./SetHookDialog";
|
||
import { addFunds } from "../state/actions/addFaucetAccount";
|
||
import { deleteHook } from "../state/actions/deployHook";
|
||
|
||
export const AccountDialog = ({
|
||
activeAccountAddress,
|
||
setActiveAccountAddress,
|
||
}: {
|
||
activeAccountAddress: string | null;
|
||
setActiveAccountAddress: React.Dispatch<React.SetStateAction<string | null>>;
|
||
}) => {
|
||
const snap = useSnapshot(state);
|
||
const [showSecret, setShowSecret] = useState(false);
|
||
const activeAccount = snap.accounts.find(
|
||
(account) => account.address === activeAccountAddress
|
||
);
|
||
return (
|
||
<Dialog
|
||
open={Boolean(activeAccountAddress)}
|
||
onOpenChange={(open) => {
|
||
setShowSecret(false);
|
||
!open && setActiveAccountAddress(null);
|
||
}}
|
||
>
|
||
<DialogContent
|
||
css={{
|
||
backgroundColor: "$mauve1 !important",
|
||
border: "1px solid $mauve2",
|
||
".dark &": {
|
||
// backgroundColor: "$black !important",
|
||
},
|
||
p: "$3",
|
||
"&:before": {
|
||
content: " ",
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
opacity: 0.2,
|
||
".dark &": {
|
||
opacity: 1,
|
||
},
|
||
zIndex: 0,
|
||
pointerEvents: "none",
|
||
backgroundImage: `url('/pattern-dark.svg'), url('/pattern-dark-2.svg')`,
|
||
backgroundRepeat: "no-repeat",
|
||
backgroundPosition: "bottom left, top right",
|
||
},
|
||
}}
|
||
>
|
||
<DialogTitle
|
||
css={{
|
||
display: "flex",
|
||
width: "100%",
|
||
alignItems: "center",
|
||
borderBottom: "1px solid $mauve6",
|
||
pb: "$3",
|
||
gap: "$3",
|
||
fontSize: "$md",
|
||
}}
|
||
>
|
||
<Wallet size="15px" /> {activeAccount?.name}
|
||
<DialogClose asChild>
|
||
<Button
|
||
size="xs"
|
||
outline
|
||
css={{ ml: "auto", mr: "$9" }}
|
||
tabIndex={-1}
|
||
onClick={() => {
|
||
const index = state.accounts.findIndex(
|
||
(acc) => acc.address === activeAccount?.address
|
||
);
|
||
state.accounts.splice(index, 1);
|
||
}}
|
||
>
|
||
Delete Account <Trash size="15px" />
|
||
</Button>
|
||
</DialogClose>
|
||
</DialogTitle>
|
||
<DialogDescription as="div" css={{ fontFamily: "$monospace" }}>
|
||
<Stack css={{ display: "flex", flexDirection: "column", gap: "$3" }}>
|
||
<Flex css={{ alignItems: "center" }}>
|
||
<Flex css={{ flexDirection: "column" }}>
|
||
<Text className={labelStyle()}>Account Address</Text>
|
||
<Text
|
||
css={{
|
||
fontFamily: "$monospace",
|
||
}}
|
||
>
|
||
{activeAccount?.address}
|
||
</Text>
|
||
</Flex>
|
||
<Flex css={{ marginLeft: "auto", color: "$mauve12" }}>
|
||
<Button
|
||
size="sm"
|
||
ghost
|
||
css={{ mt: "$3" }}
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(activeAccount?.address || "");
|
||
toast.success("Copied address to clipboard");
|
||
}}
|
||
>
|
||
<Copy size="15px" />
|
||
</Button>
|
||
</Flex>
|
||
</Flex>
|
||
<Flex css={{ alignItems: "center" }}>
|
||
<Flex css={{ flexDirection: "column" }}>
|
||
<Text className={labelStyle()}>Secret</Text>
|
||
<Text
|
||
as="div"
|
||
css={{
|
||
fontFamily: "$monospace",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
{showSecret
|
||
? activeAccount?.secret
|
||
: "•".repeat(activeAccount?.secret.length || 16)}{" "}
|
||
<Button
|
||
css={{
|
||
fontFamily: "$monospace",
|
||
lineHeight: 2,
|
||
mt: "2px",
|
||
ml: "$3",
|
||
}}
|
||
ghost
|
||
size="xs"
|
||
onClick={() => setShowSecret((curr) => !curr)}
|
||
>
|
||
{showSecret ? "Hide" : "Show"}
|
||
</Button>
|
||
</Text>
|
||
</Flex>
|
||
<Flex css={{ marginLeft: "auto", color: "$mauve12" }}>
|
||
<Button
|
||
size="sm"
|
||
ghost
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(activeAccount?.secret || "");
|
||
toast.success("Copied secret to clipboard");
|
||
}}
|
||
css={{ mt: "$3" }}
|
||
>
|
||
<Copy size="15px" />
|
||
</Button>
|
||
</Flex>
|
||
</Flex>
|
||
<Flex css={{ alignItems: "center" }}>
|
||
<Flex css={{ flexDirection: "column" }}>
|
||
<Text className={labelStyle()}>Balances & Objects</Text>
|
||
<Text
|
||
css={{
|
||
fontFamily: "$monospace",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
{Dinero({
|
||
amount: Number(activeAccount?.xrp || "0"),
|
||
precision: 6,
|
||
})
|
||
.toUnit()
|
||
.toLocaleString(undefined, {
|
||
style: "currency",
|
||
currency: "XRP",
|
||
currencyDisplay: "name",
|
||
})}
|
||
<Button
|
||
css={{
|
||
fontFamily: "$monospace",
|
||
lineHeight: 2,
|
||
mt: "2px",
|
||
ml: "$3",
|
||
}}
|
||
ghost
|
||
size="xs"
|
||
onClick={() => {
|
||
addFunds(activeAccount?.address || "");
|
||
}}
|
||
>
|
||
Add Funds
|
||
</Button>
|
||
</Text>
|
||
</Flex>
|
||
<Flex css={{ marginLeft: "auto" }}>
|
||
<a
|
||
href={`https://${process.env.NEXT_PUBLIC_EXPLORER_URL}/${activeAccount?.address}`}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
>
|
||
<Button
|
||
size="sm"
|
||
ghost
|
||
css={{ color: "$grass11 !important", mt: "$3" }}
|
||
>
|
||
<ArrowSquareOut size="15px" />
|
||
</Button>
|
||
</a>
|
||
</Flex>
|
||
</Flex>
|
||
<Flex css={{ alignItems: "center" }}>
|
||
<Flex css={{ flexDirection: "column" }}>
|
||
<Text className={labelStyle()}>Installed Hooks</Text>
|
||
<Text
|
||
css={{
|
||
fontFamily: "$monospace",
|
||
}}
|
||
>
|
||
{activeAccount && activeAccount.hooks.length > 0
|
||
? activeAccount.hooks.map((i) => truncate(i, 12)).join(",")
|
||
: "–"}
|
||
</Text>
|
||
</Flex>
|
||
{activeAccount && activeAccount?.hooks?.length > 0 && (
|
||
<Flex css={{ marginLeft: "auto" }}>
|
||
<Button
|
||
size="xs"
|
||
outline
|
||
disabled={activeAccount.isLoading}
|
||
css={{ mt: "$3", mr: "$1", ml: "auto" }}
|
||
onClick={() => {
|
||
deleteHook(activeAccount);
|
||
}}
|
||
>
|
||
Delete Hook <Trash size="15px" />
|
||
</Button>
|
||
</Flex>
|
||
)}
|
||
</Flex>
|
||
</Stack>
|
||
</DialogDescription>
|
||
<DialogClose asChild>
|
||
<Box css={{ position: "absolute", top: "$3", right: "$3" }}>
|
||
<X size="20px" />
|
||
</Box>
|
||
</DialogClose>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
interface AccountProps {
|
||
card?: boolean;
|
||
hideDeployBtn?: boolean;
|
||
showHookStats?: boolean;
|
||
}
|
||
|
||
const Accounts: FC<AccountProps> = (props) => {
|
||
const snap = useSnapshot(state);
|
||
const [activeAccountAddress, setActiveAccountAddress] = useState<
|
||
string | null
|
||
>(null);
|
||
useEffect(() => {
|
||
const fetchAccInfo = async () => {
|
||
if (snap.clientStatus === "online") {
|
||
const requests = snap.accounts.map((acc) =>
|
||
snap.client?.send({
|
||
id: `hooks-builder-req-info-${acc.address}`,
|
||
command: "account_info",
|
||
account: acc.address,
|
||
})
|
||
);
|
||
const responses = await Promise.all(requests);
|
||
responses.forEach((res: any) => {
|
||
const address = res?.account_data?.Account as string;
|
||
const balance = res?.account_data?.Balance as string;
|
||
const sequence = res?.account_data?.Sequence as number;
|
||
const accountToUpdate = state.accounts.find(
|
||
(acc) => acc.address === address
|
||
);
|
||
if (accountToUpdate) {
|
||
accountToUpdate.xrp = balance;
|
||
accountToUpdate.sequence = sequence;
|
||
}
|
||
});
|
||
const objectRequests = snap.accounts.map((acc) => {
|
||
return snap.client?.send({
|
||
id: `hooks-builder-req-objects-${acc.address}`,
|
||
command: "account_objects",
|
||
account: acc.address,
|
||
});
|
||
});
|
||
const objectResponses = await Promise.all(objectRequests);
|
||
objectResponses.forEach((res: any) => {
|
||
const address = res?.account as string;
|
||
const accountToUpdate = state.accounts.find(
|
||
(acc) => acc.address === address
|
||
);
|
||
if (accountToUpdate) {
|
||
accountToUpdate.hooks =
|
||
res.account_objects
|
||
.find((ac: any) => ac?.LedgerEntryType === "Hook")
|
||
?.Hooks?.map((oo: any) => oo.Hook.HookHash) || [];
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
let fetchAccountInfoInterval: NodeJS.Timer;
|
||
if (snap.clientStatus === "online") {
|
||
fetchAccInfo();
|
||
fetchAccountInfoInterval = setInterval(() => fetchAccInfo(), 10000);
|
||
}
|
||
|
||
return () => {
|
||
if (snap.accounts.length > 0) {
|
||
if (fetchAccountInfoInterval) {
|
||
clearInterval(fetchAccountInfoInterval);
|
||
}
|
||
}
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [snap.accounts, snap.clientStatus]);
|
||
return (
|
||
<Box
|
||
as="div"
|
||
css={{
|
||
display: "flex",
|
||
backgroundColor: props.card ? "$deep" : "$mauve1",
|
||
position: "relative",
|
||
flex: "1",
|
||
height: "100%",
|
||
border: "1px solid $mauve6",
|
||
borderRadius: props.card ? "$md" : undefined,
|
||
}}
|
||
>
|
||
<Container css={{ p: 0, flexShrink: 1, height: "100%" }}>
|
||
<Flex
|
||
css={{
|
||
py: "$3",
|
||
borderBottom: props.card ? "1px solid $mauve6" : undefined,
|
||
}}
|
||
>
|
||
<Heading
|
||
as="h3"
|
||
css={{
|
||
fontWeight: 300,
|
||
m: 0,
|
||
fontSize: "11px",
|
||
color: "$mauve12",
|
||
px: "$3",
|
||
textTransform: "uppercase",
|
||
alignItems: "center",
|
||
display: "inline-flex",
|
||
gap: "$3",
|
||
}}
|
||
>
|
||
<Wallet size="15px" /> <Text css={{ lineHeight: 1 }}>Accounts</Text>
|
||
</Heading>
|
||
<Flex css={{ ml: "auto", gap: "$3", marginRight: "$3" }}>
|
||
<Button ghost size="sm" onClick={() => addFaucetAccount(true)}>
|
||
Create
|
||
</Button>
|
||
<ImportAccountDialog />
|
||
</Flex>
|
||
</Flex>
|
||
<Stack
|
||
css={{
|
||
flexDirection: "column",
|
||
width: "100%",
|
||
fontSize: "13px",
|
||
wordWrap: "break-word",
|
||
fontWeight: "$body",
|
||
gap: 0,
|
||
height: "calc(100% - 52px)",
|
||
flexWrap: "nowrap",
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
{snap.accounts.map((account) => (
|
||
<Flex
|
||
column
|
||
key={account.address + account.name}
|
||
onClick={() => setActiveAccountAddress(account.address)}
|
||
css={{
|
||
px: "$3",
|
||
py: props.card ? "$3" : "$2",
|
||
cursor: "pointer",
|
||
borderBottom: props.card ? "1px solid $mauve6" : undefined,
|
||
"@hover": {
|
||
"&:hover": {
|
||
background: "$backgroundAlt",
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
<Flex
|
||
row
|
||
css={{
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<Box>
|
||
<Text>{account.name} </Text>
|
||
<Text
|
||
css={{
|
||
color: "$textMuted",
|
||
wordBreak: "break-word",
|
||
}}
|
||
>
|
||
{account.address} (
|
||
{Dinero({
|
||
amount: Number(account?.xrp || "0"),
|
||
precision: 6,
|
||
})
|
||
.toUnit()
|
||
.toLocaleString(undefined, {
|
||
style: "currency",
|
||
currency: "XRP",
|
||
currencyDisplay: "name",
|
||
})}
|
||
)
|
||
</Text>
|
||
</Box>
|
||
{!props.hideDeployBtn && (
|
||
<div
|
||
className="hook-deploy-button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
}}
|
||
>
|
||
<SetHookDialog account={account} />
|
||
</div>
|
||
)}
|
||
</Flex>
|
||
{props.showHookStats && (
|
||
<Text muted small css={{ mt: "$2" }}>
|
||
{account.hooks.length} hook
|
||
{account.hooks.length === 1 ? "" : "s"} installed
|
||
</Text>
|
||
)}
|
||
</Flex>
|
||
))}
|
||
</Stack>
|
||
</Container>
|
||
<AccountDialog
|
||
activeAccountAddress={activeAccountAddress}
|
||
setActiveAccountAddress={setActiveAccountAddress}
|
||
/>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export const transactionsOptions = transactionsData.map((tx) => ({
|
||
value: tx.TransactionType,
|
||
label: tx.TransactionType,
|
||
}));
|
||
|
||
const ImportAccountDialog = () => {
|
||
const [value, setValue] = useState("");
|
||
return (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button ghost size="sm">
|
||
Import
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogTitle>Import account</DialogTitle>
|
||
<DialogDescription>
|
||
<label>Add account secret</label>
|
||
<Input
|
||
name="secret"
|
||
type="password"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
/>
|
||
</DialogDescription>
|
||
|
||
<Flex
|
||
css={{
|
||
marginTop: 25,
|
||
justifyContent: "flex-end",
|
||
gap: "$3",
|
||
}}
|
||
>
|
||
<DialogClose asChild>
|
||
<Button outline>Cancel</Button>
|
||
</DialogClose>
|
||
<DialogClose asChild>
|
||
<Button
|
||
variant="primary"
|
||
onClick={() => {
|
||
importAccount(value);
|
||
setValue("");
|
||
}}
|
||
>
|
||
Import account
|
||
</Button>
|
||
</DialogClose>
|
||
</Flex>
|
||
<DialogClose asChild>
|
||
<Box css={{ position: "absolute", top: "$3", right: "$3" }}>
|
||
<X size="20px" />
|
||
</Box>
|
||
</DialogClose>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
export default Accounts;
|