feat: cargo project at root

This commit is contained in:
binarybaron 2024-08-08 00:49:04 +02:00
parent aa0c0623ca
commit 709a2820c4
No known key found for this signature in database
GPG key ID: 99B75D3E1476A26E
313 changed files with 1 additions and 740 deletions

View file

@ -0,0 +1,51 @@
import { Box, Button, makeStyles, Typography } from '@material-ui/core';
import InfoBox from '../../modal/swap/InfoBox';
const useStyles = makeStyles((theme) => ({
spacedBox: {
display: 'flex',
gap: theme.spacing(1),
},
}));
const GITHUB_ISSUE_URL =
'https://github.com/UnstoppableSwap/unstoppableswap-gui/issues/new/choose';
const MATRIX_ROOM_URL = 'https://matrix.to/#/#unstoppableswap:matrix.org';
export const DISCORD_URL = 'https://discord.gg/APJ6rJmq';
export default function ContactInfoBox() {
const classes = useStyles();
return (
<InfoBox
title="Get in touch"
mainContent={
<Typography variant="subtitle2">
If you need help or just want to reach out to the contributors of this
project you can open a GitHub issue, join our Matrix room or Discord
</Typography>
}
additionalContent={
<Box className={classes.spacedBox}>
<Button
variant="outlined"
onClick={() => window.open(GITHUB_ISSUE_URL)}
>
Open GitHub issue
</Button>
<Button
variant="outlined"
onClick={() => window.open(MATRIX_ROOM_URL)}
>
Join Matrix room
</Button>
<Button variant="outlined" onClick={() => window.open(DISCORD_URL)}>
Join Discord
</Button>
</Box>
}
icon={null}
loading={false}
/>
);
}

View file

@ -0,0 +1,25 @@
import { Typography } from '@material-ui/core';
import DepositAddressInfoBox from '../../modal/swap/DepositAddressInfoBox';
import MoneroIcon from '../../icons/MoneroIcon';
const XMR_DONATE_ADDRESS =
'87jS4C7ngk9EHdqFFuxGFgg8AyH63dRUoULshWDybFJaP75UA89qsutG5B1L1QTc4w228nsqsv8EjhL7bz8fB3611Mh98mg';
export default function DonateInfoBox() {
return (
<DepositAddressInfoBox
title="Donate"
address={XMR_DONATE_ADDRESS}
icon={<MoneroIcon />}
additionalContent={
<Typography variant="subtitle2">
We rely on generous donors like you to keep development moving
forward. To bring Atomic Swaps to life, we need resources. If you have
the possibility, please consider making a donation to the project. All
funds will be used to support contributors and critical
infrastructure.
</Typography>
}
/>
);
}

View file

@ -0,0 +1,35 @@
import { Button, Typography } from '@material-ui/core';
import { useState } from 'react';
import InfoBox from '../../modal/swap/InfoBox';
import FeedbackDialog from '../../modal/feedback/FeedbackDialog';
export default function FeedbackInfoBox() {
const [showDialog, setShowDialog] = useState(false);
return (
<InfoBox
title="Feedback"
mainContent={
<Typography variant="subtitle2">
The main goal of this project is to make Atomic Swaps easier to use,
and for that we need genuine users&apos; input. Please leave some
feedback, it takes just two minutes. I&apos;ll read each and every
survey response and take your feedback into consideration.
</Typography>
}
additionalContent={
<>
<Button variant="outlined" onClick={() => setShowDialog(true)}>
Give feedback
</Button>
<FeedbackDialog
open={showDialog}
onClose={() => setShowDialog(false)}
/>
</>
}
icon={null}
loading={false}
/>
);
}

View file

@ -0,0 +1,28 @@
import { Box, makeStyles } from '@material-ui/core';
import ContactInfoBox from './ContactInfoBox';
import FeedbackInfoBox from './FeedbackInfoBox';
import DonateInfoBox from './DonateInfoBox';
import TorInfoBox from './TorInfoBox';
import RpcControlBox from './RpcControlBox';
const useStyles = makeStyles((theme) => ({
outer: {
display: 'flex',
gap: theme.spacing(2),
flexDirection: 'column',
},
}));
export default function HelpPage() {
const classes = useStyles();
return (
<Box className={classes.outer}>
<RpcControlBox />
<TorInfoBox />
<FeedbackInfoBox />
<ContactInfoBox />
<DonateInfoBox />
</Box>
);
}

View file

@ -0,0 +1,74 @@
import { Box, makeStyles } from '@material-ui/core';
import IpcInvokeButton from 'renderer/components/IpcInvokeButton';
import { useAppSelector } from 'store/hooks';
import StopIcon from '@material-ui/icons/Stop';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
import { RpcProcessStateType } from 'models/rpcModel';
import InfoBox from '../../modal/swap/InfoBox';
import CliLogsBox from '../../other/RenderedCliLog';
import FolderOpenIcon from '@material-ui/icons/FolderOpen';
const useStyles = makeStyles((theme) => ({
actionsOuter: {
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
},
}));
export default function RpcControlBox() {
const rpcProcess = useAppSelector((state) => state.rpc.process);
const isRunning =
rpcProcess.type === RpcProcessStateType.STARTED ||
rpcProcess.type === RpcProcessStateType.LISTENING_FOR_CONNECTIONS;
const classes = useStyles();
return (
<InfoBox
title={`Swap Daemon (${rpcProcess.type})`}
mainContent={
isRunning || rpcProcess.type === RpcProcessStateType.EXITED ? (
<CliLogsBox
label="Swap Daemon Logs (current session only)"
logs={rpcProcess.logs}
/>
) : null
}
additionalContent={
<Box className={classes.actionsOuter}>
<IpcInvokeButton
variant="contained"
ipcChannel="spawn-start-rpc"
ipcArgs={[]}
endIcon={<PlayArrowIcon />}
disabled={isRunning}
requiresRpc={false}
>
Start Daemon
</IpcInvokeButton>
<IpcInvokeButton
variant="contained"
ipcChannel="stop-cli"
ipcArgs={[]}
endIcon={<StopIcon />}
disabled={!isRunning}
requiresRpc={false}
>
Stop Daemon
</IpcInvokeButton>
<IpcInvokeButton
ipcChannel="open-data-dir-in-file-explorer"
ipcArgs={[]}
endIcon={<FolderOpenIcon />}
requiresRpc={false}
isIconButton
size="small"
tooltipTitle="Open the data directory of the Swap Daemon in your file explorer"
/>
</Box>
}
icon={null}
loading={false}
/>
);
}

View file

@ -0,0 +1,71 @@
import { Box, makeStyles, Typography } from '@material-ui/core';
import IpcInvokeButton from 'renderer/components/IpcInvokeButton';
import { useAppSelector } from 'store/hooks';
import StopIcon from '@material-ui/icons/Stop';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
import InfoBox from '../../modal/swap/InfoBox';
import CliLogsBox from '../../other/RenderedCliLog';
const useStyles = makeStyles((theme) => ({
actionsOuter: {
display: 'flex',
gap: theme.spacing(1),
},
}));
export default function TorInfoBox() {
const isTorRunning = useAppSelector((state) => state.tor.processRunning);
const torStdOut = useAppSelector((s) => s.tor.stdOut);
const classes = useStyles();
return (
<InfoBox
title="Tor (The Onion Router)"
mainContent={
<Box
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<Typography variant="subtitle2">
Tor is a network that allows you to anonymously connect to the
internet. It is a free and open network that is operated by
volunteers. You can start and stop Tor by clicking the buttons
below. If Tor is running, all traffic will be routed through it and
the swap provider will not be able to see your IP address.
</Typography>
<CliLogsBox label="Tor Daemon Logs" logs={torStdOut.split('\n')} />
</Box>
}
additionalContent={
<Box className={classes.actionsOuter}>
<IpcInvokeButton
variant="contained"
disabled={isTorRunning}
ipcChannel="spawn-tor"
ipcArgs={[]}
endIcon={<PlayArrowIcon />}
requiresRpc={false}
>
Start Tor
</IpcInvokeButton>
<IpcInvokeButton
variant="contained"
disabled={!isTorRunning}
ipcChannel="stop-tor"
ipcArgs={[]}
endIcon={<StopIcon />}
requiresRpc={false}
>
Stop Tor
</IpcInvokeButton>
</Box>
}
icon={null}
loading={false}
/>
);
}

View file

@ -0,0 +1,18 @@
import { Typography } from '@material-ui/core';
import { useIsSwapRunning } from 'store/hooks';
import HistoryTable from './table/HistoryTable';
import SwapDialog from '../../modal/swap/SwapDialog';
import SwapTxLockAlertsBox from '../../alert/SwapTxLockAlertsBox';
export default function HistoryPage() {
const showDialog = useIsSwapRunning();
return (
<>
<Typography variant="h3">History</Typography>
<SwapTxLockAlertsBox />
<HistoryTable />
<SwapDialog open={showDialog} onClose={() => {}} />
</>
);
}

View file

@ -0,0 +1,86 @@
import {
Box,
Collapse,
IconButton,
makeStyles,
TableCell,
TableRow,
} from '@material-ui/core';
import { useState } from 'react';
import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
import {
getHumanReadableDbStateType,
getSwapBtcAmount,
getSwapXmrAmount,
GetSwapInfoResponse,
} from '../../../../../models/rpcModel';
import HistoryRowActions from './HistoryRowActions';
import HistoryRowExpanded from './HistoryRowExpanded';
import { BitcoinAmount, MoneroAmount } from '../../../other/Units';
type HistoryRowProps = {
swap: GetSwapInfoResponse;
};
const useStyles = makeStyles((theme) => ({
amountTransferContainer: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
},
}));
function AmountTransfer({
btcAmount,
xmrAmount,
}: {
xmrAmount: number;
btcAmount: number;
}) {
const classes = useStyles();
return (
<Box className={classes.amountTransferContainer}>
<BitcoinAmount amount={btcAmount} />
<ArrowForwardIcon />
<MoneroAmount amount={xmrAmount} />
</Box>
);
}
export default function HistoryRow({ swap }: HistoryRowProps) {
const btcAmount = getSwapBtcAmount(swap);
const xmrAmount = getSwapXmrAmount(swap);
const [expanded, setExpanded] = useState(false);
return (
<>
<TableRow>
<TableCell>
<IconButton size="small" onClick={() => setExpanded(!expanded)}>
{expanded ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell>{swap.swapId.substring(0, 5)}...</TableCell>
<TableCell>
<AmountTransfer xmrAmount={xmrAmount} btcAmount={btcAmount} />
</TableCell>
<TableCell>{getHumanReadableDbStateType(swap.stateName)}</TableCell>
<TableCell>
<HistoryRowActions swap={swap} />
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ padding: 0 }} colSpan={6}>
<Collapse in={expanded} timeout="auto">
{expanded && <HistoryRowExpanded swap={swap} />}
</Collapse>
</TableCell>
</TableRow>
</>
);
}

View file

@ -0,0 +1,90 @@
import { Tooltip } from '@material-ui/core';
import Button, { ButtonProps } from '@material-ui/core/Button/Button';
import DoneIcon from '@material-ui/icons/Done';
import ErrorIcon from '@material-ui/icons/Error';
import { green, red } from '@material-ui/core/colors';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
import IpcInvokeButton from '../../../IpcInvokeButton';
import {
GetSwapInfoResponse,
SwapStateName,
isSwapStateNamePossiblyCancellableSwap,
isSwapStateNamePossiblyRefundableSwap,
} from '../../../../../models/rpcModel';
export function SwapResumeButton({
swap,
...props
}: { swap: GetSwapInfoResponse } & ButtonProps) {
return (
<IpcInvokeButton
variant="contained"
color="primary"
disabled={swap.completed}
ipcChannel="spawn-resume-swap"
ipcArgs={[swap.swapId]}
endIcon={<PlayArrowIcon />}
requiresRpc
{...props}
>
Resume
</IpcInvokeButton>
);
}
export function SwapCancelRefundButton({
swap,
...props
}: { swap: GetSwapInfoResponse } & ButtonProps) {
const cancelOrRefundable =
isSwapStateNamePossiblyCancellableSwap(swap.stateName) ||
isSwapStateNamePossiblyRefundableSwap(swap.stateName);
if (!cancelOrRefundable) {
return <></>;
}
return (
<IpcInvokeButton
ipcChannel="spawn-cancel-refund"
ipcArgs={[swap.swapId]}
requiresRpc
displayErrorSnackbar={false}
{...props}
>
Attempt manual Cancel & Refund
</IpcInvokeButton>
);
}
export default function HistoryRowActions({
swap,
}: {
swap: GetSwapInfoResponse;
}) {
if (swap.stateName === SwapStateName.XmrRedeemed) {
return (
<Tooltip title="The swap is completed because you have redeemed the XMR">
<DoneIcon style={{ color: green[500] }} />
</Tooltip>
);
}
if (swap.stateName === SwapStateName.BtcRefunded) {
return (
<Tooltip title="The swap is completed because your BTC have been refunded">
<DoneIcon style={{ color: green[500] }} />
</Tooltip>
);
}
if (swap.stateName === SwapStateName.BtcPunished) {
return (
<Tooltip title="The swap is completed because you have been punished">
<ErrorIcon style={{ color: red[500] }} />
</Tooltip>
);
}
return <SwapResumeButton swap={swap} />;
}

View file

@ -0,0 +1,134 @@
import {
Box,
Link,
makeStyles,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
} from '@material-ui/core';
import { getBitcoinTxExplorerUrl } from 'utils/conversionUtils';
import { isTestnet } from 'store/config';
import {
getHumanReadableDbStateType,
getSwapBtcAmount,
getSwapExchangeRate,
getSwapTxFees,
getSwapXmrAmount,
GetSwapInfoResponse,
} from '../../../../../models/rpcModel';
import SwapLogFileOpenButton from './SwapLogFileOpenButton';
import { SwapCancelRefundButton } from './HistoryRowActions';
import { SwapMoneroRecoveryButton } from './SwapMoneroRecoveryButton';
import {
BitcoinAmount,
MoneroAmount,
MoneroBitcoinExchangeRate,
} from 'renderer/components/other/Units';
const useStyles = makeStyles((theme) => ({
outer: {
display: 'grid',
padding: theme.spacing(1),
gap: theme.spacing(1),
},
actionsOuter: {
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
},
}));
export default function HistoryRowExpanded({
swap,
}: {
swap: GetSwapInfoResponse;
}) {
const classes = useStyles();
const { seller, startDate } = swap;
const btcAmount = getSwapBtcAmount(swap);
const xmrAmount = getSwapXmrAmount(swap);
const txFees = getSwapTxFees(swap);
const exchangeRate = getSwapExchangeRate(swap);
return (
<Box className={classes.outer}>
<TableContainer>
<Table>
<TableBody>
<TableRow>
<TableCell>Started on</TableCell>
<TableCell>{startDate}</TableCell>
</TableRow>
<TableRow>
<TableCell>Swap ID</TableCell>
<TableCell>{swap.swapId}</TableCell>
</TableRow>
<TableRow>
<TableCell>State Name</TableCell>
<TableCell>
{getHumanReadableDbStateType(swap.stateName)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Monero Amount</TableCell>
<TableCell>
<MoneroAmount amount={xmrAmount} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>Bitcoin Amount</TableCell>
<TableCell>
<BitcoinAmount amount={btcAmount} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>Exchange Rate</TableCell>
<TableCell>
<MoneroBitcoinExchangeRate rate={exchangeRate} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>Bitcoin Network Fees</TableCell>
<TableCell>
<BitcoinAmount amount={txFees} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>Provider Address</TableCell>
<TableCell>
<Box>{seller.addresses.join(', ')}</Box>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Bitcoin lock transaction</TableCell>
<TableCell>
<Link
href={getBitcoinTxExplorerUrl(swap.txLockId, isTestnet())}
target="_blank"
>
{swap.txLockId}
</Link>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
<Box className={classes.actionsOuter}>
<SwapLogFileOpenButton
swapId={swap.swapId}
variant="outlined"
size="small"
/>
<SwapCancelRefundButton swap={swap} variant="contained" size="small" />
<SwapMoneroRecoveryButton
swap={swap}
variant="contained"
size="small"
/>
</Box>
</Box>
);
}

View file

@ -0,0 +1,53 @@
import {
Box,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from '@material-ui/core';
import { sortBy } from 'lodash';
import { parseDateString } from 'utils/parseUtils';
import {
useAppSelector,
useSwapInfosSortedByDate,
} from '../../../../../store/hooks';
import HistoryRow from './HistoryRow';
const useStyles = makeStyles((theme) => ({
outer: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
},
}));
export default function HistoryTable() {
const classes = useStyles();
const swapSortedByDate = useSwapInfosSortedByDate();
return (
<Box className={classes.outer}>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell />
<TableCell>ID</TableCell>
<TableCell>Amount</TableCell>
<TableCell>State</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{swapSortedByDate.map((swap) => (
<HistoryRow swap={swap} key={swap.swapId} />
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}

View file

@ -0,0 +1,45 @@
import { ButtonProps } from '@material-ui/core/Button/Button';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from '@material-ui/core';
import { useState } from 'react';
import { CliLog } from 'models/cliModel';
import IpcInvokeButton from '../../../IpcInvokeButton';
import CliLogsBox from '../../../other/RenderedCliLog';
export default function SwapLogFileOpenButton({
swapId,
...props
}: { swapId: string } & ButtonProps) {
const [logs, setLogs] = useState<CliLog[] | null>(null);
return (
<>
<IpcInvokeButton
ipcArgs={[swapId]}
ipcChannel="get-swap-logs"
onSuccess={(data) => {
setLogs(data as CliLog[]);
}}
{...props}
>
view log
</IpcInvokeButton>
{logs && (
<Dialog open onClose={() => setLogs(null)} fullWidth maxWidth="lg">
<DialogTitle>Logs of swap {swapId}</DialogTitle>
<DialogContent>
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
</DialogContent>
<DialogActions>
<Button onClick={() => setLogs(null)}>Close</Button>
</DialogActions>
</Dialog>
)}
</>
);
}

View file

@ -0,0 +1,119 @@
import { ButtonProps } from '@material-ui/core/Button/Button';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
Link,
} from '@material-ui/core';
import { useAppDispatch, useAppSelector } from 'store/hooks';
import { rpcResetMoneroRecoveryKeys } from 'store/features/rpcSlice';
import {
GetSwapInfoResponse,
isSwapMoneroRecoverable,
} from '../../../../../models/rpcModel';
import IpcInvokeButton from '../../../IpcInvokeButton';
import DialogHeader from '../../../modal/DialogHeader';
import ScrollablePaperTextBox from '../../../other/ScrollablePaperTextBox';
function MoneroRecoveryKeysDialog({ swap }: { swap: GetSwapInfoResponse }) {
const dispatch = useAppDispatch();
const keys = useAppSelector((s) => s.rpc.state.moneroRecovery);
function onClose() {
dispatch(rpcResetMoneroRecoveryKeys());
}
if (keys === null || keys.swapId !== swap.swapId) {
return <></>;
}
return (
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
<DialogHeader
title={`Recovery Keys for swap ${swap.swapId.substring(0, 5)}...`}
/>
<DialogContent>
<DialogContentText>
You can use the keys below to manually redeem the Monero funds from
the multi-signature wallet.
<ul>
<li>
This is useful if the swap daemon fails to redeem the funds itself
</li>
<li>
If you have come this far, there is no risk of losing funds. You
are the only one with access to these keys and can use them to
access your funds
</li>
<li>
View{' '}
<Link
href="https://www.getmonero.org/resources/user-guides/restore_from_keys.html"
target="_blank"
rel="noreferrer"
>
this guide
</Link>{' '}
for a detailed description on how to import the keys and spend the
funds.
</li>
</ul>
</DialogContentText>
<Box
style={{
display: 'flex',
gap: '0.5rem',
flexDirection: 'column',
}}
>
{[
['Primary Address', keys.keys.address],
['View Key', keys.keys.view_key],
['Spend Key', keys.keys.spend_key],
['Restore Height', keys.keys.restore_height.toString()],
].map(([title, value]) => (
<ScrollablePaperTextBox
minHeight="2rem"
title={title}
copyValue={value}
rows={[value]}
/>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary" variant="contained">
Done
</Button>
</DialogActions>
</Dialog>
);
}
export function SwapMoneroRecoveryButton({
swap,
...props
}: { swap: GetSwapInfoResponse } & ButtonProps) {
const isRecoverable = isSwapMoneroRecoverable(swap.stateName);
if (!isRecoverable) {
return <></>;
}
return (
<>
<IpcInvokeButton
ipcChannel="spawn-monero-recovery"
ipcArgs={[swap.swapId]}
requiresRpc
{...props}
>
Display Monero Recovery Keys
</IpcInvokeButton>
<MoneroRecoveryKeysDialog swap={swap} />
</>
);
}

View file

@ -0,0 +1,31 @@
import { Box } from '@material-ui/core';
import { Alert, AlertTitle } from '@material-ui/lab';
import { removeAlert } from 'store/features/alertsSlice';
import { useAppDispatch, useAppSelector } from 'store/hooks';
export default function ApiAlertsBox() {
const alerts = useAppSelector((state) => state.alerts.alerts);
const dispatch = useAppDispatch();
function onRemoveAlert(id: number) {
dispatch(removeAlert(id));
}
if (alerts.length === 0) return null;
return (
<Box style={{ display: 'flex', justifyContent: 'center', gap: '1rem' }}>
{alerts.map((alert) => (
<Alert
variant="filled"
severity={alert.severity}
key={alert.id}
onClose={() => onRemoveAlert(alert.id)}
>
<AlertTitle>{alert.title}</AlertTitle>
{alert.body}
</Alert>
))}
</Box>
);
}

View file

@ -0,0 +1,25 @@
import { Box, makeStyles } from '@material-ui/core';
import SwapWidget from './SwapWidget';
import ApiAlertsBox from './ApiAlertsBox';
const useStyles = makeStyles((theme) => ({
outer: {
display: 'flex',
width: '100%',
flexDirection: 'column',
alignItems: 'center',
paddingBottom: theme.spacing(1),
gap: theme.spacing(1),
},
}));
export default function SwapPage() {
const classes = useStyles();
return (
<Box className={classes.outer}>
<ApiAlertsBox />
<SwapWidget />
</Box>
);
}

View file

@ -0,0 +1,274 @@
import { ChangeEvent, useEffect, useState } from 'react';
import {
makeStyles,
Box,
Paper,
Typography,
TextField,
LinearProgress,
Fab,
} from '@material-ui/core';
import InputAdornment from '@material-ui/core/InputAdornment';
import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward';
import SwapHorizIcon from '@material-ui/icons/SwapHoriz';
import { Alert } from '@material-ui/lab';
import { satsToBtc } from 'utils/conversionUtils';
import { useAppSelector } from 'store/hooks';
import { ExtendedProviderStatus } from 'models/apiModel';
import { isSwapState } from 'models/storeModel';
import SwapDialog from '../../modal/swap/SwapDialog';
import ProviderSelect from '../../modal/provider/ProviderSelect';
import {
ListSellersDialogOpenButton,
ProviderSubmitDialogOpenButton,
} from '../../modal/provider/ProviderListDialog';
// After RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN failed reconnection attempts we can assume the public registry is down
const RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN = 1;
function isRegistryDown(reconnectionAttempts: number): boolean {
return reconnectionAttempts > RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN;
}
const useStyles = makeStyles((theme) => ({
inner: {
width: 'min(480px, 100%)',
minHeight: '150px',
display: 'grid',
padding: theme.spacing(1),
gridGap: theme.spacing(1),
},
header: {
padding: 0,
},
headerText: {
padding: theme.spacing(1),
},
providerInfo: {
padding: theme.spacing(1),
},
swapIconOuter: {
display: 'flex',
justifyContent: 'center',
},
swapIcon: {
marginRight: theme.spacing(1),
},
noProvidersAlertOuter: {
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
},
noProvidersAlertButtonsOuter: {
display: 'flex',
gap: theme.spacing(1),
},
}));
function Title() {
const classes = useStyles();
return (
<Box className={classes.header}>
<Typography variant="h5" className={classes.headerText}>
Swap
</Typography>
</Box>
);
}
function HasProviderSwapWidget({
selectedProvider,
}: {
selectedProvider: ExtendedProviderStatus;
}) {
const classes = useStyles();
const forceShowDialog = useAppSelector((state) =>
isSwapState(state.swap.state),
);
const [showDialog, setShowDialog] = useState(false);
const [btcFieldValue, setBtcFieldValue] = useState<number | string>(
satsToBtc(selectedProvider.minSwapAmount),
);
const [xmrFieldValue, setXmrFieldValue] = useState(1);
function onBtcAmountChange(event: ChangeEvent<HTMLInputElement>) {
setBtcFieldValue(event.target.value);
}
function updateXmrValue() {
const parsedBtcAmount = Number(btcFieldValue);
if (Number.isNaN(parsedBtcAmount)) {
setXmrFieldValue(0);
} else {
const convertedXmrAmount =
parsedBtcAmount / satsToBtc(selectedProvider.price);
setXmrFieldValue(convertedXmrAmount);
}
}
function getBtcFieldError(): string | null {
const parsedBtcAmount = Number(btcFieldValue);
if (Number.isNaN(parsedBtcAmount)) {
return 'This is not a valid number';
}
if (parsedBtcAmount < satsToBtc(selectedProvider.minSwapAmount)) {
return `The minimum swap amount is ${satsToBtc(
selectedProvider.minSwapAmount,
)} BTC. Switch to a different provider if you want to swap less.`;
}
if (parsedBtcAmount > satsToBtc(selectedProvider.maxSwapAmount)) {
return `The maximum swap amount is ${satsToBtc(
selectedProvider.maxSwapAmount,
)} BTC. Switch to a different provider if you want to swap more.`;
}
return null;
}
function handleGuideDialogOpen() {
setShowDialog(true);
}
useEffect(updateXmrValue, [btcFieldValue, selectedProvider]);
return (
// 'elevation' prop can't be passed down (type def issue)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<Box className={classes.inner} component={Paper} elevation={5}>
<Title />
<TextField
label="Send"
size="medium"
variant="outlined"
value={btcFieldValue}
onChange={onBtcAmountChange}
error={!!getBtcFieldError()}
helperText={getBtcFieldError()}
autoFocus
InputProps={{
endAdornment: <InputAdornment position="end">BTC</InputAdornment>,
}}
/>
<Box className={classes.swapIconOuter}>
<ArrowDownwardIcon fontSize="small" />
</Box>
<TextField
label="Receive"
variant="outlined"
size="medium"
value={xmrFieldValue.toFixed(6)}
InputProps={{
endAdornment: <InputAdornment position="end">XMR</InputAdornment>,
}}
/>
<ProviderSelect />
<Fab variant="extended" color="primary" onClick={handleGuideDialogOpen}>
<SwapHorizIcon className={classes.swapIcon} />
Swap
</Fab>
<SwapDialog
open={showDialog || forceShowDialog}
onClose={() => setShowDialog(false)}
/>
</Box>
);
}
function HasNoProvidersSwapWidget() {
const forceShowDialog = useAppSelector((state) =>
isSwapState(state.swap.state),
);
const isPublicRegistryDown = useAppSelector((state) =>
isRegistryDown(
state.providers.registry.failedReconnectAttemptsSinceLastSuccess,
),
);
const classes = useStyles();
const alertBox = isPublicRegistryDown ? (
<Alert severity="info">
<Box className={classes.noProvidersAlertOuter}>
<Typography>
Currently, the public registry of providers seems to be unreachable.
Here&apos;s what you can do:
<ul>
<li>
Try discovering a provider by connecting to a rendezvous point
</li>
<li>
Try again later when the public registry may be reachable again
</li>
</ul>
</Typography>
<Box>
<ListSellersDialogOpenButton />
</Box>
</Box>
</Alert>
) : (
<Alert severity="info">
<Box className={classes.noProvidersAlertOuter}>
<Typography>
Currently, there are no providers (trading partners) available in the
official registry. Here&apos;s what you can do:
<ul>
<li>
Try discovering a provider by connecting to a rendezvous point
</li>
<li>Add a new provider to the public registry</li>
<li>Try again later when more providers may be available</li>
</ul>
</Typography>
<Box>
<ProviderSubmitDialogOpenButton />
<ListSellersDialogOpenButton />
</Box>
</Box>
</Alert>
);
return (
<Box>
{alertBox}
<SwapDialog open={forceShowDialog} onClose={() => {}} />
</Box>
);
}
function ProviderLoadingSwapWidget() {
const classes = useStyles();
return (
// 'elevation' prop can't be passed down (type def issue)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<Box className={classes.inner} component={Paper} elevation={15}>
<Title />
<LinearProgress />
</Box>
);
}
export default function SwapWidget() {
const selectedProvider = useAppSelector(
(state) => state.providers.selectedProvider,
);
// If we fail more than RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN reconnect attempts, we'll show the "no providers" widget. We can assume the public registry is down.
const providerLoading = useAppSelector(
(state) =>
state.providers.registry.providers === null &&
!isRegistryDown(
state.providers.registry.failedReconnectAttemptsSinceLastSuccess,
),
);
if (providerLoading) {
return <ProviderLoadingSwapWidget />;
}
if (selectedProvider) {
return <HasProviderSwapWidget selectedProvider={selectedProvider} />;
}
return <HasNoProvidersSwapWidget />;
}

View file

@ -0,0 +1,31 @@
import { Box, makeStyles, Typography } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import WithdrawWidget from './WithdrawWidget';
const useStyles = makeStyles((theme) => ({
outer: {
display: 'flex',
flexDirection: 'column',
gridGap: theme.spacing(0.5),
},
}));
export default function WalletPage() {
const classes = useStyles();
return (
<Box className={classes.outer}>
<Typography variant="h3">Wallet</Typography>
<Alert severity="info">
You do not have to deposit money before starting a swap. Instead, you
will be greeted with a deposit address after you initiate one.
</Alert>
<Typography variant="subtitle1">
If funds are left in your wallet after a swap, you can withdraw them to
your wallet. If you decide to leave them inside the internal wallet, the
funds will automatically be used when starting a new swap.
</Typography>
<WithdrawWidget />
</Box>
);
}

View file

@ -0,0 +1,10 @@
import { Button, CircularProgress, IconButton } from '@material-ui/core';
import RefreshIcon from '@material-ui/icons/Refresh';
import IpcInvokeButton from '../../IpcInvokeButton';
import { checkBitcoinBalance } from 'renderer/rpc';
export default function WalletRefreshButton() {
return <IconButton onClick={() => checkBitcoinBalance(true)}>
<RefreshIcon />
</IconButton>
}

View file

@ -0,0 +1,64 @@
import { Box, Button, makeStyles, Typography } from '@material-ui/core';
import { useState } from 'react';
import SendIcon from '@material-ui/icons/Send';
import { useAppSelector, useIsRpcEndpointBusy } from 'store/hooks';
import { RpcMethod } from 'models/rpcModel';
import BitcoinIcon from '../../icons/BitcoinIcon';
import WithdrawDialog from '../../modal/wallet/WithdrawDialog';
import WalletRefreshButton from './WalletRefreshButton';
import InfoBox from '../../modal/swap/InfoBox';
import { SatsAmount } from 'renderer/components/other/Units';
const useStyles = makeStyles((theme) => ({
title: {
alignItems: 'center',
display: 'flex',
gap: theme.spacing(0.5),
},
}));
export default function WithdrawWidget() {
const classes = useStyles();
const walletBalance = useAppSelector((state) => state.rpc.state.balance);
const checkingBalance = useIsRpcEndpointBusy(RpcMethod.GET_BTC_BALANCE);
const [showDialog, setShowDialog] = useState(false);
function onShowDialog() {
setShowDialog(true);
}
return (
<>
<InfoBox
title={
<Box className={classes.title}>
Wallet Balance
<WalletRefreshButton />
</Box>
}
mainContent={
<Typography variant="h5">
<SatsAmount amount={walletBalance} />
</Typography>
}
icon={<BitcoinIcon />}
additionalContent={
<Button
variant="contained"
color="primary"
endIcon={<SendIcon />}
size="large"
onClick={onShowDialog}
disabled={
walletBalance === null || checkingBalance || walletBalance <= 0
}
>
Withdraw
</Button>
}
loading={false}
/>
<WithdrawDialog open={showDialog} onClose={() => setShowDialog(false)} />
</>
);
}