allow sending directly to a channel or content address (#5990)

* allow sending directly to a channel or content address
This commit is contained in:
saltrafael 2021-05-20 16:30:40 -03:00 committed by GitHub
parent 200dc66763
commit 2852138c3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 340 additions and 90 deletions

View file

@ -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));

View file

@ -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>
)}
/>
}

View file

@ -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);

View file

@ -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>
}

View file

@ -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>
);
}

View file

@ -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