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 {
}).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>
);
}
--- /dev/null
+// @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`);
+ }
+}
+// @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>
--- /dev/null
+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);
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';
<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>
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';
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 = () => {
};
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')));
}
}
},
- onBlock (account) {
+ onBlock(account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
}
},
- 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 {
onMute: PropTypes.func.isRequired,
};
- _updateEmojis () {
+ _updateEmojis() {
const node = this.node;
if (!node || autoPlayGif) {
}
}
- 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>
</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>
);
-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;
+}