allow sending directly to a channel or content address (#5990)
* allow sending directly to a channel or content address
This commit is contained in:
parent
200dc66763
commit
2852138c3a
6 changed files with 340 additions and 90 deletions
|
@ -1,16 +1,19 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectBalance, selectMyChannelClaims } from 'lbry-redux';
|
||||
import { selectBalance, selectMyChannelClaims, makeSelectClaimForUri } from 'lbry-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import WalletSend from './view';
|
||||
import { withRouter } from 'react-router';
|
||||
import { selectToast } from 'redux/selectors/notifications';
|
||||
|
||||
const perform = dispatch => ({
|
||||
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
});
|
||||
|
||||
const select = state => ({
|
||||
const select = (state, props) => ({
|
||||
balance: selectBalance(state),
|
||||
channels: selectMyChannelClaims(state),
|
||||
contentClaim: makeSelectClaimForUri(props.contentUri)(state),
|
||||
snack: selectToast(state),
|
||||
});
|
||||
|
||||
export default withRouter(connect(select, perform)(WalletSend));
|
||||
|
|
|
@ -4,20 +4,36 @@ import React from 'react';
|
|||
import Button from 'component/button';
|
||||
import { Form, FormField } from 'component/common/form';
|
||||
import { Formik } from 'formik';
|
||||
import { validateSendTx } from 'util/form-validation';
|
||||
import validateSendTx from 'util/form-validation';
|
||||
import Card from 'component/common/card';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
|
||||
|
||||
type DraftTransaction = {
|
||||
address: string,
|
||||
amount: ?number, // So we can use a placeholder in the input
|
||||
};
|
||||
import classnames from 'classnames';
|
||||
import ChannelSelector from 'component/channelSelector';
|
||||
import ClaimPreview from 'component/claimPreview';
|
||||
|
||||
type Props = {
|
||||
openModal: (id: string, { address: string, amount: number }) => void,
|
||||
openModal: (id: string, { destination: string, amount: string, isAddress: boolean }) => void,
|
||||
draftTransaction: { address: string, amount: string },
|
||||
setDraftTransaction: ({ address: string, amount: string }) => void,
|
||||
balance: number,
|
||||
isAddress: boolean,
|
||||
setIsAddress: (boolean) => void,
|
||||
contentUri: string,
|
||||
contentError: string,
|
||||
contentClaim?: StreamClaim,
|
||||
setEnteredContentUri: (string) => void,
|
||||
confirmed: boolean,
|
||||
setConfirmed: (boolean) => void,
|
||||
sendLabel: string,
|
||||
setSendLabel: (string) => void,
|
||||
snack: ?{
|
||||
linkTarget: ?string,
|
||||
linkText: ?string,
|
||||
message: string,
|
||||
isError: boolean,
|
||||
},
|
||||
};
|
||||
|
||||
class WalletSend extends React.PureComponent<Props> {
|
||||
|
@ -25,19 +41,49 @@ class WalletSend extends React.PureComponent<Props> {
|
|||
super();
|
||||
|
||||
(this: any).handleSubmit = this.handleSubmit.bind(this);
|
||||
(this: any).handleClear = this.handleClear.bind(this);
|
||||
}
|
||||
|
||||
handleSubmit(values: DraftTransaction) {
|
||||
const { openModal } = this.props;
|
||||
const { address, amount } = values;
|
||||
if (amount && address) {
|
||||
const modalProps = { address, amount };
|
||||
openModal(MODALS.CONFIRM_TRANSACTION, modalProps);
|
||||
}
|
||||
handleSubmit() {
|
||||
const { draftTransaction, openModal, isAddress, contentUri, setConfirmed } = this.props;
|
||||
const destination = isAddress ? draftTransaction.address : contentUri;
|
||||
const amount = draftTransaction.amount;
|
||||
|
||||
const modalProps = { destination, amount, isAddress, setConfirmed };
|
||||
|
||||
openModal(MODALS.CONFIRM_TRANSACTION, modalProps);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
const { setDraftTransaction, setConfirmed } = this.props;
|
||||
setDraftTransaction({
|
||||
address: '',
|
||||
amount: '',
|
||||
});
|
||||
setConfirmed(false);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { balance } = this.props;
|
||||
const {
|
||||
draftTransaction,
|
||||
setDraftTransaction,
|
||||
balance,
|
||||
isAddress,
|
||||
setIsAddress,
|
||||
contentUri,
|
||||
contentClaim,
|
||||
setEnteredContentUri,
|
||||
contentError,
|
||||
confirmed,
|
||||
sendLabel,
|
||||
setSendLabel,
|
||||
snack,
|
||||
} = this.props;
|
||||
if (confirmed) {
|
||||
this.handleClear();
|
||||
setSendLabel('Sending...');
|
||||
}
|
||||
if (snack) setSendLabel('Send');
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
@ -54,60 +100,112 @@ class WalletSend extends React.PureComponent<Props> {
|
|||
amount: '',
|
||||
}}
|
||||
onSubmit={this.handleSubmit}
|
||||
validate={validateSendTx}
|
||||
render={({ values, errors, touched, handleChange, handleBlur, handleSubmit }) => (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<fieldset-group class="fieldset-group--smushed">
|
||||
<FormField
|
||||
autoFocus
|
||||
type="number"
|
||||
name="amount"
|
||||
label={__('Amount')}
|
||||
className="form-field--price-amount"
|
||||
affixClass="form-field--fix-no-height"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="12.34"
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
value={values.amount}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="text"
|
||||
name="address"
|
||||
placeholder="bbFxRyXXXXXXXXXXXZD8nE7XTLUxYnddTs"
|
||||
className="form-field--address"
|
||||
label={__('Recipient address')}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
value={values.address}
|
||||
/>
|
||||
</fieldset-group>
|
||||
<div className="card__actions">
|
||||
render={({ values, errors, touched, handleBlur, handleSubmit }) => (
|
||||
<div>
|
||||
<div className="section">
|
||||
<Button
|
||||
button="primary"
|
||||
type="submit"
|
||||
label={__('Send')}
|
||||
disabled={
|
||||
!values.address ||
|
||||
!!Object.keys(errors).length ||
|
||||
!(parseFloat(values.amount) > 0.0) ||
|
||||
parseFloat(values.amount) === balance
|
||||
}
|
||||
key="Address"
|
||||
label={__('Address')}
|
||||
button="alt"
|
||||
onClick={() => setIsAddress(true)}
|
||||
className={classnames('button-toggle', { 'button-toggle--active': isAddress })}
|
||||
/>
|
||||
<Button
|
||||
key="Search"
|
||||
label={__('Search')}
|
||||
button="alt"
|
||||
onClick={() => setIsAddress(false)}
|
||||
className={classnames('button-toggle', { 'button-toggle--active': !isAddress })}
|
||||
/>
|
||||
{!!Object.keys(errors).length || (
|
||||
<span className="error__text">
|
||||
{(!!values.address && touched.address && errors.address) ||
|
||||
(!!values.amount && touched.amount && errors.amount) ||
|
||||
(parseFloat(values.amount) === balance &&
|
||||
__('Decrease amount to account for transaction fee')) ||
|
||||
(parseFloat(values.amount) > balance && __('Not enough Credits'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<WalletSpendableBalanceHelp />
|
||||
</Form>
|
||||
|
||||
<div className="section">
|
||||
{!isAddress && <ChannelSelector />}
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{!isAddress && (
|
||||
<FormField
|
||||
type="text"
|
||||
name="search"
|
||||
error={contentError}
|
||||
placeholder={__('Enter a name, @username or URL')}
|
||||
className="form-field--address"
|
||||
label={__('Recipient search')}
|
||||
onChange={(event) => setEnteredContentUri(event.target.value)}
|
||||
onBlur={handleBlur}
|
||||
value={values.search}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isAddress && (
|
||||
<fieldset-section>
|
||||
<ClaimPreview
|
||||
key={contentUri}
|
||||
uri={contentUri}
|
||||
actions={''}
|
||||
type={'small'}
|
||||
showNullPlaceholder
|
||||
hideMenu
|
||||
hideRepostLabel
|
||||
/>
|
||||
</fieldset-section>
|
||||
)}
|
||||
|
||||
<fieldset-group class="fieldset-group--smushed">
|
||||
<FormField
|
||||
autoFocus
|
||||
type="number"
|
||||
name="amount"
|
||||
label={__('Amount')}
|
||||
className="form-field--price-amount"
|
||||
affixClass="form-field--fix-no-height"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="12.34"
|
||||
onChange={(event) => setDraftTransaction({ address: draftTransaction.address, amount: event.target.value })}
|
||||
onBlur={handleBlur}
|
||||
value={draftTransaction.amount}
|
||||
/>
|
||||
{isAddress && (
|
||||
<FormField
|
||||
type="text"
|
||||
name="address"
|
||||
placeholder={'bbFxRyXXXXXXXXXXXZD8nE7XTLUxYnddTs'}
|
||||
className="form-field--address"
|
||||
label={__('Recipient Address')}
|
||||
onChange={(event) => setDraftTransaction({ address: event.target.value, amount: draftTransaction.amount })}
|
||||
onBlur={handleBlur}
|
||||
value={draftTransaction.address}
|
||||
/>
|
||||
)}
|
||||
</fieldset-group>
|
||||
|
||||
<div className="card__actions">
|
||||
<Button
|
||||
button="primary"
|
||||
type="submit"
|
||||
label={__(sendLabel)}
|
||||
disabled={
|
||||
!(parseFloat(draftTransaction.amount) > 0.0) ||
|
||||
parseFloat(draftTransaction.amount) >= balance ||
|
||||
sendLabel === 'Sending...' ||
|
||||
(isAddress ? !draftTransaction.address || validateSendTx(draftTransaction.address).address !== '' : !contentClaim)
|
||||
}
|
||||
/>
|
||||
{!!Object.keys(errors).length || (
|
||||
<span className="error__text">
|
||||
{(!!draftTransaction.address && touched.address && errors.address) ||
|
||||
(!!draftTransaction.amount && touched.amount && errors.amount) ||
|
||||
(parseFloat(draftTransaction.amount) === balance &&
|
||||
__('Decrease amount to account for transaction fee')) ||
|
||||
(parseFloat(draftTransaction.amount) > balance && __('Not enough Credits'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<WalletSpendableBalanceHelp />
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doSendDraftTransaction } from 'lbry-redux';
|
||||
import { doSendDraftTransaction, makeSelectClaimForUri, doSendTip } from 'lbry-redux';
|
||||
import { doHideModal } from 'redux/actions/app';
|
||||
import ModalConfirmTransaction from './view';
|
||||
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
|
||||
|
||||
const perform = dispatch => ({
|
||||
closeModal: () => dispatch(doHideModal()),
|
||||
sendToAddress: (address, amount) => dispatch(doSendDraftTransaction(address, amount)),
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.destination)(state),
|
||||
activeChannelClaim: selectActiveChannelClaim(state),
|
||||
incognito: selectIncognito(state),
|
||||
});
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
perform
|
||||
)(ModalConfirmTransaction);
|
||||
const perform = (dispatch) => ({
|
||||
closeModal: () => dispatch(doHideModal()),
|
||||
sendToAddress: (address, amount) => dispatch(doSendDraftTransaction(address, amount)),
|
||||
sendTip: (params, isSupport) => dispatch(doSendTip(params, isSupport)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(ModalConfirmTransaction);
|
||||
|
|
|
@ -5,23 +5,40 @@ import { Form } from 'component/common/form';
|
|||
import { Modal } from 'modal/modal';
|
||||
import Card from 'component/common/card';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import ClaimPreview from 'component/claimPreview';
|
||||
|
||||
type TipParams = { amount: number, claim_id: string, channel_id?: string };
|
||||
|
||||
type Props = {
|
||||
address: string,
|
||||
destination: string,
|
||||
amount: number,
|
||||
closeModal: () => void,
|
||||
sendToAddress: (string, number) => void,
|
||||
sendTip: (TipParams, boolean) => void,
|
||||
isAddress: boolean,
|
||||
claim: StreamClaim,
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
incognito: boolean,
|
||||
setConfirmed: (boolean) => void,
|
||||
};
|
||||
|
||||
class ModalConfirmTransaction extends React.PureComponent<Props> {
|
||||
onConfirmed() {
|
||||
const { closeModal, sendToAddress, amount, address } = this.props;
|
||||
sendToAddress(address, amount);
|
||||
const { closeModal, sendToAddress, sendTip, amount, destination, isAddress, claim, setConfirmed } = this.props;
|
||||
if (!isAddress) {
|
||||
const claimId = claim && claim.claim_id;
|
||||
const tipParams: TipParams = { amount: amount, claim_id: claimId };
|
||||
sendTip(tipParams, false);
|
||||
} else {
|
||||
sendToAddress(destination, amount);
|
||||
}
|
||||
setConfirmed(true);
|
||||
closeModal();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { amount, address, closeModal } = this.props;
|
||||
const { amount, destination, closeModal, isAddress, incognito, activeChannelClaim } = this.props;
|
||||
const activeChannelUrl = activeChannelClaim && activeChannelClaim.canonical_url;
|
||||
const title = __('Confirm Transaction');
|
||||
return (
|
||||
<Modal isOpen contentLabel={title} type="card" onAborted={closeModal}>
|
||||
|
@ -33,8 +50,26 @@ class ModalConfirmTransaction extends React.PureComponent<Props> {
|
|||
<div className="section">
|
||||
<div className="confirm__label">{__('Sending')}</div>
|
||||
<div className="confirm__value">{<LbcSymbol postfix={amount} size={22} />}</div>
|
||||
|
||||
{!isAddress && <div className="confirm__label">{__('From')}</div>}
|
||||
{!isAddress && (
|
||||
<div className="confirm__value">
|
||||
{incognito ? (
|
||||
'Anonymous'
|
||||
) : (
|
||||
<ClaimPreview key={activeChannelUrl} uri={activeChannelUrl} actions={''} type={'small'} hideMenu hideRepostLabel />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="confirm__label">{__('To')}</div>
|
||||
<div className="confirm__value">{address}</div>
|
||||
<div className="confirm__value">
|
||||
{!isAddress ? (
|
||||
<ClaimPreview key={destination} uri={destination} actions={''} type={'small'} hideMenu hideRepostLabel />
|
||||
) : (
|
||||
destination
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -3,10 +3,111 @@ import React from 'react';
|
|||
import Page from 'component/page';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import WalletSend from 'component/walletSend';
|
||||
import { URL as SITE_URL, URL_LOCAL, URL_DEV } from 'config';
|
||||
import { parseURI, isNameValid, isURIValid, normalizeURI } from 'lbry-redux';
|
||||
|
||||
type Props = {};
|
||||
|
||||
export default function SendPage(props: Props) {
|
||||
const [isAddress, setIsAddress] = React.useState(true);
|
||||
const [contentUri, setContentUri] = React.useState('');
|
||||
const [draftTransaction, setDraftTransaction] = React.useState({ address: '', amount: '' });
|
||||
const [enteredContent, setEnteredContentUri] = React.useState(undefined);
|
||||
const contentFirstRender = React.useRef(true);
|
||||
const [contentError, setContentError] = React.useState('');
|
||||
const [confirmed, setConfirmed] = React.useState(false);
|
||||
const [sendLabel, setSendLabel] = React.useState('Send');
|
||||
|
||||
function getSearchUri(value) {
|
||||
const WEB_DEV_PREFIX = `${URL_DEV}/`;
|
||||
const WEB_LOCAL_PREFIX = `${URL_LOCAL}/`;
|
||||
const WEB_PROD_PREFIX = `${SITE_URL}/`;
|
||||
const ODYSEE_PREFIX = `https://odysee.com/`;
|
||||
const includesLbryTvProd = value.includes(WEB_PROD_PREFIX);
|
||||
const includesOdysee = value.includes(ODYSEE_PREFIX);
|
||||
const includesLbryTvLocal = value.includes(WEB_LOCAL_PREFIX);
|
||||
const includesLbryTvDev = value.includes(WEB_DEV_PREFIX);
|
||||
const wasCopiedFromWeb = includesLbryTvDev || includesLbryTvLocal || includesLbryTvProd || includesOdysee;
|
||||
const isLbryUrl = value.startsWith('lbry://') && value !== 'lbry://';
|
||||
const error = '';
|
||||
|
||||
const addLbryIfNot = term => {
|
||||
return term.startsWith('lbry://') ? term : `lbry://${term}`;
|
||||
};
|
||||
if (wasCopiedFromWeb) {
|
||||
let prefix = WEB_PROD_PREFIX;
|
||||
if (includesLbryTvLocal) prefix = WEB_LOCAL_PREFIX;
|
||||
if (includesLbryTvDev) prefix = WEB_DEV_PREFIX;
|
||||
if (includesOdysee) prefix = ODYSEE_PREFIX;
|
||||
|
||||
let query = (value && value.slice(prefix.length).replace(/:/g, '#')) || '';
|
||||
try {
|
||||
const lbryUrl = `lbry://${query}`;
|
||||
parseURI(lbryUrl);
|
||||
return [lbryUrl, null];
|
||||
} catch (e) {
|
||||
return [query, 'error'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLbryUrl) {
|
||||
if (value.startsWith('@')) {
|
||||
if (isNameValid(value.slice(1))) {
|
||||
return [value, null];
|
||||
} else {
|
||||
return [value, error];
|
||||
}
|
||||
}
|
||||
return [addLbryIfNot(value), null];
|
||||
} else {
|
||||
try {
|
||||
const isValid = isURIValid(value);
|
||||
if (isValid) {
|
||||
let uri;
|
||||
try {
|
||||
uri = normalizeURI(value);
|
||||
} catch (e) {
|
||||
return [value, null];
|
||||
}
|
||||
return [uri, null];
|
||||
} else {
|
||||
return [value, null];
|
||||
}
|
||||
} catch (e) {
|
||||
return [value, 'error'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setContentUri given enteredUri
|
||||
React.useEffect(() => {
|
||||
if (!enteredContent && !contentFirstRender.current) {
|
||||
setContentError(__('A name is required'));
|
||||
}
|
||||
if (enteredContent) {
|
||||
contentFirstRender.current = false;
|
||||
const [searchContent, error] = getSearchUri(enteredContent);
|
||||
if (error) {
|
||||
setContentError(__('Something not quite right..'));
|
||||
} else {
|
||||
setContentError('');
|
||||
}
|
||||
try {
|
||||
const { streamName, channelName, isChannel } = parseURI(searchContent);
|
||||
if (!isChannel && streamName && isNameValid(streamName)) {
|
||||
// contentNameValid = true;
|
||||
setContentUri(searchContent);
|
||||
} else if (isChannel && channelName && isNameValid(channelName)) {
|
||||
// contentNameValid = true;
|
||||
setContentUri(searchContent);
|
||||
}
|
||||
} catch (e) {
|
||||
if (enteredContent !== '@') setContentError('');
|
||||
setContentUri(``);
|
||||
}
|
||||
}
|
||||
}, [enteredContent, setContentUri, setContentError, parseURI, isNameValid]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
noSideNavigation
|
||||
|
@ -20,7 +121,19 @@ export default function SendPage(props: Props) {
|
|||
),
|
||||
}}
|
||||
>
|
||||
<WalletSend />
|
||||
<WalletSend
|
||||
isAddress={isAddress}
|
||||
setIsAddress={setIsAddress}
|
||||
contentUri={contentUri}
|
||||
contentError={contentError}
|
||||
setEnteredContentUri={setEnteredContentUri}
|
||||
confirmed={confirmed}
|
||||
setConfirmed={setConfirmed}
|
||||
draftTransaction={draftTransaction}
|
||||
setDraftTransaction={setDraftTransaction}
|
||||
sendLabel={sendLabel}
|
||||
setSendLabel={setSendLabel}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
// @flow
|
||||
import { regexAddress } from 'lbry-redux';
|
||||
|
||||
type DraftTxValues = {
|
||||
address: string,
|
||||
// amount: number
|
||||
};
|
||||
|
||||
export const validateSendTx = (formValues: DraftTxValues) => {
|
||||
const { address } = formValues;
|
||||
const errors = {};
|
||||
export default function validateSendTx(address: string) {
|
||||
const errors = {
|
||||
address: '',
|
||||
};
|
||||
|
||||
// All we need to check is if the address is valid
|
||||
// If values are missing, users wont' be able to submit the form
|
||||
|
|
Loading…
Reference in a new issue