diff --git a/src/app/components/elements/Icon.jsx b/src/app/components/elements/Icon.jsx index 444dd67f742a0658fee5a45ed6ac4a0438cd3034..ee0c5296020425291dc1586d97baf9e26452a8e8 100644 --- a/src/app/components/elements/Icon.jsx +++ b/src/app/components/elements/Icon.jsx @@ -57,6 +57,7 @@ const icons_map = {}; for (const i of icons) icons_map[i] = require(`assets/icons/${i}.svg`); const rem_sizes = { + '0.7x': '0.7', '1x': '1.12', '1_5x': '1.5', '2x': '2', @@ -69,7 +70,16 @@ const rem_sizes = { export default class Icon extends React.Component { static propTypes = { name: PropTypes.string.isRequired, - size: PropTypes.oneOf(['1x', '1_5x', '2x', '3x', '4x', '5x', '10x']), + size: PropTypes.oneOf([ + '0.7x', + '1x', + '1_5x', + '2x', + '3x', + '4x', + '5x', + '10x', + ]), inverse: PropTypes.bool, className: PropTypes.string, }; diff --git a/src/app/components/elements/Userpic.jsx b/src/app/components/elements/Userpic.jsx index 99b40d6724071c5879fe479c422b6e54ea5ed902..b02a0c6402be710e34c8680a298c0a08907e13eb 100644 --- a/src/app/components/elements/Userpic.jsx +++ b/src/app/components/elements/Userpic.jsx @@ -1,4 +1,5 @@ import React, { Component } from 'react'; +import classnames from 'classnames'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; @@ -20,7 +21,7 @@ class Userpic extends Component { shouldComponentUpdate = shouldComponentUpdate(this, 'Userpic'); render() { - const { account, json_metadata, size } = this.props; + const { account, json_metadata, size, className = '' } = this.props; const hideIfDefault = this.props.hideIfDefault || false; const avSize = size && sizeList.indexOf(size) > -1 ? '/' + size : ''; @@ -41,7 +42,9 @@ class Userpic extends Component { 'url(' + imageProxy() + `u/${account}/avatar${avSize})`, }; - return <div className="Userpic" style={style} />; + return ( + <div className={classnames('Userpic', className)} style={style} /> + ); } } diff --git a/src/app/components/modules/ProposalList/Proposal.jsx b/src/app/components/modules/ProposalList/Proposal.jsx index b5ba6e8653cc3c7222d022af94721cbf01682e84..97b5d5b147d79449d8d50b88804a1334d88fef12 100644 --- a/src/app/components/modules/ProposalList/Proposal.jsx +++ b/src/app/components/modules/ProposalList/Proposal.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import Moment from 'moment'; import NumAbbr from 'number-abbreviate'; import tt from 'counterpart'; +import Userpic from 'app/components/elements/Userpic'; import { numberWithCommas, vestsToHpf } from 'app/utils/StateFunctions'; import Icon from 'app/components/elements/Icon'; @@ -59,11 +60,7 @@ export default function Proposal(props) { </a> </div> <div className="proposals__avatar"> - <img - src={`https://images.hive.blog/100x100/https://images.hive.blog/u/${creator}/avatar`} - alt={creator} - className="image__round" - /> + <Userpic account={creator} /> </div> <div className="proposals__description"> <span> diff --git a/src/app/components/modules/ProposalList/styles.scss b/src/app/components/modules/ProposalList/styles.scss index 7a337d77e2b860a0e4e0959d1b00a19cd956e501..7c0702c62bfc185b3c7400b19fd4d2c79c9c98b0 100644 --- a/src/app/components/modules/ProposalList/styles.scss +++ b/src/app/components/modules/ProposalList/styles.scss @@ -13,10 +13,6 @@ margin-left: auto; margin-right: auto; - img.image__round { - border-radius: 50px; - } - .proposals__header { flex-direction: row; justify-content: center; @@ -86,6 +82,10 @@ width: 65px; height: 65px; padding-left: 15px; + + @media only screen and (max-width: 768px) { + width: 90px; + } } .proposals__votes { diff --git a/src/app/components/pages/Witnesses.jsx b/src/app/components/pages/Witnesses.jsx index a0b35356b6dadbbba74948e113239494642876fb..1a2a6ce19e9984b0b4effa0ed4c9983e8d6e1f9a 100644 --- a/src/app/components/pages/Witnesses.jsx +++ b/src/app/components/pages/Witnesses.jsx @@ -1,14 +1,22 @@ import React from 'react'; +import Moment from 'moment'; +import { api } from '@steemit/steem-js'; +import classnames from 'classnames'; 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 Userpic from 'app/components/elements/Userpic'; +import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper'; +import { formatLargeNumber } from 'app/utils/ParsersAndFormatters'; import ByteBuffer from 'bytebuffer'; import { is, Set, List } from 'immutable'; import * as globalActions from 'app/redux/GlobalReducer'; +import { vestsToHpf } from 'app/utils/StateFunctions'; import tt from 'counterpart'; +import _ from 'lodash'; const Long = ByteBuffer.Long; const { string, func, object } = PropTypes; @@ -18,6 +26,7 @@ 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 < 60) return 'just now'; if (secs < 120) return 'recently'; const mins = Math.floor(secs / 60); if (mins < 120) return mins + ' mins ago'; @@ -42,7 +51,13 @@ class Witnesses extends React.Component { constructor() { super(); - this.state = { customUsername: '', proxy: '', proxyFailed: false }; + this.state = { + customUsername: '', + proxy: '', + proxyFailed: false, + witnessAccounts: {}, + witnessToHighlight: '', + }; this.accountWitnessVote = (accountName, approve, e) => { e.preventDefault(); const { username, accountWitnessVote } = this.props; @@ -64,6 +79,15 @@ class Witnesses extends React.Component { }; } + componentDidMount() { + this.setState({ + witnessToHighlight: this.props.location.query.highlight, + }); + this.loadWitnessAccounts(); + + this.scrollToHighlightedWitness(); + } + shouldComponentUpdate(np, ns) { return ( !is(np.witness_votes, this.props.witness_votes) || @@ -73,10 +97,75 @@ class Witnesses extends React.Component { np.username !== this.props.username || ns.customUsername !== this.state.customUsername || ns.proxy !== this.state.proxy || - ns.proxyFailed !== this.state.proxyFailed + ns.proxyFailed !== this.state.proxyFailed || + ns.witnessAccounts !== this.state.witnessAccounts || + ns.witnessToHighlight !== this.state.witnessToHighlight ); } + async loadWitnessAccounts() { + const witnessAccounts = this.state.witnessAccounts; + const { witnesses } = this.props; + const witnessOwners = [[]]; + let chunksCount = 0; + + witnesses.map(item => { + if (witnessOwners[chunksCount].length >= 20) { + chunksCount += 1; + witnessOwners[chunksCount] = []; + } + witnessOwners[chunksCount].push(item.get('owner')); + return true; + }); + + for (let oi = 0; oi < witnessOwners.length; oi += 1) { + const owners = witnessOwners[oi]; + const res = await api.getAccountsAsync(owners); + if (!(res && res.length > 0)) { + console.error(tt('g.account_not_found')); + return false; + } + + for (let ri = 0; ri < res.length; ri += 1) { + const witnessAccount = res[ri]; + let jsonMetadata = { witness_description: '' }; + if ( + witnessAccount.hasOwnProperty('json_metadata') && + witnessAccount.json_metadata + ) { + jsonMetadata = JSON.parse(witnessAccount.json_metadata); + } + witnessAccounts[witnessAccount.name] = jsonMetadata; + } + } + + this.setState({ witnessAccounts: { ...witnessAccounts } }); + return true; + } + + scrollToHighlightedWitness() { + if (typeof document !== 'undefined') { + setTimeout(() => { + const highlightedWitnessElement = document.querySelector( + '.Witnesses__highlight' + ); + if (highlightedWitnessElement) { + highlightedWitnessElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + } + }, 1000); + } + } + + updateWitnessToHighlight(witness) { + this.setState({ witnessToHighlight: witness }); + this.scrollToHighlightedWitness(); + window.history.pushState('', '', `/~witnesses?highlight=${witness}`); + } + render() { const { props: { @@ -85,10 +174,16 @@ class Witnesses extends React.Component { current_proxy, head_block, }, - state: { customUsername, proxy }, + state: { + customUsername, + proxy, + witnessAccounts, + witnessToHighlight, + }, accountWitnessVote, accountWitnessProxy, onWitnessChange, + updateWitnessToHighlight, } = this; const sorted_witnesses = this.props.witnesses.sort((a, b) => Long.fromString(String(b.get('votes'))).subtract( @@ -97,16 +192,71 @@ class Witnesses extends React.Component { ); let witness_vote_count = 30; let rank = 1; + let foundWitnessToHighlight = false; + let previousTotalVoteHpf = 0; const witnesses = sorted_witnesses.map(item => { const owner = item.get('owner'); - const thread = item.get('url'); + if (owner === witnessToHighlight) { + foundWitnessToHighlight = true; + } + const witnessDescription = _.get( + witnessAccounts[owner], + 'profile.witness_description', + null + ); + const totalVotesVests = item.get('votes'); + const totalVotesHpf = vestsToHpf( + this.props.state, + `${totalVotesVests / 1000000} VESTS` + ); + const totalVotesHp = formatLargeNumber(totalVotesHpf, 0); + + let requiredHpToRankUp = ''; + if (previousTotalVoteHpf !== 0) { + requiredHpToRankUp = ( + <small> + {tt('witnesses_jsx.hp_required_to_rank_up', { + votehp: formatLargeNumber( + previousTotalVoteHpf - totalVotesHpf + ), + })} + </small> + ); + } + + previousTotalVoteHpf = totalVotesHpf; + + const thread = item.get('url').replace('steemit.com', 'hive.blog'); const myVote = witness_votes ? witness_votes.has(owner) : null; const signingKey = item.get('signing_key'); + const witnessCreated = item.get('created'); + const accountBirthday = Moment.utc(`${witnessCreated}Z`).format( + 'll' + ); + const now = Moment(); + const witnessAgeDays = now.diff(accountBirthday, 'days'); + const witnessAgeWeeks = now.diff(accountBirthday, 'weeks'); + const witnessAgeMonths = now.diff(accountBirthday, 'months'); + const witnessAgeYears = now.diff(accountBirthday, 'years'); + + let witnessAge = `${witnessAgeDays} ${tt('g.days')}`; + if (witnessCreated === '1970-01-01T00:00:00') { + witnessAge = 'over 3 years'; + } else if (witnessAgeYears > 0) { + witnessAge = `${witnessAgeYears} ${tt('g.years')}`; + } else if (witnessAgeMonths > 0) { + witnessAge = `${witnessAgeMonths} ${tt('g.months')}`; + } else if (witnessAgeWeeks > 0) { + witnessAge = `${witnessAgeWeeks} ${tt('g.weeks')}`; + } + const lastBlock = item.get('last_confirmed_block_num'); + const runningVersion = item.get('running_version'); + const sbdExchangeRate = item.get('sbd_exchange_rate'); + const sbdExchangeUpdateDate = item.get('last_sbd_exchange_update'); const noBlock7days = (head_block - lastBlock) * 3 > 604800; - const isDisabled = - signingKey == DISABLED_SIGNING_KEY || noBlock7days; + const isDisabled = signingKey == DISABLED_SIGNING_KEY; const votingActive = witnessVotesInProgress.has(owner); const classUp = 'Voting__button Voting__button-up' + @@ -143,8 +293,13 @@ class Witnesses extends React.Component { : {}; return ( - <tr key={owner}> - <td width="75"> + <tr + key={owner} + className={classnames({ + Witnesses__highlight: witnessToHighlight === owner, + })} + > + <td className="Witnesses__rank"> {rank < 10 && '0'} {rank++} @@ -170,19 +325,110 @@ class Witnesses extends React.Component { )} </span> </td> - <td> + <td className="Witnesses__info"> <Link to={'/@' + owner} style={ownerStyle}> - {owner} + <Userpic + account={owner} + size="small" + className={classnames({ + disabled: isDisabled, + })} + /> </Link> - {isDisabled && ( - <small> - {' '} - ({tt('witnesses_jsx.disabled')}{' '} - {_blockGap(head_block, lastBlock)}) - </small> - )} + <div className="Witnesses__info"> + <div> + <Link to={'/@' + owner} style={ownerStyle}> + {owner} + </Link> + <Link + to={`/~witnesses?highlight=${owner}`} + onClick={event => { + event.preventDefault(); + updateWitnessToHighlight.apply(this, [ + owner, + ]); + }} + > + <Icon + name="chain" + size="0.7x" + className="Witnesses__permlink" + /> + </Link> + </div> + <div> + <small> + {noBlock7days && ( + <div> + <strong> + <span + role="img" + aria-label={tt( + 'witnesses_jsx.not_produced_over_a_week' + )} + > + âš ï¸ + </span> + {tt( + 'witnesses_jsx.not_produced_over_a_week' + )} + </strong> + </div> + )} + <div> + {witnessDescription && ( + <div className="Witnesses__description"> + {witnessDescription} + </div> + )} + {tt('witnesses_jsx.last_block')}{' '} + <Link + to={`https://hiveblocks.com/b/${lastBlock}`} + target="_blank" + > + #{lastBlock} + </Link>{' '} + {_blockGap(head_block, lastBlock)} on v{ + runningVersion + } + </div> + {isDisabled && ( + <div> + {`${tt( + 'witnesses_jsx.disabled' + )} ${_blockGap( + head_block, + lastBlock + )}`} + </div> + )} + {!isDisabled && ( + <div> + {`${tt( + 'witnesses_jsx.witness_age' + )}: ${witnessAge}`} + </div> + )} + </small> + </div> + {!isDisabled && ( + <div className="witness__thread"> + <small>{witness_link}</small> + </div> + )} + </div> + </td> + <td> + {`${totalVotesHp} HP`} + {!isDisabled && <div>{requiredHpToRankUp}</div>} + </td> + <td> + ${parseFloat(sbdExchangeRate.get('base'))} + <br /> + <small> + <TimeAgoWrapper date={sbdExchangeUpdateDate} /> + </small> </td> - <td>{witness_link}</td> </tr> ); }); @@ -246,17 +492,19 @@ class Witnesses extends React.Component { <div className="column"> <h2>{tt('witnesses_jsx.top_witnesses')}</h2> {current_proxy && current_proxy.length ? null : ( - <p> - <strong> + <div> + <p> + <strong> + {tt( + 'witnesses_jsx.you_have_votes_remaining', + { count: witness_vote_count } + )}. + </strong>{' '} {tt( - 'witnesses_jsx.you_have_votes_remaining', - { count: witness_vote_count } + 'witnesses_jsx.you_can_vote_for_maximum_of_witnesses' )}. - </strong>{' '} - {tt( - 'witnesses_jsx.you_can_vote_for_maximum_of_witnesses' - )}. - </p> + </p> + </div> )} </div> </div> @@ -266,11 +514,12 @@ class Witnesses extends React.Component { <table> <thead> <tr> - <th /> + <th>Rank</th> <th>{tt('witnesses_jsx.witness')}</th> - <th> - {tt('witnesses_jsx.information')} + <th className="Witnesses__votes"> + {tt('witnesses_jsx.votes_received')} </th> + <th>Price feed</th> </tr> </thead> <tbody>{witnesses.toArray()}</tbody> @@ -280,7 +529,13 @@ class Witnesses extends React.Component { )} {current_proxy ? null : ( - <div className="row"> + <div + className={classnames('row', { + Witnesses__highlight: + witnessToHighlight && + foundWitnessToHighlight === false, + })} + > <div className="column"> <p> {tt( @@ -298,7 +553,11 @@ class Witnesses extends React.Component { width: '75%', maxWidth: '20rem', }} - value={customUsername} + value={ + foundWitnessToHighlight === true + ? customUsername + : witnessToHighlight + } onChange={onWitnessChange} /> <div className="input-group-button"> @@ -438,6 +697,7 @@ module.exports = { witness_votes, witnessVotesInProgress, current_proxy, + state, }; }, dispatch => { diff --git a/src/app/components/pages/Witnesses.scss b/src/app/components/pages/Witnesses.scss index 5fcb7ca55c8b27219bb8e79e4ceb8045ca7f8187..16fe830c531055bd3c328503a64457e4691c4e66 100644 --- a/src/app/components/pages/Witnesses.scss +++ b/src/app/components/pages/Witnesses.scss @@ -1,22 +1,34 @@ - .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'); - } - } - td > a { - @extend .link; - @extend .link--primary; + @include themify($themes) { + fill: themed('textColorAccentHover'); + } } - + table tbody tr.Witnesses__highlight { + background-color: pink; + } + td.Witnesses__info { + display: flex; + flex-direction: row; + } + td.Witnesses__rank { + white-space: nowrap; + } + td.Witnesses__info .Userpic { + @media only screen and (max-width: 779px) { + display: none; + } + } + td.Witnesses__info .Userpic.disabled { + opacity: 0.4; + } .button { background-color: $color-text-hive-red; text-shadow: 0 1px 0 rgba(0,0,0,0.20); @@ -24,7 +36,43 @@ &:hover { background-color: $color-hive-red; } - } + } + div.Witnesses__info { + display: flex; + flex-direction: column; + margin-left: 15px; + + @media only screen and (max-width: 779px) { + margin-left: 0; + } + } + div.Witnesses__info a:first-child { + font-weight: bold; + } + div.Witnesses__info .witness__thread a { + @extend .link; + @extend .link--primary; + } + div.Witnesses__highlight form input.input-group-field { + box-shadow: 0px 0px 3px 3px pink; + } + div.Witnesses__description { + display: block; + width: calc(100% - 20px); + max-width: 660px; + font-style: italic; + max-height: 5em; + overflow-x: hidden; + overflow-y: auto; + margin-left: 20px; + margin-bottom: 10px; + border-bottom: 1px dotted #dcdcdc; + } + .Icon.Witnesses__permlink svg { + width: 0.7rem; + height: 0.7rem; + margin-left: 10px; + } } diff --git a/src/app/locales/en.json b/src/app/locales/en.json index 8734afe56973df30849700cace719ccf91f9c24b..72cda1f9cd51c3ac776c6628a6df7adc59ab0dc8 100644 --- a/src/app/locales/en.json +++ b/src/app/locales/en.json @@ -252,7 +252,11 @@ "warning": "Legitimate users, including employees of Hive, will never ask you for a private key or master password.", "checkbox": "I understand" - } + }, + "days": "days", + "weeks": "weeks", + "months": "months", + "years": "years" }, "navigation": { "about": "About Hive", @@ -475,9 +479,9 @@ } }, "witnesses_jsx": { - "witness_thread": "witness thread", - "external_site": "external site", - "disabled": "disabled", + "witness_thread": "Open witness annoucement", + "external_site": "Open external site", + "disabled": "Disabled", "top_witnesses": "Witness Voting", "you_have_votes_remaining": { "zero": "You have no votes remaining", @@ -487,7 +491,7 @@ "you_can_vote_for_maximum_of_witnesses": "You can vote for a maximum of 30 witnesses", "witness": "Witness", - "information": "Information", + "information": "More Information", "if_you_want_to_vote_outside_of_top_enter_account_name": "If you would like to vote for a witness outside of the top 100, enter the account name below to cast a vote", "set_witness_proxy": @@ -497,7 +501,14 @@ "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", + "missed_blocks": "Missed blocks", + "votes_received": "Votes received", + "hp_required_to_rank_up": "Needs %(votehp)s to level up", + "witness_age": "Witness age", + "not_produced_over_a_week": + "Has not produced any blocks for over a week.", + "last_block": "Last block" }, "votesandcomments_jsx": { "no_responses_yet_click_to_respond": diff --git a/src/app/utils/ParsersAndFormatters.js b/src/app/utils/ParsersAndFormatters.js index fd5cdc7e5b001becd5d6daf9264022a2efddffb5..2525d909e51e3b1a4b3350f2109a2f705945051e 100644 --- a/src/app/utils/ParsersAndFormatters.js +++ b/src/app/utils/ParsersAndFormatters.js @@ -77,7 +77,32 @@ export function countDecimals(amount) { const parts = amount.split('.'); return parts.length > 2 ? undefined - : parts.length === 1 ? 0 : parts[1].length; + : parts.length === 1 + ? 0 + : parts[1].length; +} + +export function formatLargeNumber(number, decimals) { + const symbols = [ + { value: 1, symbol: '' }, + { value: 1e3, symbol: 'k' }, + { value: 1e6, symbol: 'M' }, + { value: 1e9, symbol: 'G' }, + { value: 1e12, symbol: 'T' }, + { value: 1e15, symbol: 'P' }, + { value: 1e18, symbol: 'E' }, + ]; + + const regexp = /\.0+$|(\.[0-9]*[1-9])0+$/; + for (let i = symbols.length - 1; i > 0; i--) { + if (number >= symbols[i].value) { + return ( + (number / symbols[i].value) + .toFixed(decimals) + .replace(regexp, '$1') + symbols[i].symbol + ); + } + } } // this function searches for right translation of provided error (usually from back-end)