Implement Swap BTC page

This commit is contained in:
infinite-persistence 2021-03-25 19:24:49 +08:00 committed by Sean Yesmunt
parent 8e6604cfa6
commit 9c808e2b5e
22 changed files with 751 additions and 10 deletions

10
flow-typed/CoinSwap.js vendored Normal file
View file

@ -0,0 +1,10 @@
declare type CoinSwapState = {
btcAddresses: Array<string>
};
declare type CoinSwapAction = {
type: string,
data: {
btcAddress: string,
},
};

View file

@ -1729,6 +1729,37 @@
"lbry.tv is being retired in favor of %odysee% and new sign ups are disabled. Sign up on %odysee% instead": "lbry.tv is being retired in favor of %odysee% and new sign ups are disabled. Sign up on %odysee% instead",
"lbry.tv is being retired in favor of %odysee%": "lbry.tv is being retired in favor of %odysee%",
"You will have to switch to the %desktop_app% or %odysee% in the near future. Your existing login details will work on %odysee% and all of your %credits% and other settings will be there.": "You will have to switch to the %desktop_app% or %odysee% in the near future. Your existing login details will work on %odysee% and all of your %credits% and other settings will be there.",
"Swap": "Swap",
"Swap Bitcoin": "Swap Bitcoin",
"Bitcoin": "Bitcoin",
"Credits": "Credits",
"Start Swap": "Start Swap",
"To": "To",
"Receiving": "Receiving",
"Send bitcoin to the address provided and you will be sent an equivalent amount of Credits.": "Send bitcoin to the address provided and you will be sent an equivalent amount of Credits.",
"Swap Bitcoin for %lbc%": "Swap Bitcoin for %lbc%",
"Processing...": "Processing...",
"View Past Swaps": "View Past Swaps",
"Remove address": "Remove address",
"Waiting for BTC": "Waiting for BTC",
"Waiting to receive your bitcoin.": "Waiting to receive your bitcoin.",
"Sending LBC": "Sending LBC",
"Bitcoin received. Sending your LBC.": "Bitcoin received. Sending your LBC.",
"Completed": "Completed",
"LBC sent. You should see it in your wallet.": "LBC sent. You should see it in your wallet.",
"Failed": "Failed",
"An error occurred on the previous swap.": "An error occurred on the previous swap.",
"Failed to initiate swap.": "Failed to initiate swap.",
"Failed to query swap status.": "Failed to query swap status.",
"The system is currently down. Come back later.": "The system is currently down. Come back later.",
"Unable to obtain exchange rate. Try again later.": "Unable to obtain exchange rate. Try again later.",
"The BTC amount needs to be higher": "The BTC amount needs to be higher",
"The BTC amount is too high": "The BTC amount is too high",
"Confirm Address Removal": "Confirm Address Removal",
"Remove BTC Swap Address": "Remove BTC Swap Address",
"This process cannot be reversed.": "This process cannot be reversed.",
"Remove %btc_address%?": "Remove %btc_address%?",
"View transaction": "View transaction",
"We apologize for this inconvenience, but have added this additional step to prevent abuse. Users on VPN or shared connections will continue to see this message and are not eligible for Rewards.": "We apologize for this inconvenience, but have added this additional step to prevent abuse. Users on VPN or shared connections will continue to see this message and are not eligible for Rewards.",
"Help LBRY Save Crypto": "Help LBRY Save Crypto",
"The US government is attempting to destroy the cryptocurrency industry. Can you help?": "The US government is attempting to destroy the cryptocurrency industry. Can you help?",

View file

@ -221,6 +221,12 @@ export const icons = {
<line x1="1" y1="10" x2="23" y2="10" />
</g>
),
[ICONS.COIN_SWAP]: buildIcon(
<g>
<path d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</g>
),
[ICONS.LIBRARY]: buildIcon(<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />),
[ICONS.EDIT]: buildIcon(
<g>

View file

@ -155,7 +155,7 @@ const WalletBalance = (props: Props) => {
<Button button="primary" label={__('Buy')} icon={ICONS.BUY} navigate={`/$/${PAGES.BUY}`} />
<Button button="secondary" label={__('Receive')} icon={ICONS.RECEIVE} navigate={`/$/${PAGES.RECEIVE}`} />
<Button button="secondary" label={__('Send')} icon={ICONS.SEND} navigate={`/$/${PAGES.SEND}`} />
<Button button="secondary" label={__('Swap')} icon={ICONS.REFRESH} navigate={`/$/${PAGES.SWAP}`} />
<Button button="secondary" label={__('Swap')} icon={ICONS.COIN_SWAP} navigate={`/$/${PAGES.SWAP}`} />
</div>
{(otherCount > WALLET_CONSOLIDATE_UTXOS || consolidateIsPending || consolidatingUtxos) && (
<p className="help">

View file

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import WalletSwap from './view';
import { doOpenModal } from 'redux/actions/app';
import { doAddBtcAddress } from 'redux/actions/coinSwap';
import { doToast } from 'redux/actions/notifications';
import { selectBtcAddresses } from 'redux/selectors/coinSwap';
import { selectReceiveAddress, doGetNewAddress, doCheckAddressIsMine } from 'lbry-redux';
const select = (state, props) => ({
receiveAddress: selectReceiveAddress(state),
btcAddresses: selectBtcAddresses(state),
});
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToast: (options) => dispatch(doToast(options)),
doAddBtcAddress: (address) => dispatch(doAddBtcAddress(address)),
getNewAddress: () => dispatch(doGetNewAddress()),
checkAddressIsMine: (address) => dispatch(doCheckAddressIsMine(address)),
});
export default withRouter(connect(select, perform)(WalletSwap));

View file

@ -0,0 +1,490 @@
// @flow
import React from 'react';
import Button from 'component/button';
import { FormField, Form } from 'component/common/form';
import { Lbryio } from 'lbryinc';
import Card from 'component/common/card';
import LbcSymbol from 'component/common/lbc-symbol';
import Spinner from 'component/spinner';
import Nag from 'component/common/nag';
import CopyableText from 'component/copyableText';
import QRCode from 'component/common/qr-code';
import usePersistedState from 'effects/use-persisted-state';
import * as ICONS from 'constants/icons';
import * as MODALS from 'constants/modal_types';
import { clipboard } from 'electron';
import I18nMessage from 'component/i18nMessage';
const BTC_SATOSHIS = 100000000;
const BTC_MAX = 21000000;
const BTC_MIN = 1 / BTC_SATOSHIS;
const STATUS_FETCH_INTERVAL_MS = 60000;
const IS_DEV = process.env.NODE_ENV !== 'production';
const DEBOUNCE_BTC_CHANGE_MS = 400;
const INTERNAL_APIS_DOWN = 'internal_apis_down';
const BTC_API_STATUS_PENDING = 'Pending';
const BTC_API_STATUS_PROCESSING = 'Processing';
const BTC_API_STATUS_SUCCESS = 'Success';
const BTC_API_STATUS_ERROR = 'Error';
const ACTION_MAIN = 'action_main';
const ACTION_STATUS_PENDING = 'action_pending';
const ACTION_STATUS_PROCESSING = 'action_processing';
const ACTION_STATUS_SUCCESS = 'action_success';
const ACTION_PAST_SWAPS = 'action_past_swaps';
const NAG_API_STATUS_PENDING = 'Waiting to receive your bitcoin.';
const NAG_API_STATUS_PROCESSING = 'Bitcoin received. Sending your LBC.';
const NAG_API_STATUS_SUCCESS = 'LBC sent. You should see it in your wallet.';
const NAG_API_STATUS_ERROR = 'An error occurred on the previous swap.';
const NAG_SWAP_CALL_FAILED = 'Failed to initiate swap.';
// const NAG_STATUS_CALL_FAILED = 'Failed to query swap status.';
const NAG_SERVER_DOWN = 'The system is currently down. Come back later.';
const NAG_RATE_CALL_FAILED = 'Unable to obtain exchange rate. Try again later.';
type Props = {
receiveAddress: string,
btcAddresses: Array<string>,
doToast: ({ message: string }) => void,
doAddBtcAddress: (string) => void,
getNewAddress: () => void,
checkAddressIsMine: (string) => void,
openModal: (string, {}) => void,
};
function WalletSwap(props: Props) {
const {
receiveAddress,
doToast,
btcAddresses,
doAddBtcAddress,
getNewAddress,
checkAddressIsMine,
openModal,
} = props;
const [btc, setBtc] = usePersistedState('swap-btc-amount', 0.001);
const [btcError, setBtcError] = React.useState();
const [btcAddress, setBtcAddress] = React.useState();
const [lbc, setLbc] = React.useState(0);
const [action, setAction] = React.useState(ACTION_MAIN);
const [nag, setNag] = React.useState(null);
const [showQr, setShowQr] = React.useState(false);
const [isFetchingRate, setIsFetchingRate] = React.useState(false);
const [isSwapping, setIsSwapping] = React.useState(false);
const [statusMap, setStatusMap] = React.useState({});
const [isRefreshingStatus, setIsRefreshingStatus] = React.useState(false);
const status = btcAddress ? statusMap[btcAddress] : null;
const btcTxId = status && status.receipt_txid ? status.receipt_txid : null;
const lbcTxId = status && status.lbc_txid ? status.lbc_txid : null;
function formatLbcString(lbc) {
return lbc === 0 ? '---' : lbc.toLocaleString(undefined, { minimumFractionDigits: 8 });
}
function returnToMainAction() {
setIsSwapping(false);
setAction(ACTION_MAIN);
setBtcAddress(null);
}
function removeBtcAddress(btcAddress) {
openModal(MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS, {
btcAddress: btcAddress,
});
}
// Ensure 'receiveAddress' is populated
React.useEffect(() => {
if (!receiveAddress) {
getNewAddress();
} else {
checkAddressIsMine(receiveAddress);
}
}, [receiveAddress, getNewAddress, checkAddressIsMine]);
// Get 'btc::rate'
React.useEffect(() => {
if (isNaN(btc) || btc === 0) {
setLbc(0);
return;
}
setIsFetchingRate(true);
const timer = setTimeout(() => {
Lbryio.call('btc', 'rate', { satoshi: BTC_SATOSHIS })
.then((result) => {
setIsFetchingRate(false);
setLbc(btc / result);
})
.catch((e) => {
setIsFetchingRate(false);
setLbc(0);
setNag({ msg: NAG_RATE_CALL_FAILED, type: 'error' });
});
}, DEBOUNCE_BTC_CHANGE_MS);
return () => clearTimeout(timer);
}, [btc]);
function queryStatus(btcAddress, successCb, failureCb) {
Lbryio.call('btc', 'status', { pay_to_address: btcAddress })
.then((result) => {
setStatusMap((statusMap) => {
const tmpMap = { ...statusMap };
if (btcAddress) {
tmpMap[btcAddress] = result;
}
return tmpMap;
});
if (successCb) successCb(result);
})
.catch((err) => {
if (failureCb) failureCb(err);
});
}
// Poll 'btc::status'
React.useEffect(() => {
function fetchBtcStatus() {
queryStatus(
btcAddress,
(result) => {
switch (result.Status) {
case BTC_API_STATUS_PENDING:
setAction(ACTION_STATUS_PENDING);
setNag({ msg: NAG_API_STATUS_PENDING, type: 'helpful' });
break;
case BTC_API_STATUS_PROCESSING:
setAction(ACTION_STATUS_PROCESSING);
setNag({ msg: NAG_API_STATUS_PROCESSING, type: 'helpful' });
break;
case BTC_API_STATUS_SUCCESS:
setAction(ACTION_STATUS_SUCCESS);
setNag({ msg: NAG_API_STATUS_SUCCESS, type: 'helpful' });
setIsSwapping(false);
break;
case BTC_API_STATUS_ERROR:
setNag({ msg: NAG_API_STATUS_ERROR, type: 'error' });
returnToMainAction();
break;
default:
if (IS_DEV) throw new Error('Unhandled status: "' + result.Status + '"');
break;
}
},
(err) => {
returnToMainAction();
setNag({
msg: err === INTERNAL_APIS_DOWN ? NAG_SERVER_DOWN : err.message /* NAG_STATUS_CALL_FAILED */,
type: 'error',
});
}
);
}
let fetchInterval;
if (btcAddress && isSwapping) {
fetchBtcStatus();
fetchInterval = setInterval(fetchBtcStatus, STATUS_FETCH_INTERVAL_MS);
}
return () => {
if (fetchInterval) {
clearInterval(fetchInterval);
}
};
}, [btcAddress, isSwapping]);
// Validate entered BTC
React.useEffect(() => {
let msg;
if (btc < BTC_MIN) {
msg = __('The BTC amount needs to be higher');
} else if (btc > BTC_MAX) {
msg = __('The BTC amount is too high');
}
setBtcError(msg);
}, [btc]);
// 'Refresh' button feedback
React.useEffect(() => {
let timer;
if (isRefreshingStatus) {
timer = setTimeout(() => {
setIsRefreshingStatus(false);
}, 1000);
}
return () => clearTimeout(timer);
}, [isRefreshingStatus]);
function handleStartSwap() {
setIsSwapping(true);
setBtcAddress(null);
setNag(null);
Lbryio.call('btc', 'swap', {
lbc_satoshi_requested: parseInt(lbc * BTC_SATOSHIS),
btc_satoshi_provided: parseInt(btc * BTC_SATOSHIS),
pay_to_wallet_address: receiveAddress,
})
.then((result) => {
setBtcAddress(result);
doAddBtcAddress(result);
})
.catch((err) => {
setNag({ msg: err === INTERNAL_APIS_DOWN ? NAG_SWAP_CALL_FAILED : err.message, type: 'error' });
returnToMainAction();
});
}
function handleCancelPending() {
returnToMainAction();
setNag(null);
}
function handleBtcChange(event: SyntheticInputEvent<*>) {
const btc = parseFloat(event.target.value);
setBtc(btc);
}
function handleViewPastSwaps() {
setAction(ACTION_PAST_SWAPS);
setNag(null);
setIsRefreshingStatus(true);
btcAddresses.forEach((x) => {
queryStatus(x, null, null);
});
}
function getStatusStr(btcAddress) {
const status = statusMap[btcAddress];
if (!status) {
return '---';
}
let msg;
switch (status.Status) {
case BTC_API_STATUS_PENDING:
msg = __('Waiting for BTC');
break;
case BTC_API_STATUS_PROCESSING:
msg = __('Sending LBC');
break;
case BTC_API_STATUS_SUCCESS:
msg = __('Completed');
break;
case BTC_API_STATUS_ERROR:
msg = __('Failed');
break;
default:
msg = '?';
// if (IS_DEV) throw new Error('Unhandled "status": ' + status.Status);
break;
}
return msg;
}
function getViewTransactionElement(isSend) {
if (isSend) {
return btcTxId ? (
<Button button="link" href={`https://www.blockchain.com/btc/tx/${btcTxId}`} label={__('View transaction')} />
) : null;
} else {
return lbcTxId ? (
<Button button="link" href={`https://explorer.lbry.com/tx/${lbcTxId}`} label={__('View transaction')} />
) : null;
}
}
function getActionElement() {
switch (action) {
case ACTION_MAIN:
return actionMain;
case ACTION_STATUS_PENDING:
return actionPending;
case ACTION_STATUS_SUCCESS: // fall-through
case ACTION_STATUS_PROCESSING:
return actionProcessingAndSuccess;
case ACTION_PAST_SWAPS:
return actionPastSwaps;
default:
if (IS_DEV) throw new Error('Unhandled action: ' + action);
return actionMain;
}
}
const actionMain = (
<>
<div className="section section--padded card--inline confirm__wrapper">
<div className="section">
<FormField
autoFocus
label={__('Bitcoin')}
type="number"
name="btc"
className="form-field--price-amount--auto"
affixClass="form-field--fix-no-height"
max={BTC_MAX}
min={BTC_MIN}
step={1 / BTC_SATOSHIS}
placeholder="12.34"
value={btc}
error={btcError}
disabled={isSwapping}
onChange={(event) => handleBtcChange(event)}
/>
<div className="confirm__value" />
<div className="confirm__label">{__('Credits')}</div>
<div className="confirm__value">
<LbcSymbol postfix={formatLbcString(lbc)} size={22} />
{isFetchingRate && <Spinner type="small" />}
</div>
</div>
</div>
<div className="section__actions">
<Button
autoFocus
onClick={handleStartSwap}
button="primary"
disabled={isSwapping || isNaN(btc) || btc === 0 || lbc === 0 || btcError}
label={isSwapping ? __('Processing...') : __('Start Swap')}
/>
{btcAddresses.length !== 0 && (
<Button button="link" label={__('View Past Swaps')} onClick={handleViewPastSwaps} />
)}
</div>
</>
);
const actionPending = (
<>
<div className="section section--padded card--inline confirm__wrapper">
<div className="section">
<div className="confirm__label">{__('Send')}</div>
<div className="confirm__value">{btc} BTC</div>
<div className="confirm__label">{__('To')}</div>
<CopyableText primaryButton copyable={btcAddress} snackMessage={__('Address copied.')} />
<div className="card__actions--inline">
<Button
button="link"
label={showQr ? __('Hide QR code') : __('Show QR code')}
onClick={() => setShowQr(!showQr)}
/>
{showQr && btcAddress && <QRCode value={btcAddress} />}
</div>
<div className="confirm__value" />
<div className="confirm__label">{__('Receive')}</div>
<div className="confirm__value">{<LbcSymbol postfix={formatLbcString(lbc)} size={22} />}</div>
</div>
</div>
<div className="section__actions">
<Button autoFocus onClick={handleCancelPending} button="primary" label={__('Go Back')} />
</div>
</>
);
const actionProcessingAndSuccess = (
<>
<div className="section section--padded card--inline confirm__wrapper">
<div className="section">
<div className="confirm__label">{__('Sent')}</div>
<div className="confirm__value">{btc} BTC</div>
{getViewTransactionElement(true)}
<div className="confirm__value" />
<div className="confirm__label">{action === ACTION_STATUS_SUCCESS ? __('Received') : __('Receiving')}</div>
<div className="confirm__value">{<LbcSymbol postfix={formatLbcString(lbc)} size={22} />}</div>
{action === ACTION_STATUS_SUCCESS && getViewTransactionElement(false)}
</div>
</div>
<div className="section__actions">
<Button autoFocus onClick={handleCancelPending} button="primary" label={__('Go Back')} />
</div>
</>
);
const actionPastSwaps = (
<>
<div className="section section--padded card--inline confirm__wrapper">
<div className="section">
<div className="table__wrapper">
<table className="table table--btc-swap">
<thead>
<tr>
<th>BTC address</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{btcAddresses.length === 0 && (
<tr>
<td>{'---'}</td>
</tr>
)}
{btcAddresses.length !== 0 &&
btcAddresses.map((x) => {
const shortBtcAddress = x.substring(0, 7);
return (
<tr key={x}>
<td>
<Button
button="link"
className="button--hash-id"
title={x}
label={shortBtcAddress}
onClick={() => {
clipboard.writeText(x);
doToast({
message: __('Address copied.'),
});
}}
/>
</td>
<td>{isRefreshingStatus ? '...' : getStatusStr(x)}</td>
<td>
<Button
button="link"
icon={ICONS.REMOVE}
title={__('Remove address')}
onClick={() => removeBtcAddress(x)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
<div className="section__actions">
<Button autoFocus onClick={handleCancelPending} button="primary" label={__('Go Back')} />
{btcAddresses.length !== 0 && !isRefreshingStatus && (
<Button button="link" label={__('Refresh')} onClick={handleViewPastSwaps} />
)}
{isRefreshingStatus && <Spinner type="small" />}
</div>
</>
);
return (
<Form onSubmit={handleStartSwap}>
<Card
title={<I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>Swap Bitcoin for %lbc%</I18nMessage>}
subtitle={__('Send bitcoin to the address provided and you will be sent an equivalent amount of Credits.')}
actions={getActionElement()}
nag={nag ? <Nag relative type={nag.type} message={__(nag.msg)} /> : null}
/>
</Form>
);
}
export default WalletSwap;

View file

@ -282,6 +282,10 @@ export const COMMENT_RECEIVED = 'COMMENT_RECEIVED';
// Blocked channels
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';
// Coin swap
export const ADD_BTC_ADDRESS = 'ADD_BTC_ADDRESS';
export const REMOVE_BTC_ADDRESS = 'REMOVE_BTC_ADDRESS';
// Tags
export const TOGGLE_TAG_FOLLOW = 'TOGGLE_TAG_FOLLOW';
export const TAG_ADD = 'TAG_ADD';

View file

@ -27,6 +27,7 @@ export const HISTORY = 'Clock';
export const HOME = 'Home';
export const OVERVIEW = 'Activity';
export const WALLET = 'List';
export const COIN_SWAP = 'CoinSwap';
export const PHONE = 'Phone';
export const COMPLETE = 'Check';
export const COMPLETED = 'CheckCircle';

View file

@ -42,3 +42,4 @@ export const SYNC_ENABLE = 'SYNC_ENABLE';
export const IMAGE_UPLOAD = 'image_upload';
export const MOBILE_SEARCH = 'mobile_search';
export const VIEW_IMAGE = 'view_image';
export const CONFIRM_REMOVE_BTC_SWAP_ADDRESS = 'confirm_remove_btc_swap_address';

View file

@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import ModalRemoveBtcSwapAddress from './view';
import { doRemoveBtcAddress } from 'redux/actions/coinSwap';
const select = (state, props) => ({});
const perform = (dispatch) => ({
doRemoveBtcAddress: (btcAddress) => dispatch(doRemoveBtcAddress(btcAddress)),
closeModal: () => dispatch(doHideModal()),
});
export default connect(select, perform)(ModalRemoveBtcSwapAddress);

View file

@ -0,0 +1,43 @@
// @flow
import React from 'react';
import { Modal } from 'modal/modal';
import Button from 'component/button';
import Card from 'component/common/card';
import I18nMessage from 'component/i18nMessage';
type Props = {
btcAddress: string,
doRemoveBtcAddress: (string) => void,
closeModal: () => void,
};
function ModalRemoveBtcSwapAddress(props: Props) {
const { btcAddress, doRemoveBtcAddress, closeModal } = props;
return (
<Modal isOpen contentLabel={__('Confirm Address Removal')} type="card" onAborted={closeModal}>
<Card
title={__('Remove BTC Swap Address')}
subtitle={<I18nMessage tokens={{ btc_address: <em>{`${btcAddress}`}</em> }}>Remove %btc_address%?</I18nMessage>}
body={<p className="help--warning">{__('This process cannot be reversed.')}</p>}
actions={
<>
<div className="section__actions">
<Button
button="primary"
label={__('OK')}
onClick={() => {
doRemoveBtcAddress(btcAddress);
closeModal();
}}
/>
<Button button="link" label={__('Cancel')} onClick={closeModal} />
</div>
</>
}
/>
</Modal>
);
}
export default ModalRemoveBtcSwapAddress;

View file

@ -39,6 +39,7 @@ import ModalImageUpload from 'modal/modalImageUpload';
import ModalMobileSearch from 'modal/modalMobileSearch';
import ModalViewImage from 'modal/modalViewImage';
import ModalMassTipsUnlock from 'modal/modalMassTipUnlock';
import ModalRemoveBtcSwapAddress from 'modal/modalRemoveBtcSwapAddress';
type Props = {
modal: { id: string, modalProps: {} },
@ -140,6 +141,8 @@ function ModalRouter(props: Props) {
return <ModalViewImage {...modalProps} />;
case MODALS.MASS_TIP_UNLOCK:
return <ModalMassTipsUnlock {...modalProps} />;
case MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS:
return <ModalRemoveBtcSwapAddress {...modalProps} />;
default:
return null;
}

View file

@ -1,7 +1,7 @@
// @flow
import React from 'react';
import Page from 'component/page';
import LbcSymbol from 'component/common/lbc-symbol';
import WalletSwap from 'component/walletSwap';
type Props = {};
@ -9,15 +9,13 @@ export default function SwapPage(props: Props) {
return (
<Page
noSideNavigation
className="main--buy"
className="main--swap"
backout={{
backoutLabel: __('Done'),
title: (
<>
<LbcSymbol prefix={__('Swap')} size={28} />
</>
),
title: __('Swap Bitcoin'),
}}
/>
>
<WalletSwap />
</Page>
);
}

View file

@ -12,11 +12,12 @@ import rewardsReducer from 'redux/reducers/rewards';
import userReducer from 'redux/reducers/user';
import commentsReducer from 'redux/reducers/comments';
import blockedReducer from 'redux/reducers/blocked';
import coinSwapReducer from 'redux/reducers/coinSwap';
import searchReducer from 'redux/reducers/search';
import reactionsReducer from 'redux/reducers/reactions';
import syncReducer from 'redux/reducers/sync';
export default history =>
export default (history) =>
combineReducers({
router: connectRouter(history),
app: appReducer,
@ -38,6 +39,7 @@ export default history =>
subscriptions: subscriptionsReducer,
tags: tagsReducer,
blocked: blockedReducer,
coinSwap: coinSwapReducer,
user: userReducer,
wallet: walletReducer,
sync: syncReducer,

View file

@ -0,0 +1,36 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { selectPrefsReady } from 'redux/selectors/sync';
import { doAlertWaitingForSync } from 'redux/actions/app';
export const doAddBtcAddress = (btcAddress: string) => (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const ready = selectPrefsReady(state);
if (!ready) {
return dispatch(doAlertWaitingForSync());
}
dispatch({
type: ACTIONS.ADD_BTC_ADDRESS,
data: {
btcAddress,
},
});
};
export const doRemoveBtcAddress = (btcAddress: string) => (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const ready = selectPrefsReady(state);
if (!ready) {
return dispatch(doAlertWaitingForSync());
}
dispatch({
type: ACTIONS.REMOVE_BTC_ADDRESS,
data: {
btcAddress,
},
});
};

View file

@ -0,0 +1,46 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { ACTIONS as LBRY_REDUX_ACTIONS } from 'lbry-redux';
import { handleActions } from 'util/redux-utils';
const defaultState: CoinSwapState = {
btcAddresses: [],
};
export default handleActions(
{
[ACTIONS.ADD_BTC_ADDRESS]: (state: CoinSwapState, action: CoinSwapAction): CoinSwapState => {
const { btcAddresses } = state;
const { btcAddress } = action.data;
let newBtcAddresses = btcAddresses.slice();
if (!newBtcAddresses.includes(btcAddress)) {
newBtcAddresses.push(btcAddress);
}
return {
btcAddresses: newBtcAddresses,
};
},
[ACTIONS.REMOVE_BTC_ADDRESS]: (state: CoinSwapState, action: CoinSwapAction): CoinSwapState => {
const { btcAddresses } = state;
const { btcAddress } = action.data;
let newBtcAddresses = btcAddresses.slice();
newBtcAddresses = newBtcAddresses.filter((x) => x !== btcAddress);
return {
btcAddresses: newBtcAddresses,
};
},
[LBRY_REDUX_ACTIONS.USER_STATE_POPULATE]: (
state: CoinSwapState,
action: { data: { btcAddresses: ?Array<string> } }
) => {
const { btcAddresses } = action.data;
const sanitizedBtcAddresses = btcAddresses && btcAddresses.filter((e) => typeof e === 'string');
return {
...state,
btcAddresses:
sanitizedBtcAddresses && sanitizedBtcAddresses.length ? sanitizedBtcAddresses : state.btcAddresses,
};
},
},
defaultState
);

View file

@ -0,0 +1,8 @@
// @flow
import { createSelector } from 'reselect';
const selectState = (state: { coinSwap: CoinSwapState }) => state.coinSwap || {};
export const selectBtcAddresses = createSelector(selectState, (state: CoinSwapState) => {
return state.btcAddresses.filter((x) => typeof x === 'string');
});

View file

@ -359,3 +359,7 @@ svg + .button__label {
top: var(--spacing-s);
}
}
.button--hash-id {
@include font-mono;
}

View file

@ -406,6 +406,11 @@ fieldset-group {
width: 7em;
}
.form-field--price-amount--auto {
width: auto;
min-width: 100%;
}
.form-field--address {
min-width: 18em;
}

View file

@ -261,6 +261,11 @@
max-width: 34rem;
}
.main--swap {
@extend .main--buy;
max-width: 34rem;
}
.main--empty {
align-self: center;
display: flex;

View file

@ -154,6 +154,12 @@ th {
}
}
.table--btc-swap {
tr {
white-space: nowrap;
}
}
.table--rewards {
td:nth-of-type(1) {
width: 40%;

View file

@ -66,6 +66,7 @@ const searchFilter = createFilter('search', ['options']);
const tagsFilter = createFilter('tags', ['followedTags']);
const subscriptionsFilter = createFilter('subscriptions', ['subscriptions']);
const blockedFilter = createFilter('blocked', ['blockedChannels']);
const btcAddressesFilter = createFilter('coinSwap', ['btcAddresses']);
const settingsFilter = createBlacklistFilter('settings', ['loadedLanguages', 'language']);
const whiteListedReducers = [
'fileInfo',
@ -76,6 +77,7 @@ const whiteListedReducers = [
'app',
'search',
'blocked',
'coinSwap',
'settings',
'subscriptions',
];
@ -84,6 +86,7 @@ const transforms = [
fileInfoFilter,
walletFilter,
blockedFilter,
btcAddressesFilter,
tagsFilter,
appFilter,
searchFilter,
@ -119,6 +122,8 @@ const triggerSharedStateActions = [
ACTIONS.CHANNEL_SUBSCRIBE,
ACTIONS.CHANNEL_UNSUBSCRIBE,
ACTIONS.TOGGLE_BLOCK_CHANNEL,
ACTIONS.ADD_BTC_ADDRESS,
ACTIONS.REMOVE_BTC_ADDRESS,
ACTIONS.TOGGLE_TAG_FOLLOW,
LBRY_REDUX_ACTIONS.CREATE_CHANNEL_COMPLETED,
ACTIONS.SYNC_CLIENT_SETTINGS,
@ -151,6 +156,7 @@ const sharedStateFilters = {
property: 'following',
},
blocked: { source: 'blocked', property: 'blockedChannels' },
btc_addresses: { source: 'coinSwap', property: 'btcAddresses' },
settings: { source: 'settings', property: 'sharedPreferences' },
app_welcome_version: { source: 'app', property: 'welcomeVersion' },
sharing_3P: { source: 'app', property: 'allowAnalytics' },