From 0f8855cb7c98b523006096e2a5b3a6a03f2de34d Mon Sep 17 00:00:00 2001 From: symbionts-dev-ops Date: Thu, 30 Oct 2025 15:32:41 -0400 Subject: [PATCH 1/5] feat(core): add wallet security alerts (power down/routes), routes rework, and UI/FAQ improvement --- src/app/ResolveRoute.js | 2 +- src/app/ResolveRoute.test.js | 2 +- src/app/assets/stylesheets/_themes.scss | 12 + src/app/client_config.js | 4 + src/app/components/all.scss | 2 + .../cards/TransferHistoryRow/index.jsx | 11 +- .../ConfirmWithdrawVestingRoute/index.jsx | 66 ++ .../components/elements/ConversionsModal.jsx | 15 +- .../components/elements/ConvertToSteem.jsx | 16 +- .../elements/OutgoingDelegations.jsx | 8 +- .../elements/OutgoingDelegations.scss | 38 +- src/app/components/elements/RouteSettings.jsx | 461 ++++++++---- .../components/elements/RouteSettings.scss | 25 + src/app/components/elements/UserKeys.jsx | 12 +- src/app/components/elements/VotersModal.jsx | 6 +- src/app/components/elements/VotersModal.scss | 47 +- .../elements/VotersTable/SmallTable.jsx | 19 +- src/app/components/elements/Voting.scss | 43 ++ src/app/components/elements/WalletSubMenu.jsx | 16 + .../elements/WithdrawRoutesModal.jsx | 39 + .../elements/WithdrawRoutesModal.scss | 57 ++ .../elements/WithdrawRoutesTable.jsx | 63 ++ src/app/components/modules/AuthorRewards.jsx | 4 + .../components/modules/CurationRewards.jsx | 4 + src/app/components/modules/Delegations.scss | 7 +- src/app/components/modules/Powerdown.jsx | 106 ++- .../modules/ProposalList/Proposal.jsx | 43 +- .../ProposalList/ProposalContainer.jsx | 1 + .../modules/ProposalList/ProposalList.jsx | 3 +- .../modules/ProposalList/styles.scss | 25 +- src/app/components/modules/Proposals.jsx | 678 ++++++++++++++++++ .../components/modules/SidePanel/index.jsx | 12 +- src/app/components/modules/Transfer.jsx | 34 +- src/app/components/modules/UserWallet.jsx | 389 ++++++++-- src/app/components/modules/UserWallet.scss | 33 + src/app/components/modules/Witnesses.jsx | 561 +++++++++++++++ src/app/components/pages/Proposals.jsx | 660 +---------------- src/app/components/pages/UserProfile.jsx | 40 +- src/app/components/pages/Witnesses.jsx | 495 +------------ src/app/components/pages/Witnesses.scss | 21 +- src/app/help/en/faq.md | 130 ++-- src/app/help/en/welcome.md | 5 +- src/app/locales/en.json | 41 +- src/app/redux/GlobalReducer.js | 14 + src/app/redux/TransactionReducer.js | 6 + src/app/redux/TransactionSaga.js | 13 + src/app/redux/TransactionSaga.test.js | 4 +- src/app/redux/UserSaga.js | 2 +- src/app/utils/StateFunctions.js | 7 + src/app/utils/VerifiedExchangeList.js | 3 - src/app/utils/steemApi.js | 14 +- 51 files changed, 2820 insertions(+), 1499 deletions(-) create mode 100644 src/app/components/elements/ConfirmWithdrawVestingRoute/index.jsx create mode 100644 src/app/components/elements/RouteSettings.scss create mode 100644 src/app/components/elements/WithdrawRoutesModal.jsx create mode 100644 src/app/components/elements/WithdrawRoutesModal.scss create mode 100644 src/app/components/elements/WithdrawRoutesTable.jsx create mode 100644 src/app/components/modules/Proposals.jsx create mode 100644 src/app/components/modules/Witnesses.jsx diff --git a/src/app/ResolveRoute.js b/src/app/ResolveRoute.js index f5b0223bd..1af9bdd2d 100644 --- a/src/app/ResolveRoute.js +++ b/src/app/ResolveRoute.js @@ -3,7 +3,7 @@ import GDPRUserList from './utils/GDPRUserList'; export const routeRegex = { UserProfile1: /^\/(@[\w\.\d-]+)\/?$/, - UserProfile2: /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations)\/?$/, + UserProfile2: /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations|proposals|witnesses)\/?$/, }; export default function resolveRoute(path) { diff --git a/src/app/ResolveRoute.test.js b/src/app/ResolveRoute.test.js index 53f69e70d..a6e2ef173 100644 --- a/src/app/ResolveRoute.test.js +++ b/src/app/ResolveRoute.test.js @@ -7,7 +7,7 @@ describe('routeRegex', () => { ['UserProfile1', /^\/(@[\w\.\d-]+)\/?$/], [ 'UserProfile2', - /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations)\/?$/, + /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations|proposals|witnesses)\/?$/, ], ]; diff --git a/src/app/assets/stylesheets/_themes.scss b/src/app/assets/stylesheets/_themes.scss index 75164e68f..0b13dc2ac 100755 --- a/src/app/assets/stylesheets/_themes.scss +++ b/src/app/assets/stylesheets/_themes.scss @@ -9,6 +9,8 @@ $themes: ( backgroundColorOpaque: $color-background-off-white, backgroundTransparent: transparent, backgroundColorWarning: $color-background-warning, + backgroundColorDanger: $alert-color, + backgroundColorSecondary: $color-white, moduleBackgroundColor: $color-white, menuBackgroundColor: $color-background-dark, moduleMediumBackgroundColor: $color-white, @@ -20,6 +22,7 @@ $themes: ( borderDark: 1px solid $color-text-gray, borderAccent: 1px solid $color-blue, borderWarning: 1px solid $color-text-warning, + borderDanger: 1px solid $alert-color, borderTransparent: transparent, roundedCorners: 5px, roundedCornersTop: 5px 5px 0 0, @@ -30,6 +33,7 @@ $themes: ( textColorAccent: $color-text-blue, textColorAccentHover: $color-blue-original-dark, textColorError: $color-text-red, + textColorOnDanger: $color-white, contentBorderAccent: $color-transparent, buttonBackground: $color-blue-original-dark, buttonBackgroundHover: $color-blue-original-light, @@ -48,6 +52,8 @@ $themes: ( backgroundColorOpaque: $color-background-off-white, backgroundTransparent: transparent, backgroundColorWarning: $color-background-warning, + backgroundColorDanger: $alert-color, + backgroundColorSecondary: $color-white, moduleBackgroundColor: $color-white, menuBackgroundColor: $color-background-dark, moduleMediumBackgroundColor: $color-transparent, @@ -59,6 +65,7 @@ $themes: ( borderDark: 1px solid $color-text-gray, borderAccent: 1px solid $color-teal, borderWarning: 1px solid $color-text-warning, + borderDanger: 1px solid $alert-color, borderTransparent: transparent, roundedCorners: 5px, roundedCornersTop: 5px 5px 0 0, @@ -69,6 +76,7 @@ $themes: ( textColorAccent: $color-text-teal, textColorAccentHover: $color-teal, textColorError: $color-text-red, + textColorOnDanger: $color-white, contentBorderAccent: $color-teal, buttonBackground: $color-blue-black, buttonBackgroundHover: $color-teal, @@ -87,6 +95,8 @@ $themes: ( backgroundColorEmphasis: $color-background-super-dark, backgroundColorOpaque: $color-blue-dark, backgroundColorWarning: $color-background-warning-dark, + backgroundColorDanger: $alert-color, + backgroundColorSecondary: $color-blue-dark, moduleBackgroundColor: $color-background-dark, backgroundTransparent: transparent, menuBackgroundColor: $color-blue-dark, @@ -99,6 +109,7 @@ $themes: ( borderDark: 1px solid $color-text-gray-light, borderAccent: 1px solid $color-teal, borderWarning: 1px solid $color-background-warning-dark, + borderDanger: 1px solid $alert-color, borderTransparent: transparent, roundedCorners: 5px, roundedCornersTop: 5px 5px 0 0, @@ -109,6 +120,7 @@ $themes: ( textColorAccent: $color-teal, textColorAccentHover: $color-teal-light, textColorError: $color-text-red, + textColorOnDanger: $color-white, contentBorderAccent: $color-teal, buttonBackground: $color-white, buttonBackgroundHover: $color-teal, diff --git a/src/app/client_config.js b/src/app/client_config.js index 79361d549..ab8e1270c 100644 --- a/src/app/client_config.js +++ b/src/app/client_config.js @@ -51,3 +51,7 @@ export const SITE_DESCRIPTION = // various export const SUPPORT_EMAIL = 'support@' + APP_DOMAIN; + +// External links +export const STEEMSCAN_BLOCK_URL = "https://steemscan.com/block"; +export const STEEMSCAN_TRANSACTION_URL = "https://steemscan.com/transaction"; diff --git a/src/app/components/all.scss b/src/app/components/all.scss index 1b3a9a319..15c5e2d55 100644 --- a/src/app/components/all.scss +++ b/src/app/components/all.scss @@ -29,6 +29,8 @@ @import './elements/OutgoingDelegations'; @import './elements/VotersModal'; @import './elements/ProposalCreatorModal'; +@import './elements/RouteSettings'; +@import './elements/WithdrawRoutesModal'; // modules @import './modules/Header/styles'; diff --git a/src/app/components/cards/TransferHistoryRow/index.jsx b/src/app/components/cards/TransferHistoryRow/index.jsx index c2056831a..0da74a47a 100644 --- a/src/app/components/cards/TransferHistoryRow/index.jsx +++ b/src/app/components/cards/TransferHistoryRow/index.jsx @@ -6,12 +6,15 @@ import Memo from 'app/components/elements/Memo'; import { numberWithCommas, vestsToSp } from 'app/utils/StateFunctions'; import tt from 'counterpart'; import GDPRUserList from 'app/utils/GDPRUserList'; +import { STEEMSCAN_BLOCK_URL, STEEMSCAN_TRANSACTION_URL } from 'app/client_config'; class TransferHistoryRow extends React.Component { render() { const { op, context, + block, + trx, curation_reward, author_reward, benefactor_reward, @@ -287,8 +290,14 @@ class TransferHistoryRow extends React.Component { } return ( - + + {block && (
+ Block: {block} +
)} + {(trx && trx !== '0000000000000000000000000000000000000000') && (
+ TxID: {trx} +
)} { + const { from, to, percentage, asset } = operation; + + const getAssetLabel = (isAutoVest) => { + return isAutoVest ? tt('advanced_routes.steem_power') : tt('advanced_routes.steem'); + }; + + return ( +
+
+ + {tt('advanced_routes.from')} + + +
+
+ + {tt('advanced_routes.to')} + + +
+
+ + {tt('advanced_routes.percentage')} + + + +
+
+ ); +}; + +ConfirmWithdrawVestingRoute.propTypes = { + operation: PropTypes.shape({ + from: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + percentage: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + asset: PropTypes.bool.isRequired, + }).isRequired, +}; + +export default ConfirmWithdrawVestingRoute; diff --git a/src/app/components/elements/ConversionsModal.jsx b/src/app/components/elements/ConversionsModal.jsx index b6e06b8e0..d93f9cff4 100644 --- a/src/app/components/elements/ConversionsModal.jsx +++ b/src/app/components/elements/ConversionsModal.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactModal from 'react-modal'; import CloseButton from 'app/components/elements/CloseButton'; +import tt from 'counterpart'; ReactModal.defaultStyles.overlay.backgroundColor = 'rgba(0, 0, 0, 0.6)'; @@ -9,22 +10,22 @@ const ConversionsModal = ({ isOpen, onClose, combinedConversions }) => {
-

Conversions

+

{tt('converttosteem_jsx.current_conversions')}

{combinedConversions.length === 0 ? ( -

No conversion data available.

+

{tt('converttosteem_jsx.no_conversion_data')}

) : ( - - - - + + + + diff --git a/src/app/components/elements/ConvertToSteem.jsx b/src/app/components/elements/ConvertToSteem.jsx index cf232d936..54bb851a2 100644 --- a/src/app/components/elements/ConvertToSteem.jsx +++ b/src/app/components/elements/ConvertToSteem.jsx @@ -22,6 +22,7 @@ class ConvertToSteem extends React.Component { this.shouldComponentUpdate = shouldComponentUpdate(this, 'ConvertToSteem'); this.state = { toggle_check: false, + errorMessage: undefined, }; this.initForm(props); } @@ -73,10 +74,10 @@ class ConvertToSteem extends React.Component { this.setState({ loading: false }); if (onClose) onClose(); }; - const error = () => { - this.setState({ loading: false }); + const error = (msg_error) => { + this.setState({ loading: false, errorMessage: String(msg_error) }); }; - this.setState({ loading: true }); + this.setState({ errorMessage: undefined, loading: true }); convert(currentUser, amount.value, success, error); }; @@ -103,7 +104,7 @@ class ConvertToSteem extends React.Component { render() { const { onClose, currentUser, sbd_balance } = this.props; - const { loading, amount, marketRate } = this.state; + const { loading, amount, marketRate, errorMessage } = this.state; const { submitting, valid, handleSubmit } = this.state.convertToSteem; const { prices } = this.props; @@ -276,6 +277,13 @@ class ConvertToSteem extends React.Component { ) : null} + {errorMessage && ( +
+
+ {errorMessage} +
+
+ )}
diff --git a/src/app/components/elements/OutgoingDelegations.jsx b/src/app/components/elements/OutgoingDelegations.jsx index 961077570..21c2daecc 100644 --- a/src/app/components/elements/OutgoingDelegations.jsx +++ b/src/app/components/elements/OutgoingDelegations.jsx @@ -319,14 +319,12 @@ class OutgoingDelegations extends React.Component { type="text" id="delegatee" name="delegatee" + placeholder={tt( + 'outgoingdelegations_jsx.filters.search_delegatee' + )} value={delegatee} onChange={this.handleFindAccounts} /> -
diff --git a/src/app/components/elements/OutgoingDelegations.scss b/src/app/components/elements/OutgoingDelegations.scss index 1596d53c2..fe5b6e1b7 100644 --- a/src/app/components/elements/OutgoingDelegations.scss +++ b/src/app/components/elements/OutgoingDelegations.scss @@ -2,11 +2,20 @@ width: 100%; font-family: "Source Sans Pro", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1rem; - border: 1px solid #7e7d7d; outline: none; padding: 0.25rem 0.5rem 0.5rem 0; border-radius: 5px; padding: 1.5rem .5rem; + border-color: transparent; + background: transparent; + box-shadow: 0 0 5px rgba(109, 207, 246, 0.5); + @include themify($themes) { + color: themed('textColorPrimary'); + } + input::placeholder { + font-weight: lighter; + color: #7e7d7d; + } } .input-box{ @@ -16,30 +25,5 @@ .input-box input:focus{ border: 1px solid #06D6A9; -} - -.input-box label { - transform: translateX(10px) translateY(-3rem); - left: 0; - padding: 1rem; - padding-top: 0.5rem; - pointer-events: none; - font-size: 1rem; - color: #7e7d7d; - transition: 0.5s; - margin-right: 15px; - max-width: fit-content; -} - -.input-box input:focus ~ label, -.input-box .focus ~ label{ - transform: translateX(10px) translateY(-3.95rem); - font-size: .75rem; - padding: .45rem; - padding-top: .1rem; - padding-bottom: .1rem; - background-color: #06D6A9; - font-weight: bold; - color: #FFFFFF; - border-radius: 5px; + background: transparent; } diff --git a/src/app/components/elements/RouteSettings.jsx b/src/app/components/elements/RouteSettings.jsx index a14535840..8bad6ebe5 100644 --- a/src/app/components/elements/RouteSettings.jsx +++ b/src/app/components/elements/RouteSettings.jsx @@ -1,25 +1,45 @@ import React from 'react'; +import reactForm from 'app/utils/ReactForm'; import { connect } from 'react-redux'; import tt from 'counterpart'; +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; import * as transactionActions from 'app/redux/TransactionReducer'; import * as userActions from 'app/redux/UserReducer'; import * as globalActions from 'app/redux/GlobalReducer'; import Icon from 'app/components/elements/Icon'; +import { FormattedHTMLMessage } from 'app/Translator'; +import { validate_account_name } from 'app/utils/ChainValidation'; +import ConfirmWithdrawVestingRoute from 'app/components/elements/ConfirmWithdrawVestingRoute'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import { api } from '@steemit/steem-js'; class RouteSettings extends React.Component { constructor(props) { super(props); + this.shouldComponentUpdate = shouldComponentUpdate(this, 'RouteSettings'); this.state = { - broadcasting: false, errorMessage: undefined, - proxyAccount: '', - percentage: 100, - autoVest: false, + remainingPercentage: 100, + maxWithdrawRoutes: 10, }; + this.initForm(props); } - componentDidMount() { + async componentWillMount() { this.updateRemainingPercentage(this.props.withdraw_routes); + await this.fetchConfig() + } + + fetchConfig() { + api.callAsync('database_api.get_config', {}) + .then(res => { + this.setState({ + maxWithdrawRoutes: res.STEEM_MAX_WITHDRAW_ROUTES + }); + }) + .catch(err => { + console.error('Error fetching config:', err); + }); } componentDidUpdate(prevProps) { @@ -32,180 +52,337 @@ class RouteSettings extends React.Component { if (!routes) return; const totalRoutedPercentage = routes.reduce((total, route) => total + route.percent, 0); const remainingPercentage = 100 - (totalRoutedPercentage / 100); - this.setState({ percentage: remainingPercentage }); + this.setState({ remainingPercentage }); } removeWithdrawRoute = (toAccount) => { - this.setState({ broadcasting: true, errorMessage: undefined }); + this.setState({ loading: true, errorMessage: undefined }); const { account, setWithdrawVestingRoute } = this.props; - - // To remove a route, you set the percent to 0 setWithdrawVestingRoute({ account, proxy: toAccount, percent: 0, autoVest: false, - successCallback: () => this.setState({ broadcasting: false }), - errorCallback: (error) => this.setState({ broadcasting: false, errorMessage: String(error) }), + successCallback: () => this.setState({ loading: false }), + errorCallback: (error) => this.setState({ loading: false, errorMessage: String(error) }), }); }; - setWithdrawRoute = (e) => { - e.preventDefault(); - this.setState({ broadcasting: true, errorMessage: undefined }); - + setWithdrawRoute = () => { + this.setState({ loading: true, errorMessage: undefined }); const { account, setWithdrawVestingRoute, hideModal } = this.props; - const { proxyAccount, percentage, autoVest } = this.state; + const { to, percentage, asset } = this.state; setWithdrawVestingRoute({ account, - proxy: proxyAccount, - percent: Math.round(percentage * 100), // API expects percentage * 100 - autoVest, + proxy: to.value, + percent: Math.round(percentage.value * 100), // API expects percentage * 100 + autoVest: asset.value === 'SP', successCallback: () => { - this.setState({ broadcasting: false }); - hideModal(); // Close modal on success + this.setState({ loading: false }); + to.props.onChange('') + percentage.props.onChange(0) + // hideModal(); }, errorCallback: (error) => { - this.setState({ broadcasting: false, errorMessage: String(error) }); + this.setState({ loading: false, errorMessage: String(error) }); }, }); }; - handleInputChange = (event) => { - const { name, value } = event.target; - this.setState({ [name]: value }); + validatePercentage = (percentage, remainingPercentage, to) => { + const { withdraw_routes } = this.props; + const withdrawRoutes = withdraw_routes && withdraw_routes.length > 0 + ? withdraw_routes + : []; + if (remainingPercentage <= 0) { + return null + } + if (!percentage || isNaN(percentage)) { + return 'Percentage is required and must be a number.'; + } + const percentageFloat = parseFloat(percentage); + if (percentageFloat <= 0 || percentageFloat > 100) { + return `Percentage must be greater than 0 and less than or equal to ${100}.`; + } + const existingRoute = withdrawRoutes.find(route => route.to_account === to); + if (existingRoute) { + const updatedRemaining = remainingPercentage + (existingRoute.percent / 100); + if (percentageFloat > updatedRemaining) { + return `Only ${updatedRemaining}% is left.`; + } + } else { + if (percentageFloat > remainingPercentage) { + return `Only ${remainingPercentage}% is left.`; + } + } + return null; + } + + onChangeTo = async value => { + const cleanValue = value.replace(/\s+/g, ''); + this.state.to.props.onChange(cleanValue); }; - - handleCheckboxChange = (event) => { - const { name, checked } = event.target; - this.setState({ [name]: checked }); + + initForm(props) { + const fields = ['percentage', 'to','asset']; + const validate = values => { + const { remainingPercentage } = this.state; + return { + percentage: this.validatePercentage(values.percentage, remainingPercentage, values.to ), + to: validate_account_name(values.to), + }; + }; + reactForm({ + name: 'routeSettings', + instance: this, + fields, + initialValues: { percentage: 0, to: '', asset: 'STEEM' }, + validation: validate, + }); } render() { - if (!this.props.account) return null; // Render nothing if account data is not available yet + if (!this.props.account) return null; + const { remainingPercentage, maxWithdrawRoutes, errorMessage, to, percentage, asset, loading } = this.state; + const { withdraw_routes, hideModal, account } = this.props; + const { valid, handleSubmit, submitting } = this.state.routeSettings; - const { broadcasting, errorMessage, proxyAccount, percentage, autoVest } = this.state; - const { withdraw_routes, hideModal } = this.props; + const sortedRoutes = withdraw_routes && withdraw_routes.length > 0 + ? [...withdraw_routes].sort((a, b) => b.percent - a.percent) + : []; - let totalRoutedPercentage = 0; - const currentRoutesList = withdraw_routes && withdraw_routes.map(route => { - totalRoutedPercentage += route.percent; + const currentRoutesList = sortedRoutes.map(route => { return ( -
-
-
@{route.to_account}
-
{route.percent / 100}% of power down
-
-
- -
-
+
+ + + + + ); }); - - const remainingPercentage = 100 - (totalRoutedPercentage / 100); + const remainingRoutes = maxWithdrawRoutes - sortedRoutes.length const currentRoutes = ( -
-

Current Withdraw Routes

-

Your active power down routing configurations.

-
- {withdraw_routes && withdraw_routes.length > 0 ? currentRoutesList :

No withdraw routes are set.

} -
- {withdraw_routes && withdraw_routes.length > 0 && ( -
-
- Total routed: {totalRoutedPercentage / 100}% -
-
- Remaining to you: {remainingPercentage}% -
+
+
{tt('advanced_routes.current_routes', { accounts_number: (remainingRoutes) })}
+ + {sortedRoutes && sortedRoutes.length > 0 ? ( +
+
IDRequest IDAmountDate{tt('converttosteem_jsx.id')}{tt('converttosteem_jsx.request_id')}{tt('g.amount')}{tt('g.date')}
+ + {route.to_account} + + {route.percent / 100}%{route.auto_vest ? tt('advanced_routes.steem_power') : tt('advanced_routes.steem')} +
this.removeWithdrawRoute(route.to_account)} + title={tt('g.remove')} + disabled={loading} + > + + × + +
+
+ + + + + + + + + + { + + + + + } + {currentRoutesList} + +
{tt('advanced_routes.account')}{tt('advanced_routes.percentage')}{tt('advanced_routes.receive')}{tt('advanced_routes.remove')}
+ + {account} + + {remainingPercentage}%{tt('advanced_routes.steem')} +
+ ) : ( +

{tt('advanced_routes.no_routes')}

)} ); + return ( -
+
-

{tt('userwallet_jsx.advanced_routes')}

+

+ {tt('userwallet_jsx.set_advanced_routes')} +

- - {currentRoutes} -
- -

Add New Route

-
-
- - - +
{ + this.setWithdrawRoute(); + })}> +
+
+
+ +
+
+
-
- -
-
-
-

Withdraw Route Information:

-
    -
  • This only sets up routing rules for future power downs.
  • -
  • You must still initiate a power down separately.
  • -
  • Auto-vest converts payments directly to STEEM Power.
  • -
+
+
+ {tt('advanced_routes.from')} +
+
+
+ @ + +
- {errorMessage &&

{errorMessage}

}
-
+
+
+ {tt('advanced_routes.to')} +
+
+
+ @ + { + await this.onChangeTo(e.target.value); + }} + /> +
+
+ {(to && to.touched && to.error) ? ( +
+ {to && + to.touched && + to.error && + to.error}  +
+ ) : null} +
+
+
+ {tt('advanced_routes.percentage')} +
+
+
+ + {asset && asset.value && ( + + + + )} +
+
+ {(percentage && percentage.touched && percentage.error) ? ( +
+ {percentage && + percentage.touched && + percentage.error && + percentage.error}  +
+ ) : null} -
-
- - + {(remainingRoutes <= 0 || remainingPercentage <= 0) && (
+ {remainingRoutes <= 0 && tt('advanced_routes.not_remaining_routes')} + {(remainingRoutes <= 0 && remainingPercentage <= 0) &&
} + {remainingPercentage <= 0 && tt('advanced_routes.not_remaining_percentage')} +
)}
-
+ {currentRoutes} + {errorMessage && ( +
+
+ {errorMessage} +
+
+ )} +
+
+ {loading && ( + + +
+
+ )} + {!loading && ( + + + + )} +
+
+
); } @@ -218,11 +395,11 @@ export default connect( const accountName = values.get('account'); const routes = state.user.get('withdraw_routes'); - + const withdraw_routes = routes && routes.toJS ? routes.toJS() : []; - return { - ...ownProps, + return { + ...ownProps, account: accountName, withdraw_routes, }; @@ -244,6 +421,15 @@ export default connect( dispatch(userActions.getWithdrawRoutes({ account })); if (successCallback) return successCallback(...args); }; + const confirm = () => ( + + ); const isRemove = percent === 0; dispatch( transactionActions.broadcastOperation({ @@ -254,12 +440,7 @@ export default connect( percent: percent, auto_vest: autoVest, }, - confirm: isRemove - ? tt('g.are_you_sure') - : tt('userwallet_jsx.confirm_route_setup', { - percent: percent / 100, - account: proxy, - }), + confirm, successCallback: successCallbackWrapper, errorCallback, }) diff --git a/src/app/components/elements/RouteSettings.scss b/src/app/components/elements/RouteSettings.scss new file mode 100644 index 000000000..cd5ff254a --- /dev/null +++ b/src/app/components/elements/RouteSettings.scss @@ -0,0 +1,25 @@ +.flex-container-1 { + -ms-flex: 0 0 16.66667%; + flex: 0 0 16.66667%; + max-width: 16.66667%; +} + +.flex-container-2 { + -ms-flex: 0 0 83.33333%; + flex: 0 0 83.33333%; + max-width: 83.33333%; +} + +@media (max-width: 425px) { + .flex-container-1 { + -ms-flex: 0 0 33.33333%; + flex: 0 0 33.33333%; + max-width: 33.33333%; + } + + .flex-container-2 { + -ms-flex: 0 0 66.66667%; + flex: 0 0 66.66667%; + max-width: 66.66667%; + } +} diff --git a/src/app/components/elements/UserKeys.jsx b/src/app/components/elements/UserKeys.jsx index 88e0a33b5..752d5036c 100644 --- a/src/app/components/elements/UserKeys.jsx +++ b/src/app/components/elements/UserKeys.jsx @@ -456,7 +456,7 @@ class UserKeys extends Component { {tt('userkeys_jsx.public_key.desc2')}{' '} Steemscan + + Steemdb +

diff --git a/src/app/components/elements/VotersModal.jsx b/src/app/components/elements/VotersModal.jsx index 0df564ef0..cad6c639d 100644 --- a/src/app/components/elements/VotersModal.jsx +++ b/src/app/components/elements/VotersModal.jsx @@ -183,7 +183,7 @@ class VotersModal extends React.Component { }; handleResize = () => { - this.setState({ isSmallScreen: window.innerWidth <= 425 }); + this.setState({ isSmallScreen: window.innerWidth <= 650 }); }; updateVotersAccount = res => { @@ -260,7 +260,7 @@ class VotersModal extends React.Component {
open_modal} + onAfterOpen={open_modal} onRequestClose={close_modal} className={ nightmodeEnabled @@ -330,7 +330,7 @@ class VotersModal extends React.Component { />
-
+
{isSmallScreen ? ( - - {userInfo.name} - + {`${tt('proposals.voters.account')}: `} + {userInfo.name} + - {this.proxyVoteStyle( - numberWithCommas( - userInfo.steemPower - ) - )} + {`${tt('proposals.voters.own_sp')}: `} + {this.proxyVoteStyle(numberWithCommas(userInfo.steemPower), ' SP')} + - {this.proxyVoteStyle( - numberWithCommas(userInfo.proxySP) - )} + {`${tt('proposals.voters.proxy_sp')}: `} + {this.proxyVoteStyle(numberWithCommas(userInfo.proxySP), ' SP')} diff --git a/src/app/components/elements/Voting.scss b/src/app/components/elements/Voting.scss index 8465e6429..fd5f3c290 100644 --- a/src/app/components/elements/Voting.scss +++ b/src/app/components/elements/Voting.scss @@ -377,3 +377,46 @@ margin: 0 auto; padding-bottom: 2px; } + +// /* =========================== +// Voting buttons: DISABLED ONLY +// =========================== */ + +// .Voting__button-up.disabled:not(.Voting__button--upvoted) { +// path { +// @include themify($themes) { fill: #a5a5a5; } +// } +// circle { +// @include themify($themes) { +// fill: transparent; +// stroke: #a5a5a5; +// } +// } +// a { pointer-events: none; } +// & a:hover { +// path { @include themify($themes) { fill: #a5a5a5; } } +// circle { @include themify($themes) { fill: transparent; stroke: #a5a5a5; } } +// } +// } + +// .Voting__button--upvoted.disabled { + +// circle { +// @include themify($themes) { +// fill: #a5a5a5; +// stroke: #a5a5a5; +// } +// } + +// path { fill: #000; } +// a { pointer-events: none; } +// & a:hover { +// path { fill: #000; } +// circle { +// @include themify($themes) { +// fill: #a5a5a5; +// stroke: #a5a5a5; +// } +// } +// } +// } diff --git a/src/app/components/elements/WalletSubMenu.jsx b/src/app/components/elements/WalletSubMenu.jsx index 4ef46580c..e59690755 100644 --- a/src/app/components/elements/WalletSubMenu.jsx +++ b/src/app/components/elements/WalletSubMenu.jsx @@ -51,6 +51,22 @@ export default ({ accountname, isMyAccount, showTab }) => { ) : null} +
  • + + {tt('navigation.witnesses')} + +
  • +
  • + + {tt('g.proposals')} + +
  • ); }; diff --git a/src/app/components/elements/WithdrawRoutesModal.jsx b/src/app/components/elements/WithdrawRoutesModal.jsx new file mode 100644 index 000000000..65a85e04c --- /dev/null +++ b/src/app/components/elements/WithdrawRoutesModal.jsx @@ -0,0 +1,39 @@ + +import React from 'react'; +import ReactModal from 'react-modal'; +import CloseButton from 'app/components/elements/CloseButton'; +import tt from 'counterpart'; +import WithdrawRoutesTable from 'app/components/elements/WithdrawRoutesTable'; + +ReactModal.defaultStyles.overlay.backgroundColor = 'rgba(0, 0, 0, 0.6)'; + +class WithdrawRoutesModal extends React.Component { + render() { + const { isOpen, onClose, routes, accountName, steemPower } = this.props; + + return ( + + +
    +

    {tt('advanced_routes.current_withdraw_route')}

    + {routes.length === 0 ? ( +

    {tt('userwallet_jsx.no_withdraw_routes')}

    + ) : ( + + )} +
    +
    + ); + } +} + +export default WithdrawRoutesModal; diff --git a/src/app/components/elements/WithdrawRoutesModal.scss b/src/app/components/elements/WithdrawRoutesModal.scss new file mode 100644 index 000000000..a83212697 --- /dev/null +++ b/src/app/components/elements/WithdrawRoutesModal.scss @@ -0,0 +1,57 @@ +.ContainerModal__content, +.ContainerModal__content--night { + position: absolute; + min-width: 300px; + box-shadow: 2px 2px 2px 0 rgba(0, 0, 0, 0.1), 7px 7px 0 0 #06D6A9; + border-radius: 0; + border: transparent; + transition: 0.2s all ease-in-out; + min-height: 500px; + top: 50%; + left: 50%; + right: auto; + bottom: auto; + transform: translate(-50%, -45%); + overflow-y: auto; + background-color: #ffffff; + color: #000000; + padding: 1rem; + margin-top: .5rem; +} + +.ContainerModal__content .header, +.ContainerModal__content--night { + max-width: 75rem; + margin-right: auto; + margin-left: auto; +} + +.ContainerModal__content--night { + background-color: #2c3136; + color: #ffffff; +} + +@media screen and (max-width: 39.9375em) { + .reveal { + width: 100%; + max-width: none; + height: 100vh; + min-height: 100vh; + margin-left: 0; + } +} + +@media print, +screen and (min-width: 40em) { + .ContainerModal__content { + width: 600px; + max-width: 75rem; + } +} + +@media print, +screen and (min-width: 1000px) { + .ContainerModal__content { + width: 680px; + } +} diff --git a/src/app/components/elements/WithdrawRoutesTable.jsx b/src/app/components/elements/WithdrawRoutesTable.jsx new file mode 100644 index 000000000..1df271152 --- /dev/null +++ b/src/app/components/elements/WithdrawRoutesTable.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import tt from 'counterpart'; + +class WithdrawRoutesTable extends React.Component { + render() { + const { routes, accountName, steemPower } = this.props; + + const totalPercent = routes.reduce((acc, r) => acc + r.percent, 0); + const remainingPercent = 10000 - totalPercent; + + return ( +
    + + + + + + {steemPower && } + + + + + + + {steemPower && } + + {routes.map((route) => ( + + + + {steemPower && ( + + )} + + ))} + +
    {tt('advanced_routes.account')}{tt('advanced_routes.percent')}{tt('advanced_routes.receive_amount')}
    + + {accountName} + + {remainingPercent / 100}%{`${(remainingPercent / 10000 * parseFloat(steemPower)).toFixed(3)} ${tt('advanced_routes.steem')}`}
    + + {route.to_account} + + {route.percent / 100}% + {`${(route.percent / 10000 * parseFloat(steemPower)).toFixed(3)} ${route.auto_vest ? 'SP' : tt('advanced_routes.steem')}`} +
    +
    + ); + } +} + +export default WithdrawRoutesTable; diff --git a/src/app/components/modules/AuthorRewards.jsx b/src/app/components/modules/AuthorRewards.jsx index 28ef0defb..5dec78585 100644 --- a/src/app/components/modules/AuthorRewards.jsx +++ b/src/app/components/modules/AuthorRewards.jsx @@ -75,6 +75,8 @@ class AuthorRewards extends React.Component { .map((item, index) => { // Filter out rewards if (item[1].op[0] === 'author_reward') { + const trx_id = item[1].trx_id + const block_id = item[1].block if (!finalDate) { finalDate = new Date(item[1].timestamp).getTime(); } @@ -111,6 +113,8 @@ class AuthorRewards extends React.Component { ); diff --git a/src/app/components/modules/CurationRewards.jsx b/src/app/components/modules/CurationRewards.jsx index fa6da5b51..2ab4173b0 100644 --- a/src/app/components/modules/CurationRewards.jsx +++ b/src/app/components/modules/CurationRewards.jsx @@ -72,6 +72,8 @@ class CurationRewards extends React.Component { .map((item, index) => { // Filter out rewards if (item[1].op[0] === 'curation_reward') { + const trx_id = item[1].trx_id + const block_id = item[1].block if (!finalDate) { finalDate = new Date(item[1].timestamp).getTime(); } @@ -88,6 +90,8 @@ class CurationRewards extends React.Component { totalRewards += vest; return ( { + if (!routes) return; + const totalRoutedPercentage = routes.reduce((total, route) => total + route.percent, 0); + const remainingPercentage = 100 - (totalRoutedPercentage / 100); + this.setState({ remainingPercentage }); + } + render() { - const { broadcasting, new_withdraw, manual_entry } = this.state; + const { broadcasting, new_withdraw, manual_entry, remainingPercentage, toggleAckRoutes } = this.state; const { account, available_shares, @@ -46,7 +65,61 @@ class Powerdown extends React.Component { to_withdraw, vesting_shares, delegated_vesting_shares, + withdraw_routes } = this.props; + + const sortedRoutes = withdraw_routes && withdraw_routes.length > 0 + ? [...withdraw_routes].sort((a, b) => b.percent - a.percent) + : []; + const hasRoutes = sortedRoutes.length > 0; + const currentRoutesList = sortedRoutes.map(route => { + const receive = (route.percent / 10000 * parseFloat(vestsToSp(this.props.state, new_withdraw))).toFixed(3) + return ( + + + + {route.to_account} + + + {route.percent / 100}% + {`${receive} ${route.auto_vest ? 'SP' : tt('advanced_routes.steem')}`} + + ); + }); + + const currentRoutes = ( +
    +
    {tt('advanced_routes.current_withdraw_route')}
    + {hasRoutes ? ( +
    + + + + + + + + + + { + + + + } + {currentRoutesList} + +
    {tt('advanced_routes.account')}{tt('advanced_routes.percentage')}{tt('advanced_routes.receive_amount')}
    + + {account} + + {remainingPercentage}%{`${(remainingPercentage / 100 * parseFloat(vestsToSp(this.props.state, new_withdraw))).toFixed(3)} ${tt('advanced_routes.steem')}`}
    +
    + ) : ( +

    {tt('advanced_routes.no_routes')}

    + )} +
    + ); + const formatSp = amount => numberWithCommas(vestsToSp(this.props.state, amount)); const sliderChange = value => { @@ -173,11 +246,35 @@ class Powerdown extends React.Component { {LIQUID_TICKER}

      {notes}
    +
    +
    + {currentRoutes} +
    +
    + {hasRoutes && ( +
    +
    + + {tt('advanced_routes.acknowledge_routes')} + + +
    +
    + )} @@ -204,6 +301,10 @@ export default connect( const available_shares = vesting_shares - to_withdraw - withdrawn - delegated_vesting_shares; + const routes = state.user.get('withdraw_routes'); + + const withdraw_routes = routes && routes.toJS ? routes.toJS() : []; + return { ...ownProps, account, @@ -213,6 +314,7 @@ export default connect( to_withdraw, vesting_shares, withdrawn, + withdraw_routes, }; }, // mapDispatchToProps diff --git a/src/app/components/modules/ProposalList/Proposal.jsx b/src/app/components/modules/ProposalList/Proposal.jsx index 77e9e18af..db4c31f27 100644 --- a/src/app/components/modules/ProposalList/Proposal.jsx +++ b/src/app/components/modules/ProposalList/Proposal.jsx @@ -45,6 +45,22 @@ export default class Proposal extends React.Component { this.setState({ show_remove_proposal_modal: show }); }; + isNonEmptyString = (v) => { + return typeof v === 'string' && v.trim().length > 0; + } + + isSameAccount= (a, b) => { + try { + return ( + this.isNonEmptyString(a) && + this.isNonEmptyString(b) && + a.trim().toLowerCase() === b.trim().toLowerCase() + ); + } catch (error) { + return false; + } + } + render() { const { id, @@ -66,8 +82,11 @@ export default class Proposal extends React.Component { triggerModal, getNewId, paid_proposals, + walletSectionAccount, } = this.props; + const canChangeVote = !walletSectionAccount || this.isSameAccount(currentUser, walletSectionAccount); + const { show_remove_proposal_modal } = this.state; let isMyAccount; if (currentUser) { @@ -190,14 +209,34 @@ export default class Proposal extends React.Component { > {abbreviateNumber(votesToSP)}
    - + {canChangeVote ? ( + + + + ) : ( - + )} {isMyAccount && (
    +
    0) { + last_proposal = proposals[0]; + } + this.setState({ + proposals, + loading: false, + last_proposal, + limit, + }); + } + + onFilterProposals = async status => { + this.setState({ status }); + await this.load(false, { status }); + }; + + onOrderProposals = async order_by => { + this.setState({ order_by }); + await this.load(false, { order_by }); + }; + + onOrderDirection = async order_direction => { + this.setState({ order_direction }); + await this.load(false, { order_direction }); + }; + + getVotersAccounts = voters_accounts => { + this.setState({ voters_accounts }); + }; + + getVoters = (voters, lastVoter) => { + this.setState({ voters, lastVoter }); + }; + + getNewId = new_id => { + this.setState({ new_id }); + }; + + setIsVotersDataLoading = is_voters_data_loaded => { + this.setState({ is_voters_data_loaded }); + }; + setPaidProposals = paid_proposals => { + this.setState({ paid_proposals }); + }; + + getAllProposals( + last_proposal, + order_by, + order_direction, + limit, + status, + start + ) { + return this.props.listProposals({ + voter_id: this.props.walletSectionAccount || this.props.currentUser, + last_proposal, + order_by, + order_direction, + limit, + status, + start, + }); + } + + voteOnProposal = async (proposalId, voteForIt, onSuccess, onFailure) => { + return this.props.voteOnProposal( + this.props.currentUser, + [proposalId], + voteForIt, + async () => { + if (onSuccess) onSuccess(); + }, + () => { + if (onFailure) onFailure(); + } + ); + }; + + fetchGlobalProps() { + api.callAsync('condenser_api.get_dynamic_global_properties', []) + .then(res => + this.setState({ + total_vests: res.total_vesting_shares, + total_vest_steem: res.total_vesting_fund_steem, + }) + ) + .catch(err => console.log(err)); + } + + fetchVoters() { + this.fetchAllVotersWithPause({ + proposalId: this.state.new_id, + timeout: INITIAL_TIMEOUT, + maxToLoad: LOAD_ALL_VOTERS ? null : MAX_INITIAL_LOAD, + }) + .then(res => { + this.getVoters(res, ...res.slice(-1)); + }) + .catch(err => console.log(err)); + } + + fetchDataForVests() { + const voters = this.state.voters; + const new_id = this.state.new_id; + + const selected_proposal_voters = voters.filter( + v => v.proposal.proposal_id === new_id + ); + const voters_map = selected_proposal_voters.map(name => name.voter); + api.getAccountsAsync(voters_map) + .then(res => this.getVotersAccounts(res)) + .catch(err => console.log(err)); + } + + async getVotedProposals({ accountName, proposalIdsSet }) { + const votedMap = {}; + + try { + const result = await new Promise((resolve, reject) => { + api.callAsync( + 'database_api.list_proposal_votes', + { + start: [accountName], + limit: 1000, + order: 'by_voter_proposal', + order_direction: 'ascending', + status: 'all', + }, + (err, res) => { + if (err) reject(err); + else resolve(res); + } + ); + }); + + const votes = (result && result.proposal_votes) || []; + if (votes.length === 0 || votes[0].voter !== accountName) { + return votedMap; + } + + for (const vote of votes) { + if (vote.voter !== accountName) break; + const proposalId = vote.proposal.proposal_id; + if (proposalIdsSet.has(proposalId)) { + votedMap[proposalId] = true; + } + } + + return votedMap; + } catch (err) { + console.error('Error al obtener propuestas votadas:', err); + return votedMap; + } + } + + async updateProposalVotes(currentUser) { + if (typeof currentUser !== 'string' || currentUser.length <= 1) return; + + const { proposals } = this.state; + const proposalIdsSet = new Set(proposals.map(p => p.id)); + const votedMap = await this.getVotedProposals({ accountName: currentUser, proposalIdsSet }); + + const updatedProposals = proposals.map(p => ({ + ...p, + upVoted: !!votedMap[p.id], + })); + + this.setState({ proposals: updatedProposals }); + } + + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async fetchAllVotersWithPause({ + proposalId, + lastVoter = '', + accumulated = [], + timeout = INITIAL_TIMEOUT, + maxToLoad = null, + }) { + try { + const res = await new Promise((resolve, reject) => { + api.callAsync( + 'database_api.list_proposal_votes', + { + start: [proposalId, lastVoter], + limit: 1000, + order: 'by_proposal_voter', + order_direction: 'ascending', + status: 'active', + }, + (err, result) => { + if (err) reject(err); + else resolve(result); + } + ); + }); + + const votes = (res && res.proposal_votes) || []; + if (votes.length === 0) return accumulated; + const allVoters = accumulated.concat(votes); + if (maxToLoad && allVoters.length >= maxToLoad) { + return allVoters.slice(0, maxToLoad); + } + if (votes.length < 1000) { + return allVoters; + } + if (votes && votes.length >= 2) { + try { + const firstProposalId = votes[0].proposal.proposal_id; + const lastProposalId = votes.at(-1).proposal.proposal_id; + if ( + firstProposalId !== proposalId || + lastProposalId !== proposalId + ) { + return allVoters; + } + } catch (error) { + console.error(error); + } + } + const nextVoter = votes.at(-1) ? votes.at(-1).voter : undefined; + await this.delay(timeout); + const nextTimeout = Math.min(timeout + 250, MAX_TIMEOUT); + return this.fetchAllVotersWithPause({ + proposalId, + lastVoter: nextVoter, + accumulated: allVoters, + timeout: nextTimeout, + maxToLoad, + }); + } catch (err) { + console.error('Error al obtener votantes:', err); + return accumulated; + } + } + + onClickLoadMoreProposals = e => { + e.preventDefault(); + this.load(); + }; + + triggerCreatorsModal = () => { + this.setState({ + open_creators_modal: !this.state.open_creators_modal, + }); + }; + + triggerVotersModal = () => { + this.setState({ + open_voters_modal: !this.state.open_voters_modal, + }); + }; + + submitProposal = (proposal, onSuccess, onFailure) => { + this.props.createProposal( + this.props.currentUser || proposal.creator, + proposal.receiver, + proposal.startDate, + proposal.endDate, + `${parseFloat(proposal.dailyAmount).toFixed(3)} SBD`, + proposal.title, + proposal.permlink, + async () => { + this.triggerCreatorsModal(); + if (onSuccess) onSuccess(); + }, + () => { + if (onFailure) onFailure(); + } + ); + }; + + removeProposalById = id => { + this.setState(prevState => ({ + proposals: prevState.proposals.filter( + proposal => proposal.id !== id + ), + })); + }; + + render() { + const { + proposals, + loading, + status, + order_by, + order_direction, + voters, + voters_accounts, + open_creators_modal, + open_voters_modal, + total_vests, + total_vest_steem, + is_voters_data_loaded, + new_id, + } = this.state; + + const mergeVoters = [...voters]; + + const { nightmodeEnabled } = this.props; + + let showBottomLoading = false; + if (loading && proposals && proposals.length > 0) { + showBottomLoading = true; + } + const selected_proposal_voters = mergeVoters.filter( + v => v.proposal.proposal_id === new_id + ); + const voters_map = selected_proposal_voters.map(name => name.voter); // voter name + const accounts_map = []; + const acc_proxied_vests = []; + const proxies_name_by_voter = []; + const proxies_vote = {}; + voters_accounts.forEach(acc => { + accounts_map.push(acc.vesting_shares); + const proxied = acc.proxied_vsf_votes + .map(r => parseInt(r, 10)) + .reduce((a, b) => a + b, 0); + acc_proxied_vests.push(proxied); + proxies_name_by_voter.push(acc.proxy); + if (acc.proxy) { + proxies_vote[acc.proxy] = false; + } + }); + const steem_power = []; + const proxy_sp = []; + const total_sp = []; + let global_total_sp = 0; + + const calculatePowers = () => { + const total_vestsNew = parseFloat(total_vests.split(' ')[0]); + const total_vest_steemNew = parseFloat( + total_vest_steem.split(' ')[0] + ); + + for (let i = 0; i < accounts_map.length; i++) { + const vests_account = parseFloat(accounts_map[i].split(' ')[0]); + const vests_proxy = acc_proxied_vests[i]; + + const vesting_steem_account = + total_vest_steemNew * (vests_account / total_vestsNew); + const vesting_steem_proxy = + total_vest_steemNew * + (vests_proxy / total_vestsNew) * + 0.000001; + + const total = vesting_steem_account + vesting_steem_proxy; + + steem_power.push(vesting_steem_account); + proxy_sp.push(vesting_steem_proxy); + total_sp.push(total); + const voter = voters_map[i]; + if (Object.keys(proxies_vote).includes(voter)) { + proxies_vote[voter] = true; + } + global_total_sp += total; + } + }; + calculatePowers(); + const simpleVotesToSp = total_votes => { + const total_vestsNew = parseFloat(total_vests.split(' ')[0]); + const total_vest_steemNew = parseFloat( + total_vest_steem.split(' ')[0] + ); + return ( + total_vest_steemNew * + (total_votes / total_vestsNew) * + 0.000001 + ).toFixed(2); + }; + const pro_aux = proposals.find(p => p.proposal_id === new_id); + let total_votes_aux = 0; + if (pro_aux && pro_aux.total_votes) { + total_votes_aux = simpleVotesToSp(pro_aux.total_votes); + } + const total_acc_sp_obj = {}; + voters_map.forEach((voter, i) => { + const proxy_name = proxies_name_by_voter[i]; + const proxy_vote = proxies_vote[proxy_name] || false; + const influence = total_votes_aux + ? (total_sp[i] / total_votes_aux) * 100 + : 0; + total_acc_sp_obj[voter] = [ + total_sp[i], + steem_power[i], + proxy_sp[i], + proxy_name, + proxy_vote, + influence, + ]; + }); + const sort_merged_total_sp = []; + for (const value in total_acc_sp_obj) { + sort_merged_total_sp.push([value, ...total_acc_sp_obj[value]]); + } + sort_merged_total_sp.sort((a, b) => b[1] - a[1]); + + return ( +
    + + + +
    + {!loading ? ( + + {tt('proposals.load_more')} + + ) : null} + + {showBottomLoading ? ( + {tt('proposals.loading')} + ) : null} +
    +
    + ); + } +} + +Proposals.propTypes = { + listProposals: PropTypes.func.isRequired, + createProposal: PropTypes.func.isRequired, + voteOnProposal: PropTypes.func.isRequired, +}; + +export default connect( + state => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + const proposals = state.proposal.get('proposals', List()); + const last = proposals.size - 1; + const last_id = + (proposals.size && proposals.get(last).get('id')) || null; + const newProposals = + proposals.size >= 10 ? proposals.delete(last) : proposals; + + return { + currentUser, + proposals: newProposals, + last_id, + nightmodeEnabled: state.app.getIn([ + 'user_preferences', + 'nightmode', + ]), + }; + }, + dispatch => { + return { + voteOnProposal: ( + voter, + proposal_ids, + approve, + successCallback, + errorCallback + ) => { + dispatch( + transactionActions.broadcastOperation({ + type: 'update_proposal_votes', + operation: { voter, proposal_ids, approve }, + successCallback, + errorCallback, + }) + ); + }, + createProposal: ( + creator, + receiver, + start_date, + end_date, + daily_pay, + subject, + permlink, + successCallback, + errorCallback + ) => { + dispatch( + transactionActions.broadcastOperation({ + type: 'create_proposal', + operation: { + creator, + receiver, + start_date, + end_date, + daily_pay, + subject, + permlink, + }, + successCallback, + errorCallback, + }) + ); + }, + listProposals: payload => { + return new Promise((resolve, reject) => { + dispatch( + proposalActions.listProposals({ + ...payload, + resolve, + reject, + }) + ); + }); + }, + setRouteTag: () => + dispatch(appActions.setRouteTag({ routeTag: 'proposals' })), + }; + } +)(Proposals) diff --git a/src/app/components/modules/SidePanel/index.jsx b/src/app/components/modules/SidePanel/index.jsx index d0fc230af..487799c67 100644 --- a/src/app/components/modules/SidePanel/index.jsx +++ b/src/app/components/modules/SidePanel/index.jsx @@ -139,21 +139,11 @@ const SidePanel = ({ // label: tt('navigation.chat'), // link: 'https://steem.chat/home', // }, - { - value: 'jobs', - label: tt('navigation.jobs'), - link: 'https://jobs.lever.co/steemit', - }, // { // value: 'tools', // label: tt('navigation.app_center'), // link: 'https://steemprojects.com/', // }, - { - value: 'business', - label: tt('navigation.business_center'), - link: 'https://steemeconomy.com/', - }, { value: 'api_docs', label: tt('navigation.api_docs'), @@ -286,4 +276,4 @@ export default connect( dispatch(appActions.setUserPreferences(payload)); }, }) -)(SidePanel); \ No newline at end of file +)(SidePanel); diff --git a/src/app/components/modules/Transfer.jsx b/src/app/components/modules/Transfer.jsx index 86cb89dd7..60795da31 100644 --- a/src/app/components/modules/Transfer.jsx +++ b/src/app/components/modules/Transfer.jsx @@ -44,6 +44,7 @@ class TransferForm extends Component { following: PropTypes.object.isRequired, totalVestingFund: PropTypes.number.isRequired, totalVestingShares: PropTypes.number.isRequired, + errorMessage: undefined, }; static defaultProps = { @@ -312,7 +313,10 @@ class TransferForm extends Component { }; errorCallback = estr => { - this.setState({ trxError: estr, loading: false, tronLoading: false }); + this.setState({ + trxError: estr, loading: false, tronLoading: false, + errorMessage: String(estr), + }); }; balanceValue() { @@ -367,11 +371,12 @@ class TransferForm extends Component { }; onChangeTo = async value => { - this.state.to.props.onChange(value); + const cleanValue = value.replace(/\s+/g, ''); + this.state.to.props.onChange(cleanValue); const { transferType } = this.props.initialValues; if (transferType === 'Transfer to Account') { - this.checkExchangeStatus(value); - this.setState({ toggle_check: false }) + this.checkExchangeStatus(cleanValue); + this.setState({ toggle_check: false }); } }; @@ -412,7 +417,7 @@ class TransferForm extends Component { { LIQUID_TOKEN, VESTING_TOKEN } ); const { to, amount, asset, memo } = this.state; - const { loading, advanced, toggle_check } = this.state; + const { loading, advanced, toggle_check, errorMessage } = this.state; const { currentUser, toVesting, @@ -434,7 +439,7 @@ class TransferForm extends Component {
    { // steem transfer - this.setState({ loading: true }); + this.setState({ loading: true, errorMessage: undefined }); dispatchSubmit({ ...data, errorCallback: this.errorCallback, @@ -563,6 +568,14 @@ class TransferForm extends Component {
    {to.touched &&

    {toVesting && powerTip3}

    }
    + {(to && to.touched && to.error) ? ( +
    + {to && + to.touched && + to.error && + to.error}  +
    + ) : null}
    )} @@ -762,6 +775,15 @@ class TransferForm extends Component {
    )} + + {errorMessage && ( +
    +
    + {errorMessage} +
    +
    + )} +
    {loading && ( diff --git a/src/app/components/modules/UserWallet.jsx b/src/app/components/modules/UserWallet.jsx index 49651543a..b08ae387a 100644 --- a/src/app/components/modules/UserWallet.jsx +++ b/src/app/components/modules/UserWallet.jsx @@ -38,11 +38,13 @@ import { recordAdsView, userActionRecord } from 'app/utils/ServerApiClient'; import QRCode from 'react-qr'; import LoadingIndicator from 'app/components/elements/LoadingIndicator'; import ConversionsModal from 'app/components/elements/ConversionsModal'; +import WithdrawRoutesModal from 'app/components/elements/WithdrawRoutesModal'; import ChangeRecoveryAccount from 'app/components/modules/ChangeRecoveryAccount'; import { fetchData } from 'app/utils/steemApi'; const DAYS_TO_HIDE = 5; const assetPrecision = 1000; +const PD_DISMISS_DAYS = 7; class UserWallet extends React.Component { constructor() { @@ -54,6 +56,9 @@ class UserWallet extends React.Component { timestamp: null, showChangeRecoveryModal: false, showConversionsModal: false, + showWithdrawRoutesModal: false, + showPowerDownAlert: false, + showWithdrawRoutesAlert: false, conversions: [], conversionValue: 0, sbdPrice: 0, @@ -61,13 +66,6 @@ class UserWallet extends React.Component { this.shouldComponentUpdate = shouldComponentUpdate(this, 'UserWallet'); } - componentDidMount() { - const { account, getWithdrawRoutes } = this.props; - if (account && getWithdrawRoutes) { - getWithdrawRoutes(account.get('name')); - } - } - // All event handlers are defined as class methods for performance and stable 'this' context. onShowSteemTrade = e => { if (e && e.preventDefault) e.preventDefault(); @@ -170,6 +168,8 @@ class UserWallet extends React.Component { console.warn("[componentDidMount] Error parsing localStorage data:", e); } } + this.checkPowerDownAlert(); + this.checkWithdrawRoutesAlert(); } async componentDidUpdate(prevProps) { @@ -196,6 +196,7 @@ class UserWallet extends React.Component { } } } + const routesChanged = ((this.props.withdraw_routes || []).length !== (prevProps.withdraw_routes || []).length); const isMyAccount = currentUser && currentUser.get('username') === account.get('name'); const currentHistorySize = account.get('other_history', List()).size; const prevHistorySize = prevProps.account.get('other_history', List()).size; @@ -204,7 +205,7 @@ class UserWallet extends React.Component { } else if (!accountChanged && currentHistorySize !== prevHistorySize) { await this.loadInitialConversions(); } - if (account && currentUserChange && currentUser && isMyAccount) { + if ((accountChanged || currentUserChange) && isMyAccount) { try { const userName = account.get('name'); const storageKey = `button_click_${userName}`; @@ -232,8 +233,226 @@ class UserWallet extends React.Component { console.warn("[componentDidUpdate] Error parsing localStorage data:", e); } } + if (account && currentUserChange && currentUser && isMyAccount || prevProps.gprops !== this.props.gprops) { + this.checkPowerDownAlert(); + } + if (accountChanged || currentUserChange || routesChanged) { + this.checkWithdrawRoutesAlert(); + } } + computeWithdrawRoutesSignature = (routes) => { + try { + let arr = (routes || []).slice().map(function (r) { + let acct = r && r.to_account ? String(r.to_account) : ''; + let pct = r && typeof r.percent !== 'undefined' ? Number(r.percent) : 0; + let pctN = isFinite(pct) ? pct : 0; + return acct + ':' + pctN; + }); + arr.sort(); + return arr.join('|'); + } catch (e) { + console.warn('[WithdrawRoutesAlert] compute signature failed', e); + return ''; + } + }; + + getWithdrawRoutesStorageKey = () => { + const { account } = this.props; + if (!account) return null; + const userName = account.get('name'); + return 'withdraw_routes_alert_' + userName; + }; + + showAdvanced = e => { + e.preventDefault(); + const { account } = this.props; + this.props.showAdvanced({ + account: account.get('name'), + }); + }; + + isDismissedWithdrawRoutesAlert = (currentSignature) => { + try { + let key = this.getWithdrawRoutesStorageKey(); + if (!key) return false; + let raw = localStorage.getItem(key); + if (!raw) return false; + let parsed = JSON.parse(raw); + if (!parsed || !parsed.dismissedAt) return false; + let lastSig = typeof parsed.sig === 'string' ? parsed.sig : ''; + if (currentSignature && currentSignature !== lastSig) { + return false; + } + let ts = new Date(parsed.dismissedAt).getTime(); + let diffDays = (Date.now() - ts) / (1000 * 60 * 60 * 24); + return diffDays <= PD_DISMISS_DAYS; + } catch (e) { + console.warn('[WithdrawRoutesAlert] read localStorage failed', e); + return false; + } + }; + + dismissWithdrawRoutesAlert = () => { + try { + let key = this.getWithdrawRoutesStorageKey(); + if (key) { + let routes = this.props.withdraw_routes || []; + let sig = this.computeWithdrawRoutesSignature(routes); + localStorage.setItem( + key, + JSON.stringify({ + dismissedAt: new Date().toISOString(), + sig: sig + }) + ); + } + } catch (e) { + console.warn('[WithdrawRoutesAlert] write localStorage failed', e); + } + this.setState({ showWithdrawRoutesAlert: false }); + }; + + checkWithdrawRoutesAlert = () => { + let account = this.props.account; + let currentUser = this.props.currentUser; + let gprops = this.props.gprops; + let routes = this.props.withdraw_routes || []; + if (!account || !currentUser || !gprops) { + this.setState({ showWithdrawRoutesAlert: false }); + return; + } + + let isMyAccount = currentUser.get('username') === account.get('name'); + if (!isMyAccount) { + this.setState({ showWithdrawRoutesAlert: false }); + return; + } + try { + let acc = account.toJS(); + let gp = typeof gprops.toJS === 'function' ? gprops.toJS() : gprops; + let pdAmount = Number(powerdownSteem(acc, gp)) || 0; + if (!pdAmount || pdAmount <= 0) { + try { + let k1 = this.getWithdrawRoutesStorageKey(); + if (k1) localStorage.removeItem(k1); + } catch (_) {} + this.setState({ showWithdrawRoutesAlert: false }); + return; + } + } catch (e) { + console.warn('[WithdrawRoutesAlert] powerdown calc failed', e); + this.setState({ showWithdrawRoutesAlert: false }); + return; + } + let count = routes && routes.length ? routes.length : 0; + if (!count || count <= 0) { + // try { + // let key = this.getWithdrawRoutesStorageKey(); + // if (key) localStorage.removeItem(key); + // } catch (_) {} + this.setState({ showWithdrawRoutesAlert: false }); + return; + } + + let sig = this.computeWithdrawRoutesSignature(routes); + if (this.isDismissedWithdrawRoutesAlert(sig)) { + this.setState({ showWithdrawRoutesAlert: false }); + return; + } + + this.setState({ showWithdrawRoutesAlert: true }); + }; + + getPowerDownStorageKey = () => { + const { account } = this.props; + if (!account) return null; + const userName = account.get('name'); + return `powerdown_alert_${userName}`; + }; + isDismissedPowerDownAlert = (currentPdAmount) => { + try { + const key = this.getPowerDownStorageKey(); + if (!key) return false; + const raw = localStorage.getItem(key); + if (!raw) return false; + const parsed = JSON.parse(raw); + if (!parsed || !parsed.dismissedAt) return false; + const lastDismissedAmount = typeof parsed.pdAmount === 'number' ? parsed.pdAmount : 0; + const EPS = 1; + if (typeof currentPdAmount === 'number' && Math.abs(currentPdAmount - lastDismissedAmount) > EPS) { + return false; + } + const ts = new Date(parsed.dismissedAt).getTime(); + const diffDays = (Date.now() - ts) / (1000 * 60 * 60 * 24); + return diffDays <= PD_DISMISS_DAYS; + } catch (e) { + console.warn('[PowerDownAlert] read localStorage failed', e); + return false; + } + }; + dismissPowerDownAlert = () => { + try { + const key = this.getPowerDownStorageKey(); + if (key) { + const { account, gprops } = this.props; + let pdAmount = 0; + if (account && gprops) { + const acc = account.toJS(); + const gp = gprops.toJS ? gprops.toJS() : gprops; + pdAmount = Number(powerdownSteem(acc, gp)) || 0; + } + localStorage.setItem( + key, + JSON.stringify({ + dismissedAt: new Date().toISOString(), + pdAmount: pdAmount, + }) + ); + } + } catch (e) { + console.warn('[PowerDownAlert] write localStorage failed', e); + } + this.setState({ showPowerDownAlert: false }); + }; + checkPowerDownAlert = () => { + const { account, gprops, currentUser } = this.props; + + if (!account || !gprops) { + this.setState({ showPowerDownAlert: false }); + return; + } + + const isMyAccount = + currentUser && currentUser.get('username') === account.get('name'); + + if (!isMyAccount) { + this.setState({ showPowerDownAlert: false }); + return; + } + + const acc = account.toJS(); + const gp = gprops.toJS ? gprops.toJS() : gprops; + const totalSP = vestingSteem(acc, gp); + const pdAmount = powerdownSteem(acc, gp); + + if (!pdAmount || pdAmount <= 0) { + try { + const key = this.getPowerDownStorageKey(); + if (key) localStorage.removeItem(key); + } catch (_) {} + this.setState({ showPowerDownAlert: false }); + return; + } + if (this.isDismissedPowerDownAlert(pdAmount)) { + this.setState({ showPowerDownAlert: false }); + return; + } + + const pct = totalSP > 0 ? (pdAmount / totalSP) * 100 : 0; + this.setState({ showPowerDownAlert: true }); + }; + async loadInitialConversions() { try { const account = this.props.account; @@ -289,14 +508,6 @@ class UserWallet extends React.Component { } }; - showAdvanced = e => { - e.preventDefault(); - const { account } = this.props; - this.props.showAdvanced({ - account: account.get('name'), - }); - }; - getCurrentApr = gprops => { // The inflation was set to 9.5% at block 7m const initialInflationRate = 9.5; @@ -352,10 +563,10 @@ class UserWallet extends React.Component { account, currentUser, open_orders, - notify, withdraw_routes, + notify, } = this.props; - const { showQR, hasClicked, showChangeRecoveryModal, conversionValue } = this.state; + const { showQR, hasClicked, showChangeRecoveryModal, conversionValue, showPowerDownAlert } = this.state; const gprops = this.props.gprops.toJS(); // do not render if account is not loaded or available @@ -371,6 +582,73 @@ class UserWallet extends React.Component { const isMyAccount = currentUser && currentUser.get('username') === account.get('name'); + let powerDownWarningBox = null; + if (showPowerDownAlert && powerdown_steem > 0 && isMyAccount) { + try { + const pct = vesting_steem > 0 ? (powerdown_steem * 4 / vesting_steem) * 100 : 0; + const isHighRisk = pct >= 50; + const warningBoxClass = 'UserWallet__warningbox' + (isHighRisk ? ' UserWallet__warningbox--danger' : ''); + powerDownWarningBox = ( +
    +
    +
    + + {tt('powerdown_alert.message', { + amount: numberWithCommas((powerdown_steem * 4).toFixed(3)), + percent: pct.toFixed(2), + })} + +
    + +
    +
    +
    +
    + ); + } catch (error) { + console.log(error) + } + } + let withdrawRoutesWarningBox = null; + if (this.state.showWithdrawRoutesAlert) { + try { + withdrawRoutesWarningBox = ( +
    +
    +
    + + {tt('advanced_routes.withdraw_routes_detected')} + +
    + + +
    +
    +
    +
    + ); + } catch (e) { + console.warn(e); + } + } const disabledWarning = false; // isMyAccount = false; // false to hide wallet transactions @@ -439,9 +717,7 @@ class UserWallet extends React.Component { } const balance_steem = parseFloat(account.get('balance').split(' ')[0]); - const saving_balance_steem = parseFloat( - savings_balance.split(' ')[0] - ); + const saving_balance_steem = parseFloat(savings_balance.split(' ')[0]); const divesting = parseFloat(account.get('vesting_withdraw_rate').split(' ')[0]) > 0.0; @@ -517,7 +793,8 @@ class UserWallet extends React.Component { .map(item => { const data = item.getIn([1, 'op', 1]); const type = item.getIn([1, 'op', 0]); - + const trx_id = item.getIn([1, 'trx_id']) + const block_id = item.getIn([1, 'block']) // Filter out rewards if ( type === 'curation_reward' || @@ -536,6 +813,8 @@ class UserWallet extends React.Component { ); @@ -726,6 +1005,33 @@ class UserWallet extends React.Component { break; default: } + // withdraw routes + const routes = withdraw_routes && withdraw_routes.length > 0 ? [...withdraw_routes] : []; + const sortedRoutes = routes.sort((a, b) => b.percent - a.percent); + let message = null; + + if (sortedRoutes.length === 1 && sortedRoutes[0].percent === 10000) { + message = ( + + {tt('userwallet_jsx.routed_to_single')} + + {`@${sortedRoutes[0].to_account}`}. + + + ); + } else if (sortedRoutes.length >= 1 && sortedRoutes[0].percent < 10000) { + message = ( + + this.setState({ showWithdrawRoutesModal: true })} + > + {tt('userwallet_jsx.view_all_withdraw_routes')} + . + + ); + } const combinedConversions = [ ...(this.state.conversions || []).map(item => ({ @@ -842,22 +1148,6 @@ class UserWallet extends React.Component { console.error(e); } - let advancedRoutesNotification = null; - if (isMyAccount && withdraw_routes && withdraw_routes.size > 0) { - const message = - 'Custom withdrawal routes were configured to receive vesting payments. Please reconfirm in the Advanced Routes options.'; - - advancedRoutesNotification = ( -
    -
    -
    -

    {message}

    -
    -
    -
    - ); - } - return (
    {(showChangeRecoveryModal && accountToRecover && recoveryAccount) && ( @@ -869,8 +1159,13 @@ class UserWallet extends React.Component { /> )} {recoveryWarningBox} + {withdrawRoutesWarningBox} + {powerDownWarningBox} {claimbox}
    + {/*
    + +
    */}
    - {advancedRoutesNotification}
    {powerdown_steem != 0 && ( @@ -1116,8 +1410,19 @@ class UserWallet extends React.Component { )} />{' '} {'(~' + powerdown_balance_str + ' STEEM)'}. + {' '} + {message} )} + {this.state.showWithdrawRoutesModal && ( + this.setState({ showWithdrawRoutesModal: false })} + routes={sortedRoutes} + accountName={account.get('name')} + steemPower={powerdown_balance_str} + /> + )}
    {disabledWarning && ( @@ -1172,12 +1477,12 @@ export default connect( (state, ownProps) => { const price_per_steem = pricePerSteem(state); const savings_withdraws = state.user.get('savings_withdraws'); + const routes = state.user.get('withdraw_routes'); + const withdraw_routes = routes && routes.toJS ? routes.toJS() : []; const gprops = state.global.get('props'); const sbd_interest = gprops.get('sbd_interest_rate'); // This is current logined user. const currentUser = ownProps.currentUser; - const withdraw_routes = state.user.get('withdraw_routes'); - return { ...ownProps, open_orders: state.market.get('open_orders'), @@ -1186,8 +1491,8 @@ export default connect( sbd_interest, gprops, trackingId: state.app.getIn(['trackingId'], null), - currentUser, withdraw_routes, + currentUser, conversionsSuccess: state.transaction.get('conversions'), prices: state.transaction.get('prices'), }; diff --git a/src/app/components/modules/UserWallet.scss b/src/app/components/modules/UserWallet.scss index c39fc5379..6b51ac1dc 100644 --- a/src/app/components/modules/UserWallet.scss +++ b/src/app/components/modules/UserWallet.scss @@ -155,3 +155,36 @@ } } } + +.UserWallet__warningbox--danger { + @include themify($themes) { + background-color: themed('backgroundColorDanger'); + border: themed('borderDanger'); + color: themed('textColorOnDanger'); + } ++ + .UserWallet__warningbox__text { + @include themify($themes) { + color: themed('textColorOnDanger'); + } + } ++ + .UserWallet__warningbox__buttons { + .button.hollow { + @include themify($themes) { + border: 1px solid themed('textColorOnDanger'); + color: themed('textColorOnDanger'); + } + &:hover, + &:focus { + @include themify($themes) { + border-color: themed('textColorOnDanger'); + color: themed('textColorOnDanger'); + } + text-shadow: none; + box-shadow: none; + background: rgba(255, 255, 255, 0.12); + } + } + } +} diff --git a/src/app/components/modules/Witnesses.jsx b/src/app/components/modules/Witnesses.jsx new file mode 100644 index 000000000..aa5eb9b5d --- /dev/null +++ b/src/app/components/modules/Witnesses.jsx @@ -0,0 +1,561 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Link } from 'react-router'; +import links from 'app/utils/Links'; +import Icon from 'app/components/elements/Icon'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import ByteBuffer from 'bytebuffer'; +import { is, Set, List } from 'immutable'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as appActions from 'app/redux/AppReducer'; +import tt from 'counterpart'; +import { userActionRecord } from 'app/utils/ServerApiClient'; +import { FormattedHTMLMessage } from 'app/Translator'; + +const Long = ByteBuffer.Long; +const { string, func, object } = PropTypes; + +const DISABLED_SIGNING_KEY = 'STM1111111111111111111111111111111114T1Anm'; + +function _blockGap(head_block, last_block) { + if (!last_block || last_block < 1) return 'forever'; + const secs = (head_block - last_block) * 3; + if (secs < 120) return 'recently'; + const mins = Math.floor(secs / 60); + if (mins < 120) return mins + ' mins ago'; + const hrs = Math.floor(mins / 60); + if (hrs < 48) return hrs + ' hrs ago'; + const days = Math.floor(hrs / 24); + if (days < 14) return days + ' days ago'; + const weeks = Math.floor(days / 7); + if (weeks < 104) return weeks + ' weeks ago'; +} + +class Witnesses extends React.Component { + static propTypes = { + // HTML properties + + // Redux connect properties + witnesses: object.isRequired, + accountWitnessVote: func.isRequired, + username: string, + witness_votes: object, + }; + + constructor() { + super(); + this.state = { customUsername: '', proxy: '', proxyFailed: false }; + this.accountWitnessVote = (accountName, approve, e) => { + e.preventDefault(); + const { username, accountWitnessVote } = this.props; + this.setState({ customUsername: '' }); + accountWitnessVote(username, accountName, approve); + }; + this.onWitnessChange = e => { + const customUsername = e.target.value; + this.setState({ customUsername }); + // Force update to ensure witness vote appears + this.forceUpdate(); + }; + this.accountWitnessProxy = e => { + e.preventDefault(); + const { username, accountWitnessProxy } = this.props; + accountWitnessProxy(username, this.state.proxy, state => { + this.setState(state); + }); + }; + } + + componentWillMount() { + this.props.setRouteTag(); + } + componentDidMount() { + if (this.props.walletSectionAccount && !this.props.current_proxy && !this.props.username) { + const { fetchAccountWitnessVotes } = this.props; + fetchAccountWitnessVotes(this.props.walletSectionAccount); + } + } + + componentDidUpdate(prevProps) { + const { walletSectionAccount, fetchAccountWitnessVotes } = this.props; + if (walletSectionAccount !== prevProps.walletSectionAccount) { + if (walletSectionAccount && !this.props.current_proxy && !this.props.username) { + fetchAccountWitnessVotes(walletSectionAccount); + } + } + } + + shouldComponentUpdate(np, ns) { + return ( + !is(np.witness_votes, this.props.witness_votes) || + !is(np.witnessVotesInProgress, this.props.witnessVotesInProgress) || + np.witnesses !== this.props.witnesses || + np.current_proxy !== this.props.current_proxy || + np.username !== this.props.username || + ns.customUsername !== this.state.customUsername || + ns.proxy !== this.state.proxy || + ns.proxyFailed !== this.state.proxyFailed + ); + } + + render() { + const { + props: { + witness_votes, + witnessVotesInProgress, + current_proxy, + head_block, + }, + state: { customUsername, proxy }, + accountWitnessVote, + accountWitnessProxy, + onWitnessChange, + } = this; + const sorted_witnesses = this.props.witnesses.sort((a, b) => + Long.fromString(String(b.get('votes'))).subtract( + Long.fromString(String(a.get('votes'))).toString() + ) + ); + let witness_vote_count = 30; + let rank = 1; + const { canVote } = this.props + const witnesses = sorted_witnesses.map(item => { + const owner = item.get('owner'); + const thread = item.get('url'); + const myVote = witness_votes ? witness_votes.has(owner) : null; + const signingKey = item.get('signing_key'); + const lastBlock = item.get('last_confirmed_block_num'); + const isDisabled = + signingKey == DISABLED_SIGNING_KEY; + const votingActive = witnessVotesInProgress.has(owner); + const classUp = + 'Voting__button Voting__button-up' + + (myVote === true ? ' Voting__button--upvoted' : '') + + (votingActive ? ' votingUp' : ''); + const up = ( + + ); + + let witness_link = ''; + if (thread) { + if (!/^https?:\/\//.test(thread)) { + witness_link = '(No URL provided)'; + } else if (links.remote.test(thread)) { + witness_link = ( + + {tt('witnesses_jsx.external_site')}  + + ); + } else { + witness_link = ( + + {tt('witnesses_jsx.witness_thread')}  + + ); + } + } + + const ownerStyle = isDisabled + ? { textDecoration: 'line-through', color: '#AAA' } + : {}; + + return ( + + + {rank < 10 && '0'} + {rank++} +    + + {votingActive ? ( + up + ) : canVote ? ( + + {up} + + ) : ( + + {up} + + )} + + + + + {owner} + + {isDisabled && ( + + {' '} + ({tt('witnesses_jsx.disabled')}{' '} + {_blockGap(head_block, lastBlock)}) + + )} + + {witness_link} + + ); + }); + + let addl_witnesses = false; + if (witness_votes) { + witness_vote_count -= witness_votes.size; + addl_witnesses = witness_votes + .union(witnessVotesInProgress) + .filter(item => { + return !sorted_witnesses.has(item); + }) + .map(item => { + const votingActive = witnessVotesInProgress.has(item); + const classUp = + 'Voting__button Voting__button-up' + + (votingActive + ? ' votingUp' + : ' Voting__button--upvoted'); + const up = ( + + ); + return ( +
    +
    + + {/*className="Voting"*/} + + {votingActive ? ( + up + ) : ( + + {up} + + )} +   + + + {item} +
    +
    + ); + }) + .toArray(); + } + + return ( +
    +
    +
    +

    {tt('witnesses_jsx.top_witnesses')}

    + + + {tt('witnesses_jsx.witnesses_description')} + + + + {current_proxy && current_proxy.length ? null : ( +

    + + {tt( + 'witnesses_jsx.you_have_votes_remaining', + { count: witness_vote_count } + )}. + {' '} + {tt( + 'witnesses_jsx.you_can_vote_for_maximum_of_witnesses' + )}. +

    + )} +
    +
    + {current_proxy ? null : ( +
    +
    + + + + + + + + {witnesses.toArray()} +
    + {tt('witnesses_jsx.witness')} + {tt('witnesses_jsx.information')} +
    +
    +
    + )} + + {current_proxy ? null : ( +
    +
    +

    + {tt( + 'witnesses_jsx.if_you_want_to_vote_outside_of_top_enter_account_name' + )}. +

    + +
    + @ + +
    + +
    +
    + +
    + {addl_witnesses} +
    +
    +
    +
    + )} + +
    +
    +

    + {current_proxy + ? tt('witnesses_jsx.witness_set') + : tt('witnesses_jsx.set_witness_proxy')} +

    + {current_proxy ? ( +
    +
    + {tt('witnesses_jsx.witness_proxy_current')}:{' '} + {current_proxy} +
    + +
    +
    + +
    + +
    +
    +
    +
    + ) : ( +
    +
    + @ + { + this.setState({ + proxy: e.target.value, + }); + }} + /> +
    + +
    +
    +
    + )} + {this.state.proxyFailed && ( +

    + {tt('witnesses_jsx.proxy_update_error')}. +

    + )} +
    +
    +
    +
    + ); + } +} + +const isNonEmptyString = v => typeof v === 'string' && v.trim().length > 0; +const norm = s => (typeof s === 'string' ? s.trim().toLowerCase() : ''); + +export default connect( + (state, ownProps) => { + // const current_user = state.user.get('current'); + // const username = current_user && current_user.get('username'); + // const current_account = + // current_user && state.global.getIn(['accounts', username]); + // const witness_votes = + // current_account && current_account.get('witness_votes').toSet(); + // const current_proxy = + // current_account && current_account.get('proxy'); + // const witnesses = state.global.get('witnesses', List()); + // const witnessVotesInProgress = state.global.get( + // `transaction_witness_vote_active_${username}`, + // Set() + // ); + const current_user = state.user.get('current'); + const username = current_user && current_user.get('username'); + + const hasPropAccount = isNonEmptyString(ownProps.walletSectionAccount); + const sourceAccountName = hasPropAccount + ? ownProps.walletSectionAccount.trim() + : username; + + const source_account = isNonEmptyString(sourceAccountName) + ? state.global.getIn(['accounts', sourceAccountName]) + : null; + + const current_proxy = source_account ? source_account.get('proxy') : null; + const witness_votes = source_account && source_account.get('witness_votes') + ? source_account.get('witness_votes').toSet() + : undefined; + + const witnessVotesInProgress = isNonEmptyString(sourceAccountName) + ? state.global.get( + `transaction_witness_vote_active_${sourceAccountName}`, + Set() + ) + : Set(); + + const witnesses = state.global.get('witnesses', List()); + const canVote = + !hasPropAccount || (isNonEmptyString(username) && norm(username) === norm(sourceAccountName)); + + return { + head_block: state.global.getIn(['props', 'head_block_number']), + witnesses, + username, + witness_votes, + witnessVotesInProgress, + current_proxy, + canVote, + sourceAccountName, + }; + }, + dispatch => { + return { + accountWitnessVote: (username, witness, approve) => { + userActionRecord('account_witness_vote', { + username, + witness, + }); + dispatch( + transactionActions.broadcastOperation({ + type: 'account_witness_vote', + operation: { account: username, witness, approve }, + confirm: !approve + ? 'You are about to remove your vote for this witness' + : null, + }) + ); + }, + accountWitnessProxy: (account, proxy, stateCallback) => { + userActionRecord('account_witness_proxy', { + username: account, + proxy, + }); + dispatch( + transactionActions.broadcastOperation({ + type: 'account_witness_proxy', + operation: { account, proxy }, + confirm: proxy.length + ? 'Set proxy to: ' + proxy + : 'You are about to remove your proxy.', + successCallback: () => { + dispatch( + globalActions.updateAccountWitnessProxy({ + account, + proxy, + }) + ); + stateCallback({ + proxyFailed: false, + proxy: '', + }); + }, + errorCallback: e => { + stateCallback({ proxyFailed: true }); + }, + }) + ); + }, + setRouteTag: () => + dispatch( + appActions.setRouteTag({ routeTag: 'vote_to_witness' }) + ), + fetchAccountWitnessVotes: (accountName) => { + return dispatch(transactionActions.fetchAccountWitnessVotes({accountName})) + }, + }; + } +)(Witnesses) diff --git a/src/app/components/pages/Proposals.jsx b/src/app/components/pages/Proposals.jsx index c6bc7eb17..00f322f6f 100644 --- a/src/app/components/pages/Proposals.jsx +++ b/src/app/components/pages/Proposals.jsx @@ -1,582 +1,12 @@ import React from 'react'; -import { connect } from 'react-redux'; -import { actions as proposalActions } from 'app/redux/ProposalSaga'; -import * as transactionActions from 'app/redux/TransactionReducer'; // TODO: Only import what we need. -import * as appActions from 'app/redux/AppReducer'; -import { List } from 'immutable'; import PropTypes from 'prop-types'; -import { api } from '@steemit/steem-js'; -import ProposalListContainer from 'app/components/modules/ProposalList/ProposalListContainer'; -import { - LOAD_ALL_VOTERS, - MAX_INITIAL_LOAD, - INITIAL_TIMEOUT, - MAX_TIMEOUT, -} from 'app/components/modules/ProposalList/constants'; -import VotersModal from 'app/components/elements/VotersModal'; -import ProposalCreatorModal from 'app/components/elements/ProposalCreatorModal'; -import tt from 'counterpart'; +import ProposalsComponent from 'app/components/modules/Proposals'; class Proposals extends React.Component { - constructor(props) { - super(props); - this.state = { - proposals: [], - loading: true, - limit: 50, - last_proposal: false, - status: 'votable', - order_by: 'by_total_votes', - order_direction: 'descending', - open_voters_modal: false, - open_creators_modal: false, - voters: [], - voters_accounts: [], - total_vests: '', - total_vest_steem: '', - new_id: '', - is_voters_data_loaded: false, - lastVoter: '', - paid_proposals: [], - }; - this.fetchVoters = this.fetchVoters.bind(this); - this.fetchGlobalProps = this.fetchGlobalProps.bind(this); - this.fetchDataForVests = this.fetchDataForVests.bind(this); - this.setIsVotersDataLoading = this.setIsVotersDataLoading.bind(this); - this.getVotedProposals = this.getVotedProposals.bind(this); - } - async componentWillMount() { - this.props.setRouteTag(); - await this.load(); - } - componentDidMount() { - this.fetchGlobalProps(); - } - - componentDidUpdate(prevProps, prevState) { - if (prevState.new_id !== this.state.new_id) { - this.fetchVoters(); - this.setIsVotersDataLoading(false); - } - if (prevState.voters !== this.state.voters) { - this.fetchDataForVests(); - } - if (prevState.voters_accounts !== this.state.voters_accounts) { - this.setIsVotersDataLoading(!this.state.is_voters_data_loaded); - } - if (prevProps.currentUser !== this.props.currentUser) { - this.updateProposalVotes(this.props.currentUser); - } - } - - getStartValue(order_by, order_direction) { - const minDate = '1970-01-01T00:00:00'; - const maxDate = '2038-01-19T03:14:07'; - const startValueByOrderType = { - by_total_votes: { - ascending: [0], - descending: [-1, 0], - }, - by_creator: { - ascending: [''], - descending: ['zzzzzzzzzzzzzz'], - }, - by_start_date: { - ascending: [minDate], - descending: [maxDate], - }, - by_end_date: { - ascending: [minDate], - descending: [maxDate], - }, - }; - const value = startValueByOrderType[order_by][order_direction]; - return value; - } - - async load(quiet = false, options = {}) { - if (quiet) { - this.setState({ loading: true }); - } - - const { status, order_by, order_direction } = options; - - const isFiltering = !!(status || order_by || order_direction); - - let limit; - - if (isFiltering) { - limit = this.state.limit; - } else { - limit = this.state.limit + this.state.proposals.length; - } - - const start = this.getStartValue( - order_by || this.state.order_by, - order_direction || this.state.order_direction - ); - const proposals = - (await this.getAllProposals( - this.state.last_proposal, - order_by || this.state.order_by, - order_direction || this.state.order_direction, - limit, - status || this.state.status, - start - )) || []; - let last_proposal = false; - if (proposals.length > 0) { - last_proposal = proposals[0]; - } - this.setState({ - proposals, - loading: false, - last_proposal, - limit, - }); - } - - onFilterProposals = async status => { - this.setState({ status }); - await this.load(false, { status }); - }; - - onOrderProposals = async order_by => { - this.setState({ order_by }); - await this.load(false, { order_by }); - }; - - onOrderDirection = async order_direction => { - this.setState({ order_direction }); - await this.load(false, { order_direction }); - }; - - getVotersAccounts = voters_accounts => { - this.setState({ voters_accounts }); - }; - - getVoters = (voters, lastVoter) => { - this.setState({ voters, lastVoter }); - }; - - getNewId = new_id => { - this.setState({ new_id }); - }; - - setIsVotersDataLoading = is_voters_data_loaded => { - this.setState({ is_voters_data_loaded }); - }; - setPaidProposals = paid_proposals => { - this.setState({ paid_proposals }); - }; - - getAllProposals( - last_proposal, - order_by, - order_direction, - limit, - status, - start - ) { - return this.props.listProposals({ - voter_id: this.props.currentUser, - last_proposal, - order_by, - order_direction, - limit, - status, - start, - }); - } - - voteOnProposal = async (proposalId, voteForIt, onSuccess, onFailure) => { - return this.props.voteOnProposal( - this.props.currentUser, - [proposalId], - voteForIt, - async () => { - if (onSuccess) onSuccess(); - }, - () => { - if (onFailure) onFailure(); - } - ); - }; - - fetchGlobalProps() { - api.callAsync('condenser_api.get_dynamic_global_properties', []) - .then(res => - this.setState({ - total_vests: res.total_vesting_shares, - total_vest_steem: res.total_vesting_fund_steem, - }) - ) - .catch(err => console.log(err)); - } - - fetchVoters() { - this.fetchAllVotersWithPause({ - proposalId: this.state.new_id, - timeout: INITIAL_TIMEOUT, - maxToLoad: LOAD_ALL_VOTERS ? null : MAX_INITIAL_LOAD, - }) - .then(res => { - this.getVoters(res, ...res.slice(-1)); - }) - .catch(err => console.log(err)); - } - - fetchDataForVests() { - const voters = this.state.voters; - const new_id = this.state.new_id; - - const selected_proposal_voters = voters.filter( - v => v.proposal.proposal_id === new_id - ); - const voters_map = selected_proposal_voters.map(name => name.voter); - api.getAccountsAsync(voters_map) - .then(res => this.getVotersAccounts(res)) - .catch(err => console.log(err)); - } - - async getVotedProposals({ accountName, proposalIdsSet }) { - const votedMap = {}; - - try { - const result = await new Promise((resolve, reject) => { - api.callAsync( - 'database_api.list_proposal_votes', - { - start: [accountName], - limit: 1000, - order: 'by_voter_proposal', - order_direction: 'ascending', - status: 'all', - }, - (err, res) => { - if (err) reject(err); - else resolve(res); - } - ); - }); - - const votes = (result && result.proposal_votes) || []; - if (votes.length === 0 || votes[0].voter !== accountName) { - return votedMap; - } - - for (const vote of votes) { - if (vote.voter !== accountName) break; - const proposalId = vote.proposal.proposal_id; - if (proposalIdsSet.has(proposalId)) { - votedMap[proposalId] = true; - } - } - - return votedMap; - } catch (err) { - console.error('Error al obtener propuestas votadas:', err); - return votedMap; - } - } - - async updateProposalVotes(currentUser) { - if (typeof currentUser !== 'string' || currentUser.length <= 1) return; - - const { proposals } = this.state; - const proposalIdsSet = new Set(proposals.map(p => p.id)); - const votedMap = await this.getVotedProposals({ accountName: currentUser, proposalIdsSet }); - - const updatedProposals = proposals.map(p => ({ - ...p, - upVoted: !!votedMap[p.id], - })); - - this.setState({ proposals: updatedProposals }); - } - - delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async fetchAllVotersWithPause({ - proposalId, - lastVoter = '', - accumulated = [], - timeout = INITIAL_TIMEOUT, - maxToLoad = null, - }) { - try { - const res = await new Promise((resolve, reject) => { - api.callAsync( - 'database_api.list_proposal_votes', - { - start: [proposalId, lastVoter], - limit: 1000, - order: 'by_proposal_voter', - order_direction: 'ascending', - status: 'active', - }, - (err, result) => { - if (err) reject(err); - else resolve(result); - } - ); - }); - - const votes = (res && res.proposal_votes) || []; - if (votes.length === 0) return accumulated; - const allVoters = accumulated.concat(votes); - if (maxToLoad && allVoters.length >= maxToLoad) { - return allVoters.slice(0, maxToLoad); - } - if (votes.length < 1000) { - return allVoters; - } - if (votes && votes.length >= 2) { - try { - const firstProposalId = votes[0].proposal.proposal_id; - const lastProposalId = votes.at(-1).proposal.proposal_id; - if ( - firstProposalId !== proposalId || - lastProposalId !== proposalId - ) { - return allVoters; - } - } catch (error) { - console.error(error); - } - } - const nextVoter = votes.at(-1) ? votes.at(-1).voter : undefined; - await this.delay(timeout); - const nextTimeout = Math.min(timeout + 250, MAX_TIMEOUT); - return this.fetchAllVotersWithPause({ - proposalId, - lastVoter: nextVoter, - accumulated: allVoters, - timeout: nextTimeout, - maxToLoad, - }); - } catch (err) { - console.error('Error al obtener votantes:', err); - return accumulated; - } - } - - onClickLoadMoreProposals = e => { - e.preventDefault(); - this.load(); - }; - - triggerCreatorsModal = () => { - this.setState({ - open_creators_modal: !this.state.open_creators_modal, - }); - }; - - triggerVotersModal = () => { - this.setState({ - open_voters_modal: !this.state.open_voters_modal, - }); - }; - - submitProposal = (proposal, onSuccess, onFailure) => { - this.props.createProposal( - this.props.currentUser || proposal.creator, - proposal.receiver, - proposal.startDate, - proposal.endDate, - `${parseFloat(proposal.dailyAmount).toFixed(3)} SBD`, - proposal.title, - proposal.permlink, - async () => { - this.triggerCreatorsModal(); - if (onSuccess) onSuccess(); - }, - () => { - if (onFailure) onFailure(); - } - ); - }; - - removeProposalById = id => { - this.setState(prevState => ({ - proposals: prevState.proposals.filter( - proposal => proposal.id !== id - ), - })); - }; - render() { - const { - proposals, - loading, - status, - order_by, - order_direction, - voters, - voters_accounts, - open_creators_modal, - open_voters_modal, - total_vests, - total_vest_steem, - is_voters_data_loaded, - new_id, - } = this.state; - - const mergeVoters = [...voters]; - - const { nightmodeEnabled } = this.props; - - let showBottomLoading = false; - if (loading && proposals && proposals.length > 0) { - showBottomLoading = true; - } - const selected_proposal_voters = mergeVoters.filter( - v => v.proposal.proposal_id === new_id - ); - const voters_map = selected_proposal_voters.map(name => name.voter); // voter name - const accounts_map = []; - const acc_proxied_vests = []; - const proxies_name_by_voter = []; - const proxies_vote = {}; - voters_accounts.forEach(acc => { - accounts_map.push(acc.vesting_shares); - const proxied = acc.proxied_vsf_votes - .map(r => parseInt(r, 10)) - .reduce((a, b) => a + b, 0); - acc_proxied_vests.push(proxied); - proxies_name_by_voter.push(acc.proxy); - if (acc.proxy) { - proxies_vote[acc.proxy] = false; - } - }); - const steem_power = []; - const proxy_sp = []; - const total_sp = []; - let global_total_sp = 0; - - const calculatePowers = () => { - const total_vestsNew = parseFloat(total_vests.split(' ')[0]); - const total_vest_steemNew = parseFloat( - total_vest_steem.split(' ')[0] - ); - - for (let i = 0; i < accounts_map.length; i++) { - const vests_account = parseFloat(accounts_map[i].split(' ')[0]); - const vests_proxy = acc_proxied_vests[i]; - - const vesting_steem_account = - total_vest_steemNew * (vests_account / total_vestsNew); - const vesting_steem_proxy = - total_vest_steemNew * - (vests_proxy / total_vestsNew) * - 0.000001; - - const total = vesting_steem_account + vesting_steem_proxy; - - steem_power.push(vesting_steem_account); - proxy_sp.push(vesting_steem_proxy); - total_sp.push(total); - const voter = voters_map[i]; - if (Object.keys(proxies_vote).includes(voter)) { - proxies_vote[voter] = true; - } - global_total_sp += total; - } - }; - calculatePowers(); - const simpleVotesToSp = total_votes => { - const total_vestsNew = parseFloat(total_vests.split(' ')[0]); - const total_vest_steemNew = parseFloat( - total_vest_steem.split(' ')[0] - ); - return ( - total_vest_steemNew * - (total_votes / total_vestsNew) * - 0.000001 - ).toFixed(2); - }; - const pro_aux = proposals.find(p => p.proposal_id === new_id); - let total_votes_aux = 0; - if (pro_aux && pro_aux.total_votes) { - total_votes_aux = simpleVotesToSp(pro_aux.total_votes); - } - const total_acc_sp_obj = {}; - voters_map.forEach((voter, i) => { - const proxy_name = proxies_name_by_voter[i]; - const proxy_vote = proxies_vote[proxy_name] || false; - const influence = total_votes_aux - ? (total_sp[i] / total_votes_aux) * 100 - : 0; - total_acc_sp_obj[voter] = [ - total_sp[i], - steem_power[i], - proxy_sp[i], - proxy_name, - proxy_vote, - influence, - ]; - }); - const sort_merged_total_sp = []; - for (const value in total_acc_sp_obj) { - sort_merged_total_sp.push([value, ...total_acc_sp_obj[value]]); - } - sort_merged_total_sp.sort((a, b) => b[1] - a[1]); - return ( -
    - - - -
    - {!loading ? ( - - {tt('proposals.load_more')} - - ) : null} - - {showBottomLoading ? ( - {tt('proposals.loading')} - ) : null} -
    -
    - ); + + ) } } @@ -588,87 +18,5 @@ Proposals.propTypes = { module.exports = { path: 'proposals', - component: connect( - state => { - const user = state.user.get('current'); - const currentUser = user && user.get('username'); - const proposals = state.proposal.get('proposals', List()); - const last = proposals.size - 1; - const last_id = - (proposals.size && proposals.get(last).get('id')) || null; - const newProposals = - proposals.size >= 10 ? proposals.delete(last) : proposals; - - return { - currentUser, - proposals: newProposals, - last_id, - nightmodeEnabled: state.app.getIn([ - 'user_preferences', - 'nightmode', - ]), - }; - }, - dispatch => { - return { - voteOnProposal: ( - voter, - proposal_ids, - approve, - successCallback, - errorCallback - ) => { - dispatch( - transactionActions.broadcastOperation({ - type: 'update_proposal_votes', - operation: { voter, proposal_ids, approve }, - successCallback, - errorCallback, - }) - ); - }, - createProposal: ( - creator, - receiver, - start_date, - end_date, - daily_pay, - subject, - permlink, - successCallback, - errorCallback - ) => { - dispatch( - transactionActions.broadcastOperation({ - type: 'create_proposal', - operation: { - creator, - receiver, - start_date, - end_date, - daily_pay, - subject, - permlink, - }, - successCallback, - errorCallback, - }) - ); - }, - listProposals: payload => { - return new Promise((resolve, reject) => { - dispatch( - proposalActions.listProposals({ - ...payload, - resolve, - reject, - }) - ); - }); - }, - setRouteTag: () => - dispatch(appActions.setRouteTag({ routeTag: 'proposals' })), - }; - } - )(Proposals), + component: Proposals, }; diff --git a/src/app/components/pages/UserProfile.jsx b/src/app/components/pages/UserProfile.jsx index 48f845510..9a3b7e977 100644 --- a/src/app/components/pages/UserProfile.jsx +++ b/src/app/components/pages/UserProfile.jsx @@ -14,6 +14,8 @@ import UserKeys from 'app/components/elements/UserKeys'; import PasswordReset from 'app/components/elements/PasswordReset'; import CreateCommunity from 'app/components/elements/CreateCommunity'; import Delegations from 'app/components/modules/Delegations'; +import Proposals from 'app/components/modules/Proposals'; +import Witnesses from 'app/components/modules/Witnesses'; import UserWallet from 'app/components/modules/UserWallet'; import Settings from 'app/components/modules/Settings'; import CurationRewards from 'app/components/modules/CurationRewards'; @@ -70,6 +72,10 @@ export default class UserProfile extends React.Component { } componentDidUpdate(prevProps) { + const { accountname, getWithdrawRoutes } = this.props; + if (accountname && accountname !== prevProps.accountname) { + getWithdrawRoutes(accountname); + } this.redirect(); } @@ -150,7 +156,39 @@ export default class UserProfile extends React.Component { />
    ); - } else if (section === 'delegations') { + } else if (section === 'proposals') { + walletClass = 'active'; + tab_content = ( +
    +
    +
    + +
    +
    +
    + +
    + ); + } else if (section === 'witnesses') { + walletClass = 'active'; + tab_content = ( +
    +
    +
    + +
    +
    +
    + +
    + ); + } else if (section === 'delegations') { walletClass = 'active'; tab_content = (
    diff --git a/src/app/components/pages/Witnesses.jsx b/src/app/components/pages/Witnesses.jsx index 7781b0471..9f081b29f 100644 --- a/src/app/components/pages/Witnesses.jsx +++ b/src/app/components/pages/Witnesses.jsx @@ -1,504 +1,15 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { Link } from 'react-router'; -import links from 'app/utils/Links'; -import Icon from 'app/components/elements/Icon'; -import * as transactionActions from 'app/redux/TransactionReducer'; -import ByteBuffer from 'bytebuffer'; -import { is, Set, List } from 'immutable'; -import * as globalActions from 'app/redux/GlobalReducer'; -import * as appActions from 'app/redux/AppReducer'; -import tt from 'counterpart'; -import { userActionRecord } from 'app/utils/ServerApiClient'; - -const Long = ByteBuffer.Long; -const { string, func, object } = PropTypes; - -const DISABLED_SIGNING_KEY = 'STM1111111111111111111111111111111114T1Anm'; - -function _blockGap(head_block, last_block) { - if (!last_block || last_block < 1) return 'forever'; - const secs = (head_block - last_block) * 3; - if (secs < 120) return 'recently'; - const mins = Math.floor(secs / 60); - if (mins < 120) return mins + ' mins ago'; - const hrs = Math.floor(mins / 60); - if (hrs < 48) return hrs + ' hrs ago'; - const days = Math.floor(hrs / 24); - if (days < 14) return days + ' days ago'; - const weeks = Math.floor(days / 7); - if (weeks < 104) return weeks + ' weeks ago'; -} +import WitnessesComponent from 'app/components/modules/Witnesses'; class Witnesses extends React.Component { - static propTypes = { - // HTML properties - - // Redux connect properties - witnesses: object.isRequired, - accountWitnessVote: func.isRequired, - username: string, - witness_votes: object, - }; - - constructor() { - super(); - this.state = { customUsername: '', proxy: '', proxyFailed: false }; - this.accountWitnessVote = (accountName, approve, e) => { - e.preventDefault(); - const { username, accountWitnessVote } = this.props; - this.setState({ customUsername: '' }); - accountWitnessVote(username, accountName, approve); - }; - this.onWitnessChange = e => { - const customUsername = e.target.value; - this.setState({ customUsername }); - // Force update to ensure witness vote appears - this.forceUpdate(); - }; - this.accountWitnessProxy = e => { - e.preventDefault(); - const { username, accountWitnessProxy } = this.props; - accountWitnessProxy(username, this.state.proxy, state => { - this.setState(state); - }); - }; - } - - componentWillMount() { - this.props.setRouteTag(); - } - - shouldComponentUpdate(np, ns) { - return ( - !is(np.witness_votes, this.props.witness_votes) || - !is(np.witnessVotesInProgress, this.props.witnessVotesInProgress) || - np.witnesses !== this.props.witnesses || - np.current_proxy !== this.props.current_proxy || - np.username !== this.props.username || - ns.customUsername !== this.state.customUsername || - ns.proxy !== this.state.proxy || - ns.proxyFailed !== this.state.proxyFailed - ); - } - render() { - const { - props: { - witness_votes, - witnessVotesInProgress, - current_proxy, - head_block, - }, - state: { customUsername, proxy }, - accountWitnessVote, - accountWitnessProxy, - onWitnessChange, - } = this; - const sorted_witnesses = this.props.witnesses.sort((a, b) => - Long.fromString(String(b.get('votes'))).subtract( - Long.fromString(String(a.get('votes'))).toString() - ) - ); - let witness_vote_count = 30; - let rank = 1; - - const witnesses = sorted_witnesses.map(item => { - const owner = item.get('owner'); - const thread = item.get('url'); - const myVote = witness_votes ? witness_votes.has(owner) : null; - const signingKey = item.get('signing_key'); - const lastBlock = item.get('last_confirmed_block_num'); - const noBlock7days = (head_block - lastBlock) * 3 > 604800; - const isDisabled = - signingKey == DISABLED_SIGNING_KEY || noBlock7days; - const votingActive = witnessVotesInProgress.has(owner); - const classUp = - 'Voting__button Voting__button-up' + - (myVote === true ? ' Voting__button--upvoted' : '') + - (votingActive ? ' votingUp' : ''); - const up = ( - - ); - - let witness_link = ''; - if (thread) { - if (!/^https?:\/\//.test(thread)) { - witness_link = '(No URL provided)'; - } else if (links.remote.test(thread)) { - witness_link = ( - - {tt('witnesses_jsx.external_site')}  - - ); - } else { - witness_link = ( - - {tt('witnesses_jsx.witness_thread')}  - - ); - } - } - - const ownerStyle = isDisabled - ? { textDecoration: 'line-through', color: '#AAA' } - : {}; - - return ( - - - {rank < 10 && '0'} - {rank++} -    - - {votingActive ? ( - up - ) : ( - - {up} - - )} - - - - - {owner} - - {isDisabled && ( - - {' '} - ({tt('witnesses_jsx.disabled')}{' '} - {_blockGap(head_block, lastBlock)}) - - )} - - {witness_link} - - ); - }); - - let addl_witnesses = false; - if (witness_votes) { - witness_vote_count -= witness_votes.size; - addl_witnesses = witness_votes - .union(witnessVotesInProgress) - .filter(item => { - return !sorted_witnesses.has(item); - }) - .map(item => { - const votingActive = witnessVotesInProgress.has(item); - const classUp = - 'Voting__button Voting__button-up' + - (votingActive - ? ' votingUp' - : ' Voting__button--upvoted'); - const up = ( - - ); - return ( -
    -
    - - {/*className="Voting"*/} - - {votingActive ? ( - up - ) : ( - - {up} - - )} -   - - - {item} -
    -
    - ); - }) - .toArray(); - } - return ( -
    -
    -
    -

    {tt('witnesses_jsx.top_witnesses')}

    - {current_proxy && current_proxy.length ? null : ( -

    - - {tt( - 'witnesses_jsx.you_have_votes_remaining', - { count: witness_vote_count } - )}. - {' '} - {tt( - 'witnesses_jsx.you_can_vote_for_maximum_of_witnesses' - )}. -

    - )} -
    -
    - {current_proxy ? null : ( -
    -
    - - - - - - - - {witnesses.toArray()} -
    - {tt('witnesses_jsx.witness')} - {tt('witnesses_jsx.information')} -
    -
    -
    - )} - - {current_proxy ? null : ( -
    -
    -

    - {tt( - 'witnesses_jsx.if_you_want_to_vote_outside_of_top_enter_account_name' - )}. -

    -
    -
    - @ - -
    - -
    -
    -
    -
    - {addl_witnesses} -
    -
    -
    -
    - )} - -
    -
    -

    - {current_proxy - ? tt('witnesses_jsx.witness_set') - : tt('witnesses_jsx.set_witness_proxy')} -

    - {current_proxy ? ( -
    -
    - {tt('witnesses_jsx.witness_proxy_current')}:{' '} - {current_proxy} -
    - -
    -
    - -
    - -
    -
    -
    -
    - ) : ( -
    -
    - @ - { - this.setState({ - proxy: e.target.value, - }); - }} - /> -
    - -
    -
    -
    - )} - {this.state.proxyFailed && ( -

    - {tt('witnesses_jsx.proxy_update_error')}. -

    - )} -
    -
    -
    -
    + ); } } module.exports = { path: '/~witnesses(/:witness)', - component: connect( - state => { - const current_user = state.user.get('current'); - const username = current_user && current_user.get('username'); - const current_account = - current_user && state.global.getIn(['accounts', username]); - const witness_votes = - current_account && current_account.get('witness_votes').toSet(); - const current_proxy = - current_account && current_account.get('proxy'); - const witnesses = state.global.get('witnesses', List()); - const witnessVotesInProgress = state.global.get( - `transaction_witness_vote_active_${username}`, - Set() - ); - return { - head_block: state.global.getIn(['props', 'head_block_number']), - witnesses, - username, - witness_votes, - witnessVotesInProgress, - current_proxy, - }; - }, - dispatch => { - return { - accountWitnessVote: (username, witness, approve) => { - userActionRecord('account_witness_vote', { - username, - witness, - }); - dispatch( - transactionActions.broadcastOperation({ - type: 'account_witness_vote', - operation: { account: username, witness, approve }, - confirm: !approve - ? 'You are about to remove your vote for this witness' - : null, - }) - ); - }, - accountWitnessProxy: (account, proxy, stateCallback) => { - userActionRecord('account_witness_proxy', { - username: account, - proxy, - }); - dispatch( - transactionActions.broadcastOperation({ - type: 'account_witness_proxy', - operation: { account, proxy }, - confirm: proxy.length - ? 'Set proxy to: ' + proxy - : 'You are about to remove your proxy.', - successCallback: () => { - dispatch( - globalActions.updateAccountWitnessProxy({ - account, - proxy, - }) - ); - stateCallback({ - proxyFailed: false, - proxy: '', - }); - }, - errorCallback: e => { - console.log('error:', e); - stateCallback({ proxyFailed: true }); - }, - }) - ); - }, - setRouteTag: () => - dispatch( - appActions.setRouteTag({ routeTag: 'vote_to_witness' }) - ), - }; - } - )(Witnesses), + component: Witnesses, }; diff --git a/src/app/components/pages/Witnesses.scss b/src/app/components/pages/Witnesses.scss index 915b439fb..3cdaddbf4 100644 --- a/src/app/components/pages/Witnesses.scss +++ b/src/app/components/pages/Witnesses.scss @@ -2,19 +2,19 @@ .Witnesses { .extlink path { transition: 0.2s all ease-in-out; - @include themify($themes) { - fill: themed('textColorAccent'); - } + @include themify($themes) { + fill: themed('textColorAccent'); + } } a:hover .extlink path { transition: 0.2s all ease-in-out; - @include themify($themes) { - fill: themed('textColorAccentHover'); - } + @include themify($themes) { + fill: themed('textColorAccentHover'); + } } td > a { @extend .link; - @extend .link--primary; + @extend .link--primary; } .button { @@ -24,7 +24,12 @@ &:hover { background-color: $color-teal; } - } + } + .inline-text { + display: inline; + padding-left: 5px; + font-size: 100%; + } } diff --git a/src/app/help/en/faq.md b/src/app/help/en/faq.md index d0b1364d9..bb2f0640e 100644 --- a/src/app/help/en/faq.md +++ b/src/app/help/en/faq.md @@ -87,6 +87,7 @@ - How do I get more STEEM Power? - How long does it take STEEM or STEEM Power that I purchased to show up in my account? - What is powering up and down? +- What is a withdraw route? - What do the dollar amounts for pending payouts represent? - Will 1 Steem Dollar always be worth $1.00 USD? - How do Steem Dollar to STEEM conversions work? @@ -206,7 +207,7 @@ Steemit has redefined social media by building a living, breathing, and growing ^ ## How does Steemit work? -Steemit.com is one of the many websites (including [Busy.org](https://busy.org/), [DTube](https://d.tube/), and [Utopian.io](https://utopian.io/)) that are powered by the Steem blockchain and STEEM cryptocurrency. All of these websites read and write content to the Steem blockchain, which stores the content in an immutable blockchain ledger, and rewards users for their contributions with digital tokens called STEEM. +Steemit.com is one of the many websites (including [SteemPro](https://www.steempro.com/), [SteemX](https://steemx.org/), and [UPVU](https://upvu.org/)) that are powered by the Steem blockchain and STEEM cryptocurrency. All of these websites read and write content to the Steem blockchain, which stores the content in an immutable blockchain ledger, and rewards users for their contributions with digital tokens called STEEM. Every day, the Steem blockchain mints new STEEM tokens and adds them to a community's "rewards pool". These tokens are then awarded to users for their contributions, based on the votes that their content receives. Users who hold more tokens in their account as "Steem Power" will get to decide where a larger portion of the rewards pool is distributed. @@ -238,7 +239,33 @@ You can earn digital tokens on Steemit by: **Voting and curating** - If you discover a post and upvote it before it becomes popular, you can earn a curation reward. The reward amount will depend on the amount of Steem Power you have. -**Purchasing** - Users can purchase STEEM or Steem Dollar tokens directly through their Steemit wallet using bitcoin, Ether, or BitShares tokens. They are also available from other markets and exchanges including [Binance](https://www.binance.com/), [Bithumb](https://www.bithumb.com/), [BitShares](https://wallet.bitshares.org/), [Bittrex](https://bittrex.com), [Changelly](https://changelly.com), [GOBADA](https://www.gobaba.com/), [HitBTC](https://hitbtc.com/), [Huobi](https://www.huobi.pro/), [LocalBitcoinCash](https://www.localbitcoincash.org/), [Poloniex](https://poloniex.com), [Shapeshift.io](https://shapeshift.io), [UpBit](https://upbit.com/), and [Yensesa](https://yensesa.com). +**Purchasing** – Users can purchase **STEEM** or **Steem Dollar (SBD)** tokens directly through their **Steemit wallet** by buying them on exchanges and then trading in the internal market, or by directly buying them from exchanges. + +The **current exchanges that support STEEM and SBD** include: + +| # | Exchange | Supported Tokens | Link | +|----|---------------|------------------|------| +| 1 | HTX (Huobi) | STEEM / **SBD** | [htx.com](https://www.htx.com/) | +| 2 | Poloniex | STEEM | [poloniex.com](https://poloniex.com/) | +| 3 | CoinUp.io | STEEM | [coinup.io](https://coinup.io/) | +| 4 | MEXC | STEEM | [mexc.com](https://www.mexc.com/) | +| 5 | Binance | STEEM | [binance.com](https://www.binance.com/) | +| 6 | WhiteBIT | STEEM | [whitebit.com](https://whitebit.com/) | +| 7 | Upbit | STEEM | [upbit.com](https://upbit.com/) | +| 8 | BitKan | STEEM | [bitkan.com](https://bitkan.com/) | +| 9 | Bithumb | STEEM | [bithumb.com](https://www.bithumb.com/) | +| 10 | WEEX | STEEM | [weex.com](https://www.weex.com/) | +| 11 | Gate | STEEM | [gate.io](https://www.gate.io/) | +| 12 | Koinbay | STEEM | [koinbay.com](https://koinbay.com/) | +| 13 | Bitexen | STEEM | [bitexen.com](https://www.bitexen.com/) | +| 14 | ZKE | STEEM | [zkex.com](https://www.zke.com/) | +| 15 | DigiFinex | STEEM | [digifinex.com](https://www.digifinex.com/) | +| 16 | ProBit Global | STEEM | [probit.com](https://www.probit.com/) | +| 17 | CoinEx | STEEM | [coinex.com](https://www.coinex.com/) | +| 18 | ChangeNOW | STEEM | [changenow.io](https://changenow.io/) | +| 19 | CoinDCX | STEEM | [coindcx.com](https://coindcx.com/) | +| 20 | ONUS Pro | STEEM | [onus.pro](https://goonus.io/) | +| 21 | Pionex | STEEM | [pionex.com](https://www.pionex.com/) | **Vesting** - STEEM tokens that are powered up to Steem Power will earn a small amount of new tokens for holding. @@ -270,7 +297,7 @@ It is best to have realistic expectations, without focusing on rewards when you Click on the "Sign Up" link at the top of steemit.com to get started. -You will be asked to enter your email address and verify your phone number. After your information has been verified, you will be added to the waiting list to receive an account. You will be notified via email once your account is approved. +You will be asked to enter your email address and verify your phone number. After your information has been verified, you will be added to receive an account. You will be notified via email once your account is approved. After you receive notification that your account is approved, click on the link in the email to finish the account creation process. **Be sure to save and backup your username and password.** It is very important that you do not lose your password. There is no way to recover your password or access your account if it is lost. Once your password is saved and backed up, click on the "Create Account" button to create the account. @@ -635,21 +662,29 @@ Out of the new tokens that are generated: STEEM and SBD are listed on the following exchanges: -| Exchange | STEEM | SBD | -| ------------- |:-------------:| -----:| -| [Binance](https://www.binance.com/) | Y | N | -| [Bithumb](https://www.bithumb.com/) | Y | N | -| [BitShares](https://wallet.bitshares.org/) | Y | Y | -| [Bittrex](https://bittrex.com) | Y | Y | -| [Changelly](https://changelly.com) | Y | N | -| [GOBADA](https://www.gobaba.com/) | Y | N | -| [HitBTC](https://hitbtc.com/) | Y | Y | -| [Huobi](https://www.huobi.pro/) | Y | N | -| [LocalBitcoinCash](https://www.localbitcoincash.org/) | Y | N | -| [Poloniex](https://poloniex.com) | Y | Y | -| [Shapeshift.io](https://shapeshift.io) | Y | N | -| [UpBit](https://upbit.com/) | Y | Y | -| [Yensesa](https://yensesa.com) | Y | Y | +| # | Exchange | Supported Tokens | Link | +|----|---------------|------------------|------| +| 1 | HTX (Huobi) | STEEM / **SBD** | [htx.com](https://www.htx.com/) | +| 2 | Poloniex | STEEM | [poloniex.com](https://poloniex.com/) | +| 3 | CoinUp.io | STEEM | [coinup.io](https://coinup.io/) | +| 4 | MEXC | STEEM | [mexc.com](https://www.mexc.com/) | +| 5 | Binance | STEEM | [binance.com](https://www.binance.com/) | +| 6 | WhiteBIT | STEEM | [whitebit.com](https://whitebit.com/) | +| 7 | Upbit | STEEM | [upbit.com](https://upbit.com/) | +| 8 | BitKan | STEEM | [bitkan.com](https://bitkan.com/) | +| 9 | Bithumb | STEEM | [bithumb.com](https://www.bithumb.com/) | +| 10 | WEEX | STEEM | [weex.com](https://www.weex.com/) | +| 11 | Gate | STEEM | [gate.io](https://www.gate.io/) | +| 12 | Koinbay | STEEM | [koinbay.com](https://koinbay.com/) | +| 13 | Bitexen | STEEM | [bitexen.com](https://www.bitexen.com/) | +| 14 | ZKE | STEEM | [zkex.com](https://www.zke.com/) | +| 15 | DigiFinex | STEEM | [digifinex.com](https://www.digifinex.com/) | +| 16 | ProBit Global | STEEM | [probit.com](https://www.probit.com/) | +| 17 | CoinEx | STEEM | [coinex.com](https://www.coinex.com/) | +| 18 | ChangeNOW | STEEM | [changenow.io](https://changenow.io/) | +| 19 | CoinDCX | STEEM | [coindcx.com](https://coindcx.com/) | +| 20 | ONUS Pro | STEEM | [onus.pro](https://goonus.io/) | +| 21 | Pionex | STEEM | [pionex.com](https://www.pionex.com/) | ^ ## What is the reward pool? @@ -721,25 +756,18 @@ The price of STEEM is based on the supply and demand of the token, as determined ^ ## How do I get more Steem Power? -With STEEM tokens in your wallet, click "Power Up" to turn them into Steem Power. If you have Steem Dollars, you can convert them to STEEM from your wallet, and then power up the STEEM. +With **STEEM tokens** in your wallet, you can click **“Power Up”** to convert them into **Steem Power**. +If you hold **Steem Dollars (SBD)**, you may first convert them into **STEEM** within your wallet and then power up the STEEM, or alternatively, you can directly purchase STEEM through the internal market. If you do not already have STEEM or Steem Dollars in your wallet, they can be obtained from the exchanges that support these tokens. -If you don’t already have STEEM or Steem Dollars in your wallet, you can purchase them using bitcoin (BTC), Ether (ETH), Litecoin (LTC), or BitShares (BTS) tokens. You may purchase BTC on various exchanges, such as Coinbase.com or Localbitcoins.com. +Bitcoin can also be exchanged for **STEEM** on external markets such as [Binance](https://www.binance.com/), [Upbit](https://upbit.com/), [CoinEx](https://www.coinex.com/), [Poloniex](https://poloniex.com/), and [ChangeNOW](https://changenow.io/). -To buy: -- Click "Buy Steem" from the main menu in the top right corner of steemit.com, or from your wallet. -- Select the currency to deposit, and enter the amount of that currency you wish to use. -- Enter your Steemit account name (without the @) for "Your receive address". -- Click the "Get Deposit Address" button. -- Send the currency to the provided address. - -bitcoin can also be exchanged for STEEM on external markets such as [Binance](https://www.binance.com/), [Bithumb](https://www.bithumb.com/), [BitShares](https://wallet.bitshares.org/), [Bittrex](https://bittrex.com), [Changelly](https://changelly.com), [GOBADA](https://www.gobaba.com/), [HitBTC](https://hitbtc.com/), [Huobi](https://www.huobi.pro/), [LocalBitcoinCash](https://www.localbitcoincash.org/), [Poloniex](https://poloniex.com), [Shapeshift.io](https://shapeshift.io), [UpBit](https://upbit.com/), and [Yensesa](https://yensesa.com). ^ ## How long does it take STEEM or Steem Power that I purchased to show up in my account? Transactions on the Steem blockchain typically only take about three seconds to process, but when you are purchasing the STEEM tokens using bitcoin or some other token, then the transaction must wait for the transaction to be confirmed on the other network. This can take several hours, and sometimes even days. -If you paid using bitcoin, the third party website bitcoinfees.21.co can estimate the approximate wait time of the transaction based on the fees that were paid. The third party website blockchain.info will lookup the fees that were paid on a specific blockchain transaction. +If you paid using bitcoin, the third party website btc.network can estimate the approximate wait time of the transaction based on the fees that were paid. The third party website blockchain.info will lookup the fees that were paid on a specific blockchain transaction. ^ ## What is powering up and down? @@ -748,6 +776,13 @@ If you paid using bitcoin, the third party website ^ +## What is a withdraw route? + +A withdraw route on Steem is simply an instruction on your Steem account that tells the blockchain where your weekly power down payouts should go. When Steem Power (SP) is powered down, the withdrawn amount is divided according to the configured routes, if they exist, and each destination may receive either liquid STEEM or additional SP via automatic power up. Users can also remove routes at any time, regardless of whether the power down process is in progress. + +The number of permissible routes is defined by the blockchain. The account holder can allocate up to a total of 100% across all routes. If the configured routes sum to 100%, the entire payout is redirected to designated recipients; if the sum is below 100%, the remaining percentage normally returns to the owner’s account as STEEM, unless specified otherwise. + ^ ## What do the dollar amounts for pending payouts represent? @@ -791,7 +826,7 @@ You can exchange them. Visit the internal Market, found in the main menu. There ^ ## What is a MVEST? -A VEST is a unit of measurement for Steem Power. A MVEST is one million VESTS. The amount of Steem Power in one MVEST can be found on steemdb.io or SteemScan +A VEST is a unit of measurement for Steem Power. A MVEST is one million VESTS. The amount of Steem Power in one MVEST can be found on steemscan.com as `steem_per_mvests`. or steemdb.io ^ ## Can I sell goods and services on Steemit? @@ -810,15 +845,6 @@ It is recommended that you withdraw a small amount first, to verify it works bef #### Sell Steem Dollars via Poloniex https://steemit.com/steemit/@ash/steemit-how-to-sell-steem-dollars-via-poloniex-newbie-friendly -#### Withdraw Steem Dollars to a Bitcoin address -https://steemit.com/steem-help/@piedpiper/how-to-withdraw-your-steem-dollars-in-less-that-a-minute - -#### Convert Steem Dollars to a country’s currency and withdraw to a bank account -https://steemit.com/tutorial/@beanz/how-to-get-my-usdteemit-money-into-my-bank-account - -#### Convert STEEM to many other cryptocurrencies via ShapeShift -https://steemit.com/steemit/@shapeshiftio/official-announcement-shapeshift-has-added-steem-to-the-exchange - ^ ## Will I get a 1099 from Steemit? @@ -867,7 +893,10 @@ Upvotes and downvotes use the same amount of voting power. ^ ## Where can I check my voting power? -You can view your current voting power using third party tools such as https://steemdb.io/@youraccount or https://steemscan.com/account/youraccount +You can view your current voting power using third party tools such as: + +https://steemscan.com/account/youraccount +https://steemdb.io/@youraccount ^ ## What determines how much of the curation reward goes to the author versus curators? @@ -1047,8 +1076,9 @@ Every user has a limited amount of bandwidth to use each week. The more transact Normally everyone's bandwidth allowance is quite high, and users are able to use the network freely without any interruptions. Sometimes when the blockchain becomes busy however (due to heavy use), everyone's individual allowances may go down until the network becomes less busy. You can check how much bandwidth you currently have based on the current limit at: -https://steemdb.io/@youraccount -https://steemscan.com/account/youraccount + +https://steemscan.com/account/youraccount +https://steemdb.io/@youraccount If users are below their bandwidth limit, they will be unable to transact with the blockchain until their bandwidth recharges or their limit is raised. @@ -1087,8 +1117,9 @@ The Steem blockchain schedules witnesses to produce a new block every 3 seconds. ^ ## Is there a way to see the raw data that is stored in the blockchain? -Yes. The blockchain data can be viewed in different ways with third-party tools such as steemdb.io or SteemScan - +Yes. The blockchain data can be viewed in different ways with third-party tools such as +steemscan.com, and +steemdb.io. ^ ## Where can I find the information for the official launch of the blockchain? @@ -1257,7 +1288,7 @@ The Steem blockchain requires a set of people to create blocks and uses a consen ^ ## How can I vote for witnesses? -Visit https://steemit.com/~witnesses. +Visit https://steemitwallet.com/~witnesses, or https://steemitwallet.com/@youraccount/witnesses. ^ ## How many witnesses can I vote for? @@ -1279,7 +1310,7 @@ The SPS (DAO) is funded by 10% of the annual token inflation. These funds are he ^ ## How to create or cancel a proposal? -To submit a proposal to the Steem DAO, community members must complete the official form available through the Steemit Wallet interface. Required fields include the proposal title, daily requested amount in SBD, start and end dates, a valid proposal permlink (linking to a post that outlines the proposal), the creator’s username, and the intended receiver’s username. +To submit a proposal to the Steem DAO, community members must complete the official form available through the Steemit Wallet interface or their account dashboard. The required fields include the proposal title, the daily requested amount in SBD, the start and end dates, a valid proposal permlink (linking to the post that outlines the proposal), the creator’s username, and the intended receiver’s username. Proposal creation is subject to a submission fee. The exact amount is defined by the blockchain protocol and is displayed during the proposal creation process. Once submitted, the proposal becomes active and open for voting by Steem Power holders. @@ -1306,7 +1337,7 @@ No. Once submitted to the DAO, a proposal cannot be edited or updated. It can on ^ ## Where can community members view active proposals? -Active and votable proposals can be viewed on the official Steemit Wallet interface. +Active and votable proposals can be viewed on the official Steemit Wallet interface or on an account’s dashboard. ^ @@ -1314,7 +1345,6 @@ Active and votable proposals can be viewed on the official What third-party tools are there for Steemit? - ^ ## Is there an official Steemit Facebook page? @@ -1344,10 +1374,10 @@ https://steem.io/SteemWhitePaper.pdf ## Third Party References and User Links -Binance, bitcoinfees, Bitcointalk, Bithumb, BitShares, Bittrex, blockchain.info, Busy.org, Changelly, @cheetah, Coinbase, DTube, GOBADA, HitBTC, Huobi, LocalBitcoinCash, Localbitcoins, Markdown Cheatsheet, Pexels, Pixabay, Poloniex, Postimage, Shapeshift.io, Steemcleaners, SteemCreate, steemd, SteemStats, The Steemit Shop, UpBit, Utopian.io, Vessel, and Yensesa are third party applications/services, and are not owned or maintained by Steemit, Inc. Their listing here, as well as any other third party applications or websites that are listed, does not constitute and endorsement or recommendation on behalf of Steemit, Inc. +Binance, bitcoinfees, Bitcointalk, Bithumb, blockchain.info, DTube, Huobi (HTX), Markdown Cheatsheet, Pexels, Pixabay, Poloniex, Postimage, SteemCreate, SteemStats, UpBit, Vessel, WhiteBIT, MEXC, Gate, WEEX, BitKan, DigiFinex, CoinEx, ProBit Global, ChangeNOW, CoinDCX, Bitexen, ZKE, CoinUp.io, ONUS Pro, Koinbay, Pionex, SteemPro, SteemX, Boy, UPVU, as well as other third-party applications and services, are not owned or maintained by Steemit, Inc. Their listing here, as well as any other third-party applications or websites that are referenced, does not constitute an endorsement or recommendation on behalf of Steemit, Inc. All links to user posts were created by our users and do not necessarily represent the views of Steemit, Inc. or its management. Their listing here does not constitute and endorsement or recommendation on behalf of Steemit, Inc. Please use the third party tools and content at your own risk. -^ +^ \ No newline at end of file diff --git a/src/app/help/en/welcome.md b/src/app/help/en/welcome.md index 427e65d89..9ba9f1015 100644 --- a/src/app/help/en/welcome.md +++ b/src/app/help/en/welcome.md @@ -272,7 +272,6 @@ Don't get discouraged if you don't earn much at first. Keep up the good work! ## Users to Follow - @steemitblog - Official Steemit Announcements -- @ned - Ned Scott, CEO and Co-Founder of Steemit, Inc. ^ ## Other Resources @@ -281,8 +280,8 @@ Don't get discouraged if you don't earn much at first. Keep up the good work! - [Bluepaper](https://steem.io/steem-bluepaper.pdf) - Explanation of how the platform works - [The Steem Whitepaper](https://steem.io/SteemWhitePaper.pdf) - Technical details of the Steem blockchain - [Apps Built on Steem] will come up soon - Directory of apps, sites and tools built by Steem community -- [Steem Blockchain Explorer](https://Steemdb.io/) - Analysis pages for the Steem blockchain data -- [Steem Blockchain Explorer](https://steemscan.com/) - Analysis pages for the Steem blockchain data +- [SteemDB](https://Steemdb.io/) - Analysis pages for the Steem blockchain data +- [SteemScan](https://steemscan.com/) - Analysis pages for the Steem blockchain data ^ ## Get Help diff --git a/src/app/locales/en.json b/src/app/locales/en.json index a9ca302bd..551109a56 100644 --- a/src/app/locales/en.json +++ b/src/app/locales/en.json @@ -85,6 +85,7 @@ "previous": "Previous", "price": "Price", "print": "Print", + "proposals": "Proposals", "promote": "Promote", "promoted": "Promoted", "re": "RE", @@ -597,7 +598,9 @@ "witness_proxy_current": "Your current proxy is", "witness_proxy_set": "Set proxy", "witness_proxy_clear": "Clear proxy", - "proxy_update_error": "Your proxy was not updated" + "proxy_update_error": "Your proxy was not updated", + "witnesses_description": "Witnesses are trusted accounts that run and secure the Steem blockchain.", + "witnesses_learn_more": "Learn more in the FAQ." }, "votesandcomments_jsx": { "no_responses_yet_click_to_respond": @@ -721,7 +724,11 @@ "This is a price feed conversion. The 3.5 day delay is necessary to prevent abuse from gaming the price feed average.", "convert_to_LIQUID_TOKEN": "Convert to %(LIQUID_TOKEN)s", "DEBT_TOKEN_will_be_unavailable": - "The conversion process takes 3.5 days to complete and cannot be canceled once initiated. During this period, the specified %(DEBT_TOKEN)s (SBD) amount becomes immediately unavailable. The final amount of STEEM received is not fixed, as it is determined by the median witness price feed averaged over the entire 3.5-day window. As a result, conversions carry risk, particularly during periods of price volatility. Due to this, exercising due diligence before initiating a conversion is strongly advised." + "The conversion process takes 3.5 days to complete and cannot be canceled once initiated. During this period, the specified %(DEBT_TOKEN)s (SBD) amount becomes immediately unavailable. The final amount of STEEM received is not fixed, as it is determined by the median witness price feed averaged over the entire 3.5-day window. As a result, conversions carry risk, particularly during periods of price volatility. Due to this, exercising due diligence before initiating a conversion is strongly advised.", + "current_conversions": "Current Conversions", + "no_conversion_data": "No conversion data available.", + "id": "ID", + "request_id": "Request ID" }, "tips_js": { "liquid_token": @@ -1040,6 +1047,8 @@ "power_down": "Power Down", "delegate": "Delegate", "advanced_routes": "Advanced Routes", + "set_advanced_routes": "Set Withdraw Route", + "advanced_routes_visit_faq":"Set or update withdraw routes. Visit the FAQ for more details.", "route_settings": "Route Settings", "market": "Market", "convert_to_LIQUID_TOKEN": "Convert to %(LIQUID_TOKEN)s", @@ -1055,6 +1064,8 @@ "estimated_account_value": "Estimated Account Value", "next_power_down_is_scheduled_to_happen": "The next power down is scheduled to happen", + "routed_to_single": "100%% routed to the following account: ", + "view_all_withdraw_routes": "View all withdraw routes", "transfers_are_temporary_disabled": "Transfers are temporary disabled.", "history": "HISTORY", "redeem_rewards": "Redeem Rewards (Transfer to Balance)", @@ -1063,6 +1074,26 @@ "incorrect_account_format": "Incorrect account format", "confirm_route_setup": "Are you sure you want to set up %(percent)s%% of your Steem Power withdrawal to go to @%(account)s?" }, + "advanced_routes": { + "from": "From", + "to": "To", + "percent": "Percentage", + "current_routes": "Current Withdraw Routes (%(accounts_number)s left)", + "current_withdraw_route": "Current Withdraw Routes", + "account": "Account", + "percentage": "Percentage", + "receive": "Receive As", + "receive_amount": "Receive", + "remove": "Remove", + "steem_power": "Steem Power", + "steem": "STEEM", + "no_routes": "No withdraw routes are set.", + "not_remaining_routes": "No additional routes can be added due to a blockchain limitation.", + "not_remaining_percentage": "All 100%% has already been distributed. No additional routes can be added.", + "withdraw_routes_detected": "Active withdraw routes detected on your account. Review now to prevent assets loss.", + "take_action": "Take Action", + "acknowledge_routes": "I have read and acknowledge the current withdraw routes." + }, "powerdown_jsx": { "power_down": "Power Down", "amount": "Amount", @@ -1075,6 +1106,12 @@ "Leaving less than %(AMOUNT)s %(VESTING_TOKEN)s in your account is not recommended and can leave your account in a unusable state.", "error": "Unable to power down (ERROR: %(MESSAGE)s)" }, + "powerdown_alert": { + "message": "%(percent)s%% of your Steem Power (%(amount)s SP) is currently being powered down.", + "high_risk_label": "High risk", + "learn_more": "Learn more", + "aria_label": "Power-down alert" + }, "checkloginowner_jsx": { "your_password_permissions_were_reduced": "Your password permissions were reduced", diff --git a/src/app/redux/GlobalReducer.js b/src/app/redux/GlobalReducer.js index 30ec262da..d1f2a7a3d 100644 --- a/src/app/redux/GlobalReducer.js +++ b/src/app/redux/GlobalReducer.js @@ -14,6 +14,7 @@ const RECEIVE_STATE = 'global/RECEIVE_STATE'; const RECEIVE_ACCOUNT = 'global/RECEIVE_ACCOUNT'; const RECEIVE_ACCOUNTS = 'global/RECEIVE_ACCOUNTS'; const UPDATE_ACCOUNT_WITNESS_VOTE = 'global/UPDATE_ACCOUNT_WITNESS_VOTE'; +const UPDATE_ACCOUNT_WITNESS_VOTES = 'global/UPDATE_ACCOUNT_WITNESS_VOTES'; const UPDATE_ACCOUNT_WITNESS_PROXY = 'global/UPDATE_ACCOUNT_WITNESS_PROXY'; const FETCHING_DATA = 'global/FETCHING_DATA'; const RECEIVE_DATA = 'global/RECEIVE_DATA'; @@ -130,6 +131,14 @@ export default function reducer(state = defaultState, action = {}) { ); } + case UPDATE_ACCOUNT_WITNESS_VOTES: { + const { account, witness_votes } = action.payload; + return state.setIn( + ['accounts', account, 'witness_votes'], + Set(witness_votes) + ); + } + case UPDATE_ACCOUNT_WITNESS_PROXY: { const { account, proxy } = payload; return state.setIn(['accounts', account, 'proxy'], proxy); @@ -349,6 +358,11 @@ export const updateAccountWitnessVote = payload => ({ payload, }); +export const updateAccountWitnessVotes = payload => ({ + type: UPDATE_ACCOUNT_WITNESS_VOTES, + payload, +}); + export const updateAccountWitnessProxy = payload => ({ type: UPDATE_ACCOUNT_WITNESS_PROXY, payload, diff --git a/src/app/redux/TransactionReducer.js b/src/app/redux/TransactionReducer.js index 7493cc9b7..2bb2e4ec5 100644 --- a/src/app/redux/TransactionReducer.js +++ b/src/app/redux/TransactionReducer.js @@ -19,6 +19,7 @@ export const UPDATE_PRICES = 'transaction/UPDATE_PRICES'; export const SET_PRICES = 'transaction/SET_PRICES'; // Saga-related export const RECOVER_ACCOUNT = 'transaction/RECOVER_ACCOUNT'; +export const FETCH_ACCOUNT_WITNESS_VOTES = 'transaction/FETCH_ACCOUNT_WITNESS_VOTES'; const defaultState = fromJS({ operations: [], status: { key: '', error: false, busy: false }, @@ -271,3 +272,8 @@ export const setPrices = payload => ({ type: SET_PRICES, payload, }); + +export const fetchAccountWitnessVotes = payload => ({ + type: FETCH_ACCOUNT_WITNESS_VOTES, + payload, +}); diff --git a/src/app/redux/TransactionSaga.js b/src/app/redux/TransactionSaga.js index 53239fe79..38ac0c9a9 100644 --- a/src/app/redux/TransactionSaga.js +++ b/src/app/redux/TransactionSaga.js @@ -33,6 +33,7 @@ export const transactionWatches = [ takeEvery(transactionActions.UPDATE_AUTHORITIES, updateAuthorities), takeEvery(transactionActions.RECOVER_ACCOUNT, recoverAccount), takeEvery(transactionActions.UPDATE_PRICES, updatePricesSaga), + takeEvery(transactionActions.FETCH_ACCOUNT_WITNESS_VOTES, refreshAccountWitnessVotes), ]; const hook = { @@ -169,6 +170,17 @@ function* error_account_witness_vote({ ); } +export function* refreshAccountWitnessVotes({ payload: { accountName } }) { + try { + const [account] = yield call([api, api.getAccountsAsync], [accountName]); + if (account) { + yield put(globalActions.updateAccountWitnessVotes({account: account.name, witness_votes: account.witness_votes || []})); + } + } catch (err) { + console.error('Error refreshing witness_votes:', err); + } +} + /** Keys, username, and password are not needed for the initial call. This will check the login and may trigger an action to prompt for the password / key. */ export function* broadcastOperation({ payload: { @@ -746,3 +758,4 @@ export function* updateAuthorities({ }; yield call(broadcastOperation, { payload }); } + diff --git a/src/app/redux/TransactionSaga.test.js b/src/app/redux/TransactionSaga.test.js index 0c0121c94..3ce195307 100644 --- a/src/app/redux/TransactionSaga.test.js +++ b/src/app/redux/TransactionSaga.test.js @@ -12,7 +12,8 @@ import { transactionWatches, broadcastOperation, updateAuthorities, - updatePricesSaga + updatePricesSaga, + refreshAccountWitnessVotes } from './TransactionSaga'; import { DEBT_TICKER } from 'app/client_config'; @@ -61,6 +62,7 @@ describe('TransactionSaga', () => { transactionActions.UPDATE_PRICES, updatePricesSaga ), + takeEvery(transactionActions.FETCH_ACCOUNT_WITNESS_VOTES, refreshAccountWitnessVotes), ]); }); }); diff --git a/src/app/redux/UserSaga.js b/src/app/redux/UserSaga.js index 8c7968e0c..1410b051a 100644 --- a/src/app/redux/UserSaga.js +++ b/src/app/redux/UserSaga.js @@ -86,7 +86,7 @@ export function* getWithdrawRoutes(action) { const highSecurityPages = [ /\/market/, - /\/@.+\/(transfers|permissions|password|communities|delegations)/, + /\/@.+\/(transfers|permissions|password|communities|delegations|proposals|witnesses)/, /\/~witnesses/, /\/proposals/, ]; diff --git a/src/app/utils/StateFunctions.js b/src/app/utils/StateFunctions.js index f59b6f2b5..af3dbac05 100644 --- a/src/app/utils/StateFunctions.js +++ b/src/app/utils/StateFunctions.js @@ -124,6 +124,13 @@ export function isFetchingOrRecentlyUpdated(global_status, order, category) { return false; } +export function getRedirectPagePath(location) { + if (location.match(/^\/(@[\w\.\d-]+)\/(witnesses)\/?$/)) { + return { location: '/~witnesses', search_account: location.replace('/witnesses', '/transfers') }; + } + return { location, search_account: false }; +} + export function contentStats(content) { if (!content) return {}; if (!(content instanceof Map)) content = fromJS(content); diff --git a/src/app/utils/VerifiedExchangeList.js b/src/app/utils/VerifiedExchangeList.js index 43379f9b8..db47311a9 100644 --- a/src/app/utils/VerifiedExchangeList.js +++ b/src/app/utils/VerifiedExchangeList.js @@ -2,7 +2,6 @@ const list = ` bittrex blocktrades changelly -deepcrypto8 gopax-deposit hitbtc-exchange poloniex @@ -17,7 +16,6 @@ gateiodeposit bingxsteem probitsteem coinexofficial -whitebit rudex cold.dunamu binance-hot2 @@ -31,7 +29,6 @@ hot5.dunamu hot1.dunamu polopw-01 htx-s8vznt82 -bittrex user.dunamu deepcrypto8 bithumbrecv1 diff --git a/src/app/utils/steemApi.js b/src/app/utils/steemApi.js index 17e96118f..0bfda856b 100644 --- a/src/app/utils/steemApi.js +++ b/src/app/utils/steemApi.js @@ -1,13 +1,19 @@ import { api } from '@steemit/steem-js'; - +import { getRedirectPagePath } from './StateFunctions' import stateCleaner from 'app/redux/stateCleaner'; +import Witnesses from '../components/modules/Witnesses'; export async function getStateAsync(url) { // strip off query string const path = url.split('?')[0]; - - const raw = await api.getStateAsync(path); - + let raw; + if (path.match(/^\/(@[\w\.\d-]+)\/(witnesses)\/?$/)) { + raw = await api.getStateAsync(path.replace('/witnesses', '/transfers')); + let witnesses = await api.getStateAsync('/~witnesses'); + raw.witnesses = witnesses ? witnesses.witnesses : raw.witnesses + } else { + raw = await api.getStateAsync(path); + } const cleansed = stateCleaner(raw); return cleansed; From 8ca1853720e4b18a84e84910ac399aaf8954ee04 Mon Sep 17 00:00:00 2001 From: Victor Von Frankenstein <6106483+VictorVonFrankenstein@users.noreply.github.com> Date: Fri, 28 Nov 2025 21:27:39 +0100 Subject: [PATCH 2/5] fix(ui): switch SteemScan links to steemdb.io for history and block lookups --- src/app/client_config.js | 4 ++-- src/app/components/cards/TransferHistoryRow/index.jsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/client_config.js b/src/app/client_config.js index ab8e1270c..190d7b289 100644 --- a/src/app/client_config.js +++ b/src/app/client_config.js @@ -53,5 +53,5 @@ export const SITE_DESCRIPTION = export const SUPPORT_EMAIL = 'support@' + APP_DOMAIN; // External links -export const STEEMSCAN_BLOCK_URL = "https://steemscan.com/block"; -export const STEEMSCAN_TRANSACTION_URL = "https://steemscan.com/transaction"; +export const STEEMDB_BLOCK_URL = "https://steemdb.io/block"; +export const STEEMDB_TRANSACTION_URL = "https://steemdb.io/tx"; \ No newline at end of file diff --git a/src/app/components/cards/TransferHistoryRow/index.jsx b/src/app/components/cards/TransferHistoryRow/index.jsx index 0da74a47a..5b97f50fd 100644 --- a/src/app/components/cards/TransferHistoryRow/index.jsx +++ b/src/app/components/cards/TransferHistoryRow/index.jsx @@ -6,7 +6,7 @@ import Memo from 'app/components/elements/Memo'; import { numberWithCommas, vestsToSp } from 'app/utils/StateFunctions'; import tt from 'counterpart'; import GDPRUserList from 'app/utils/GDPRUserList'; -import { STEEMSCAN_BLOCK_URL, STEEMSCAN_TRANSACTION_URL } from 'app/client_config'; +import { STEEMDB_BLOCK_URL, STEEMDB_TRANSACTION_URL } from 'app/client_config'; class TransferHistoryRow extends React.Component { render() { @@ -293,10 +293,10 @@ class TransferHistoryRow extends React.Component { {block && (
    - Block: {block} + Block: {block}
    )} {(trx && trx !== '0000000000000000000000000000000000000000') && (
    - TxID: {trx} + TxID: {trx}
    )} Date: Tue, 26 May 2026 12:03:22 -0400 Subject: [PATCH 3/5] feat(core): add Steem tools to the wallet --- src/app/ResolveRoute.js | 2 +- src/app/ResolveRoute.test.js | 2 +- src/app/assets/stylesheets/_themes.scss | 12 + src/app/components/all.scss | 2 + .../ConfirmAuthorityManagement/index.jsx | 76 ++ .../elements/ConfirmCreateWitness/index.jsx | 87 ++ .../elements/ConfirmDisableWitness/index.jsx | 42 + src/app/components/elements/PdfDownload.jsx | 625 +++++++++ .../SteemToolsContent/SteemToolsContent.jsx | 187 +++ .../SteemToolsContent/SteemToolsContent.scss | 341 +++++ .../SteemToolsContent/SteemToolsMenu.jsx | 121 ++ .../SteemToolsContent/SteemToolsMenu.scss | 222 ++++ .../sections/AuthorityManagement.jsx | 1145 +++++++++++++++++ .../AuthorityManagementSmallTable.jsx | 103 ++ .../sections/ChangeRecovery.jsx | 514 ++++++++ .../sections/ClaimDiscounted.jsx | 820 ++++++++++++ .../sections/ConfirmClaimAct.jsx | 87 ++ .../sections/ConfirmCreateAccount.jsx | 55 + .../sections/CreateAccount.jsx | 794 ++++++++++++ .../sections/CreateWitness.jsx | 452 +++++++ .../sections/DeclineVoting.jsx | 685 ++++++++++ .../sections/DisableWitness.jsx | 403 ++++++ .../sections/GenerateBrainKeys.jsx | 403 ++++++ .../sections/KeyGeneration.jsx | 334 +++++ .../sections/PublishPriceFeed.jsx | 639 +++++++++ .../sections/UpdateProxy.jsx | 457 +++++++ .../SteemToolsContent/sections/steem_words.js | 1 + src/app/components/elements/WalletSubMenu.jsx | 10 + .../modules/ConfirmTransactionForm.jsx | 17 +- src/app/components/modules/UserWallet.jsx | 282 +++- src/app/components/pages/UserProfile.jsx | 18 + src/app/help/en/faq.md | 129 +- src/app/locales/en.json | 326 ++++- src/app/redux/AuthSaga.js | 6 + src/app/redux/FetchDataSaga.js | 2 + src/app/redux/TransactionReducer.js | 13 + src/app/redux/TransactionSaga.js | 110 ++ src/app/redux/UserSaga.js | 2 +- src/shared/UniversalRender.jsx | 2 + 39 files changed, 9445 insertions(+), 83 deletions(-) create mode 100644 src/app/components/elements/ConfirmAuthorityManagement/index.jsx create mode 100644 src/app/components/elements/ConfirmCreateWitness/index.jsx create mode 100644 src/app/components/elements/ConfirmDisableWitness/index.jsx create mode 100644 src/app/components/elements/PdfDownload.jsx create mode 100644 src/app/components/elements/SteemToolsContent/SteemToolsContent.jsx create mode 100644 src/app/components/elements/SteemToolsContent/SteemToolsContent.scss create mode 100644 src/app/components/elements/SteemToolsContent/SteemToolsMenu.jsx create mode 100644 src/app/components/elements/SteemToolsContent/SteemToolsMenu.scss create mode 100644 src/app/components/elements/SteemToolsContent/sections/AuthorityManagement.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/AuthorityManagementSmallTable.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/ChangeRecovery.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/ClaimDiscounted.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/ConfirmClaimAct.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/CreateAccount.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/CreateWitness.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/DeclineVoting.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/DisableWitness.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/GenerateBrainKeys.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/KeyGeneration.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/PublishPriceFeed.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/UpdateProxy.jsx create mode 100644 src/app/components/elements/SteemToolsContent/sections/steem_words.js diff --git a/src/app/ResolveRoute.js b/src/app/ResolveRoute.js index 1af9bdd2d..08849402f 100644 --- a/src/app/ResolveRoute.js +++ b/src/app/ResolveRoute.js @@ -3,7 +3,7 @@ import GDPRUserList from './utils/GDPRUserList'; export const routeRegex = { UserProfile1: /^\/(@[\w\.\d-]+)\/?$/, - UserProfile2: /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations|proposals|witnesses)\/?$/, + UserProfile2: /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations|proposals|witnesses|steem_tools)\/?$/, }; export default function resolveRoute(path) { diff --git a/src/app/ResolveRoute.test.js b/src/app/ResolveRoute.test.js index a6e2ef173..141a068e3 100644 --- a/src/app/ResolveRoute.test.js +++ b/src/app/ResolveRoute.test.js @@ -7,7 +7,7 @@ describe('routeRegex', () => { ['UserProfile1', /^\/(@[\w\.\d-]+)\/?$/], [ 'UserProfile2', - /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations|proposals|witnesses)\/?$/, + /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations|proposals|witnesses|steem_tools)\/?$/, ], ]; diff --git a/src/app/assets/stylesheets/_themes.scss b/src/app/assets/stylesheets/_themes.scss index 0b13dc2ac..8af017884 100755 --- a/src/app/assets/stylesheets/_themes.scss +++ b/src/app/assets/stylesheets/_themes.scss @@ -12,6 +12,7 @@ $themes: ( backgroundColorDanger: $alert-color, backgroundColorSecondary: $color-white, moduleBackgroundColor: $color-white, + sectionBackgroundColor: $color-white, menuBackgroundColor: $color-background-dark, moduleMediumBackgroundColor: $color-white, navBackgroundColor: $color-white, @@ -41,6 +42,9 @@ $themes: ( buttonTextShadow: 0 1px 0 rgba(0,0,0,0.20), buttonTextHover: $color-text-white, buttonBoxShadow: $color-transparent, + overlayBackground: rgba(0, 0, 0, 0.35), + codeBlockBackground: #1e1e1e, + codeBlockText: #d4d4d4, ), light: ( colorAccent: $color-teal, @@ -55,6 +59,7 @@ $themes: ( backgroundColorDanger: $alert-color, backgroundColorSecondary: $color-white, moduleBackgroundColor: $color-white, + sectionBackgroundColor: $color-white, menuBackgroundColor: $color-background-dark, moduleMediumBackgroundColor: $color-transparent, navBackgroundColor: $color-white, @@ -85,6 +90,9 @@ $themes: ( buttonTextHover: $color-white, buttonBoxShadow: $color-teal, buttonBoxShadowHover: $color-blue-black, + overlayBackground: rgba(0, 0, 0, 0.35), + codeBlockBackground: #1e1e1e, + codeBlockText: #d4d4d4, ), dark: ( colorAccent: $color-teal, @@ -98,6 +106,7 @@ $themes: ( backgroundColorDanger: $alert-color, backgroundColorSecondary: $color-blue-dark, moduleBackgroundColor: $color-background-dark, + sectionBackgroundColor: $color-blue-dark, backgroundTransparent: transparent, menuBackgroundColor: $color-blue-dark, moduleMediumBackgroundColor: $color-background-dark, @@ -130,6 +139,9 @@ $themes: ( buttonBoxShadow: $color-teal, buttonBoxShadowHover: $color-white, inputPriceWarning: rgba(255, 153, 0, 0.83), + overlayBackground: rgba(0, 0, 0, 0.35), + codeBlockBackground: #10151b, + codeBlockText: #d4d4d4, ), ); diff --git a/src/app/components/all.scss b/src/app/components/all.scss index 15c5e2d55..f646f7b97 100644 --- a/src/app/components/all.scss +++ b/src/app/components/all.scss @@ -31,6 +31,8 @@ @import './elements/ProposalCreatorModal'; @import './elements/RouteSettings'; @import './elements/WithdrawRoutesModal'; +@import './elements/SteemToolsContent/SteemToolsContent'; +@import './elements/SteemToolsContent/SteemToolsMenu'; // modules @import './modules/Header/styles'; diff --git a/src/app/components/elements/ConfirmAuthorityManagement/index.jsx b/src/app/components/elements/ConfirmAuthorityManagement/index.jsx new file mode 100644 index 000000000..218ab32b6 --- /dev/null +++ b/src/app/components/elements/ConfirmAuthorityManagement/index.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +const LABELS = { + action: () => + tt('steem_tools.authority_management.confirm_action', { + fallback: 'Action', + }), + account: () => + tt('steem_tools.authority_management.account_to_modify', { + fallback: 'Account', + }), + authority_type: () => + tt('steem_tools.authority_management.authority_type', { + fallback: 'Authority Type', + }), + target_account: () => + tt('steem_tools.authority_management.add_account_authority', { + fallback: 'Account Authority', + }), + weight: () => + tt('steem_tools.authority_management.weight', { + fallback: 'Weight', + }), + weight_threshold: () => + tt('steem_tools.authority_management.weight_threshold', { + fallback: 'Weight Threshold', + }), +}; + +const ConfirmAuthorityManagement = ({ operation }) => { + const fields = Object.keys(operation).filter((key) => { + const value = operation[key]; + return value !== undefined && value !== null && value !== ''; + }); + + return ( +
    + {fields.map((key, index) => ( +
    + + {LABELS[key] ? LABELS[key]() : key} + + +
    + ))} +
    + ); +}; + +ConfirmAuthorityManagement.propTypes = { + operation: PropTypes.shape({ + action: PropTypes.string, + account: PropTypes.string, + authority_type: PropTypes.string, + target_account: PropTypes.string, + weight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + weight_threshold: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }).isRequired, +}; + +export default ConfirmAuthorityManagement; diff --git a/src/app/components/elements/ConfirmCreateWitness/index.jsx b/src/app/components/elements/ConfirmCreateWitness/index.jsx new file mode 100644 index 000000000..c92f9b59e --- /dev/null +++ b/src/app/components/elements/ConfirmCreateWitness/index.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +const ConfirmCreateWitness = ({ operation }) => { + const { + owner, + url, + new_signing_key, + account_creation_fee, + maximum_block_size, + sbd_interest_rate, + mode, + } = operation; + + const modeLabel = + mode === 'update' + ? tt('steem_tools.create_witness.confirm_mode_update') + : tt('steem_tools.create_witness.confirm_mode_create'); + + return ( +
    +
    + + {tt('steem_tools.create_witness.witness_account')} + + +
    + +
    + + {tt('steem_tools.create_witness.confirm_mode')} + + +
    + +
    + + {tt('steem_tools.create_witness.block_signing_key')} + + +
    + +
    + + {tt('steem_tools.create_witness.witness_url')} + + +
    + +
    + + {tt('steem_tools.create_witness.account_creation_fee')} + + +
    + +
    + + {tt('steem_tools.create_witness.maximum_block_size')} + + +
    + +
    + + {tt('steem_tools.create_witness.sbd_interest_rate')} + + +
    +
    + ); +}; + +ConfirmCreateWitness.propTypes = { + operation: PropTypes.shape({ + owner: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + new_signing_key: PropTypes.string.isRequired, + account_creation_fee: PropTypes.string.isRequired, + maximum_block_size: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + sbd_interest_rate: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + mode: PropTypes.oneOf(['create', 'update']).isRequired, + }).isRequired, +}; + +export default ConfirmCreateWitness; diff --git a/src/app/components/elements/ConfirmDisableWitness/index.jsx b/src/app/components/elements/ConfirmDisableWitness/index.jsx new file mode 100644 index 000000000..74ef74e79 --- /dev/null +++ b/src/app/components/elements/ConfirmDisableWitness/index.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +const ConfirmDisableWitness = ({ operation }) => { + const { owner, current_signing_key, new_signing_key } = operation; + + return ( +
    +
    + + {tt('steem_tools.disable_witness.witness_account')} + + +
    + +
    + + {tt('steem_tools.disable_witness.current_signing_key')} + + +
    + +
    + + {tt('steem_tools.disable_witness.null_signing_key')} + + +
    +
    + ); +}; + +ConfirmDisableWitness.propTypes = { + operation: PropTypes.shape({ + owner: PropTypes.string.isRequired, + current_signing_key: PropTypes.string, + new_signing_key: PropTypes.string.isRequired, + }).isRequired, +}; + +export default ConfirmDisableWitness; \ No newline at end of file diff --git a/src/app/components/elements/PdfDownload.jsx b/src/app/components/elements/PdfDownload.jsx new file mode 100644 index 000000000..c2ec7479f --- /dev/null +++ b/src/app/components/elements/PdfDownload.jsx @@ -0,0 +1,625 @@ +import React from 'react'; +import QRious from 'qrious'; +import jsPDF from 'jspdf'; +import RobotoRegular from 'app/assets/fonts/Roboto-Regular.ttf'; +import RobotoBold from 'app/assets/fonts/Roboto-Bold.ttf'; +import RobotoMonoRegular from 'app/assets/fonts/RobotoMono-Regular.ttf'; +import pdfLogoSvg from 'app/assets/images/pdf-logo.svg'; + +function image2canvas(image, bgcolor) { + var canvas = document.createElement('canvas'); + canvas.width = image.width * 32; + canvas.height = image.height * 32; + + var ctx = canvas.getContext('2d'); + ctx.fillStyle = bgcolor; + ctx.fillRect(0.0, 0.0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + + return canvas; +} + +class PdfDownload extends React.Component { + constructor(props) { + super(props); + this.renderPdf = this.renderPdf.bind(this); + } + + componentDidUpdate(prevProps) { + if (this.props.dlPdf && !prevProps.dlPdf) { + try { + var keys = this.props.keys; + var name = this.props.name; + var filename = 'Keys for @' + name + '.pdf'; + this.renderPdf(keys, name, filename).save(filename); + } catch (error) { + console.error(error); + } + if (this.props.resetDlPdf) { + this.props.resetDlPdf(); + } + } + } + + drawFilledRect(ctx, x, y, w, h, options) { + ctx.setDrawColor(0); + ctx.setFillColor(options.color); + ctx.rect(x, y, w, h, 'F'); + } + + drawImageFromCanvas(ctx, selector, x, y, w, h, bgcolor) { + var el = document.querySelector(selector); + if (!el) return; + var canvas = image2canvas(el, bgcolor); + ctx.addImage(canvas, 'JPEG', x, y, w, h); + } + + drawQr(ctx, data, x, y, size, bgcolor) { + var canvas = document.createElement('canvas'); + new QRious({ + element: canvas, + size: 250, + value: data, + background: bgcolor, + }); + ctx.addImage(canvas, 'PNG', x, y, size, size); + } + + renderText(ctx, text, options) { + var textLines = ctx + .setFont(options.font) + .setFontSize(options.fontSize * options.scale) + .setTextColor(options.color) + .splitTextToSize(text, options.maxWidth); + ctx.text(textLines, options.x, options.y + options.fontSize); + return textLines.length * options.fontSize * options.lineHeight; + } + + renderPdf(keys, name, filename) { + var widthInches = this.props.widthInches || 8.5; + var lineHeight = 1.2; + var margin = 0.3; + var maxLineWidth = widthInches - margin * 2.0; + var scale = 72; + var qrSize = 1.1; + + var ctx = new jsPDF({ + orientation: 'portrait', + unit: 'in', + lineHeight: lineHeight, + format: 'letter', + }).setProperties({ title: filename }); + + ctx.addFont(RobotoRegular, 'Roboto-Regular', 'normal'); + ctx.addFont(RobotoBold, 'Roboto-Bold', 'normal'); + ctx.addFont(RobotoMonoRegular, 'RobotoMono-Regular', 'normal'); + + var offset = 0.0; + var sectionStart = 0; + var sectionHeight = 0; + + // HEADER + sectionHeight = 1.29; + this.drawFilledRect(ctx, 0.0, 0.0, widthInches, sectionHeight, { + color: '#1f0fd1', + }); + + this.drawImageFromCanvas( + ctx, + '.pdf-logo', + widthInches - margin - 1.9, + 0.36, + 0.98 * 1.8, + 0.3 * 1.8, + '#1F0FD1' + ); + + offset += 0.265; + offset += this.renderText(ctx, 'Keys for @' + name, { + scale: scale, + x: margin, + y: offset, + lineHeight: 1.0, + maxWidth: maxLineWidth, + color: 'white', + fontSize: 0.36, + font: 'Roboto-Bold', + }); + + offset += 0.15; + offset += this.renderText( + ctx, + 'Generated at ' + + new Date() + .toISOString() + .replace(/\.\d{3}/, '') + + ' by steemit.com', + { + scale: scale, + x: margin, + y: offset, + lineHeight: 1.0, + maxWidth: maxLineWidth, + color: 'white', + fontSize: 0.14, + font: 'Roboto-Bold', + } + ); + + offset = sectionStart + sectionHeight; + + // BODY - PRIVATE KEYS INTRO + offset += 0.1; + offset += this.renderText( + ctx, + 'Instead of password based authentication, blockchain accounts ' + + 'have a set of public and private key pairs that are used for ' + + 'authentication as well as the encryption and decryption of ' + + 'data. Do not share this file with anyone.', + { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + // Steemit Account + offset += 0.4; + offset += this.renderText(ctx, 'Your Steemit Private Keys', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.18, + font: 'Roboto-Bold', + }); + offset += 0.1; + + // POSTING KEY + sectionStart = offset; + sectionHeight = qrSize + 0.15 * 2; + this.drawFilledRect(ctx, 0.0, offset, widthInches, sectionHeight, { + color: 'f4f4f4', + }); + + offset += 0.15; + this.drawQr( + ctx, + 'steem://import/wif/' + keys.postingPrivate + '/account/' + name, + margin, + offset, + qrSize, + '#f4f4f4' + ); + + offset += 0.1; + offset += this.renderText(ctx, 'Private Posting Key', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText( + ctx, + 'Used to log in to apps such as Steemit.com and perform social ' + + 'actions such as posting, commenting, and voting.', + { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - (qrSize + 0.1), + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + offset += 0.075; + offset += this.renderText(ctx, keys.postingPrivate, { + scale: scale, + x: margin + qrSize + 0.1, + y: sectionStart + sectionHeight - 0.6, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + offset += 0.2; + offset = sectionStart + sectionHeight; + + // MEMO KEY + sectionStart = offset; + sectionHeight = qrSize + 0.15 * 2; + + offset += 0.15; + this.drawQr( + ctx, + 'steem://import/wif/' + keys.memoPrivate + '/account/' + name, + margin, + offset, + qrSize, + '#ffffff' + ); + + offset += 0.1; + offset += this.renderText(ctx, 'Private Memo Key', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, 'Used to decrypt private transfer memos.', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - (qrSize + 0.1), + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + }); + + offset += 0.075; + offset += this.renderText(ctx, keys.memoPrivate, { + scale: scale, + x: margin + qrSize + 0.1, + y: sectionStart + sectionHeight - 0.6, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + offset += 0.1; + offset = sectionStart + sectionHeight; + + // ACTIVE KEY + sectionStart = offset; + sectionHeight = qrSize + 0.15 * 2; + this.drawFilledRect(ctx, 0.0, offset, widthInches, sectionHeight, { + color: '#f4f4f4', + }); + + offset += 0.15; + this.drawQr( + ctx, + 'steem://import/wif/' + keys.activePrivate + '/account/' + name, + margin, + offset, + qrSize, + '#f4f4f4' + ); + + offset += 0.1; + offset += this.renderText(ctx, 'Private Active Key', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText( + ctx, + 'Used for monetary and wallet related actions, such as ' + + 'transferring tokens or powering STEEM up and down.', + { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - (qrSize + 0.1), + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + offset += 0.075; + offset += this.renderText(ctx, keys.activePrivate, { + scale: scale, + x: margin + qrSize + 0.1, + y: sectionStart + sectionHeight - 0.6, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + offset += 0.6; + + // OWNER KEY + sectionStart = offset; + sectionHeight = qrSize + 0.15 * 2; + + offset += 0.15; + this.drawQr( + ctx, + 'steem://import/wif/' + keys.ownerPrivate + '/account/' + name, + margin, + offset, + qrSize, + '#ffffff' + ); + + offset += 0.1; + offset += this.renderText(ctx, 'Private Owner Key', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - qrSize - 0.1, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText( + ctx, + 'This key is used to reset all your other keys. It is ' + + 'recommended to keep it offline at all times. If your ' + + 'account is compromised, use this key to recover it ' + + 'within 30 days at https://steemitwallet.com.', + { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - (qrSize + 0.1), + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + offset += 0.075; + offset += this.renderText(ctx, keys.ownerPrivate, { + scale: scale, + x: margin + qrSize + 0.1, + y: sectionStart + sectionHeight - 0.6, + lineHeight: lineHeight, + maxWidth: maxLineWidth - qrSize - 0.1, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + offset = sectionStart + sectionHeight; + + // MASTER PASSWORD + sectionHeight = 1; + sectionStart = offset; + this.drawFilledRect(ctx, 0.0, offset, widthInches, sectionHeight, { + color: '#f4f4f4', + }); + + offset += 0.2; + offset += this.renderText(ctx, 'Master Password', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText( + ctx, + 'The seed password used to generate this document. ' + + 'Do not share this key.', + { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + offset += 0.075; + offset += this.renderText(ctx, keys.master || this.props.password || '', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + offset = sectionStart + sectionHeight; + + // PUBLIC KEYS INTRO + sectionStart = offset; + sectionHeight = 1.0; + + offset += 0.1; + offset += this.renderText(ctx, 'Your Steemit Public Keys', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.18, + font: 'Roboto-Bold', + }); + + offset += 0.1; + offset += this.renderText( + ctx, + 'Public keys are associated with usernames and are used to ' + + 'encrypt and verify messages. Your public keys are not required ' + + 'for login. You can view these anytime at: https://steemscan.com/account/' + + name, + { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.15, + font: 'Roboto-Regular', + } + ); + + offset = sectionStart + sectionHeight; + + // PUBLIC KEYS + this.renderText(ctx, 'Posting Public', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, keys.postingPublic, { + scale: scale, + x: 1.25, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + this.renderText(ctx, 'Memo Public', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, keys.memoPublic, { + scale: scale, + x: 1.25, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + this.renderText(ctx, 'Active Public', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, keys.activePublic, { + scale: scale, + x: 1.25, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + this.renderText(ctx, 'Owner Public', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, keys.ownerPublic, { + scale: scale, + x: 1.25, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + this.renderText(ctx, 'v0.1', { + scale: scale, + x: maxLineWidth - 0.2, + y: offset - 0.2, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: '#bbbbbb', + fontSize: 0.14, + font: 'Roboto-Regular', + }); + + return ctx; + } + + render() { + // The SVG from svg-inline-loader is raw markup; we wrap it in a data URI + // so it can be rendered as an for canvas conversion. + var svgDataUri = + 'data:image/svg+xml;base64,' + + (typeof btoa !== 'undefined' + ? btoa(pdfLogoSvg) + : ''); + + return ( +
    + +
    + ); + } +} + +export default PdfDownload; diff --git a/src/app/components/elements/SteemToolsContent/SteemToolsContent.jsx b/src/app/components/elements/SteemToolsContent/SteemToolsContent.jsx new file mode 100644 index 000000000..aaead1999 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/SteemToolsContent.jsx @@ -0,0 +1,187 @@ +import React from "react"; +import { SteemToolsMenu, DEFAULT_MENU_DATA } from "./SteemToolsMenu"; +import ClaimDiscounted from "./sections/ClaimDiscounted"; +import KeyGeneration from "./sections/KeyGeneration"; +import CreateAccount from "./sections/CreateAccount"; +import ChangeRecovery from "./sections/ChangeRecovery"; +import AuthorityManagement from "./sections/AuthorityManagement"; +import UpdateProxy from "./sections/UpdateProxy"; +import DeclineVoting from "./sections/DeclineVoting"; +import CreateWitness from "./sections/CreateWitness"; +import GenerateBrainKeys from "./sections/GenerateBrainKeys"; +import PublishPriceFeed from "./sections/PublishPriceFeed"; +import DisableWitness from "./sections/DisableWitness"; + + +class SteemToolsContent extends React.Component { + constructor(props) { + super(props); + + this.DESKTOP_MIN = 900; + + this.state = { + collapsed: false, + activeItemId: props.defaultKey || "claim-discounted", + activeCategoryId: null, + query: "", + variant: "collapsible", + isDesktop: false, + }; + + this.handleToggle = this.handleToggle.bind(this); + this.handleSelectItem = this.handleSelectItem.bind(this); + this.scrollContentTop = this.scrollContentTop.bind(this); + this.renderContent = this.renderContent.bind(this); + this.getCategorySectionIds = this.getCategorySectionIds.bind(this); + this.onResize = this.onResize.bind(this); + } + + componentDidMount() { + this.onResize(); + window.addEventListener("resize", this.onResize); + this.scrollContentTop(); + } + + componentWillUnmount() { + window.removeEventListener("resize", this.onResize); + } + + onResize() { + var isDesktopNow = window.innerWidth >= this.DESKTOP_MIN; + + this.setState(function (s) { + if (s.isDesktop === isDesktopNow) return null; + + return { + isDesktop: isDesktopNow, + collapsed: isDesktopNow ? false : true, + }; + }); + } + + scrollContentTop() { + if (this._contentRef) { + this._contentRef.scrollTop = 0; + } + } + + handleToggle() { + this.setState(function (s) { + return { collapsed: !s.collapsed }; + }); + } + + handleSelectItem(item) { + var shouldAutoClose = (this.state.variant === "collapsible") && !this.state.isDesktop; + + this.setState({ + activeItemId: item.id, + activeCategoryId: null, + collapsed: shouldAutoClose ? true : this.state.collapsed, + }); + + this.scrollContentTop(); + if (this.props.onSelect) this.props.onSelect(item); + } + + getCategorySectionIds(menuData, categoryId) { + var sec = (menuData || []).filter(function (s) { + return s.id === categoryId; + })[0]; + if (!sec) return []; + return (sec.items || []).map(function (it) { + return it.id; + }); + } + + renderContent(sectionId) { + switch (sectionId) { + case "claim-discounted": return ; + case "key-generation": return ; + case "create-account": return ; + + case "change-recovery": return ; + case "authority-management": return ; + + case "update-proxy": return ; + case "decline-voting": return ; + + case "create-witness": return ; + case "generate-brain-keys": return ; + case "publish-price-feed": return ; + case "disable-witness": return ; + + default: + return (
    ); + } + } + + render() { + var palette = this.props.menuData || DEFAULT_MENU_DATA; + var collapsed = this.state.collapsed; + var variant = this.state.variant; + + var wrapperClass = [ + "advtools-wrapper", + collapsed && variant === "collapsible" ? "collapsed" : "", + ].filter(Boolean).join(" "); + + var sectionIds = []; + if (this.state.activeCategoryId) { + sectionIds = this.getCategorySectionIds(palette, this.state.activeCategoryId); + } else if (this.state.activeItemId) { + sectionIds = [this.state.activeItemId]; + } + + return ( +
    +
    +
    + + + + + {!this.state.isDesktop && !collapsed && ( +
    + )} + +
    + {sectionIds.map(function (id) { + return ( +
    + {this.renderContent(id)} +
    + ); + }.bind(this))} +
    +
    + +
    +
    + ); + } +} + +export default SteemToolsContent; diff --git a/src/app/components/elements/SteemToolsContent/SteemToolsContent.scss b/src/app/components/elements/SteemToolsContent/SteemToolsContent.scss new file mode 100644 index 000000000..a5b51fe31 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/SteemToolsContent.scss @@ -0,0 +1,341 @@ +.advtools-wrapper { + --advtools-offset: 0px; + display: flex; + height: calc(100vh - var(--advtools-offset)); + min-height: 480px; + border-radius: 20px; + overflow: hidden; + position: relative; + + @include themify($themes) { + background: themed('backgroundColor'); + border: themed('border'); + } +} + +.advtools-content { + flex: 1 1 auto; + min-width: 0; + overflow-y: auto; + padding: 32px 36px; + + @include themify($themes) { + background: themed('backgroundColor'); + } +} + +.advtools-overlay { + content: ""; + position: absolute; + inset: 0; + display: none; + z-index: 10; + backdrop-filter: blur(1px); + + @include themify($themes) { + background: themed('overlayBackground'); + } +} + +.advtools-wrapper:not(.collapsed) .advtools-overlay { + display: none; +} + +.advtools-input-group-error { + .input-group-label { + @include themify($themes) { + color: themed('backgroundColorDanger'); + border-color: themed('backgroundColorDanger'); + } + + background-color: rgba(220, 53, 69, 0.15); + } + + .input-group-field { + @include themify($themes) { + border-color: themed('backgroundColorDanger'); + } + } +} + +.code-block { + padding: 14px 16px; + border-radius: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 13px; + overflow-x: auto; + position: relative; + + @include themify($themes) { + background-color: themed('codeBlockBackground'); + color: themed('codeBlockText'); + } +} + +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; +} + +.advtools-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.advtools-grid-dynamic-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.advtools-grid-dynamic-1 { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.KeyGenerationTableWrapper { + overflow-x: auto; +} + +.KeyGenerationTable { + width: 100%; + text-align: left; + font-family: monospace; + + th, + td { + padding: 10px; + } + + thead { + @include themify($themes) { + background-color: themed('tableRowEvenBackgroundColor'); + color: themed('textColorPrimary'); + } + } + + .key-type-cell { + font-weight: bold; + width: 220px; + } + + .key-value-cell { + word-break: break-all; + } + + tbody { + tr.role-group-even { + @include themify($themes) { + background-color: themed('sectionBackgroundColor'); + } + } + + tr.role-group-odd { + @include themify($themes) { + background-color: themed('tableRowEvenBackgroundColor'); + } + } + } +} + +.advtools-panel { + border-radius: 12px; + border: transparent; + transition: 0.2s all ease-in-out; + padding: 22px; + margin-top: 12px; + + @include themify($themes) { + background: themed('sectionBackgroundColor'); + box-shadow: 2px 2px 2px 0 rgba(0, 0, 0, 0.1), 7px 7px 0 0 themed('buttonBoxShadow'); + } +} + +.advtools-btn-primary { + border: none; + transition: 0.2s all ease-in-out; + + @include themify($themes) { + background-color: themed('buttonBackground'); + box-shadow: 0px 0px 0px 0 rgba(0, 0, 0, 0), 5px 5px 0 0 themed('buttonBoxShadow'); + color: themed('buttonText'); + } + + &:hover, + &:focus { + @include themify($themes) { + background-color: themed('buttonBackgroundHover'); + box-shadow: 2px 2px 2px 0 rgba(0, 0, 0, 0.1), 7px 7px 0 0 themed('buttonBoxShadowHover'); + color: themed('buttonTextHover'); + } + } + + &.disabled, + &[disabled] { + opacity: 0.25; + cursor: not-allowed; + + @include themify($themes) { + background-color: themed('buttonBackground'); + box-shadow: 0px 0px 0px 0 rgba(0, 0, 0, 0); + color: themed('buttonText'); + } + } +} + +.advtools-panel .advtools-master-check, +.advtools-panel .advtools-acknowledge-check { + display: flex; + align-items: center; + padding: 10px; + border-radius: 4px; + + label.switch { + margin-top: auto; + margin-bottom: auto; + } +} + +.advtools-master-check { + @include themify($themes) { + background: themed('highlightBackgroundColor'); + border-left: 4px solid themed('colorAccent'); + } +} + +.advtools-acknowledge-check { + @include themify($themes) { + background: themed('backgroundColorWarning'); + border-left: 4px solid themed('backgroundColorDanger'); + } +} + +.advtools-message-error { + display: flex; + align-items: center; + padding: 10px; + border-radius: 4px; + margin: 1rem 0; + + @include themify($themes) { + background: themed('backgroundColorWarning'); + border-left: 4px solid themed('backgroundColorDanger'); + color: themed('textColorPrimary'); + } +} + +.advtools-message-success { + display: flex; + align-items: center; + padding: 10px; + border-radius: 4px; + margin: 1rem 0; + + @include themify($themes) { + background: themed('highlightBackgroundColor'); + border-left: 4px solid themed('colorAccent'); + color: themed('textColorPrimary'); + } +} + +.advtools-error-hint { + font-size: 12px; + margin-bottom: 1rem; + + @include themify($themes) { + color: themed('textColorError'); + } +} + +#generate-button { + margin-right: 0 !important; +} + +@media (max-width: 900px) { + .advtools-content { + padding-top: 56px; + } +} + +@media (max-width: 1024px) { + .grid-2 { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .advtools-content { + padding: 20px; + } + + .advtools-wrapper:not(.collapsed) .advtools-overlay { + display: block; + } +} + +@media (max-width: 550px) { + .advtools-panel { + #generate-button { + width: 100%; + } + + .row-column-mobile { + display: flex; + flex-direction: column; + } + + .flex-mobile-full { + width: 100%; + flex: none; + max-width: 100%; + padding-top: 0; + padding-bottom: 8px; + } + + .input-group-mobile { + width: 100%; + } + + .grid-column-mobile { + grid-template-columns: 1fr; + } + } +} + +.flex-container-1-extended { + -ms-flex: 0 0 33.33333%; + flex: 0 0 33.33333%; + max-width: 33.33333%; +} + +.flex-container-2-extended { + -ms-flex: 0 0 66.66667%; + flex: 0 0 66.66667%; + max-width: 66.66667%; +} + +.change-recovery-account-hint { + margin-top: 0; + font-size: 12px; + opacity: 0.8; + margin-bottom: 1.25rem; +} + +@media (max-width: 425px) { + .flex-container-1-extended { + -ms-flex: 0 0 41.66667%; + flex: 0 0 41.66667%; + max-width: 41.66667%; + } + + .flex-container-2-extended { + -ms-flex: 0 0 58.33333%; + flex: 0 0 58.33333%; + max-width: 58.33333%; + } +} diff --git a/src/app/components/elements/SteemToolsContent/SteemToolsMenu.jsx b/src/app/components/elements/SteemToolsContent/SteemToolsMenu.jsx new file mode 100644 index 000000000..3a17d0b1f --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/SteemToolsMenu.jsx @@ -0,0 +1,121 @@ +import React from "react"; +import tt from 'counterpart'; + +const DEFAULT_MENU_DATA = [ + { + id: 'account-creation', + title: tt('steem_tools.menu.account_creation'), + items: [ + { id: 'claim-discounted', label: tt('steem_tools.menu.claim_account_creation_token') }, + { id: 'key-generation', label: tt('steem_tools.menu.key_generator') }, + { id: 'create-account', label: tt('steem_tools.menu.create_account') }, + ], + }, + { + id: 'account-security', + title: tt('steem_tools.menu.account_security'), + items: [ + { id: 'change-recovery', label: tt('steem_tools.menu.change_recovery_account') }, + { id: 'authority-management', label: tt('steem_tools.menu.authority_management') }, + ], + }, + { + id: 'governance', + title: tt('steem_tools.menu.governance'), + items: [ + { id: 'update-proxy', label: tt('steem_tools.menu.update_proxy') }, + { id: 'decline-voting', label: tt('steem_tools.menu.decline_voting_rights') }, + ], + }, + { + id: 'witness-operations', + title: tt('steem_tools.menu.witness_operations'), + items: [ + { id: 'create-witness', label: tt('steem_tools.menu.create_update_witness') }, + { id: 'generate-brain-keys', label: tt('steem_tools.menu.generate_brain_keys') }, + { id: 'publish-price-feed', label: tt('steem_tools.menu.publish_witness_price_feed') }, + { id: 'disable-witness', label: tt('steem_tools.menu.disable_witness') }, + ], + }, +]; + +class SteemToolsMenu extends React.Component { + constructor(props) { + super(props); + this.handleSelectItem = this.handleSelectItem.bind(this); + } + + handleSelectItem(item) { + if (this.props.onSelectItem) this.props.onSelectItem(item); + } + + filterItems(menu, query) { + if (!query) return menu; + var q = String(query || "").toLowerCase(); + return (menu || []) + .map(function (sec) { + return { + id: sec.id, + title: sec.title, + items: (sec.items || []).filter(function (it) { + return it.label.toLowerCase().indexOf(q) !== -1; + }), + }; + }) + .filter(function (sec) { + return sec.items.length > 0; + }); + } + + render() { + var menuData = this.props.menuData || DEFAULT_MENU_DATA; + var filtered = this.filterItems(menuData, this.props.query); + var collapsed = !!this.props.collapsed; + var variant = this.props.variant || "collapsible"; + var activeItemId = this.props.activeItemId; + + return ( +
    +
    + {filtered.map(function (section) { + return ( +
    +
    + {section.title} +
    + + {section.items.map(function (item) { + return ( +
    +
    {item.label}
    +
    + ); + }.bind(this))} +
    + ); + }.bind(this))} +
    +
    + ); + } +} + +export { SteemToolsMenu, DEFAULT_MENU_DATA }; diff --git a/src/app/components/elements/SteemToolsContent/SteemToolsMenu.scss b/src/app/components/elements/SteemToolsContent/SteemToolsMenu.scss new file mode 100644 index 000000000..ab030b39e --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/SteemToolsMenu.scss @@ -0,0 +1,222 @@ +.advtools-wrapper { + display: flex; + min-height: 480px; + border-radius: 20px; + overflow: hidden; + position: relative; + --advtools-offset: 0px; + height: max-content; + + @include themify($themes) { + border: themed('border'); + background: themed('backgroundColor'); + } +} + +.advtools-sidebar { + position: relative; + display: flex; + flex-direction: column; + width: 280px; + flex: 0 0 auto; + flex-basis: 280px; + overflow: hidden; + transition: width .25s ease, flex-basis .25s ease, opacity .2s ease; + + @include themify($themes) { + background: themed('moduleBackgroundColor'); + color: themed('textColorPrimary'); + } +} + +.advtools-scroll { + flex: 1; + overflow: auto; + padding: 8px 10px 14px; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.28) rgba(0, 0, 0, 0.04); +} + +.advtools-scroll::-webkit-scrollbar { + width: 10px; +} + +.advtools-scroll::-webkit-scrollbar-track { + margin-block: 8px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.04); +} + +.advtools-scroll::-webkit-scrollbar-thumb { + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; + background: rgba(0, 0, 0, 0.28); +} + +.advtools-menu-section { + margin: 8px 0 14px; +} + +.advtools-section-title { + font-weight: 800; + padding: 0 10px; + text-transform: uppercase; + letter-spacing: .6px; + opacity: .95; + font-size: 12px; + @include themify($themes) { + color: themed('colorAccent'); + } +} + +.advtools-item { + display: flex; + align-items: center; + gap: 10px; + padding: 2.5px 10px; + margin: 6px 0; + border-radius: 10px; + cursor: pointer; + transition: background .15s ease, transform .05s ease, box-shadow .15s ease, color .15s ease; + font-size: 14px; + + @include themify($themes) { + color: themed('textColorPrimary'); + } +} + +.advtools-item:hover { + background: rgba(31, 191, 143, 0.10); + box-shadow: inset 0 0 0 1px rgba(31, 191, 143, 0.15); +} + +.advtools-item:active { + transform: scale(.98); +} + +.advtools-item.active { + background: rgba(31, 191, 143, 0.18); + box-shadow: inset 0 0 0 1px rgba(31, 191, 143, 0.22); +} + +.advtools-item .ico { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + opacity: .95; + + @include themify($themes) { + color: themed('textColorPrimary'); + } +} + +.advtools-item.active .ico { + @include themify($themes) { + color: themed('colorAccent'); + } +} + +.advtools-item .label { + font-size: 14px; +} + +.advtools-toggle { + position: absolute; + top: 20px; + left: 280px; + width: 18px; + height: 55px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 30; + border: 1px solid #d7dbe0; + border-left: none; + border-radius: 0 10px 10px 0; + box-shadow: 2px 0 6px rgba(0, 0, 0, 0.03); + transition: background .15s ease, border-color .15s ease, left .25s ease; + appearance: none; + + @include themify($themes) { + background: themed('backgroundColorEmphasis'); + } +} + +.advtools-toggle:hover { + @include themify($themes) { + background: themed('moduleBackgroundColor'); + } +} + +.advtools-toggle-chevron { + width: 7.5px; + height: 7.5px; + border-right: 1.75px solid #aaa; + border-bottom: 1.75px solid #aaa; + transform: rotate(135deg); + margin-right: 2px; + transition: transform .2s ease, border-color .1s ease, margin-right .2s ease; +} + +.advtools-toggle-chevron.flipped { + transform: rotate(-45deg); + margin-right: 5px; +} + +.advtools-toggle:hover .advtools-toggle-chevron { + @include themify($themes) { + border-color: themed('colorAccent'); + } +} + +.advtools-wrapper.collapsed .advtools-sidebar { + width: 0; + flex-basis: 0; + opacity: 0; + overflow: hidden; +} + +.advtools-wrapper.collapsed .advtools-toggle { + left: 0; +} + +.advtools-overlay { + content: ""; + position: absolute; + inset: 0; + display: none; + z-index: 10; + backdrop-filter: blur(1px); + + @include themify($themes) { + background: themed('overlayBackground'); + } +} + +@media (max-width: 768px) { + .advtools-sidebar { + position: absolute; + z-index: 20; + height: 100%; + width: 260px; + flex: 0 0 260px; + transform: translateX(0); + transition: transform .2s ease; + box-shadow: 0 6px 30px rgba(0, 0, 0, 0.2); + } + + .advtools-toggle { + left: 260px; + } + + .advtools-wrapper.collapsed .advtools-sidebar { + transform: translateX(-110%); + width: 260px; + flex: 0 0 260px; + } +} diff --git a/src/app/components/elements/SteemToolsContent/sections/AuthorityManagement.jsx b/src/app/components/elements/SteemToolsContent/sections/AuthorityManagement.jsx new file mode 100644 index 000000000..3939e0575 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/AuthorityManagement.jsx @@ -0,0 +1,1145 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { api } from '@steemit/steem-js'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import ConfirmAuthorityManagement from 'app/components/elements/ConfirmAuthorityManagement'; +import AuthorityManagementSmallTable from './AuthorityManagementSmallTable'; +import { validate_account_name } from 'app/utils/ChainValidation'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; + +const AUTHORITY_TYPES = ['owner', 'active', 'posting']; +const MAX_AUTHORITY_WEIGHT = 65535; + +function getFieldValue(source, key, fallback = undefined) { + if (!source) return fallback; + if (typeof source.get === 'function') { + const value = source.get(key); + return value === undefined ? fallback : value; + } + if (Object.prototype.hasOwnProperty.call(source, key)) { + const value = source[key]; + return value === undefined ? fallback : value; + } + return fallback; +} + +function toJSValue(value) { + if (!value) return value; + if (typeof value.toJS === 'function') return value.toJS(); + return value; +} + +function normalizeAuthority(authority) { + const raw = toJSValue(authority) || {}; + return { + weight_threshold: Number(raw.weight_threshold || 1), + account_auths: Array.isArray(raw.account_auths) + ? raw.account_auths.map((item) => [item[0], Number(item[1])]) + : [], + key_auths: Array.isArray(raw.key_auths) + ? raw.key_auths.map((item) => [item[0], Number(item[1])]) + : [], + }; +} + +function sanitizeAccountInput(value) { + return String(value || '').replace(/\s/g, '').toLowerCase(); +} + +function sanitizeIntegerInput(value) { + return String(value == null ? '' : value).replace(/[^\d]/g, ''); +} + +function preventInvalidNumberKeys(e) { + if (['e', 'E', '+', '-', '.', ',', ' '].includes(e.key)) { + e.preventDefault(); + } +} + +class AuthorityManagement extends React.Component { + constructor(props) { + super(props); + + this.state = { + authorityType: 'posting', + targetAuthorityAccount: '', + weight: '1', + weightThreshold: '1', + loading: false, + error: null, + success: null, + nameError: null, + nameAvailable: null, + isCheckingName: false, + optimisticAuthorities: {}, + acknowledged: false, + }; + + this.checkAccountNameTimer = null; + + this.onChange = this.onChange.bind(this); + this.onAuthorityTypeChange = this.onAuthorityTypeChange.bind(this); + this.onAddAuthority = this.onAddAuthority.bind(this); + this.onRemoveAllAuthorities = this.onRemoveAllAuthorities.bind(this); + this.onRemoveSingleAuthority = this.onRemoveSingleAuthority.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.checkAccountName = this.checkAccountName.bind(this); + this.logAuthoritiesSnapshot = this.logAuthoritiesSnapshot.bind(this); + this.onAcknowledgeToggle = this.onAcknowledgeToggle.bind(this); + } + + componentDidMount() { + this.syncThresholdFromProps(this.state.authorityType, this.props); + this.logAuthoritiesSnapshot(this.props); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevState.authorityType !== this.state.authorityType || + prevProps.account !== this.props.account + ) { + this.syncThresholdFromProps(this.state.authorityType, this.props); + } + + if (prevProps.account !== this.props.account) { + this.logAuthoritiesSnapshot(this.props); + } + } + + componentWillUnmount() { + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + } + + logAuthoritiesSnapshot(props = this.props) { + const snapshot = AUTHORITY_TYPES.reduce((acc, type) => { + acc[type] = this.getSelectedAuthority(type, props); + return acc; + }, {}); + console.log('[AuthorityManagement] authorities snapshot:', snapshot); + } + + getSelectedAuthority(authorityType = this.state.authorityType, props = this.props) { + const { account } = props; + const { optimisticAuthorities } = this.state; + + if (optimisticAuthorities && optimisticAuthorities[authorityType]) { + return normalizeAuthority(optimisticAuthorities[authorityType]); + } + + if (!account) { + return normalizeAuthority(null); + } + return normalizeAuthority(getFieldValue(account, authorityType, null)); + } + + getAuthorityRows(props = this.props) { + return AUTHORITY_TYPES.flatMap((type) => { + const authority = this.getSelectedAuthority(type, props); + return authority.account_auths.map(([name, accountWeight]) => ({ + type, + name, + weight: accountWeight, + })); + }); + } + + syncThresholdFromProps(authorityType, props = this.props) { + const authority = this.getSelectedAuthority(authorityType, props); + const nextThreshold = String(authority.weight_threshold || 1); + if (this.state.weightThreshold !== nextThreshold) { + this.setState({ weightThreshold: nextThreshold }); + } + } + + onChange(e) { + const { name, value } = e.target; + + if (name === 'targetAuthorityAccount') { + const sanitizedValue = sanitizeAccountInput(value); + const nameValidationError = sanitizedValue + ? validate_account_name(sanitizedValue) + : null; + + this.setState({ + targetAuthorityAccount: sanitizedValue, + nameError: nameValidationError, + nameAvailable: null, + isCheckingName: false, + error: null, + success: null, + }); + + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + + if (sanitizedValue && !nameValidationError) { + this.checkAccountName(sanitizedValue); + } + + return; + } + + if (name === 'weight' || name === 'weightThreshold') { + const sanitizedValue = sanitizeIntegerInput(value); + this.setState({ + [name]: sanitizedValue, + error: null, + success: null, + }); + return; + } + + this.setState({ + [name]: value, + error: null, + success: null, + }); + } + + onAuthorityTypeChange(e) { + const authorityType = e.target.value; + const authority = this.getSelectedAuthority(authorityType); + this.setState({ + authorityType, + weightThreshold: String(authority.weight_threshold || 1), + error: null, + success: null, + }); + } + + checkAccountName(username) { + this.setState({ nameAvailable: null }); + + this.checkAccountNameTimer = setTimeout(() => { + this.setState({ isCheckingName: true }); + + const normalizedUsername = username.trim().toLowerCase(); + + api.callAsync('condenser_api.lookup_accounts', [normalizedUsername, 1]) + .then((accounts) => { + const exists = + Array.isArray(accounts) && + accounts.length > 0 && + String(accounts[0]).toLowerCase() === normalizedUsername; + + if (this.state.targetAuthorityAccount === normalizedUsername) { + this.setState({ + nameAvailable: exists, + isCheckingName: false, + nameError: exists + ? null + : tt( + 'steem_tools.authority_management.error_account_not_found', + { + fallback: + 'Account not found. Authority account must exist.', + } + ), + }); + } + }) + .catch((error) => { + console.error( + 'API Error checking authority account name:', + error + ); + if (this.state.targetAuthorityAccount === normalizedUsername) { + this.setState({ + isCheckingName: false, + nameAvailable: null, + }); + } + }); + }, 500); + } + + onAcknowledgeToggle(e) { + this.setState({ acknowledged: e.target.checked }); + } + + onFailure(error) { + let errorMessage = error; + if ( + !errorMessage || + errorMessage === 0 || + errorMessage === false || + String(errorMessage).toLowerCase().includes('undefined') + ) { + errorMessage = tt( + 'steem_tools.authority_management.unexpected_error' + ); + } + + this.setState({ + loading: false, + error: errorMessage, + success: null, + }); + } + + onSuccess(authorityType, updatedAuthority) { + const { currentUser, refreshAccount } = this.props; + refreshAccount(currentUser); + this.setState((prevState) => ({ + loading: false, + error: null, + success: tt('steem_tools.authority_management.success_message'), + targetAuthorityAccount: '', + weight: '1', + nameError: null, + nameAvailable: null, + isCheckingName: false, + optimisticAuthorities: { + ...(prevState.optimisticAuthorities || {}), + [authorityType]: updatedAuthority, + }, + })); + } + + buildUpdatedAuthority(removeMode = false, removeAllMode = false) { + const { account } = this.props; + const { + authorityType, + targetAuthorityAccount, + weightThreshold, + weight, + } = this.state; + + if (!account) { + throw new Error( + tt('steem_tools.authority_management.error_no_account_data') + ); + } + + const currentAuthority = this.getSelectedAuthority(authorityType); + const parsedThreshold = parseInt(weightThreshold, 10); + + if (!Number.isInteger(parsedThreshold) || parsedThreshold < 1) { + throw new Error( + tt('steem_tools.authority_management.error_invalid_threshold') + ); + } + + if (removeAllMode) { + return { + weight_threshold: parsedThreshold, + account_auths: [], + key_auths: currentAuthority.key_auths, + }; + } + + const normalizedTarget = sanitizeAccountInput(targetAuthorityAccount); + const nameValidationError = validate_account_name(normalizedTarget); + const parsedWeight = parseInt(weight, 10); + + if (!normalizedTarget) { + throw new Error( + tt( + 'steem_tools.authority_management.error_no_target_authority_account' + ) + ); + } + + if (nameValidationError) { + throw new Error(nameValidationError); + } + + if ( + !removeMode && + (!Number.isInteger(parsedWeight) || parsedWeight < 1) + ) { + throw new Error( + tt('steem_tools.authority_management.error_invalid_weight') + ); + } + + if (!removeMode && parsedWeight > MAX_AUTHORITY_WEIGHT) { + throw new Error( + tt('steem_tools.authority_management.error_weight_too_large', { + max: MAX_AUTHORITY_WEIGHT, + fallback: `Weight cannot be greater than ${MAX_AUTHORITY_WEIGHT}.`, + }) + ); + } + + const nextAccountAuths = [...currentAuthority.account_auths]; + const existingIndex = nextAccountAuths.findIndex( + ([name]) => name === normalizedTarget + ); + + if (removeMode) { + if (existingIndex === -1) { + throw new Error( + tt( + 'steem_tools.authority_management.error_authority_not_found' + ) + ); + } + nextAccountAuths.splice(existingIndex, 1); + } else if (existingIndex >= 0) { + nextAccountAuths[existingIndex] = [normalizedTarget, parsedWeight]; + } else { + nextAccountAuths.push([normalizedTarget, parsedWeight]); + } + + nextAccountAuths.sort((a, b) => a[0].localeCompare(b[0])); + + return { + weight_threshold: parsedThreshold, + account_auths: nextAccountAuths, + key_auths: currentAuthority.key_auths, + }; + } + + getConfirmPayload(removeMode, removeAllMode, updatedAuthority, currentAuthority) { + const { accountName } = this.props; + const { authorityType, targetAuthorityAccount, weight } = this.state; + + const authorityTypeLabel = tt( + `steem_tools.authority_management.authority_${authorityType}` + ); + + if (removeAllMode) { + const accountNames = currentAuthority.account_auths.map( + ([name]) => `@${name}` + ); + const count = accountNames.length; + + if (count <= 5) { + return tt( + 'steem_tools.authority_management.confirm_remove_multiple_message', + { + count, + authorityType: authorityTypeLabel, + account: `@${accountName}`, + accounts: accountNames.join(', '), + fallback: `You are about to remove ${count} ${authorityTypeLabel} account authorit${ + count === 1 ? 'y' : 'ies' + } from @${accountName}: ${accountNames.join( + ', ' + )}. Continue?`, + } + ); + } + + return tt( + 'steem_tools.authority_management.confirm_remove_multiple_count_message', + { + count, + authorityType: authorityTypeLabel, + account: `@${accountName}`, + fallback: `You are about to remove ${count} ${authorityTypeLabel} account authorities from @${accountName}. Continue?`, + } + ); + } + + const normalizedTarget = sanitizeAccountInput(targetAuthorityAccount); + const existingRow = currentAuthority.account_auths.find( + ([name]) => name === normalizedTarget + ); + const operation = { + action: removeMode + ? tt( + 'steem_tools.authority_management.confirm_action_remove_single', + { + fallback: 'Remove authority', + } + ) + : tt( + 'steem_tools.authority_management.confirm_action_add_single', + { + fallback: 'Add authority', + } + ), + account: `@${accountName}`, + authority_type: authorityTypeLabel, + target_account: `@${normalizedTarget}`, + weight_threshold: String(updatedAuthority.weight_threshold), + weight: removeMode + ? String(existingRow ? existingRow[1] : '') + : String(parseInt(weight, 10)), + }; + + return () => ; + } + + submitAuthorityChange(removeMode = false, removeAllMode = false) { + const { currentUser, accountName, account, updateAuthority } = this.props; + const { authorityType, nameError, nameAvailable, isCheckingName, acknowledged } = + this.state; + + if (!acknowledged) { + return; + } + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.authority_management.error_no_account'), + success: null, + }); + return; + } + + if (!accountName) { + this.setState({ + error: tt( + 'steem_tools.authority_management.error_no_target_account' + ), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.authority_management.error_not_allowed'), + success: null, + }); + return; + } + + if (!removeMode && !removeAllMode) { + if (isCheckingName) { + this.setState({ + error: tt( + 'steem_tools.authority_management.error_checking_account', + { + fallback: 'Still checking account name. Please wait.', + } + ), + success: null, + }); + return; + } + + if (nameError || !nameAvailable) { + this.setState({ + error: + nameError || + tt( + 'steem_tools.authority_management.error_invalid_account', + { + fallback: 'Invalid authority account', + } + ), + success: null, + }); + return; + } + } + + try { + const currentAuthority = this.getSelectedAuthority(authorityType); + const authority = this.buildUpdatedAuthority( + removeMode, + removeAllMode + ); + const memoKey = getFieldValue(account, 'memo_key', ''); + const jsonMetadata = getFieldValue(account, 'json_metadata', ''); + const confirm = this.getConfirmPayload( + removeMode, + removeAllMode, + authority, + currentAuthority + ); + + this.setState({ + loading: true, + error: null, + success: null, + }); + + updateAuthority( + accountName, + authorityType, + authority, + memoKey, + jsonMetadata, + confirm, + () => this.onSuccess(authorityType, authority), + this.onFailure + ); + } catch (e) { + this.setState({ + error: + e && e.message + ? e.message + : tt( + 'steem_tools.authority_management.unexpected_error' + ), + success: null, + }); + } + } + + onAddAuthority() { + this.submitAuthorityChange(false, false); + } + + onRemoveAllAuthorities() { + this.submitAuthorityChange(false, true); + } + + onRemoveSingleAuthority(type, name) { + const { loading } = this.state; + const { currentUser, accountName } = this.props; + + if (loading || currentUser !== accountName) return; + + this.setState( + { + authorityType: type, + targetAuthorityAccount: name, + nameError: null, + nameAvailable: true, + isCheckingName: false, + error: null, + success: null, + }, + () => { + this.submitAuthorityChange(true, false); + } + ); + } + + renderCurrentAuthoritiesDesktop(rows, canEdit) { + if (!rows.length) { + return ( +
    + {tt('steem_tools.authority_management.no_account_authorities')} +
    + ); + } + + return ( +
    + + + + + + + + + + {rows.map(({ type, name, weight }) => ( + + + + + + + ))} + +
    {tt('steem_tools.authority_management.authority_type')} + {tt( + 'steem_tools.authority_management.authorized_account_header' + )} + {tt('steem_tools.authority_management.weight_header')} +
    + {tt( + `steem_tools.authority_management.authority_${type}` + )} + + @{name} + {weight} + +
    +
    + ); + } + + render() { + const { currentUser, accountName } = this.props; + const { + authorityType, + targetAuthorityAccount, + weight, + weightThreshold, + loading, + error, + success, + nameError, + nameAvailable, + isCheckingName, + acknowledged, + } = this.state; + + const isOwner = !!currentUser && !!accountName && currentUser === accountName; + const canEdit = !loading && isOwner; + const currentSelectedAuthority = this.getSelectedAuthority(authorityType); + const rows = this.getAuthorityRows(); + const hasAuthorities = currentSelectedAuthority.account_auths.length > 0; + + return ( +
    +
    +
    +

    + {tt('steem_tools.authority_management.title')} +

    +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + {tt( + 'steem_tools.authority_management.account_to_modify' + )} +
    +
    +
    +
    + @ + +
    +
    +
    + +
    +
    +
    + {tt( + 'steem_tools.authority_management.authority_type' + )} +
    +
    +
    + +
    +
    + +
    +
    +
    + {tt( + 'steem_tools.authority_management.weight_threshold' + )} +
    +
    +
    + +
    + {tt( + 'steem_tools.authority_management.weight_threshold_hint' + )} +
    +
    +
    + +
    +
    +
    + {tt( + 'steem_tools.authority_management.add_account_authority' + )} +
    +
    +
    +
    + @ + + {isCheckingName && ( +
    + +
    + )} +
    + + {nameError && ( +
    + {nameError} +
    + )} + + {nameAvailable === true && !nameError && ( +
    + {tt( + 'steem_tools.authority_management.success_account_found', + { + fallback: 'Account found', + } + )} +
    + )} + + {!nameError && + !nameAvailable && + !isCheckingName && ( +
    + {tt('steem_tools.authority_management.account_authority_hint',)} +
    + )} +
    +
    + +
    +
    +
    + {tt('steem_tools.authority_management.weight')} +
    +
    +
    + +
    + {tt( + 'steem_tools.authority_management.weight_hint', + { + max: MAX_AUTHORITY_WEIGHT, + } + )} +
    +
    +
    + +
    +
    +

    + {tt( + 'steem_tools.authority_management.current_account_authorities' + )} +

    +
    +
    + +
    +
    +
    + +
    +
    + {this.renderCurrentAuthoritiesDesktop( + rows, + canEdit && acknowledged + )} +
    +
    +
    + + {!loading && !isOwner && currentUser && accountName ? ( +
    +
    +
    + {tt( + 'steem_tools.authority_management.error_not_allowed' + )} +
    +
    +
    + ) : null} + + {!loading && error ? ( +
    +
    +
    + {error} +
    +
    +
    + ) : null} + + {!loading && success ? ( +
    +
    +
    + {success} +
    +
    +
    + ) : null} + +
    +
    + + {tt('steem_tools.authority_management.acknowledge_warning')} + + +
    +
    + +
    +
    + {loading ? ( + + + + ) : ( + + + + + + )} +
    +
    +
    +
    +
    + ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + + const accountName = ownProps.accountname || currentUser || ''; + + const account = accountName + ? state.global.getIn(['accounts', accountName]) + : null; + + return { + currentUser, + accountName, + account, + }; + }, + (dispatch) => ({ + updateAuthority: ( + account, + authorityType, + authority, + memoKey, + jsonMetadata, + confirm, + successCallback, + errorCallback + ) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${account}/permissions` })); + if (successCallback) successCallback(); + }; + + const operation = { + account, + memo_key: memoKey || '', + json_metadata: jsonMetadata || '', + [authorityType]: authority, + }; + + dispatch( + transactionActions.broadcastOperation({ + type: 'account_update', + operation, + confirm, + successCallback: successCb, + errorCallback, + }) + ); + }, + refreshAccount: (username) => + dispatch( + userActions.refreshAccount({ + username, + }) + ), + removeNotification: (key) => + dispatch(appActions.removeNotification({ key })), + }) +)(AuthorityManagement); diff --git a/src/app/components/elements/SteemToolsContent/sections/AuthorityManagementSmallTable.jsx b/src/app/components/elements/SteemToolsContent/sections/AuthorityManagementSmallTable.jsx new file mode 100644 index 000000000..d7cac215e --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/AuthorityManagementSmallTable.jsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Link } from 'react-router'; +import tt from 'counterpart'; + +const AuthorityManagementSmallTable = ({ + rows, + loading, + canEdit, + onRemoveSingleAuthority, +}) => { + if (!rows.length) { + return ( +
    + {tt('steem_tools.authority_management.no_account_authorities')} +
    + ); + } + + return ( + + + + + + + + {rows.map(({ type, name, weight }) => ( + + + + ))} + +
    + + + {`${tt( + 'steem_tools.authority_management.current_account_authorities' + )} (${rows.length})`} + + +
    + + + {`${tt('steem_tools.authority_management.authority_type')}: `} + + {tt( + `steem_tools.authority_management.authority_${type}` + )} + + + + + {`${tt( + 'steem_tools.authority_management.authorized_account_header' + )}: `} + + @{name} + + + + + {`${tt( + 'steem_tools.authority_management.weight_header' + )}: `} + + {weight} + + + +
    + ); +}; + +export default AuthorityManagementSmallTable; diff --git a/src/app/components/elements/SteemToolsContent/sections/ChangeRecovery.jsx b/src/app/components/elements/SteemToolsContent/sections/ChangeRecovery.jsx new file mode 100644 index 000000000..d918fa744 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/ChangeRecovery.jsx @@ -0,0 +1,514 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { api } from '@steemit/steem-js'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import { validate_account_name } from 'app/utils/ChainValidation'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; +import ConfirmChangeRecoveryAccount from 'app/components/elements/ConfirmChangeRecoveryAccount'; + +class ChangeRecovery extends React.Component { + constructor(props) { + super(props); + this.state = { + newRecoveryAccount: '', + loading: false, + error: null, + success: null, + nameError: null, + nameAvailable: null, + isCheckingName: false, + acknowledged: false, + }; + this.checkAccountNameTimer = null; + + this.onChange = this.onChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.checkAccountName = this.checkAccountName.bind(this); + this.onAcknowledgeToggle = this.onAcknowledgeToggle.bind(this); + } + + componentWillUnmount() { + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + } + + onChange(e) { + const value = e.target.value.replace(/\s/g, '').toLowerCase(); + const nameValidationError = value ? validate_account_name(value) : null; + + this.setState({ + newRecoveryAccount: value, + nameError: nameValidationError, + nameAvailable: null, + isCheckingName: false, + error: null, + success: null, + }); + + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + + if (value && !nameValidationError) { + this.checkAccountName(value); + } + } + + checkAccountName(username) { + this.setState({ nameAvailable: null }); + this.checkAccountNameTimer = setTimeout(() => { + this.setState({ isCheckingName: true }); + const normalizedUsername = username.trim().toLowerCase(); + api.callAsync('condenser_api.lookup_accounts', [normalizedUsername, 1]) + .then(accounts => { + const exists = Array.isArray(accounts) && accounts.length > 0 && String(accounts[0]).toLowerCase() === normalizedUsername; + if (this.state.newRecoveryAccount === normalizedUsername) { + let existsError = !exists ? tt('steem_tools.change_recovery_account.error_account_not_found') : null; + if (exists && this.props.accountName === normalizedUsername) { + existsError = tt('steem_tools.change_recovery_account.error_same_account', { fallback: 'Cannot change to the same account' }); + } + this.setState({ + nameAvailable: exists && !existsError, + isCheckingName: false, + nameError: existsError + }); + } + }) + .catch(e => { + console.error('API Error checking account name:', e); + if (this.state.newRecoveryAccount === normalizedUsername) { + this.setState({ isCheckingName: false }); + } + }); + }, 500); + } + + onAcknowledgeToggle(e) { + this.setState({ acknowledged: e.target.checked }); + } + + onFailure(error) { + let errorMessage = error; + if ( + !errorMessage || + errorMessage === 0 || + errorMessage === false || + String(errorMessage).toLowerCase().includes('undefined') + ) { + errorMessage = tt('steem_tools.change_recovery_account.unexpected_error'); + } + + this.setState({ + loading: false, + error: errorMessage, + success: null, + }); + } + + onSuccess() { + const { currentUser, refreshAccount } = this.props; + refreshAccount(currentUser); + this.setState({ + loading: false, + error: null, + success: tt('steem_tools.change_recovery_account.success_message'), + newRecoveryAccount: '', + }); + } + + onSubmit() { + const { currentUser, account, accountName, changeRecoveryAccount } = this.props; + const { newRecoveryAccount, acknowledged } = this.state; + + if (!acknowledged) { + return; + } + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.change_recovery_account.error_no_account'), + success: null, + }); + return; + } + + let recoveryAccount = ''; + let pendingRecoveryAccount = ''; + + if (account) { + const recoveryInfo = account.get('account_recovery'); + if (recoveryInfo) { + pendingRecoveryAccount = recoveryInfo.get('recovery_account') || ''; + } + recoveryAccount = account.get('recovery_account') || ''; + } + + if (!accountName) { + this.setState({ + error: tt('steem_tools.change_recovery_account.error_no_target_account'), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.change_recovery_account.error_not_allowed'), + success: null, + }); + return; + } + + if (!newRecoveryAccount) { + this.setState({ + error: tt('g.required'), + success: null, + }); + return; + } + + if (this.state.nameError || !this.state.nameAvailable) { + this.setState({ + error: tt('steem_tools.change_recovery_account.error_invalid_account'), + success: null, + }); + return; + } + + this.setState({ + loading: true, + error: null, + success: null, + }); + + changeRecoveryAccount( + accountName, + recoveryAccount, + pendingRecoveryAccount, + newRecoveryAccount, + this.onSuccess, + this.onFailure + ); + } + + render() { + const { currentUser, account, accountName } = this.props; + const { newRecoveryAccount, loading, error, success, nameError, nameAvailable, isCheckingName, acknowledged } = this.state; + + let recoveryAccount = ''; + let pendingRecoveryAccount = ''; + let daysLeft = 0; + + if (account) { + const recoveryInfo = account.get('account_recovery'); + if (recoveryInfo) { + pendingRecoveryAccount = (recoveryInfo.get('recovery_account') || '').trim(); + + const effectiveOn = recoveryInfo.get('effective_on'); + if (effectiveOn) { + const effectiveDate = new Date(effectiveOn); + const now = new Date(); + daysLeft = Math.ceil((effectiveDate - now) / (1000 * 60 * 60 * 24)); + if (daysLeft < 0) daysLeft = 0; + } + } + recoveryAccount = account.get('recovery_account') || ''; + } + + const isOwner = + !!currentUser && + !!accountName && + currentUser === accountName; + + const canChangeRecovery = !loading && isOwner; + + return ( +
    +
    +
    +

    {tt('steem_tools.change_recovery_account.title')}

    +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    {tt('steem_tools.change_recovery_account.current_account')}
    +
    +
    +
    + @ + +
    +
    +
    + +
    +
    +
    {tt('steem_tools.change_recovery_account.current_recovery_account')}
    +
    +
    +
    + @ + +
    +
    +
    + + {pendingRecoveryAccount ? ( +
    +
    +
    {tt('steem_tools.change_recovery_account.pending_recovery_account')}
    +
    +
    +
    + @ + +
    + {daysLeft > 0 ? ( +
    + {tt('steem_tools.change_recovery_account.days_left_message', { days: daysLeft })} +
    + ) : null} +
    +
    + ) : null} + +
    +
    +
    {tt('steem_tools.change_recovery_account.new_account')}
    +
    +
    +
    + @ + + {isCheckingName && ( +
    + +
    + )} +
    + {nameError && ( +
    + {nameError} +
    + )} + {nameAvailable === true && !nameError && ( +
    + {tt('steem_tools.change_recovery_account.success_account_found', { fallback: 'Account found' })} +
    + )} + {!nameError && !nameAvailable && !isCheckingName && ( +
    + {tt('steem_tools.change_recovery_account.new_account_hint')} +
    + )} +
    +
    + + {!loading && !isOwner && currentUser && accountName ? ( +
    +
    +
    + {tt('steem_tools.change_recovery_account.error_not_allowed')} +
    +
    +
    + ) : null} + + {!loading && error ? ( +
    +
    +
    {error}
    +
    +
    + ) : null} + + {!loading && success ? ( +
    +
    +
    {success}
    +
    +
    + ) : null} + +
    +
    + + {tt('steem_tools.change_recovery_account.acknowledge_warning')} + + +
    +
    + +
    +
    + {loading ? ( + + + + ) : ( + + )} +
    +
    +
    +
    +
    + ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + + const accountName = + ownProps.accountname || + currentUser || + ''; + + const account = accountName + ? state.global.getIn(['accounts', accountName]) + : null; + + return { + currentUser, + accountName, + account, + }; + }, + dispatch => ({ + changeRecoveryAccount: ( + account, + current_recovery_account, + pending_recovery_account, + new_account, + successCallback, + errorCallback + ) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${account}/transfers` })); + if (successCallback) successCallback(); + }; + + const operation = { + account_to_recover: account, + new_recovery_account: new_account, + extensions: [], + }; + + const confirm = () => ( + pending_recovery_account + ? ( + + ) : ( + + ) + ); + + dispatch( + transactionActions.broadcastOperation({ + type: 'change_recovery_account', + operation, + successCallback: successCb, + errorCallback, + confirm, + }) + ); + }, + refreshAccount: username => + dispatch( + userActions.refreshAccount({ + username, + }) + ), + removeNotification: key => dispatch(appActions.removeNotification({ key })), + }) +)(ChangeRecovery); diff --git a/src/app/components/elements/SteemToolsContent/sections/ClaimDiscounted.jsx b/src/app/components/elements/SteemToolsContent/sections/ClaimDiscounted.jsx new file mode 100644 index 000000000..beef1a3e1 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/ClaimDiscounted.jsx @@ -0,0 +1,820 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { api } from '@steemit/steem-js'; +import { FormattedHTMLMessage } from 'app/Translator'; +import { numberWithCommas } from 'app/utils/StateFunctions'; + +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import Icon from 'app/components/elements/Icon'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as userActions from 'app/redux/UserReducer'; +import * as appActions from 'app/redux/AppReducer'; +import ConfirmClaimAct from './ConfirmClaimAct'; + +let STEEM_RC_REGEN_TIME = 60 * 60 * 24 * 5; // 5 days +let STEEM_BLOCK_INTERVAL = 3; +let STEEM_NUM_RESOURCE_TYPES = 5; + +let RESOURCE_HISTORY_BYTES = 0; +let RESOURCE_NEW_ACCOUNTS = 1; +let RESOURCE_MARKET_BYTES = 2; +let RESOURCE_STATE_BYTES = 3; +let RESOURCE_EXECUTION_TIME = 4; + +let RESOURCE_NAMES = [ + 'resource_history_bytes', + 'resource_new_accounts', + 'resource_market_bytes', + 'resource_state_bytes', + 'resource_execution_time', +]; + +function computeRcCostOfResource(curveParams, currentPool, resourceCount, rcRegen) { + if (resourceCount <= BigInt(0)) return BigInt(0); + + let coeffA = BigInt(curveParams.coeff_a); + let coeffB = BigInt(curveParams.coeff_b); + let shift = BigInt(curveParams.shift); + + let num = rcRegen * coeffA; + num = num >> shift; + num = num + BigInt(1); + num = num * resourceCount; + + let denom = coeffB; + if (currentPool > BigInt(0)) denom = denom + currentPool; + + let result = num / denom; + return result + BigInt(1); +} + +function regenerateMana(currentMana, lastUpdateTime, maxMana, nowSec) { + let maxManaBig = BigInt(maxMana); + let manaBig = BigInt(currentMana); + + let elapsed = nowSec - Number(lastUpdateTime); + if (elapsed <= 0) return manaBig; + + let regenAmount = + (maxManaBig * BigInt(elapsed)) / BigInt(STEEM_RC_REGEN_TIME); + manaBig = manaBig + regenAmount; + + if (manaBig > maxManaBig) manaBig = maxManaBig; + return manaBig; +} + +function getClaimAccountResourceUsage(sizeInfo) { + let TX_SIZE = BigInt(120); + + let txObjBase = + sizeInfo && sizeInfo.transaction_object_base_size + ? BigInt(sizeInfo.transaction_object_base_size) + : BigInt(35) * BigInt(174); + + let txObjByte = + sizeInfo && sizeInfo.transaction_object_byte_size + ? BigInt(sizeInfo.transaction_object_byte_size) + : BigInt(174); + + let resourceCount = new Array(STEEM_NUM_RESOURCE_TYPES); + for (let i = 0; i < resourceCount.length; i++) resourceCount[i] = BigInt(0); + + resourceCount[RESOURCE_HISTORY_BYTES] = TX_SIZE; + resourceCount[RESOURCE_NEW_ACCOUNTS] = BigInt(1); + resourceCount[RESOURCE_MARKET_BYTES] = BigInt(0); + resourceCount[RESOURCE_STATE_BYTES] = txObjBase + txObjByte * TX_SIZE; + resourceCount[RESOURCE_EXECUTION_TIME] = BigInt(10000); + + return resourceCount; +} + +function computeClaimAccountCost(resourceParams, resourcePool, rcRegen, sizeInfo) { + let resourceUsage = getClaimAccountResourceUsage(sizeInfo); + let totalCost = BigInt(0); + + for (let i = 0; i < STEEM_NUM_RESOURCE_TYPES; i++) { + let name = RESOURCE_NAMES[i]; + let params = resourceParams ? resourceParams[name] : null; + let poolObj = resourcePool ? resourcePool[name] : null; + + if (!params || !poolObj) continue; + + let resourceDynamicsParams = params.resource_dynamics_params; + let resourceUnit = + resourceDynamicsParams && resourceDynamicsParams.resource_unit + ? BigInt(resourceDynamicsParams.resource_unit) + : BigInt(1); + + let pool = + poolObj && poolObj.pool !== undefined ? BigInt(poolObj.pool) : BigInt(0); + let curveParams = params.price_curve_params; + + let scaledUsage = resourceUsage[i] * resourceUnit; + + let c = + rcRegen > BigInt(0) + ? computeRcCostOfResource(curveParams, pool, scaledUsage, rcRegen) + : BigInt(0); + + totalCost = totalCost + c; + } + + return totalCost; +} + +function isInvalidErrorValue(value) { + if ( + value === false || + value === 0 || + value === null || + value === undefined + ) { + return true; + } + + let text = ''; + + if (typeof value === 'string') { + text = value; + } else if (value instanceof Error) { + text = value.message || String(value); + } else { + try { + text = String(value); + } catch (e) { + return true; + } + } + + let normalized = text.trim().toLowerCase(); + + if (!normalized) return true; + if (normalized === '0') return true; + if (normalized === 'false') return true; + if (normalized === 'null') return true; + if (normalized === 'undefined') return true; + if (normalized.includes('undefined')) return true; + + return false; +} + +function normalizeErrorMessage(value, fallback = tt('g.error')) { + if (isInvalidErrorValue(value)) { + return fallback; + } + + if (typeof value === 'string') { + return value.trim(); + } + + if (value instanceof Error) { + let msg = (value.message || String(value) || '').trim(); + return isInvalidErrorValue(msg) ? fallback : msg; + } + + try { + let msg = String(value).trim(); + return isInvalidErrorValue(msg) ? fallback : msg; + } catch (e) { + return fallback; + } +} + +class ClaimDiscounted extends React.Component { + constructor(props) { + super(props); + this.shouldComponentUpdate = shouldComponentUpdate(this, 'ClaimDiscounted'); + + this.state = { + loading: false, + error: null, + + username: props.username || '', + claimedAct: 0, + steemBalance: 0, + steemPerAct: props.steemPerActRaw || '3.000 STEEM', + + rcCurrent: BigInt(0), + rcMax: BigInt(0), + rcPerAct: BigInt(0), + + claimableByRc: 0, + claimableBySteem: 0, + + payment: 'RC', + claimAmount: 1, + }; + + this.MAX_CLAIM_AMOUNT = 600; + } + + componentDidMount() { + this.refresh(); + } + + componentDidUpdate(prevProps) { + if (prevProps.username !== this.props.username) { + let nextUsername = this.props.username || ''; + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ username: nextUsername }, () => this.refresh()); + } + if ( + prevProps.pending_claimed_accounts !== this.props.pending_claimed_accounts || + prevProps.balance !== this.props.balance || + prevProps.totalVestingShares !== this.props.totalVestingShares || + prevProps.steemPerActRaw !== this.props.steemPerActRaw || + prevProps.steemPerActNum !== this.props.steemPerActNum + ) { + this.refresh(); + } + } + + async refresh() { + console.log('refreshing claim discounted info'); + let username = this.props.accountname || this.state.username; + if (!username) return; + + this.setState({ loading: true, error: null }); + try { + let calls = await Promise.all([ + api.callAsync('rc_api.find_rc_accounts', { accounts: [username] }), + api.callAsync('rc_api.get_resource_params', {}), + api.callAsync('rc_api.get_resource_pool', {}), + ]); + + let rcAccRes = calls[0]; + let resParamsRes = calls[1]; + let resPoolRes = calls[2]; + + let claimedAct = Number(this.props.pending_claimed_accounts || 0); + let steemBalance = parseFloat(String(this.props.balance || '0.000')) || 0; + let steemPerActRaw = this.props.steemPerActRaw || '3.000 STEEM'; + + let totalVestingSharesRaw = String(this.props.totalVestingShares || '0.00'); + let totalVestingShares = BigInt(totalVestingSharesRaw.replace('.', '')); + + let rcRegenDivisor = BigInt(STEEM_RC_REGEN_TIME / STEEM_BLOCK_INTERVAL); + let rcRegen = + rcRegenDivisor > BigInt(0) ? totalVestingShares / rcRegenDivisor : BigInt(0); + + let rcAcc = + rcAccRes && rcAccRes.rc_accounts && rcAccRes.rc_accounts.length + ? rcAccRes.rc_accounts[0] + : null; + + let nowSec = Math.floor(Date.now() / 1000); + + let rcCurrent = rcAcc + ? regenerateMana( + rcAcc.rc_manabar.current_mana, + rcAcc.rc_manabar.last_update_time, + rcAcc.max_rc, + nowSec + ) + : BigInt(0); + + let rcMax = rcAcc ? BigInt(rcAcc.max_rc) : BigInt(0); + + let resourceParams = + resParamsRes && resParamsRes.resource_params ? resParamsRes.resource_params : {}; + let resourcePool = + resPoolRes && resPoolRes.resource_pool ? resPoolRes.resource_pool : {}; + + let sizeInfo = + resParamsRes && resParamsRes.size_info && resParamsRes.size_info.resource_state_bytes + ? resParamsRes.size_info.resource_state_bytes + : null; + + let rcPerAct = computeClaimAccountCost(resourceParams, resourcePool, rcRegen, sizeInfo); + + let claimableByRc = rcPerAct > BigInt(0) ? Number(rcCurrent / rcPerAct) : 0; + + let steemPerActNum = Number(this.props.steemPerActNum || 3); + let claimableBySteem = steemPerActNum > 0 ? Math.floor(steemBalance / steemPerActNum) : 0; + + let payment = this.state.payment; + let maxClaimable = payment === 'RC' ? claimableByRc : claimableBySteem; + let maxAllowed = Math.min(maxClaimable, this.MAX_CLAIM_AMOUNT); + + let nextClaimAmount = this.state.claimAmount || 1; + if (nextClaimAmount > Math.max(maxAllowed, 1)) nextClaimAmount = Math.max(maxAllowed, 1); + if (nextClaimAmount < 1) nextClaimAmount = 1; + + this.setState({ + claimedAct: claimedAct, + steemBalance: steemBalance, + steemPerAct: steemPerActRaw, + + rcCurrent: rcCurrent, + rcMax: rcMax, + rcPerAct: rcPerAct, + + claimableByRc: claimableByRc, + claimableBySteem: claimableBySteem, + + claimAmount: nextClaimAmount, + loading: false, + }); + } catch (e) { + console.error('ClaimDiscounted.refresh', e); + this.setState({ + loading: false, + error: normalizeErrorMessage( + e, + tt('steem_tools.claim_act.unexpected_error') + ), + }); + } + } + + onChangePayment = (e) => { + let payment = e.target.value; + let maxClaimable = payment === 'RC' ? this.state.claimableByRc : this.state.claimableBySteem; + let maxAllowed = Math.min(maxClaimable, this.MAX_CLAIM_AMOUNT); + + let nextClaimAmount = this.state.claimAmount || 1; + if (nextClaimAmount > Math.max(maxAllowed, 1)) nextClaimAmount = Math.max(maxAllowed, 1); + if (nextClaimAmount < 1) nextClaimAmount = 1; + + this.setState({ payment: payment, claimAmount: nextClaimAmount }); + }; + + onChangeClaimAmount = (e) => { + let v = Number(e.target.value); + let maxClaimable = + this.state.payment === 'RC' ? this.state.claimableByRc : this.state.claimableBySteem; + let maxAllowed = Math.min(maxClaimable, this.MAX_CLAIM_AMOUNT); + + let safe = Number.isFinite(v) ? v : 1; + let next = safe; + if (next < 1) next = 1; + if (next > Math.max(maxAllowed, 1)) next = Math.max(maxAllowed, 1); + + this.setState({ claimAmount: next }); + }; + + onClaim = () => { + let username = this.state.username; + if (!this.props.isLoggedIn || !username) { + this.setState({ error: tt('steem_tools.claim_act.login_required') }); + return; + } + + let payment = this.state.payment; + let claimAmount = this.state.claimAmount; + + let claimable = payment === 'RC' ? this.state.claimableByRc : this.state.claimableBySteem; + let maxAllowed = Math.min(claimable, this.MAX_CLAIM_AMOUNT); + + if (claimAmount < 1 || claimAmount > maxAllowed) { + this.setState({ error: tt('steem_tools.claim_act.invalid_amount') }); + return; + } + + let fee = payment === 'RC' ? '0.000 STEEM' : this.props.steemPerActRaw; + + this.setState({ loading: true, error: null }); + + this.props.claimAct({ + creator: username, + fee, + payment, + claimAmount, + claimable, + rcPerAct: this.state.rcPerAct, + steemPerAct: this.state.steemPerAct, + optimisticUpdate: () => { + let nextClaimed = Number(this.state.claimedAct || 0) + Number(claimAmount || 0); + + let nextRcCurrent = this.state.rcCurrent; + let nextSteemBalance = this.state.steemBalance; + + if (payment === 'RC') { + let spent = BigInt(0); + try { + spent = BigInt(this.state.rcPerAct || BigInt(0)) * BigInt(Number(claimAmount || 0)); + } catch (e) { + spent = BigInt(0); + } + nextRcCurrent = nextRcCurrent > spent ? nextRcCurrent - spent : BigInt(0); + } else { + let steemPerActNum = Number(this.props.steemPerActNum || 3); + let spentSteem = steemPerActNum * Number(claimAmount || 0); + nextSteemBalance = Math.max(0, Number(nextSteemBalance || 0) - spentSteem); + } + + let nextClaimableByRc = + this.state.rcPerAct > BigInt(0) ? Number(nextRcCurrent / this.state.rcPerAct) : 0; + + let steemPerActNum2 = Number(this.props.steemPerActNum || 3); + let nextClaimableBySteem = + steemPerActNum2 > 0 ? Math.floor(nextSteemBalance / steemPerActNum2) : 0; + + let nextMaxClaimable = + payment === 'RC' ? nextClaimableByRc : nextClaimableBySteem; + let nextMaxAllowed = Math.min(nextMaxClaimable, this.MAX_CLAIM_AMOUNT); + + let nextClaimAmount = this.state.claimAmount || 1; + if (nextClaimAmount > Math.max(nextMaxAllowed, 1)) + nextClaimAmount = Math.max(nextMaxAllowed, 1); + if (nextClaimAmount < 1) nextClaimAmount = 1; + console.log('optimistic update', { + nextClaimed, + nextRcCurrent: String(nextRcCurrent), + nextSteemBalance, + nextClaimableByRc, + nextClaimableBySteem, + nextClaimAmount, + }); + + this.setState({ + claimedAct: nextClaimed, + rcCurrent: nextRcCurrent, + steemBalance: nextSteemBalance, + claimableByRc: nextClaimableByRc, + claimableBySteem: nextClaimableBySteem, + claimAmount: nextClaimAmount, + }); + }, + successCallback: () => { + this.setState({ loading: false }); + }, + errorCallback: (err) => { + this.setState({ + loading: false, + error: normalizeErrorMessage( + err, + tt('steem_tools.claim_act.unexpected_error') + ), + }); + }, + }); + }; + + render() { + let loading = this.state.loading; + let rawError = this.state.error; + let error = isInvalidErrorValue(rawError) + ? null + : normalizeErrorMessage( + rawError, + tt('steem_tools.claim_act.unexpected_error') + ); + + let username = this.state.username || this.props.accountname; + let claimedAct = this.state.claimedAct; + + let payment = this.state.payment; + let claimAmount = this.state.claimAmount; + + let claimableByRc = this.state.claimableByRc; + let claimableBySteem = this.state.claimableBySteem; + + let steemPerAct = this.state.steemPerAct; + let rcPerAct = this.state.rcPerAct; + + let claimable = payment === 'RC' ? claimableByRc : claimableBySteem; + let maxAllowed = Math.min(claimable, this.MAX_CLAIM_AMOUNT); + + let tooltipRc = tt('steem_tools.claim_act.claimable_rc_tooltip', {rc_per_act: numberWithCommas(String(rcPerAct))}) + let tooltipSteem = tt('steem_tools.claim_act.claimable_steem_tooltip', {steem_per_act: String(steemPerAct)}) + + let canClaim = + Boolean(this.props.isLoggedIn) && + !loading && + Number.isFinite(Number(claimAmount)) && + Number(claimAmount) >= 1 && + Number(claimAmount) <= Number(maxAllowed); + + return ( +
    +
    +
    +

    {tt('steem_tools.claim_act.title')}

    +
    + +
    +
    +
    + +
    +
    +
    + +
    + {loading ? ( +
    +
    + +
    +
    + ) : ( +
    +
    +
    +
    +
    {tt('steem_tools.claim_act.account')}
    +
    +
    + +
    +
    + @ + +
    +
    +
    + +
    +
    + {tt('steem_tools.claim_act.claimed')} +
    + +
    +
    + + + {tt('steem_tools.claim_act.act_suffix')} + +
    +
    +
    + +
    +
    +
    +
    {tt('steem_tools.claim_act.claimable')}
    +
    +
    + +
    +
    +
    +
    +
    {tt('steem_tools.claim_act.based_on_rc')}
    +
    + + {tooltipRc} +
    +
    + +
    + +
    +
    + +
    +
    +
    {tt('steem_tools.claim_act.based_on_steem')}
    +
    + + {tooltipSteem} +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    {tt('steem_tools.claim_act.payment')}
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    {tt('steem_tools.claim_act.amount')}
    +
    + +
    +
    + + + {tt('steem_tools.claim_act.act_suffix')} + +
    + +
    + {payment === 'RC' + ? `${numberWithCommas(String(rcPerAct))} RC per ACT` + : `${String(steemPerAct)} per ACT`} +
    +
    +
    + + {error ? ( +
    +
    +
    {error}
    +
    +
    + ) : null} + +
    +
    + +
    +
    +
    + )} +
    +
    +
    + ); + } +} + +export default connect( + function mapState(state, ownProps) { + let current = + state && state.user && state.user.get ? state.user.get('current') : null; + + let username = + current && current.get && current.get('username') ? current.get('username') : ''; + + let account = ownProps.accountname + ? state.global.getIn(['accounts', ownProps.accountname]) + : null; + + let steem_balance = + account && account.get && account.get('balance') ? account.get('balance') : '0.000 STEEM'; + + let pending_claimed_accounts = + account && account.get && account.get('pending_claimed_accounts') + ? account.get('pending_claimed_accounts') + : 0; + + let balance = String(steem_balance).split(' ')[0] || '0.000'; + + let totalVestingShares = + state.global.getIn(['props', 'total_vesting_shares']) + ? String(state.global.getIn(['props', 'total_vesting_shares'])).split(' ')[0] + : '0.00'; + + let DEFAULT_ACCOUNT_CREATION_FEE = '3.000 STEEM'; + + let witness_schedule = state.global.get ? state.global.get('witness_schedule') : null; + let median_props = + witness_schedule && witness_schedule.get ? witness_schedule.get('median_props') : null; + let account_creation_fee = + median_props && median_props.get ? median_props.get('account_creation_fee') : null; + + let steemPerActRaw = + account_creation_fee && String(account_creation_fee).includes(' ') + ? String(account_creation_fee) + : DEFAULT_ACCOUNT_CREATION_FEE; + + let steemPerActNum = steemPerActRaw + ? parseFloat(String(steemPerActRaw).split(' ')[0]) || 3 + : 3; + + let accounts = state.global.get ? state.global.get('accounts') : null; + let isLoggedIn = !!username; + + return { + ...ownProps, + username: username || '', + isLoggedIn, + balance, + accounts, + pending_claimed_accounts, + totalVestingShares, + steemPerActNum, + steemPerActRaw, + }; + }, + function mapDispatch(dispatch) { + return { + claimAct: ({ + creator, + fee, + payment, + claimAmount, + claimable, + rcPerAct, + steemPerAct, + optimisticUpdate, + successCallback, + errorCallback, + }) => { + let confirm = () => ( + + ); + + let refreshAndNotify = () => { + dispatch(userActions.refreshAccount({ owner: creator })); + dispatch( + appActions.addNotification({ + key: 'claim_act_' + Date.now(), + message: tt('steem_tools.claim_act.claim_notification', { + count: claimAmount, + }), + dismissAfter: 5000, + }) + ); + }; + + let operations = []; + for (let i = 0; i < claimAmount; i++) { + operations.push([ + 'claim_account', + { + creator: creator, + fee: fee, + extensions: [], + }, + ]); + } + + dispatch( + transactionActions.broadcastOperations({ + operations, + confirm, + confirmTitle: tt('steem_tools.claim_act.confirm_title'), + successCallback: () => { + refreshAndNotify(); + if (optimisticUpdate) optimisticUpdate(); + if (successCallback) successCallback(); + }, + errorCallback: (err) => { + if (errorCallback) errorCallback(err); + }, + }) + ); + }, + }; + } +)(ClaimDiscounted); diff --git a/src/app/components/elements/SteemToolsContent/sections/ConfirmClaimAct.jsx b/src/app/components/elements/SteemToolsContent/sections/ConfirmClaimAct.jsx new file mode 100644 index 000000000..18abdc5d1 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/ConfirmClaimAct.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +const ConfirmClaimAct = ({ operation }) => { + const { account, payment, fee, claimAmount, claimable, rcPerAct, steemPerAct } = operation; + + const paymentLabel = payment === 'RC' ? tt('steem_tools.claim_act.pay_with_rc') : tt('steem_tools.claim_act.pay_with_steem'); + + const perActLabel = payment === 'RC' + ? String(rcPerAct) + : String(steemPerAct); + + return ( +
    +
    + + {tt('steem_tools.claim_act.account')} + + +
    + +
    + + {tt('steem_tools.claim_act.payment')} + + +
    + +
    + + {tt('steem_tools.claim_act.modal_per_act')} + + + + {paymentLabel} + +
    + +
    + + {tt('steem_tools.claim_act.modal_amount')} + + + + {tt('steem_tools.claim_act.act_suffix')} + +
    +
    + ); +}; + + + +ConfirmClaimAct.propTypes = { + operation: PropTypes.shape({ + account: PropTypes.string.isRequired, + payment: PropTypes.oneOf(['RC', 'STEEM']).isRequired, + fee: PropTypes.string.isRequired, + claimAmount: PropTypes.number.isRequired, + claimable: PropTypes.number.isRequired, + rcPerAct: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + steemPerAct: PropTypes.string.isRequired, + }).isRequired, +}; + +export default ConfirmClaimAct; diff --git a/src/app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount.jsx b/src/app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount.jsx new file mode 100644 index 000000000..41c5a7900 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +var ConfirmCreateAccount = function(props) { + var operation = props.operation; + var creator = operation.creator; + var newAccount = operation.newAccount; + var paymentMode = operation.paymentMode; + var fee = operation.fee; + + var paymentLabel = paymentMode === 'TOKEN' + ? tt('steem_tools.create_account.confirm_payment_act') + : tt('steem_tools.create_account.confirm_payment_steem', { fee: fee }); + + return ( +
    +
    + + {tt('steem_tools.create_account.confirm_creator')} + + +
    + +
    + + {tt('steem_tools.create_account.confirm_new_account')} + + +
    + +
    + + {tt('steem_tools.create_account.confirm_payment')} + + +
    + +
    + {tt('steem_tools.create_account.confirm_note')} +
    +
    + ); +}; + +ConfirmCreateAccount.propTypes = { + operation: PropTypes.shape({ + creator: PropTypes.string.isRequired, + newAccount: PropTypes.string.isRequired, + paymentMode: PropTypes.oneOf(['TOKEN', 'STEEM']).isRequired, + fee: PropTypes.string, + }).isRequired, +}; + +export default ConfirmCreateAccount; diff --git a/src/app/components/elements/SteemToolsContent/sections/CreateAccount.jsx b/src/app/components/elements/SteemToolsContent/sections/CreateAccount.jsx new file mode 100644 index 000000000..cce805b38 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/CreateAccount.jsx @@ -0,0 +1,794 @@ + +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { PrivateKey } from '@steemit/steem-js/lib/auth/ecc'; +import { api } from '@steemit/steem-js'; +import PdfDownload from 'app/components/elements/PdfDownload'; +import ConfirmCreateAccount from 'app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount'; +import { FormattedHTMLMessage } from 'app/Translator'; + +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as userActions from 'app/redux/UserReducer'; +import * as appActions from 'app/redux/AppReducer'; +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; +import { validate_account_name } from 'app/utils/ChainValidation'; + +function isInvalidErrorValue(value) { + if ( + value === false || + value === 0 || + value === null || + value === undefined + ) { + return true; + } + + let text = ''; + + if (typeof value === 'string') { + text = value; + } else if (value instanceof Error) { + text = value.message || String(value); + } else { + try { + text = String(value); + } catch (e) { + return true; + } + } + + let normalized = text.trim().toLowerCase(); + + if (!normalized) return true; + if (normalized === '0') return true; + if (normalized === 'false') return true; + if (normalized === 'null') return true; + if (normalized === 'undefined') return true; + if (normalized.includes('undefined')) return true; + + return false; +} + +function normalizeErrorMessage(value, fallback = tt('g.error')) { + if (isInvalidErrorValue(value)) { + return fallback; + } + + if (typeof value === 'string') { + return value.trim(); + } + + if (value instanceof Error) { + let msg = (value.message || String(value) || '').trim(); + return isInvalidErrorValue(msg) ? fallback : msg; + } + + try { + let msg = String(value).trim(); + return isInvalidErrorValue(msg) ? fallback : msg; + } catch (e) { + return fallback; + } +} + +class CreateAccount extends React.Component { + constructor(props) { + super(props); + this.shouldComponentUpdate = shouldComponentUpdate(this, 'CreateAccount'); + + this.state = { + loading: false, + error: null, + + creator: props.accountname || props.username || '', + createe: '', + + active_priv: '', + active_pub: '', + posting_priv: '', + posting_pub: '', + owner_priv: '', + owner_pub: '', + memo_priv: '', + memo_pub: '', + + paymentMode: 'TOKEN', + hasExported: false, + acknowledged: false, + successMessage: null, + dlPdf: false, + + useMasterPassword: true, + masterPassword: '', + nameError: null, + nameAvailable: null, + isCheckingName: false, + }; + this.checkAccountNameTimer = null; + } + + componentDidMount() { + this.generateMasterPassword(); + } + + componentDidUpdate(prevProps) { + if ( + prevProps.username !== this.props.username || + prevProps.accountname !== this.props.accountname + ) { + let nextUsername = this.props.accountname || this.props.username || ''; + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ creator: nextUsername }); + } + } + + componentWillUnmount() { + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + if (this.deriveKeysTimer) { + clearTimeout(this.deriveKeysTimer); + } + } + + onChange = (e) => { + let name = e.target.name; + let value = e.target.value; + + if (name.includes('_pub') || name.includes('_priv')) { + value = value.replace(/\s/g, ''); + } + + this.setState({ [name]: value, successMessage: null, error: null }); + + if (name.includes('_pub') || name.includes('_priv') || name === 'createe') { + this.setState({ hasExported: false, acknowledged: false }); + } + }; + + onPaymentModeChange = (e) => { + this.setState({ paymentMode: e.target.value }); + }; + + onAcknowledgeToggle = (e) => { + this.setState({ acknowledged: e.target.checked }); + }; + + onToggleMasterPassword = (e) => { + var checked = e.target.checked; + if (checked) { + this.setState({ + useMasterPassword: true, + hasExported: false, + acknowledged: false, + }); + } else { + this.setState({ + useMasterPassword: false, + masterPassword: '', + active_priv: '', active_pub: '', + posting_priv: '', posting_pub: '', + owner_priv: '', owner_pub: '', + memo_priv: '', memo_pub: '', + hasExported: false, + acknowledged: false, + }); + } + }; + + onMasterPasswordChange = (e) => { + var password = e.target.value.replace(/\s/g, ''); + this.setState({ masterPassword: password, hasExported: false, acknowledged: false }, () => { + if (this.deriveKeysTimer) { + clearTimeout(this.deriveKeysTimer); + } + this.deriveKeysTimer = setTimeout(() => { + this.deriveKeysFromMaster(password); + }, 300); + }); + }; + + generateMasterPassword = () => { + var randomSeed = Date.now() + '-' + Math.random().toString(36).substring(2, 15) + '-' + Math.random().toString(36).substring(2, 15); + var privateKey = PrivateKey.fromSeed(randomSeed).toWif(); + var generatedWif = 'P' + privateKey; + this.setState({ masterPassword: generatedWif, hasExported: false, acknowledged: false }, () => { + if (this.deriveKeysTimer) { + clearTimeout(this.deriveKeysTimer); + } + this.deriveKeysTimer = setTimeout(() => { + this.deriveKeysFromMaster(generatedWif); + }, 300); + }); + }; + + deriveKeysFromMaster = (password) => { + var createe = this.state.createe; + var nameValidationError = createe ? validate_account_name(createe) : true; + + if (!createe || !password || nameValidationError) { + this.setState({ + active_priv: '', active_pub: '', + posting_priv: '', posting_pub: '', + owner_priv: '', owner_pub: '', + memo_priv: '', memo_pub: '', + }); + return; + } + + try { + var roles = ['active', 'posting', 'owner', 'memo']; + var newState = {}; + roles.forEach(function(role) { + var pk = PrivateKey.fromSeed(createe + role + password); + newState[role + '_priv'] = pk.toWif(); + newState[role + '_pub'] = pk.toPublicKey().toString(); + }); + this.setState(newState); + } catch (e) { + this.setState({ error: String(e.message) }); + } + }; + + onCreateeChange = (e) => { + var value = e.target.value.replace(/\s/g, '').toLowerCase(); + var nameValidationError = value ? validate_account_name(value) : null; + this.setState({ + createe: value, + nameError: nameValidationError, + nameAvailable: null, + isCheckingName: false, + successMessage: null, + error: null, + hasExported: false, + acknowledged: false + }, () => { + if (this.state.useMasterPassword && this.state.masterPassword) { + if (this.deriveKeysTimer) { + clearTimeout(this.deriveKeysTimer); + } + this.deriveKeysTimer = setTimeout(() => { + this.deriveKeysFromMaster(this.state.masterPassword); + }, 300); + } + }); + + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + + if (value && !nameValidationError) { + this.checkAccountName(value); + } + }; + + checkAccountName = (username) => { + this.setState({ nameAvailable: null }); + this.checkAccountNameTimer = setTimeout(() => { + this.setState({ isCheckingName: true }); + const normalizedUsername = username.trim().toLowerCase(); + api.callAsync('condenser_api.lookup_accounts', [normalizedUsername, 1]) + .then(accounts => { + const exists = Array.isArray(accounts) && accounts.length > 0 && String(accounts[0]).toLowerCase() === normalizedUsername; + if (this.state.createe === normalizedUsername) { + this.setState({ + nameAvailable: !exists, + isCheckingName: false, + nameError: exists ? tt('steem_tools.create_account.error_account_exists') : null + }); + } + }) + .catch(e => { + console.error('API Error checking account name:', e); + if (this.state.createe === normalizedUsername) { + this.setState({ isCheckingName: false }); + } + }); + }, 500); + }; + + onExportKeys = () => { + var st = this.state; + + if (!st.createe) { + this.setState({ error: tt('steem_tools.create_account.error_no_account_export') }); + return; + } + + var content = 'Account: ' + st.createe + '\n'; + if (st.useMasterPassword && st.masterPassword) { + content += 'Master Password: ' + st.masterPassword + '\n'; + } + content += '\n'; + + var roles = [ + { id: 'owner', pub: st.owner_pub, priv: st.owner_priv }, + { id: 'active', pub: st.active_pub, priv: st.active_priv }, + { id: 'posting', pub: st.posting_pub, priv: st.posting_priv }, + { id: 'memo', pub: st.memo_pub, priv: st.memo_priv } + ]; + + roles.forEach(function(role) { + content += role.id.toUpperCase() + ' KEYS\n'; + if (role.priv) content += 'Private: ' + role.priv + '\n'; + content += 'Public: ' + role.pub + '\n\n'; + }); + + var element = document.createElement("a"); + var file = new Blob([content], { type: 'text/plain' }); + element.href = URL.createObjectURL(file); + element.download = st.createe + '_steem_keys.txt'; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + + this.setState({ hasExported: true, error: null }); + }; + + onExportPdf = () => { + var st = this.state; + if (!st.createe) { + this.setState({ error: tt('steem_tools.create_account.error_no_account_export') }); + return; + } + this.setState({ dlPdf: true, hasExported: true, error: null }); + }; + + resetDlPdf = () => { + this.setState({ dlPdf: false }); + }; + + onCreateAccount = () => { + var st = this.state; + + if (st.nameError) { + this.setState({ error: st.nameError }); + return; + } + + if (!st.creator || !st.createe) { + this.setState({ error: tt('steem_tools.create_account.error_no_creator') }); + return; + } + + if (!st.active_pub || !st.posting_pub || !st.owner_pub || !st.memo_pub) { + this.setState({ error: tt('steem_tools.create_account.error_no_keys') }); + return; + } + + if (!st.hasExported || !st.acknowledged) { + this.setState({ error: tt('steem_tools.create_account.error_no_export') }); + return; + } + + this.setState({ loading: true, error: null, successMessage: null }); + + var ownerParams = { weight_threshold: 1, account_auths: [], key_auths: [[st.owner_pub, 1]] }; + var activeParams = { weight_threshold: 1, account_auths: [], key_auths: [[st.active_pub, 1]] }; + var postingParams = { weight_threshold: 1, account_auths: [], key_auths: [[st.posting_pub, 1]] }; + + var operationType = st.paymentMode === 'TOKEN' ? 'create_claimed_account' : 'account_create'; + var operationDetails = { + creator: st.creator, + new_account_name: st.createe, + owner: ownerParams, + active: activeParams, + posting: postingParams, + memo_key: st.memo_pub, + json_metadata: "", + extensions: [], + }; + + if (st.paymentMode === 'STEEM') { + operationDetails.fee = this.props.steemPerActRaw; + } + + var self = this; + var accountName = st.createe; + + var refreshAndNotify = function() { + self.props.notifyAction(accountName); + self.setState({ + loading: false, + successMessage: tt('steem_tools.create_account.success_created', { account: accountName }), + createe: '', + masterPassword: '', + active_priv: '', active_pub: '', + posting_priv: '', posting_pub: '', + owner_priv: '', owner_pub: '', + memo_priv: '', memo_pub: '', + hasExported: false, acknowledged: false + }); + }; + + var confirmData = { + creator: st.creator, + newAccount: st.createe, + paymentMode: st.paymentMode, + fee: st.paymentMode === 'STEEM' ? this.props.steemPerActRaw : '', + }; + + this.props.broadcastCreate(operationType, operationDetails, confirmData, refreshAndNotify, function(err) { + self.setState({ loading: false, error: String(err) }); + }); + }; + + render() { + var st = this.state; + var loading = st.loading; + var rawError = st.error; + var error = isInvalidErrorValue(rawError) + ? null + : normalizeErrorMessage( + rawError, + tt('g.error') + ); + var successMessage = st.successMessage; + var creator = st.creator; + var createe = st.createe; + var paymentMode = st.paymentMode; + var hasExported = st.hasExported; + var acknowledged = st.acknowledged; + var useMasterPassword = st.useMasterPassword; + var masterPassword = st.masterPassword; + var nameAvailable = st.nameAvailable; + var isCheckingName = st.isCheckingName; + + var pendingTokens = Number(this.props.pending_claimed_accounts || 0); + var steemBalance = this.props.balance || '0.000 STEEM'; + var steemFeeRaw = this.props.steemPerActRaw; + + + + var canCreate = hasExported && acknowledged && !isCheckingName && nameAvailable === true; + var hasKeys = st.active_pub && st.posting_pub && st.owner_pub && st.memo_pub; + + var rolesData = [ + { id: 'owner', label: tt('steem_tools.create_account.owner_key'), publicVal: st.owner_pub, privateVal: st.owner_priv, pubName: 'owner_pub', privName: 'owner_priv' }, + { id: 'active', label: tt('steem_tools.create_account.active_key'), publicVal: st.active_pub, privateVal: st.active_priv, pubName: 'active_pub', privName: 'active_priv' }, + { id: 'posting', label: tt('steem_tools.create_account.posting_key'), publicVal: st.posting_pub, privateVal: st.posting_priv, pubName: 'posting_pub', privName: 'posting_priv' }, + { id: 'memo', label: tt('steem_tools.create_account.memo_key'), publicVal: st.memo_pub, privateVal: st.memo_priv, pubName: 'memo_pub', privName: 'memo_priv' } + ]; + + return ( +
    +
    +
    +

    {tt('steem_tools.create_account.panel_title')}

    +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    {tt('steem_tools.create_account.creator_label')}
    +
    +
    +
    +
    + @ + +
    +
    +
    + +
    +
    +
    +
    {tt('steem_tools.create_account.new_account_label')}
    +
    +
    +
    +
    + @ + + {isCheckingName && ( +
    + +
    + )} +
    + {st.nameError && ( +
    + {st.nameError} +
    + )} + {nameAvailable === true && !st.nameError && ( +
    + {tt('steem_tools.create_account.success_account_available')} +
    + )} +
    +
    + +
    +
    +
    +
    {tt('steem_tools.create_account.payment_mode_label')}
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + {tt('steem_tools.create_account.keys_description')} + +
    +
    +
    + +
    +
    + + {tt('steem_tools.create_account.use_master_password')} + + +
    +
    + + {useMasterPassword && ( +
    +
    +
    +
    {tt('steem_tools.create_account.master_password_label')}
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    + )} + + {rolesData.map(function(role) { + return ( +
    +
    +
    {role.label}
    +
    + {useMasterPassword && ( +
    + +
    + )} +
    + +
    +
    +
    +
    + ); + }.bind(this))} + + {error ? ( +
    +
    +
    {error}
    +
    +
    + ) : null} + + {successMessage ? ( +
    +
    +
    {successMessage}
    +
    +
    + ) : null} + +
    +
    + + {tt('steem_tools.create_account.acknowledge_warning')} + + +
    +
    + +
    +
    + + + +    + +
    +
    +
    +
    + + +
    + ); + } +} + +export default connect( + function mapState(state, ownProps) { + var current = + state && state.user && state.user.get ? state.user.get('current') : null; + var username = + current && current.get && current.get('username') ? current.get('username') : ''; + + var account = ownProps.accountname + ? state.global.getIn(['accounts', ownProps.accountname]) + : username + ? state.global.getIn(['accounts', username]) + : null; + + var steem_balance = account && account.get && account.get('balance') ? account.get('balance') : '0.000 STEEM'; + var pending_claimed_accounts = account && account.get && account.get('pending_claimed_accounts') ? account.get('pending_claimed_accounts') : 0; + + var DEFAULT_ACCOUNT_CREATION_FEE = '3.000 STEEM'; + var witness_schedule = state.global.get ? state.global.get('witness_schedule') : null; + var median_props = witness_schedule && witness_schedule.get ? witness_schedule.get('median_props') : null; + var account_creation_fee = median_props && median_props.get ? median_props.get('account_creation_fee') : null; + + var steemPerActRaw = account_creation_fee && String(account_creation_fee).includes(' ') + ? String(account_creation_fee) + : DEFAULT_ACCOUNT_CREATION_FEE; + + return { + ...ownProps, + username: username || '', + balance: steem_balance, + pending_claimed_accounts: pending_claimed_accounts, + steemPerActRaw: steemPerActRaw, + }; + }, + function mapDispatch(dispatch) { + return { + broadcastCreate: function(type, operation, confirmData, successCallback, errorCallback) { + var confirm = function() { + return ( + + ); + }; + dispatch( + transactionActions.broadcastOperation({ + type: type, + operation: operation, + confirm: confirm, + successCallback: successCallback, + errorCallback: errorCallback, + }) + ); + }, + notifyAction: function(accountName) { + dispatch( + appActions.addNotification({ + key: 'create_acc_' + Date.now(), + message: tt('steem_tools.create_account.notify_created', { account: accountName }), + dismissAfter: 5000, + }) + ); + } + }; + } +)(CreateAccount); diff --git a/src/app/components/elements/SteemToolsContent/sections/CreateWitness.jsx b/src/app/components/elements/SteemToolsContent/sections/CreateWitness.jsx new file mode 100644 index 000000000..6150026c5 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/CreateWitness.jsx @@ -0,0 +1,452 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import { api } from '@steemit/steem-js'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; +import ConfirmCreateWitness from 'app/components/elements/ConfirmCreateWitness'; + +const NULL_WITNESS_SIGNING_KEY = 'STM1111111111111111111111111111111114T1Anm'; + +class CreateWitness extends React.Component { + constructor(props) { + super(props); + this.state = { + blockSigningKey: '', + witnessUrl: '', + accountCreationFee: '0.000 STEEM', + maximumBlockSize: '65536', + sbdInterestRate: '0', + loading: false, + loadingWitnessData: false, + error: null, + success: null, + isExistingWitness: false, + }; + + this.onChange = this.onChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.loadWitnessData = this.loadWitnessData.bind(this); + this.isNullWitnessSigningKey = this.isNullWitnessSigningKey.bind(this); + this.isValidSigningKey = this.isValidSigningKey.bind(this); + } + + componentDidMount() { + this.loadWitnessData(this.props); + } + + componentDidUpdate(prevProps) { + if (prevProps.accountName !== this.props.accountName) { + this.loadWitnessData(this.props); + } + } + + onChange(e) { + const { name, value } = e.target; + let nextValue = value; + if (name === 'sbdInterestRate') { + if (value === '') { + nextValue = ''; + } else { + const numericValue = parseFloat(value); + if (!Number.isNaN(numericValue)) { + nextValue = String(Math.min(100, Math.max(0, numericValue))); + } + } + } + this.setState({ + [name]: nextValue, + error: null, + success: null, + }); + } + + isNullWitnessSigningKey(value) { + return String(value || '').trim() === NULL_WITNESS_SIGNING_KEY; + } + + isValidSigningKey(value) { + const normalized = String(value || '').trim(); + if (!normalized) return false; + return ( + normalized === NULL_WITNESS_SIGNING_KEY || + /^STM[1-9A-HJ-NP-Za-km-z]{50}$/.test(normalized) + ); + } + + async loadWitnessData(props = this.props) { + const { accountName } = props; + if (!accountName) return; + + this.setState({ loadingWitnessData: true, error: null, success: null }); + + try { + const witness = await api.getWitnessByAccountAsync(accountName); + + if (witness) { + const propsData = witness.props || {}; + const blockchainSbdInterestRate = Number(propsData.sbd_interest_rate || 0); + const displaySbdInterestRate = String(blockchainSbdInterestRate / 100); + + this.setState({ + blockSigningKey: witness.signing_key || '', + witnessUrl: witness.url || '', + accountCreationFee: propsData.account_creation_fee || '0.000 STEEM', + maximumBlockSize: String(propsData.maximum_block_size || 65536), + sbdInterestRate: displaySbdInterestRate, + isExistingWitness: true, + loadingWitnessData: false, + }); + return; + } + + this.setState({ + blockSigningKey: '', + witnessUrl: '', + accountCreationFee: '0.000 STEEM', + maximumBlockSize: '65536', + sbdInterestRate: '0', + isExistingWitness: false, + loadingWitnessData: false, + }); + } catch (error) { + this.setState({ + loadingWitnessData: false, + error: tt('steem_tools.create_witness.error_loading_witness'), + }); + } + } + + onFailure(error) { + let errorMessage = error; + if (!errorMessage || String(errorMessage).toLowerCase().includes('undefined')) { + errorMessage = tt('steem_tools.create_witness.unexpected_error'); + } + this.setState({ loading: false, error: errorMessage, success: null }); + } + + onSuccess() { + const { currentUser, refreshAccount } = this.props; + refreshAccount(currentUser); + this.setState({ + loading: false, + error: null, + success: tt('steem_tools.create_witness.success_message'), + }); + this.loadWitnessData(); + } + + onSubmit() { + const { currentUser, accountName, broadcastWitnessSetProperties } = this.props; + const { + blockSigningKey, + witnessUrl, + accountCreationFee, + maximumBlockSize, + sbdInterestRate, + } = this.state; + + if (!currentUser || currentUser !== accountName) { + this.setState({ error: tt('steem_tools.create_witness.error_not_allowed') }); + return; + } + + if (!blockSigningKey.trim() || !witnessUrl.trim()) { + this.setState({ error: tt('steem_tools.create_witness.error_no_signing_key') }); + return; + } + + if (!this.isValidSigningKey(blockSigningKey)) { + this.setState({ error: tt('steem_tools.create_witness.error_invalid_signing_key') }); + return; + } + + if (!/^https?:\/\/.+/i.test(witnessUrl.trim())) { + this.setState({ error: tt('steem_tools.create_witness.error_invalid_url') }); + return; + } + + const feeValue = parseFloat(accountCreationFee.replace(' STEEM', '')); + const parsedMaxBlockSize = parseInt(maximumBlockSize, 10); + const parsedSbdRatePercent = parseFloat(sbdInterestRate); + + if (isNaN(parsedSbdRatePercent) || parsedSbdRatePercent < 0 || parsedSbdRatePercent > 100) { + this.setState({ error: tt('steem_tools.create_witness.error_invalid_sbd_interest_rate') }); + return; + } + + const blockchainSbdInterestRate = Math.round(parsedSbdRatePercent * 100); + + const operation = { + owner: accountName, + props: { + account_creation_fee: feeValue.toFixed(3) + ' STEEM', + maximum_block_size: parsedMaxBlockSize, + sbd_interest_rate: blockchainSbdInterestRate, + url: witnessUrl.trim(), + new_signing_key: blockSigningKey.trim(), + }, + extensions: [], + }; + + this.setState({ loading: true, error: null, success: null }); + broadcastWitnessSetProperties( + operation, + this.state.isExistingWitness, + this.onSuccess, + this.onFailure + ); + } + + render() { + const { currentUser, accountName } = this.props; + const { + blockSigningKey, + witnessUrl, + accountCreationFee, + maximumBlockSize, + sbdInterestRate, + loading, + loadingWitnessData, + error, + success, + isExistingWitness, + } = this.state; + + const isOwner = !!currentUser && !!accountName && currentUser === accountName; + const isNullSigningKey = this.isNullWitnessSigningKey(blockSigningKey); + const isKeyValid = blockSigningKey === '' || this.isValidSigningKey(blockSigningKey); + const isUrlValid = witnessUrl === '' || /^https?:\/\/.+/i.test(witnessUrl.trim()); + const parsedSbdRate = parseFloat(sbdInterestRate); + const isSbdRateValid = sbdInterestRate === '' || (!Number.isNaN(parsedSbdRate) && parsedSbdRate >= 0 && parsedSbdRate <= 100); + + const canEdit = !loading && !loadingWitnessData && isOwner; + const canSubmit = + canEdit && + blockSigningKey.trim() !== '' && + witnessUrl.trim() !== '' && + isKeyValid && + isUrlValid && + isSbdRateValid; + + return ( +
    +
    +

    {tt('steem_tools.create_witness.title')}

    +
    + +
    +
    + +
    +
    + +
    +
    +
    + {tt('steem_tools.create_witness.witness_account')} +
    +
    +
    + @ + +
    +
    + {isExistingWitness + ? tt('steem_tools.create_witness.mode_update') + : tt('steem_tools.create_witness.mode_create')} +
    +
    +
    + +
    +
    + {tt('steem_tools.create_witness.block_signing_key')} +
    +
    + + {!isKeyValid ? ( +
    + {tt('steem_tools.create_witness.error_invalid_signing_key')} +
    + ) : isNullSigningKey ? ( +
    + {tt('steem_tools.create_witness.disable_signing_key_hint')} +
    + ) : ( +
    + {tt('steem_tools.create_witness.block_signing_key_hint')} +
    + )} +
    +
    + +
    +
    + {tt('steem_tools.create_witness.witness_url')} +
    +
    + + {!isUrlValid && ( +
    + {tt('steem_tools.create_witness.error_invalid_url')} +
    + )} +
    +
    + +
    +
    + {tt('steem_tools.create_witness.account_creation_fee')} +
    +
    +
    + + this.onChange({ + target: { + name: 'accountCreationFee', + value: `${e.target.value} STEEM`, + }, + }) + } + disabled={!canEdit} + /> + STEEM +
    +
    +
    + +
    +
    + {tt('steem_tools.create_witness.sbd_interest_rate')} +
    +
    +
    + + % +
    + {!isSbdRateValid && ( +
    + {tt('steem_tools.create_witness.error_invalid_sbd_interest_rate')} +
    + )} +
    +
    + + {(error || success) && ( +
    +
    + {error &&
    {error}
    } + {success &&
    {success}
    } +
    +
    + )} + +
    +
    + {loading || loadingWitnessData ? ( + + ) : ( + + )} +
    +
    +
    +
    + ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + const accountName = ownProps.accountname || currentUser || ''; + const account = accountName ? state.global.getIn(['accounts', accountName]) : null; + return { currentUser, accountName, account }; + }, + (dispatch) => ({ + broadcastWitnessSetProperties: (operation, isExistingWitness, successCallback, errorCallback) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${operation.owner}/witnesses` })); + if (successCallback) successCallback(); + }; + + const confirm = () => ( + + ); + + dispatch( + transactionActions.broadcastOperation({ + type: 'witness_set_properties', + operation, + confirm, + successCallback: successCb, + errorCallback, + }) + ); + }, + refreshAccount: (username) => dispatch(userActions.refreshAccount({ username })), + }) +)(CreateWitness); diff --git a/src/app/components/elements/SteemToolsContent/sections/DeclineVoting.jsx b/src/app/components/elements/SteemToolsContent/sections/DeclineVoting.jsx new file mode 100644 index 000000000..a00d29520 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/DeclineVoting.jsx @@ -0,0 +1,685 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { api } from '@steemit/steem-js'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; + +class DeclineVoting extends React.Component { + constructor(props) { + super(props); + this.state = { + confirmPermanentDecline: false, + loading: false, + checkingRequest: false, + hasPendingDeclineRequest: false, + declineRequest: null, + requestLookupError: null, + error: null, + success: null, + }; + + this.requestLookupSeq = 0; + this.unmounted = false; + + this.onCheckboxChange = this.onCheckboxChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.onSubmitCancel = this.onSubmitCancel.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.loadDeclineRequestStatus = + this.loadDeclineRequestStatus.bind(this); + this.extractRequests = this.extractRequests.bind(this); + this.formatEffectiveDate = this.formatEffectiveDate.bind(this); + this.getPendingDeclineMeta = this.getPendingDeclineMeta.bind(this); + } + + componentDidMount() { + this.loadDeclineRequestStatus(this.props); + } + + componentDidUpdate(prevProps) { + if ( + prevProps.accountName !== this.props.accountName || + prevProps.currentUser !== this.props.currentUser + ) { + this.setState( + { + confirmPermanentDecline: false, + error: null, + success: null, + }, + () => this.loadDeclineRequestStatus(this.props) + ); + } + } + + componentWillUnmount() { + this.unmounted = true; + } + + onCheckboxChange(e) { + this.setState({ + confirmPermanentDecline: e.target.checked, + error: null, + success: null, + }); + } + + extractRequests(result) { + if (Array.isArray(result)) return result; + if (result && Array.isArray(result.requests)) return result.requests; + return []; + } + + formatEffectiveDate(value) { + const raw = String(value || '').trim(); + const match = raw.match( + /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + if (!match) return raw; + + const [, year, month, day] = match; + return `${day}-${month}-${year}`; + } + + getPendingDeclineMeta() { + const { declineRequest } = this.state; + const effectiveRaw = + declineRequest && declineRequest.effective_date + ? declineRequest.effective_date + : null; + + if (!effectiveRaw) { + return { + daysLeft: null, + dayLabel: null, + effectiveDateText: '', + }; + } + + const effectiveDate = new Date(effectiveRaw); + const now = new Date(); + + const daysLeft = Math.max( + 0, + Math.ceil((effectiveDate - now) / (1000 * 60 * 60 * 24)) + ); + + return { + daysLeft, + dayLabel: daysLeft === 1 ? 'day' : 'days', + effectiveDateText: this.formatEffectiveDate(effectiveRaw), + }; + } + + loadDeclineRequestStatus(props = this.props) { + const { currentUser, accountName } = props; + const lookupSeq = ++this.requestLookupSeq; + + if (!currentUser || !accountName || currentUser !== accountName) { + this.setState({ + checkingRequest: false, + hasPendingDeclineRequest: false, + declineRequest: null, + requestLookupError: null, + confirmPermanentDecline: false, + }); + return; + } + + this.setState({ + checkingRequest: true, + requestLookupError: null, + }); + + api.callAsync('database_api.find_decline_voting_rights_requests', { + accounts: [accountName], + }) + .then((result) => { + if (this.unmounted || lookupSeq !== this.requestLookupSeq) { + return; + } + + const requests = this.extractRequests(result); + const normalizedAccount = String(accountName || '').toLowerCase(); + + const declineRequest = + requests.find( + (item) => + String(item.account || '').toLowerCase() === + normalizedAccount + ) || null; + + this.setState({ + checkingRequest: false, + hasPendingDeclineRequest: !!declineRequest, + declineRequest, + requestLookupError: null, + confirmPermanentDecline: false, + }); + }) + .catch((error) => { + if (this.unmounted || lookupSeq !== this.requestLookupSeq) { + return; + } + + this.setState({ + checkingRequest: false, + hasPendingDeclineRequest: false, + declineRequest: null, + requestLookupError: + (error && error.message) || + tt( + 'steem_tools.decline_voting.error_check_pending_request' + ), + }); + }); + } + + onFailure(error) { + let errorMessage = error; + if ( + !errorMessage || + errorMessage === 0 || + errorMessage === false || + String(errorMessage).toLowerCase().includes('undefined') + ) { + errorMessage = tt('steem_tools.decline_voting.unexpected_error'); + } + + this.setState({ + loading: false, + error: errorMessage, + success: null, + }); + } + + onSuccess(mode) { + const { currentUser, refreshAccount } = this.props; + + refreshAccount(currentUser); + + this.setState( + (prevState) => ({ + loading: false, + error: null, + success: + mode === 'cancel' + ? tt( + 'steem_tools.decline_voting.cancel_success_message' + ) + : tt('steem_tools.decline_voting.success_message'), + confirmPermanentDecline: false, + ...(mode === 'decline' + ? { + hasPendingDeclineRequest: true, + declineRequest: prevState.declineRequest || { + effective_date: '', + account: currentUser, + }, + } + : {}), + ...(mode === 'cancel' + ? { + hasPendingDeclineRequest: false, + declineRequest: null, + } + : {}), + }), + () => { + this.loadDeclineRequestStatus(this.props); + } + ); + } + + onSubmit() { + const { currentUser, accountName, declineVotingRights } = this.props; + const { + confirmPermanentDecline, + checkingRequest, + hasPendingDeclineRequest, + } = this.state; + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.decline_voting.error_no_account'), + success: null, + }); + return; + } + + if (!accountName) { + this.setState({ + error: tt('steem_tools.decline_voting.error_no_target_account'), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.decline_voting.error_not_allowed'), + success: null, + }); + return; + } + + if (checkingRequest) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.checking_pending_request' + ), + success: null, + }); + return; + } + + if (hasPendingDeclineRequest) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.pending_request_already_exists' + ), + success: null, + }); + return; + } + + if (!confirmPermanentDecline) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.error_confirmation_required' + ), + success: null, + }); + return; + } + + this.setState({ + loading: true, + error: null, + success: null, + }); + + declineVotingRights( + accountName, + true, + () => this.onSuccess('decline'), + this.onFailure + ); + } + + onSubmitCancel() { + const { currentUser, accountName, declineVotingRights } = this.props; + const { checkingRequest, hasPendingDeclineRequest } = this.state; + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.decline_voting.error_no_account'), + success: null, + }); + return; + } + + if (!accountName) { + this.setState({ + error: tt('steem_tools.decline_voting.error_no_target_account'), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.decline_voting.error_not_allowed'), + success: null, + }); + return; + } + + if (checkingRequest) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.checking_pending_request' + ), + success: null, + }); + return; + } + + if (!hasPendingDeclineRequest) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.error_no_pending_request' + ), + success: null, + }); + return; + } + + this.setState({ + loading: true, + error: null, + success: null, + }); + + declineVotingRights( + accountName, + false, + () => this.onSuccess('cancel'), + this.onFailure + ); + } + + render() { + const { currentUser, accountName } = this.props; + const { + confirmPermanentDecline, + loading, + checkingRequest, + hasPendingDeclineRequest, + requestLookupError, + error, + success, + } = this.state; + + const isOwner = + !!currentUser && + !!accountName && + currentUser === accountName; + + const canDecline = + !loading && + !checkingRequest && + isOwner && + confirmPermanentDecline && + !hasPendingDeclineRequest; + + const canCancelPending = + !loading && + !checkingRequest && + isOwner && + hasPendingDeclineRequest; + + const pendingMeta = this.getPendingDeclineMeta(); + + const pendingWarningMessage = + pendingMeta.daysLeft != null + ? tt('steem_tools.decline_voting.pending_warning_message', { + days: pendingMeta.daysLeft, + day_label: pendingMeta.dayLabel, + account: accountName, + effective_date: pendingMeta.effectiveDateText, + }) + : tt( + 'steem_tools.decline_voting.pending_warning_message_no_date', + { + account: accountName, + } + ); + + return ( +
    +
    +
    +

    + {tt('steem_tools.decline_voting.title')} +

    +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + {tt('steem_tools.decline_voting.current_account')} +
    +
    +
    +
    + @ + +
    +
    +
    + + {checkingRequest && isOwner ? ( +
    +
    +
    + + + {tt( + 'steem_tools.decline_voting.checking_pending_request' + )} + +
    +
    +
    + ) : null} + + {!checkingRequest && requestLookupError && isOwner ? ( +
    +
    +
    + {requestLookupError} +
    +
    +
    + ) : null} + + {!checkingRequest && hasPendingDeclineRequest ? ( +
    +
    +
    + {pendingWarningMessage} +
    +
    +
    + ) : ( +
    +
    +
    + +
    +
    +
    + )} + + {!hasPendingDeclineRequest ? ( +
    +
    + + {tt( + 'steem_tools.decline_voting.confirmation_label' + )} + + +
    +
    + ) : null} + + {!loading && !isOwner && currentUser && accountName ? ( +
    +
    +
    + {tt( + 'steem_tools.decline_voting.error_not_allowed' + )} +
    +
    +
    + ) : null} + + {!loading && error ? ( +
    +
    +
    + {error} +
    +
    +
    + ) : null} + + {!loading && success ? ( +
    +
    +
    + {success} +
    +
    +
    + ) : null} + +
    +
    + {loading ? ( + + + + ) : hasPendingDeclineRequest ? ( + + ) : ( + + )} +
    +
    +
    +
    +
    + ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + + const accountName = + ownProps.accountname || + currentUser || + ''; + + const account = accountName + ? state.global.getIn(['accounts', accountName]) + : null; + + return { + currentUser, + accountName, + account, + }; + }, + dispatch => ({ + declineVotingRights: ( + account, + decline, + successCallback, + errorCallback + ) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${account}/permissions` })); + if (successCallback) successCallback(); + }; + + const operation = { + account, + decline, + }; + + const conf = decline + ? tt( + 'steem_tools.decline_voting.confirm_broadcast_message', + { account } + ) + : tt( + 'steem_tools.decline_voting.confirm_cancel_broadcast_message', + { account } + ); + + dispatch( + transactionActions.broadcastOperation({ + type: 'decline_voting_rights', + operation, + confirm: conf + '?', + confirmTitle: decline ? tt('steem_tools.decline_voting.confirm_decline_voting_rights') : tt('steem_tools.decline_voting.cancel_decline_voting_rights_request'), + successCallback: successCb, + errorCallback, + }) + ); + }, + refreshAccount: username => + dispatch( + userActions.refreshAccount({ + username, + }) + ), + removeNotification: key => + dispatch(appActions.removeNotification({ key })), + }) +)(DeclineVoting); diff --git a/src/app/components/elements/SteemToolsContent/sections/DisableWitness.jsx b/src/app/components/elements/SteemToolsContent/sections/DisableWitness.jsx new file mode 100644 index 000000000..ffa2fb537 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/DisableWitness.jsx @@ -0,0 +1,403 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import { api } from '@steemit/steem-js'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; +import ConfirmDisableWitness from 'app/components/elements/ConfirmDisableWitness'; + +class DisableWitness extends React.Component { + constructor(props) { + super(props); + this.state = { + loading: false, + loadingWitnessData: false, + error: null, + success: null, + isWitness: false, + currentSigningKey: '', + witnessUrl: '', + accountCreationFee: '0.000 STEEM', + maximumBlockSize: '65536', + sbdInterestRate: '0', + }; + + this.onSubmit = this.onSubmit.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.loadWitnessData = this.loadWitnessData.bind(this); + } + + componentDidMount() { + this.loadWitnessData(this.props); + } + + componentDidUpdate(prevProps) { + if (prevProps.accountName !== this.props.accountName) { + this.loadWitnessData(this.props); + } + } + + async loadWitnessData(props = this.props) { + const { accountName } = props; + + if (!accountName) return; + + this.setState({ + loadingWitnessData: true, + error: null, + success: null, + }); + + try { + const witness = await api.getWitnessByAccountAsync(accountName); + + if (witness) { + const propsData = witness.props || {}; + this.setState({ + isWitness: true, + currentSigningKey: witness.signing_key || '', + witnessUrl: witness.url || '', + accountCreationFee: propsData.account_creation_fee || '0.000 STEEM', + maximumBlockSize: String(propsData.maximum_block_size || 65536), + sbdInterestRate: String(propsData.sbd_interest_rate || 0), + loadingWitnessData: false, + }); + return; + } + + this.setState({ + isWitness: false, + currentSigningKey: '', + witnessUrl: '', + accountCreationFee: '0.000 STEEM', + maximumBlockSize: '65536', + sbdInterestRate: '0', + loadingWitnessData: false, + }); + } catch (error) { + this.setState({ + loadingWitnessData: false, + error: tt('steem_tools.disable_witness.error_loading_witness'), + success: null, + }); + } + } + + onFailure(error) { + let errorMessage = error; + if ( + !errorMessage || + errorMessage === 0 || + errorMessage === false || + String(errorMessage).toLowerCase().includes('undefined') + ) { + errorMessage = tt('steem_tools.disable_witness.unexpected_error'); + } + + this.setState({ + loading: false, + error: errorMessage, + success: null, + }); + } + + onSuccess() { + const { currentUser, refreshAccount } = this.props; + refreshAccount(currentUser); + this.setState({ + loading: false, + error: null, + success: tt('steem_tools.disable_witness.success_message'), + }); + this.loadWitnessData(); + } + + onSubmit() { + const { currentUser, accountName, disableWitness } = this.props; + const { + isWitness, + witnessUrl, + accountCreationFee, + maximumBlockSize, + sbdInterestRate, + currentSigningKey, + } = this.state; + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.disable_witness.error_no_account'), + success: null, + }); + return; + } + + if (!accountName) { + this.setState({ + error: tt('steem_tools.disable_witness.error_no_target_account'), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.disable_witness.error_not_allowed'), + success: null, + }); + return; + } + + if (!isWitness) { + this.setState({ + error: tt('steem_tools.disable_witness.error_not_witness'), + success: null, + }); + return; + } + + this.setState({ + loading: true, + error: null, + success: null, + }); + + disableWitness( + { + owner: accountName, + url: witnessUrl || '', + block_signing_key: 'STM1111111111111111111111111111111114T1Anm', + props: { + account_creation_fee: accountCreationFee, + maximum_block_size: parseInt(maximumBlockSize, 10) || 65536, + sbd_interest_rate: parseInt(sbdInterestRate, 10) || 0, + }, + fee: '0.000 STEEM', + current_signing_key: currentSigningKey || '', + }, + this.onSuccess, + this.onFailure + ); + } + + render() { + const { + currentUser, + accountName, + } = this.props; + + const { + loading, + loadingWitnessData, + error, + success, + isWitness, + currentSigningKey, + } = this.state; + + const isOwner = + !!currentUser && + !!accountName && + currentUser === accountName; + + const canDisable = !loading && !loadingWitnessData && isOwner && isWitness; + + return ( +
    +
    +
    +

    {tt('steem_tools.disable_witness.title')}

    +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    {tt('steem_tools.disable_witness.witness_account')}
    +
    +
    +
    + @ + +
    + {!loadingWitnessData ? ( +
    + {isWitness + ? tt('steem_tools.disable_witness.witness_status_yes') + : tt('steem_tools.disable_witness.witness_status_no')} +
    + ) : null} +
    +
    + + {isWitness ? ( +
    +
    +
    {tt('steem_tools.disable_witness.current_signing_key')}
    +
    +
    + +
    +
    + ) : null} + + {!loading && !loadingWitnessData && !isOwner && currentUser && accountName ? ( +
    +
    +
    + {tt('steem_tools.disable_witness.error_not_allowed')} +
    +
    +
    + ) : null} + + {!loading && !loadingWitnessData && isOwner && !isWitness && accountName ? ( +
    +
    +
    + {tt('steem_tools.disable_witness.error_not_witness')} +
    +
    +
    + ) : null} + + {!loading && !loadingWitnessData && error ? ( +
    +
    +
    {error}
    +
    +
    + ) : null} + + {!loading && !loadingWitnessData && success ? ( +
    +
    +
    {success}
    +
    +
    + ) : null} + +
    +
    + {loading || loadingWitnessData ? ( + + + + ) : ( + + )} +
    +
    +
    +
    +
    + ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + + const accountName = + ownProps.accountname || + currentUser || + ''; + + const account = accountName + ? state.global.getIn(['accounts', accountName]) + : null; + + return { + currentUser, + accountName, + account, + }; + }, + dispatch => ({ + disableWitness: ( + operation, + successCallback, + errorCallback + ) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${operation.owner}/witnesses` })); + if (successCallback) successCallback(); + }; + + const confirm = () => ( + + ); + + dispatch( + transactionActions.broadcastOperation({ + type: 'witness_update', + operation: { + owner: operation.owner, + url: operation.url, + block_signing_key: operation.block_signing_key, + props: operation.props, + fee: operation.fee, + }, + confirm, + successCallback: successCb, + errorCallback, + }) + ); + }, + refreshAccount: username => + dispatch( + userActions.refreshAccount({ + username, + }) + ), + removeNotification: key => + dispatch(appActions.removeNotification({ key })), + }) +)(DisableWitness); diff --git a/src/app/components/elements/SteemToolsContent/sections/GenerateBrainKeys.jsx b/src/app/components/elements/SteemToolsContent/sections/GenerateBrainKeys.jsx new file mode 100644 index 000000000..4505ec6ca --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/GenerateBrainKeys.jsx @@ -0,0 +1,403 @@ +import React from 'react'; +import tt from 'counterpart'; +import createHash from 'create-hash'; +import { PrivateKey } from '@steemit/steem-js/lib/auth/ecc'; +import { FormattedHTMLMessage } from 'app/Translator'; +import { STEEM_WORDS } from './steem_words'; + +class GenerateBrainKeys extends React.Component { + constructor(props) { + super(props); + this.state = { + keysVisible: false, + brainKey: '', + editableBrainKey: '', + privateKey: '', + publicKey: '', + error: null, + success: null, + }; + + this.generateKeys = this.generateKeys.bind(this); + this.handleEditableBrainKeyChange = this.handleEditableBrainKeyChange.bind(this); + this.exportKeys = this.exportKeys.bind(this); + this.buildBrainSequence = this.buildBrainSequence.bind(this); + this.normalizeBrainKey = this.normalizeBrainKey.bind(this); + this.sha256 = this.sha256.bind(this); + this.sha512 = this.sha512.bind(this); + this.bufferToBigInt = this.bufferToBigInt.bind(this); + this.getSecureRandomBytes = this.getSecureRandomBytes.bind(this); + this.derivePrivateKey = this.derivePrivateKey.bind(this); + this.generateKeyPairFromBrainKey = this.generateKeyPairFromBrainKey.bind(this); + this.updateKeysFromBrainKey = this.updateKeysFromBrainKey.bind(this); + } + + componentDidMount() { + this.generateKeys(); + } + + normalizeBrainKey(value) { + return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase() + } + + sha256(data) { + return createHash('sha256').update(data).digest(); + } + + sha512(data) { + return createHash('sha512').update(data).digest(); + } + + bufferToBigInt(bufferLike) { + const buffer = bufferLike instanceof Uint8Array ? bufferLike : new Uint8Array(bufferLike); + let hex = ''; + + for (let i = 0; i < buffer.length; i += 1) { + hex += buffer[i].toString(16).padStart(2, '0'); + } + + return hex ? BigInt(`0x${hex}`) : BigInt(0); + } + + getSecureRandomBytes(length) { + if ( + typeof window !== 'undefined' && + window.crypto && + typeof window.crypto.getRandomValues === 'function' + ) { + const bytes = new Uint8Array(length); + window.crypto.getRandomValues(bytes); + return bytes; + } + + throw new Error('Secure random generator is not available in this environment'); + } + + buildBrainSequence(wordCount = 16) { + if (!Array.isArray(STEEM_WORDS) || !STEEM_WORDS.length) { + throw new Error('STEEM_WORDS is empty or invalid'); + } + + const entropy1 = this.getSecureRandomBytes(32); + const entropy2 = this.getSecureRandomBytes(32); + + let entropy = + (this.bufferToBigInt(entropy1) << BigInt(32 * 8)) + + this.bufferToBigInt(entropy2); + + const wordListSize = BigInt(STEEM_WORDS.length); + const words = []; + + for (let i = 0; i < wordCount; i += 1) { + const choice = Number(entropy % wordListSize); + entropy = entropy / wordListSize; + words.push(STEEM_WORDS[choice]); + } + + return this.normalizeBrainKey(words.join(' ')).toUpperCase(); + } + + derivePrivateKey(brainKey, sequence = 0) { + const normalizedBrainKey = this.normalizeBrainKey(brainKey); + + if (!normalizedBrainKey) { + throw new Error('Brain key is empty'); + } + + const seed = `${normalizedBrainKey} ${sequence}`; + const privateKeyBuffer = this.sha256(seed); + return PrivateKey.fromBuffer(privateKeyBuffer); + } + + generateKeyPairFromBrainKey(brainKey) { + const normalizedBrainKey = this.normalizeBrainKey(brainKey); + const privateKey = this.derivePrivateKey(normalizedBrainKey, 0); + const wifPrivateKey = privateKey.toWif(); + const publicKey = privateKey.toPublicKey().toString(); + + return { + brainKey: normalizedBrainKey.toUpperCase(), + privateKey: wifPrivateKey, + publicKey, + }; + } + + updateKeysFromBrainKey(rawBrainKey) { + const normalizedBrainKey = this.normalizeBrainKey(rawBrainKey); + + if (!normalizedBrainKey) { + this.setState({ + editableBrainKey: rawBrainKey, + keysVisible: false, + brainKey: '', + privateKey: '', + publicKey: '', + error: null, + success: null, + }); + return; + } + + try { + const generatedKeys = this.generateKeyPairFromBrainKey(rawBrainKey); + + this.setState({ + editableBrainKey: rawBrainKey, + keysVisible: true, + brainKey: generatedKeys.brainKey, + privateKey: generatedKeys.privateKey, + publicKey: generatedKeys.publicKey, + error: null, + success: tt('steem_tools.generate_brain_keys.success_message'), + }); + } catch (e) { + this.setState({ + editableBrainKey: rawBrainKey, + keysVisible: false, + brainKey: '', + privateKey: '', + publicKey: '', + error: tt('steem_tools.generate_brain_keys.error_generating', { + message: e && e.message ? e.message : 'Unknown error', + }), + success: null, + }); + } + } + + handleEditableBrainKeyChange(e) { + const value = e.target.value; + this.updateKeysFromBrainKey(value); + } + + generateKeys() { + try { + const generatedBrainKey = this.buildBrainSequence(16); + const generatedKeys = this.generateKeyPairFromBrainKey(generatedBrainKey); + + this.setState({ + keysVisible: true, + brainKey: generatedKeys.brainKey, + editableBrainKey: generatedKeys.brainKey, + privateKey: generatedKeys.privateKey, + publicKey: generatedKeys.publicKey, + error: null, + success: tt('steem_tools.generate_brain_keys.success_message'), + }); + } catch (e) { + this.setState({ + error: tt('steem_tools.generate_brain_keys.error_generating', { + message: e && e.message ? e.message : 'Unknown error', + }), + success: null, + }); + } + } + + exportKeys() { + const { keysVisible, brainKey, privateKey, publicKey } = this.state; + + if (!keysVisible) return; + + try { + const payload = { + brain_priv_key: brainKey.toUpperCase(), + wif_priv_key: privateKey, + pub_key: publicKey, + }; + + const file = new Blob([JSON.stringify(payload, null, 2)], { + type: 'application/json', + }); + + const element = document.createElement('a'); + element.href = URL.createObjectURL(file); + element.download = `witness_brain_keys_${Date.now()}.json`; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + URL.revokeObjectURL(element.href); + + this.setState({ + error: null, + success: tt('steem_tools.generate_brain_keys.export_success_message'), + }); + } catch (e) { + this.setState({ + error: tt('steem_tools.generate_brain_keys.error_exporting', { + message: e && e.message ? e.message : 'Unknown error', + }), + success: null, + }); + } + } + + render() { + const { + keysVisible, + brainKey, + editableBrainKey, + privateKey, + publicKey, + error, + success, + } = this.state; + + const keyRows = [ + { + key: 'brain', + type: tt('steem_tools.generate_brain_keys.brain_sequence_label'), + value: brainKey, + }, + { + key: 'private', + type: tt('steem_tools.generate_brain_keys.private_key_label'), + value: privateKey, + }, + { + key: 'public', + type: tt('steem_tools.generate_brain_keys.public_key_label'), + value: publicKey, + }, + ]; + + return ( +
    +
    +
    +

    + {tt('steem_tools.generate_brain_keys.panel_title')} +

    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    + {tt('steem_tools.generate_brain_keys.brain_sequence_label')} +
    + +