diff options
7 files changed, 459 insertions, 67 deletions
diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.js b/app/javascript/mastodon/components/autosuggest_hashtag.js index e2f4e320d..9e9d888f8 100644 --- a/app/javascript/mastodon/components/autosuggest_hashtag.js +++ b/app/javascript/mastodon/components/autosuggest_hashtag.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { shortNumberFormat } from 'mastodon/utils/numbers'; +import ShortNumber from 'mastodon/components/short_number'; import { FormattedMessage } from 'react-intl'; export default class AutosuggestHashtag extends React.PureComponent { @@ -13,14 +13,28 @@ export default class AutosuggestHashtag extends React.PureComponent { }).isRequired, }; - render () { + render() { const { tag } = this.props; - const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); + const weeklyUses = tag.history && ( + <ShortNumber + value={tag.history.reduce((total, day) => total + day.uses * 1, 0)} + /> + ); return ( <div className='autosuggest-hashtag'> - <div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div> - {tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>} + <div className='autosuggest-hashtag__name'> + #<strong>{tag.name}</strong> + </div> + {tag.history !== undefined && ( + <div className='autosuggest-hashtag__uses'> + <FormattedMessage + id='autosuggest_hashtag.per_week' + defaultMessage='{count} per week' + values={{ count: weeklyUses }} + /> + </div> + )} </div> ); } diff --git a/app/javascript/mastodon/components/common_counter.js b/app/javascript/mastodon/components/common_counter.js new file mode 100644 index 000000000..4fdf3babf --- /dev/null +++ b/app/javascript/mastodon/components/common_counter.js @@ -0,0 +1,62 @@ +// @ts-check +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +/** + * Returns custom renderer for one of the common counter types + * + * @param {"statuses" | "following" | "followers"} counterType + * Type of the counter + * @param {boolean} isBold Whether display number must be displayed in bold + * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} + * Renderer function + * @throws If counterType is not covered by this function + */ +export function counterRenderer(counterType, isBold = true) { + /** + * @type {(displayNumber: JSX.Element) => JSX.Element} + */ + const renderCounter = isBold + ? (displayNumber) => <strong>{displayNumber}</strong> + : (displayNumber) => displayNumber; + + switch (counterType) { + case 'statuses': { + return (displayNumber, pluralReady) => ( + <FormattedMessage + id='account.statuses_counter' + defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}' + values={{ + count: pluralReady, + counter: renderCounter(displayNumber), + }} + /> + ); + } + case 'following': { + return (displayNumber, pluralReady) => ( + <FormattedMessage + id='account.following_counter' + defaultMessage='{count, plural, other {{counter} Following}}' + values={{ + count: pluralReady, + counter: renderCounter(displayNumber), + }} + /> + ); + } + case 'followers': { + return (displayNumber, pluralReady) => ( + <FormattedMessage + id='account.followers_counter' + defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}' + values={{ + count: pluralReady, + counter: renderCounter(displayNumber), + }} + /> + ); + } + default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`); + } +} diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js index 62d613262..d766ca90d 100644 --- a/app/javascript/mastodon/components/hashtag.js +++ b/app/javascript/mastodon/components/hashtag.js @@ -1,26 +1,65 @@ +// @ts-check import React from 'react'; import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Permalink from './permalink'; -import { shortNumberFormat } from '../utils/numbers'; +import ShortNumber from 'mastodon/components/short_number'; + +/** + * Used to render counter of how much people are talking about hashtag + * + * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} + */ +const accountsCountRenderer = (displayNumber, pluralReady) => ( + <FormattedMessage + id='trends.counter_by_accounts' + defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + }} + /> +); const Hashtag = ({ hashtag }) => ( <div className='trends__item'> <div className='trends__item__name'> - <Permalink href={hashtag.get('url')} to={`/timelines/tag/${hashtag.get('name')}`}> + <Permalink + href={hashtag.get('url')} + to={`/timelines/tag/${hashtag.get('name')}`} + > #<span>{hashtag.get('name')}</span> </Permalink> - <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} /> + <ShortNumber + value={ + hashtag.getIn(['history', 0, 'accounts']) * 1 + + hashtag.getIn(['history', 1, 'accounts']) * 1 + } + renderer={accountsCountRenderer} + /> </div> <div className='trends__item__current'> - {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)} + <ShortNumber + value={ + hashtag.getIn(['history', 0, 'uses']) * 1 + + hashtag.getIn(['history', 1, 'uses']) * 1 + } + /> </div> <div className='trends__item__sparkline'> - <Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}> + <Sparklines + width={50} + height={28} + data={hashtag + .get('history') + .reverse() + .map((day) => day.get('uses')) + .toArray()} + > <SparklinesCurve style={{ fill: 'none' }} /> </Sparklines> </div> diff --git a/app/javascript/mastodon/components/short_number.js b/app/javascript/mastodon/components/short_number.js new file mode 100644 index 000000000..535c17727 --- /dev/null +++ b/app/javascript/mastodon/components/short_number.js @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers'; +import { FormattedMessage, FormattedNumber } from 'react-intl'; +// @ts-check + +/** + * @callback ShortNumberRenderer + * @param {JSX.Element} displayNumber Number to display + * @param {number} pluralReady Number used for pluralization + * @returns {JSX.Element} Final render of number + */ + +/** + * @typedef {object} ShortNumberProps + * @property {number} value Number to display in short variant + * @property {ShortNumberRenderer} [renderer] + * Custom renderer for numbers, provided as a prop. If another renderer + * passed as a child of this component, this prop won't be used. + * @property {ShortNumberRenderer} [children] + * Custom renderer for numbers, provided as a child. If another renderer + * passed as a prop of this component, this one will be used instead. + */ + +/** + * Component that renders short big number to a shorter version + * + * @param {ShortNumberProps} param0 Props for the component + * @returns {JSX.Element} Rendered number + */ +function ShortNumber({ value, renderer, children }) { + const shortNumber = toShortNumber(value); + const [, division] = shortNumber; + + // eslint-disable-next-line eqeqeq + if (children != null && renderer != null) { + console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'); + } + + // eslint-disable-next-line eqeqeq + const customRenderer = children != null ? children : renderer; + + const displayNumber = <ShortNumberCounter value={shortNumber} />; + + // eslint-disable-next-line eqeqeq + return customRenderer != null + ? customRenderer(displayNumber, pluralReady(value, division)) + : displayNumber; +} + +ShortNumber.propTypes = { + value: PropTypes.number.isRequired, + renderer: PropTypes.func, + children: PropTypes.func, +}; + +/** + * @typedef {object} ShortNumberCounterProps + * @property {import('../utils/number').ShortNumber} value Short number + */ + +/** + * Renders short number into corresponding localizable react fragment + * + * @param {ShortNumberCounterProps} param0 Props for the component + * @returns {JSX.Element} FormattedMessage ready to be embedded in code + */ +function ShortNumberCounter({ value }) { + const [rawNumber, unit, maxFractionDigits = 0] = value; + + const count = ( + <FormattedNumber + value={rawNumber} + maximumFractionDigits={maxFractionDigits} + /> + ); + + let values = { count, rawNumber }; + + switch (unit) { + case DECIMAL_UNITS.THOUSAND: { + return ( + <FormattedMessage + id='units.short.thousand' + defaultMessage='{count}K' + values={values} + /> + ); + } + case DECIMAL_UNITS.MILLION: { + return ( + <FormattedMessage + id='units.short.million' + defaultMessage='{count}M' + values={values} + /> + ); + } + case DECIMAL_UNITS.BILLION: { + return ( + <FormattedMessage + id='units.short.billion' + defaultMessage='{count}B' + values={values} + /> + ); + } + // Not sure if we should go farther - @Sasha-Sorokin + default: return count; + } +} + +ShortNumberCounter.propTypes = { + value: PropTypes.arrayOf(PropTypes.number), +}; + +export default React.memo(ShortNumber); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index eca0b7901..144f6bd94 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -8,7 +8,8 @@ import { autoPlayGif, me, isStaff } from 'mastodon/initial_state'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; import Avatar from 'mastodon/components/avatar'; -import { shortNumberFormat } from 'mastodon/utils/numbers'; +import { counterRenderer } from 'mastodon/components/common_counter'; +import ShortNumber from 'mastodon/components/short_number'; import { NavLink } from 'react-router-dom'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import AccountNoteContainer from '../containers/account_note_container'; @@ -328,15 +329,24 @@ class Header extends ImmutablePureComponent { <div className='account__header__extra__links'> <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}> - <strong>{shortNumberFormat(account.get('statuses_count'))}</strong> <FormattedMessage id='account.posts' defaultMessage='Toots' /> + <ShortNumber + value={account.get('statuses_count')} + renderer={counterRenderer('statuses')} + /> </NavLink> <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}> - <strong>{shortNumberFormat(account.get('following_count'))}</strong> <FormattedMessage id='account.follows' defaultMessage='Follows' /> + <ShortNumber + value={account.get('following_count')} + renderer={counterRenderer('following')} + /> </NavLink> <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}> - <strong>{shortNumberFormat(account.get('followers_count'))}</strong> <FormattedMessage id='account.followers' defaultMessage='Followers' /> + <ShortNumber + value={account.get('followers_count')} + renderer={counterRenderer('followers')} + /> </NavLink> </div> </div> diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js index cb47d9db4..419ab9e11 100644 --- a/app/javascript/mastodon/features/directory/components/account_card.js +++ b/app/javascript/mastodon/features/directory/components/account_card.js @@ -11,8 +11,14 @@ import RelativeTimestamp from 'mastodon/components/relative_timestamp'; import IconButton from 'mastodon/components/icon_button'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state'; -import { shortNumberFormat } from 'mastodon/utils/numbers'; -import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts'; +import ShortNumber from 'mastodon/components/short_number'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + unmuteAccount, +} from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; import { initMuteModal } from 'mastodon/actions/mutes'; @@ -22,7 +28,10 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, - unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, + unfollowConfirm: { + id: 'confirmations.unfollow.confirm', + defaultMessage: 'Unfollow', + }, }); const makeMapStateToProps = () => { @@ -36,15 +45,25 @@ const makeMapStateToProps = () => { }; const mapDispatchToProps = (dispatch, { intl }) => ({ - - onFollow (account) { - if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + onFollow(account) { + if ( + account.getIn(['relationship', 'following']) || + account.getIn(['relationship', 'requested']) + ) { if (unfollowModal) { - dispatch(openModal('CONFIRM', { - message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, - confirm: intl.formatMessage(messages.unfollowConfirm), - onConfirm: () => dispatch(unfollowAccount(account.get('id'))), - })); + dispatch( + openModal('CONFIRM', { + message: ( + <FormattedMessage + id='confirmations.unfollow.message' + defaultMessage='Are you sure you want to unfollow {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + ), + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + }), + ); } else { dispatch(unfollowAccount(account.get('id'))); } @@ -53,7 +72,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, - onBlock (account) { + onBlock(account) { if (account.getIn(['relationship', 'blocking'])) { dispatch(unblockAccount(account.get('id'))); } else { @@ -61,17 +80,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, - onMute (account) { + onMute(account) { if (account.getIn(['relationship', 'muting'])) { dispatch(unmuteAccount(account.get('id'))); } else { dispatch(initMuteModal(account)); } }, - }); -export default @injectIntl +export default +@injectIntl @connect(makeMapStateToProps, mapDispatchToProps) class AccountCard extends ImmutablePureComponent { @@ -83,7 +102,7 @@ class AccountCard extends ImmutablePureComponent { onMute: PropTypes.func.isRequired, }; - _updateEmojis () { + _updateEmojis() { const node = this.node; if (!node || autoPlayGif) { @@ -104,68 +123,113 @@ class AccountCard extends ImmutablePureComponent { } } - componentDidMount () { + componentDidMount() { this._updateEmojis(); } - componentDidUpdate () { + componentDidUpdate() { this._updateEmojis(); } handleEmojiMouseEnter = ({ target }) => { target.src = target.getAttribute('data-original'); - } + }; handleEmojiMouseLeave = ({ target }) => { target.src = target.getAttribute('data-static'); - } + }; handleFollow = () => { this.props.onFollow(this.props.account); - } + }; handleBlock = () => { this.props.onBlock(this.props.account); - } + }; handleMute = () => { this.props.onMute(this.props.account); - } + }; setRef = (c) => { this.node = c; - } + }; - render () { + render() { const { account, intl } = this.props; let buttons; - if (account.get('id') !== me && account.get('relationship', null) !== null) { + if ( + account.get('id') !== me && + account.get('relationship', null) !== null + ) { const following = account.getIn(['relationship', 'following']); const requested = account.getIn(['relationship', 'requested']); - const blocking = account.getIn(['relationship', 'blocking']); - const muting = account.getIn(['relationship', 'muting']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); if (requested) { - buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; + buttons = ( + <IconButton + disabled + icon='hourglass' + title={intl.formatMessage(messages.requested)} + /> + ); } else if (blocking) { - buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; + buttons = ( + <IconButton + active + icon='unlock' + title={intl.formatMessage(messages.unblock, { + name: account.get('username'), + })} + onClick={this.handleBlock} + /> + ); } else if (muting) { - buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />; + buttons = ( + <IconButton + active + icon='volume-up' + title={intl.formatMessage(messages.unmute, { + name: account.get('username'), + })} + onClick={this.handleMute} + /> + ); } else if (!account.get('moved') || following) { - buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + buttons = ( + <IconButton + icon={following ? 'user-times' : 'user-plus'} + title={intl.formatMessage( + following ? messages.unfollow : messages.follow, + )} + onClick={this.handleFollow} + active={following} + /> + ); } } return ( <div className='directory__card'> <div className='directory__card__img'> - <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' /> + <img + src={ + autoPlayGif ? account.get('header') : account.get('header_static') + } + alt='' + /> </div> <div className='directory__card__bar'> - <Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <Permalink + className='directory__card__bar__name' + href={account.get('url')} + to={`/accounts/${account.get('id')}`} + > <Avatar account={account} size={48} /> <DisplayName account={account} /> </Permalink> @@ -176,13 +240,44 @@ class AccountCard extends ImmutablePureComponent { </div> <div className='directory__card__extra' ref={this.setRef}> - <div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} /> + <div + className='account__header__content' + dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} + /> </div> <div className='directory__card__extra'> - <div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div> - <div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div> - <div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div> + <div className='accounts-table__count'> + <ShortNumber value={account.get('statuses_count')} /> + <small> + <FormattedMessage id='account.posts' defaultMessage='Toots' /> + </small> + </div> + <div className='accounts-table__count'> + <ShortNumber value={account.get('followers_count')} />{' '} + <small> + <FormattedMessage + id='account.followers' + defaultMessage='Followers' + /> + </small> + </div> + <div className='accounts-table__count'> + {account.get('last_status_at') === null ? ( + <FormattedMessage + id='account.never_active' + defaultMessage='Never' + /> + ) : ( + <RelativeTimestamp timestamp={account.get('last_status_at')} /> + )}{' '} + <small> + <FormattedMessage + id='account.last_status' + defaultMessage='Last active' + /> + </small> + </div> </div> </div> ); diff --git a/app/javascript/mastodon/utils/numbers.js b/app/javascript/mastodon/utils/numbers.js index af18dcfdd..6f2505cae 100644 --- a/app/javascript/mastodon/utils/numbers.js +++ b/app/javascript/mastodon/utils/numbers.js @@ -1,16 +1,71 @@ -import React, { Fragment } from 'react'; -import { FormattedNumber } from 'react-intl'; - -export const shortNumberFormat = number => { - if (number < 1000) { - return <FormattedNumber value={number} />; - } else if (number < 10000) { - return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>; - } else if (number < 1000000) { - return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={0} />K</Fragment>; - } else if (number < 10000000) { - return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>; - } else { - return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={0} />M</Fragment>; +// @ts-check + +export const DECIMAL_UNITS = Object.freeze({ + ONE: 1, + TEN: 10, + HUNDRED: Math.pow(10, 2), + THOUSAND: Math.pow(10, 3), + MILLION: Math.pow(10, 6), + BILLION: Math.pow(10, 9), + TRILLION: Math.pow(10, 12), +}); + +const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10; +const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10; + +/** + * @typedef {[number, number, number]} ShortNumber + * Array of: shorten number, unit of shorten number and maximum fraction digits + */ + +/** + * @param {number} sourceNumber Number to convert to short number + * @returns {ShortNumber} Calculated short number + * @example + * shortNumber(5936); + * // => [5.936, 1000, 1] + */ +export function toShortNumber(sourceNumber) { + if (sourceNumber < DECIMAL_UNITS.THOUSAND) { + return [sourceNumber, DECIMAL_UNITS.ONE, 0]; + } else if (sourceNumber < DECIMAL_UNITS.MILLION) { + return [ + sourceNumber / DECIMAL_UNITS.THOUSAND, + DECIMAL_UNITS.THOUSAND, + sourceNumber < TEN_THOUSAND ? 1 : 0, + ]; + } else if (sourceNumber < DECIMAL_UNITS.BILLION) { + return [ + sourceNumber / DECIMAL_UNITS.MILLION, + DECIMAL_UNITS.MILLION, + sourceNumber < TEN_MILLIONS ? 1 : 0, + ]; + } else if (sourceNumber < DECIMAL_UNITS.TRILLION) { + return [ + sourceNumber / DECIMAL_UNITS.BILLION, + DECIMAL_UNITS.BILLION, + 0, + ]; } -}; + + return [sourceNumber, DECIMAL_UNITS.ONE, 0]; +} + +/** + * @param {number} sourceNumber Original number that is shortened + * @param {number} division The scale in which short number is displayed + * @returns {number} Number that can be used for plurals when short form used + * @example + * pluralReady(1793, DECIMAL_UNITS.THOUSAND) + * // => 1790 + */ +export function pluralReady(sourceNumber, division) { + // eslint-disable-next-line eqeqeq + if (division == null || division < DECIMAL_UNITS.HUNDRED) { + return sourceNumber; + } + + let closestScale = division / DECIMAL_UNITS.TEN; + + return Math.trunc(sourceNumber / closestScale) * closestScale; +} |