From 2852138c3a2eabc04be9bd92d6c31b9c43e82d1b Mon Sep 17 00:00:00 2001 From: saltrafael <76502841+saltrafael@users.noreply.github.com> Date: Thu, 20 May 2021 16:30:40 -0300 Subject: [PATCH] allow sending directly to a channel or content address (#5990) * allow sending directly to a channel or content address --- ui/component/walletSend/index.js | 7 +- ui/component/walletSend/view.jsx | 230 +++++++++++++++------- ui/modal/modalConfirmTransaction/index.js | 21 +- ui/modal/modalConfirmTransaction/view.jsx | 45 ++++- ui/page/send/view.jsx | 115 ++++++++++- ui/util/form-validation.js | 12 +- 6 files changed, 340 insertions(+), 90 deletions(-) diff --git a/ui/component/walletSend/index.js b/ui/component/walletSend/index.js index b8e630d41..3f13b15f2 100644 --- a/ui/component/walletSend/index.js +++ b/ui/component/walletSend/index.js @@ -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)); diff --git a/ui/component/walletSend/view.jsx b/ui/component/walletSend/view.jsx index 62989a45e..9e4fc4bf1 100644 --- a/ui/component/walletSend/view.jsx +++ b/ui/component/walletSend/view.jsx @@ -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 { @@ -25,19 +41,49 @@ class WalletSend extends React.PureComponent { 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 ( { amount: '', }} onSubmit={this.handleSubmit} - validate={validateSendTx} - render={({ values, errors, touched, handleChange, handleBlur, handleSubmit }) => ( -
- - - - - -
+ render={({ values, errors, touched, handleBlur, handleSubmit }) => ( +
+
- - + +
+ {!isAddress && } + +
+ {!isAddress && ( + setEnteredContentUri(event.target.value)} + onBlur={handleBlur} + value={values.search} + /> + )} + + {!isAddress && ( + + + + )} + + + setDraftTransaction({ address: draftTransaction.address, amount: event.target.value })} + onBlur={handleBlur} + value={draftTransaction.amount} + /> + {isAddress && ( + setDraftTransaction({ address: event.target.value, amount: draftTransaction.amount })} + onBlur={handleBlur} + value={draftTransaction.address} + /> + )} + + +
+
+ + +
+
)} /> } diff --git a/ui/modal/modalConfirmTransaction/index.js b/ui/modal/modalConfirmTransaction/index.js index e8ee7ca65..62f0cea23 100644 --- a/ui/modal/modalConfirmTransaction/index.js +++ b/ui/modal/modalConfirmTransaction/index.js @@ -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); diff --git a/ui/modal/modalConfirmTransaction/view.jsx b/ui/modal/modalConfirmTransaction/view.jsx index a84df5939..7ec609b65 100644 --- a/ui/modal/modalConfirmTransaction/view.jsx +++ b/ui/modal/modalConfirmTransaction/view.jsx @@ -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 { 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 ( @@ -33,8 +50,26 @@ class ModalConfirmTransaction extends React.PureComponent {
{__('Sending')}
{}
+ + {!isAddress &&
{__('From')}
} + {!isAddress && ( +
+ {incognito ? ( + 'Anonymous' + ) : ( + + )} +
+ )} +
{__('To')}
-
{address}
+
+ {!isAddress ? ( + + ) : ( + destination + )} +
} diff --git a/ui/page/send/view.jsx b/ui/page/send/view.jsx index 4cd2c7beb..dbab04873 100644 --- a/ui/page/send/view.jsx +++ b/ui/page/send/view.jsx @@ -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 ( - + ); } diff --git a/ui/util/form-validation.js b/ui/util/form-validation.js index a697155ad..5841925bc 100644 --- a/ui/util/form-validation.js +++ b/ui/util/form-validation.js @@ -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