590 lines
19 KiB
TypeScript
590 lines
19 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, Label } 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'
|
||
import { capitalize } from '../utils/helpers'
|
||
import { deleteAccount } from '../state/actions/deleteAccount'
|
||
import { xrplSend } from '../state/actions/xrpl-client'
|
||
|
||
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={() => {
|
||
deleteAccount(activeAccount?.address)
|
||
}}
|
||
>
|
||
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',
|
||
a: { '&:hover': { textDecoration: 'underline' } }
|
||
}}
|
||
>
|
||
<a
|
||
href={`https://${process.env.NEXT_PUBLIC_EXPLORER_URL}/${activeAccount?.address}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{activeAccount?.address}
|
||
</a>
|
||
</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: 'XAH',
|
||
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',
|
||
a: { '&:hover': { textDecoration: 'underline' } }
|
||
}}
|
||
>
|
||
{activeAccount && activeAccount.hooks.length > 0
|
||
? activeAccount.hooks.map(i => {
|
||
return (
|
||
<a
|
||
key={i}
|
||
href={`https://${process.env.NEXT_PUBLIC_EXPLORER_URL}/${i}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{truncate(i, 12)}
|
||
</a>
|
||
)
|
||
})
|
||
: '–'}
|
||
</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 =>
|
||
xrplSend({
|
||
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
|
||
accountToUpdate.error = null
|
||
} else {
|
||
const oldAccount = state.accounts.find(acc => acc.address === res?.account)
|
||
if (oldAccount) {
|
||
oldAccount.xrp = '0'
|
||
oldAccount.error = {
|
||
code: res?.error,
|
||
message: res?.error_message
|
||
}
|
||
}
|
||
}
|
||
})
|
||
const objectRequests = snap.accounts.map(acc => {
|
||
return xrplSend({
|
||
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.length, 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' }}>
|
||
<ImportAccountDialog type="create" />
|
||
<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}{' '}
|
||
{!account?.error ? (
|
||
`(${Dinero({
|
||
amount: Number(account?.xrp || '0'),
|
||
precision: 6
|
||
})
|
||
.toUnit()
|
||
.toLocaleString(undefined, {
|
||
style: 'currency',
|
||
currency: 'XAH',
|
||
currencyDisplay: 'name'
|
||
})})`
|
||
) : (
|
||
<Box css={{ color: '$red11' }}>
|
||
(Account not found, request funds to activate account)
|
||
</Box>
|
||
)}
|
||
</Text>
|
||
</Box>
|
||
{!props.hideDeployBtn && (
|
||
<div
|
||
className="hook-deploy-button"
|
||
onClick={e => {
|
||
e.stopPropagation()
|
||
}}
|
||
>
|
||
<SetHookDialog accountAddress={account.address} />
|
||
</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 = ({ type = 'import' }: { type?: 'import' | 'create' }) => {
|
||
const [secret, setSecret] = useState('')
|
||
const [name, setName] = useState('')
|
||
|
||
const btnText = type === 'import' ? 'Import' : 'Create'
|
||
const title = type === 'import' ? 'Import Account' : 'Create Account'
|
||
|
||
const handleSubmit = async () => {
|
||
if (type === 'create') {
|
||
const value = capitalize(name)
|
||
await addFaucetAccount(value, true)
|
||
setName('')
|
||
setSecret('')
|
||
return
|
||
}
|
||
importAccount(secret, name)
|
||
setName('')
|
||
setSecret('')
|
||
}
|
||
return (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button ghost size="sm">
|
||
{btnText}
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent aria-describedby={undefined}>
|
||
<DialogTitle css={{ mb: '$4' }}>{title}</DialogTitle>
|
||
<Flex column>
|
||
<Box css={{ mb: '$2' }}>
|
||
<Label>
|
||
Account name <Text muted>(optional)</Text>
|
||
</Label>
|
||
<Input
|
||
name="name"
|
||
type="text"
|
||
autoComplete="off"
|
||
autoCapitalize="on"
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
/>
|
||
</Box>
|
||
{type === 'import' && (
|
||
<Box>
|
||
<Label>Account secret</Label>
|
||
<Input
|
||
required
|
||
name="secret"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={secret}
|
||
onChange={e => setSecret(e.target.value)}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</Flex>
|
||
|
||
<Flex
|
||
css={{
|
||
marginTop: 25,
|
||
justifyContent: 'flex-end',
|
||
gap: '$3'
|
||
}}
|
||
>
|
||
<DialogClose asChild>
|
||
<Button outline>Cancel</Button>
|
||
</DialogClose>
|
||
<DialogClose asChild>
|
||
<Button type="submit" variant="primary" onClick={handleSubmit}>
|
||
{title}
|
||
</Button>
|
||
</DialogClose>
|
||
</Flex>
|
||
<DialogClose asChild>
|
||
<Box css={{ position: 'absolute', top: '$3', right: '$3' }}>
|
||
<X size="20px" />
|
||
</Box>
|
||
</DialogClose>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
export default Accounts
|