]> cat aescling's git repositories - mastodon.git/commitdiff
Replace shortNumberFormat with <ShortNumber> (#14061)
authorSasha Sorokin <dafri.nochiterov8@gmail.com>
Mon, 6 Jul 2020 12:27:32 +0000 (19:27 +0700)
committerGitHub <noreply@github.com>
Mon, 6 Jul 2020 12:27:32 +0000 (14:27 +0200)
This commit introduces new utility component - ShortNumber. It should
work almost the same way as original shortNumberFormat function,
though it also localizes units and accepts one more prop - renderer.

Renderer is a function that takes rendered short formatted number
and also ready-to-pluralize number to format display result accordingly.
Ready-to-pluralize number allows to correctly select plural for
compactly notated numbers, respecting thousands and other units.

Issue #12451 accurately describes the issue with using raw numbers
when replacing counter with short version. In short, it doesn't work
with languages such as Russian, that require different plurals,
according to the unit number was compacted to.

All previous usages of shortNumberFormat were replaced with new
function, and as it became unused, it was removed to avoid misleading.

app/javascript/mastodon/components/autosuggest_hashtag.js
app/javascript/mastodon/components/common_counter.js [new file with mode: 0644]
app/javascript/mastodon/components/hashtag.js
app/javascript/mastodon/components/short_number.js [new file with mode: 0644]
app/javascript/mastodon/features/account/components/header.js
app/javascript/mastodon/features/directory/components/account_card.js
app/javascript/mastodon/utils/numbers.js

index e2f4e320dd1db663f8d416e7447216a19c0a56ca..9e9d888f83b48eb4b6ebe68daa0bfdccbc9289c0 100644 (file)
@@ -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 (file)
index 0000000..4fdf3ba
--- /dev/null
@@ -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`);
+  }
+}
index 62d61326261b3ac5efb1e949ec198a929003a46b..d766ca90d78f772c013f9c8a07a9ae7d06bd3d2f 100644 (file)
@@ -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 (file)
index 0000000..535c177
--- /dev/null
@@ -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);
index eca0b7901ac6935dea4db81a7f818e9c168c40ed..144f6bd94c1039e379a0e7489abdf4dad7884dbd 100644 (file)
@@ -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>
index cb47d9db4b6ecf35c9daf37b9f7ef92769076b4a..419ab9e11447fa19e80c826947746cc1feca8c92 100644 (file)
@@ -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>
     );
index af18dcfddb2a80cb552206d553938a03d9a53d4b..6f2505cae87b2eb4795ccf00426904e9041284cf 100644 (file)
@@ -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;
+}