about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/api/v1/statuses/histories_controller.rb2
-rw-r--r--app/helpers/languages_helper.rb14
-rw-r--r--app/javascript/mastodon/actions/history.js37
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js149
-rw-r--r--app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js27
-rw-r--r--app/javascript/mastodon/components/edited_timestamp/index.js70
-rw-r--r--app/javascript/mastodon/components/inline_account.js34
-rw-r--r--app/javascript/mastodon/components/loading_indicator.js27
-rw-r--r--app/javascript/mastodon/components/relative_timestamp.js23
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/compare_history_modal.js79
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js6
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/reducers/history.js28
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/styles/mastodon/components.scss228
-rw-r--r--app/lib/admin/metrics/dimension/languages_dimension.rb2
-rw-r--r--app/lib/admin/metrics/dimension/tag_languages_dimension.rb2
-rw-r--r--app/lib/formatter.rb4
-rw-r--r--app/models/account_suggestions/global_source.rb2
-rw-r--r--app/models/status_edit.rb5
-rw-r--r--app/serializers/rest/status_edit_serializer.rb12
-rw-r--r--app/views/admin/accounts/show.html.haml2
-rw-r--r--app/views/admin/follow_recommendations/show.html.haml2
-rw-r--r--app/views/admin/trends/links/_preview_card.html.haml2
-rw-r--r--app/views/settings/preferences/appearance/show.html.haml2
-rw-r--r--app/views/settings/preferences/other/show.html.haml4
-rw-r--r--spec/helpers/languages_helper_spec.rb12
28 files changed, 645 insertions, 141 deletions
diff --git a/app/controllers/api/v1/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb
index c2c1fac5d..7fe73a6f5 100644
--- a/app/controllers/api/v1/statuses/histories_controller.rb
+++ b/app/controllers/api/v1/statuses/histories_controller.rb
@@ -7,7 +7,7 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController
   before_action :set_status
 
   def show
-    render json: @status.edits, each_serializer: REST::StatusEditSerializer
+    render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer
   end
 
   private
diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb
index f3ed7b314..3a65af686 100644
--- a/app/helpers/languages_helper.rb
+++ b/app/helpers/languages_helper.rb
@@ -209,8 +209,8 @@ module LanguagesHelper
     'zh-TW': '繁體中文(臺灣)',
   }.freeze
 
-  def human_locale(locale)
-    if locale == 'und'
+  def native_locale_name(locale)
+    if locale.blank? || locale == 'und'
       I18n.t('generic.none')
     elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym])
       supported_locale[1]
@@ -221,6 +221,16 @@ module LanguagesHelper
     end
   end
 
+  def standard_locale_name(locale)
+    if locale.blank?
+      I18n.t('generic.none')
+    elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym])
+      supported_locale[0]
+    else
+      locale
+    end
+  end
+
   def valid_locale_or_nil(str)
     return if str.blank?
 
diff --git a/app/javascript/mastodon/actions/history.js b/app/javascript/mastodon/actions/history.js
new file mode 100644
index 000000000..c142aaf61
--- /dev/null
+++ b/app/javascript/mastodon/actions/history.js
@@ -0,0 +1,37 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+
+export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST';
+export const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS';
+export const HISTORY_FETCH_FAIL    = 'HISTORY_FETCH_FAIL';
+
+export const fetchHistory = statusId => (dispatch, getState) => {
+  const loading = getState().getIn(['history', statusId, 'loading']);
+
+  if (loading) {
+    return;
+  }
+
+  dispatch(fetchHistoryRequest(statusId));
+
+  api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => {
+    dispatch(importFetchedAccounts(data.map(x => x.account)));
+    dispatch(fetchHistorySuccess(statusId, data));
+  }).catch(error => dispatch(fetchHistoryFail(error)));
+};
+
+export const fetchHistoryRequest = statusId => ({
+  type: HISTORY_FETCH_REQUEST,
+  statusId,
+});
+
+export const fetchHistorySuccess = (statusId, history) => ({
+  type: HISTORY_FETCH_SUCCESS,
+  statusId,
+  history,
+});
+
+export const fetchHistoryFail = error => ({
+  type: HISTORY_FETCH_FAIL,
+  error,
+});
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 7d0588901..4b4ad8355 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -6,6 +6,8 @@ import Overlay from 'react-overlays/lib/Overlay';
 import Motion from '../features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import { CircularProgress } from 'mastodon/components/loading_indicator';
 
 const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 let id = 0;
@@ -17,13 +19,18 @@ class DropdownMenu extends React.PureComponent {
   };
 
   static propTypes = {
-    items: PropTypes.array.isRequired,
+    items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+    loading: PropTypes.bool,
+    scrollable: PropTypes.bool,
     onClose: PropTypes.func.isRequired,
     style: PropTypes.object,
     placement: PropTypes.string,
     arrowOffsetLeft: PropTypes.string,
     arrowOffsetTop: PropTypes.string,
     openedViaKeyboard: PropTypes.bool,
+    renderItem: PropTypes.func,
+    renderHeader: PropTypes.func,
+    onItemClick: PropTypes.func.isRequired,
   };
 
   static defaultProps = {
@@ -45,9 +52,11 @@ class DropdownMenu extends React.PureComponent {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('keydown', this.handleKeyDown, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+
     if (this.focusedItem && this.props.openedViaKeyboard) {
       this.focusedItem.focus({ preventScroll: true });
     }
+
     this.setState({ mounted: true });
   }
 
@@ -66,7 +75,7 @@ class DropdownMenu extends React.PureComponent {
   }
 
   handleKeyDown = e => {
-    const items = Array.from(this.node.getElementsByTagName('a'));
+    const items = Array.from(this.node.querySelectorAll('a, button'));
     const index = items.indexOf(document.activeElement);
     let element = null;
 
@@ -109,21 +118,11 @@ class DropdownMenu extends React.PureComponent {
   }
 
   handleClick = e => {
-    const i = Number(e.currentTarget.getAttribute('data-index'));
-    const { action, to } = this.props.items[i];
-
-    this.props.onClose();
-
-    if (typeof action === 'function') {
-      e.preventDefault();
-      action(e);
-    } else if (to) {
-      e.preventDefault();
-      this.context.router.history.push(to);
-    }
+    const { onItemClick } = this.props;
+    onItemClick(e);
   }
 
-  renderItem (option, i) {
+  renderItem = (option, i) => {
     if (option === null) {
       return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
     }
@@ -140,9 +139,11 @@ class DropdownMenu extends React.PureComponent {
   }
 
   render () {
-    const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
+    const { items, style, placement, arrowOffsetLeft, arrowOffsetTop, scrollable, renderHeader, loading } = this.props;
     const { mounted } = this.state;
 
+    let renderItem = this.props.renderItem || this.renderItem;
+
     return (
       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
         {({ opacity, scaleX, scaleY }) => (
@@ -152,9 +153,23 @@ class DropdownMenu extends React.PureComponent {
           <div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
             <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
 
-            <ul>
-              {items.map((option, i) => this.renderItem(option, i))}
-            </ul>
+            <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })}>
+              {loading && (
+                <CircularProgress size={30} strokeWidth={3.5} />
+              )}
+
+              {!loading && renderHeader && (
+                <div className='dropdown-menu__container__header'>
+                  {renderHeader(items)}
+                </div>
+              )}
+
+              {!loading && (
+                <ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
+                  {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
+                </ul>
+              )}
+            </div>
           </div>
         )}
       </Motion>
@@ -170,11 +185,14 @@ export default class Dropdown extends React.PureComponent {
   };
 
   static propTypes = {
-    icon: PropTypes.string.isRequired,
-    items: PropTypes.array.isRequired,
-    size: PropTypes.number.isRequired,
+    children: PropTypes.node,
+    icon: PropTypes.string,
+    items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+    loading: PropTypes.bool,
+    size: PropTypes.number,
     title: PropTypes.string,
     disabled: PropTypes.bool,
+    scrollable: PropTypes.bool,
     status: ImmutablePropTypes.map,
     isUserTouching: PropTypes.func,
     onOpen: PropTypes.func.isRequired,
@@ -182,6 +200,9 @@ export default class Dropdown extends React.PureComponent {
     dropdownPlacement: PropTypes.string,
     openDropdownId: PropTypes.number,
     openedViaKeyboard: PropTypes.bool,
+    renderItem: PropTypes.func,
+    renderHeader: PropTypes.func,
+    onItemClick: PropTypes.func,
   };
 
   static defaultProps = {
@@ -237,17 +258,21 @@ export default class Dropdown extends React.PureComponent {
   }
 
   handleItemClick = e => {
+    const { onItemClick } = this.props;
     const i = Number(e.currentTarget.getAttribute('data-index'));
-    const { action, to } = this.props.items[i];
+    const item = this.props.items[i];
 
     this.handleClose();
 
-    if (typeof action === 'function') {
+    if (typeof onItemClick === 'function') {
+      e.preventDefault();
+      onItemClick(item, i);
+    } else if (item && typeof item.action === 'function') {
       e.preventDefault();
-      action();
-    } else if (to) {
+      item.action();
+    } else if (item && item.to) {
       e.preventDefault();
-      this.context.router.history.push(to);
+      this.context.router.history.push(item.to);
     }
   }
 
@@ -265,29 +290,67 @@ export default class Dropdown extends React.PureComponent {
     }
   }
 
+  close = () => {
+    this.handleClose();
+  }
+
   render () {
-    const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props;
+    const {
+      icon,
+      items,
+      size,
+      title,
+      disabled,
+      loading,
+      scrollable,
+      dropdownPlacement,
+      openDropdownId,
+      openedViaKeyboard,
+      children,
+      renderItem,
+      renderHeader,
+    } = this.props;
+
     const open = this.state.id === openDropdownId;
 
+    const button = children ? React.cloneElement(React.Children.only(children), {
+      ref: this.setTargetRef,
+      onClick: this.handleClick,
+      onMouseDown: this.handleMouseDown,
+      onKeyDown: this.handleButtonKeyDown,
+      onKeyPress: this.handleKeyPress,
+    }) : (
+      <IconButton
+        icon={icon}
+        title={title}
+        active={open}
+        disabled={disabled}
+        size={size}
+        ref={this.setTargetRef}
+        onClick={this.handleClick}
+        onMouseDown={this.handleMouseDown}
+        onKeyDown={this.handleButtonKeyDown}
+        onKeyPress={this.handleKeyPress}
+      />
+    );
+
     return (
-      <div>
-        <IconButton
-          icon={icon}
-          title={title}
-          active={open}
-          disabled={disabled}
-          size={size}
-          ref={this.setTargetRef}
-          onClick={this.handleClick}
-          onMouseDown={this.handleMouseDown}
-          onKeyDown={this.handleButtonKeyDown}
-          onKeyPress={this.handleKeyPress}
-        />
+      <React.Fragment>
+        {button}
 
         <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
-          <DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
+          <DropdownMenu
+            items={items}
+            loading={loading}
+            scrollable={scrollable}
+            onClose={this.handleClose}
+            openedViaKeyboard={openedViaKeyboard}
+            renderItem={renderItem}
+            renderHeader={renderHeader}
+            onItemClick={this.handleItemClick}
+          />
         </Overlay>
-      </div>
+      </React.Fragment>
     );
   }
 
diff --git a/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js
new file mode 100644
index 000000000..e30c18372
--- /dev/null
+++ b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu';
+import { fetchHistory } from 'mastodon/actions/history';
+import DropdownMenu from 'mastodon/components/dropdown_menu';
+
+const mapStateToProps = (state, { statusId }) => ({
+  dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
+  openDropdownId: state.getIn(['dropdown_menu', 'openId']),
+  openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
+  items: state.getIn(['history', statusId, 'items']),
+  loading: state.getIn(['history', statusId, 'loading']),
+});
+
+const mapDispatchToProps = (dispatch, { statusId }) => ({
+
+  onOpen (id, onItemClick, dropdownPlacement, keyboard) {
+    dispatch(fetchHistory(statusId));
+    dispatch(openDropdownMenu(id, dropdownPlacement, keyboard));
+  },
+
+  onClose (id) {
+    dispatch(closeDropdownMenu(id));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/mastodon/components/edited_timestamp/index.js b/app/javascript/mastodon/components/edited_timestamp/index.js
new file mode 100644
index 000000000..bebf93886
--- /dev/null
+++ b/app/javascript/mastodon/components/edited_timestamp/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import Icon from 'mastodon/components/icon';
+import DropdownMenu from './containers/dropdown_menu_container';
+import { connect } from 'react-redux';
+import { openModal } from 'mastodon/actions/modal';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import InlineAccount from 'mastodon/components/inline_account';
+
+const mapDispatchToProps = (dispatch, { statusId }) => ({
+
+  onItemClick (index) {
+    dispatch(openModal('COMPARE_HISTORY', { index, statusId }));
+  },
+
+});
+
+export default @connect(null, mapDispatchToProps)
+@injectIntl
+class EditedTimestamp extends React.PureComponent {
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    timestamp: PropTypes.string.isRequired,
+    intl: PropTypes.object.isRequired,
+    onItemClick: PropTypes.func.isRequired,
+  };
+
+  handleItemClick = (item, i) => {
+    const { onItemClick } = this.props;
+    onItemClick(i);
+  };
+
+  renderHeader = items => {
+    return (
+      <FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {{count} time} other {{count} times}}' values={{ count: items.size - 1 }} />
+    );
+  }
+
+  renderItem = (item, index, { onClick, onKeyPress }) => {
+    const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />;
+    const formattedName = <InlineAccount accountId={item.get('account')} />;
+
+    const label = item.get('original') ? (
+      <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
+    ) : (
+      <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
+    );
+
+    return (
+      <li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}>
+        <button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button>
+      </li>
+    );
+  }
+
+  render () {
+    const { timestamp, intl, statusId } = this.props;
+
+    return (
+      <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
+        <button className='dropdown-menu__text-button'>
+          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' />
+        </button>
+      </DropdownMenu>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/inline_account.js b/app/javascript/mastodon/components/inline_account.js
new file mode 100644
index 000000000..a1b495590
--- /dev/null
+++ b/app/javascript/mastodon/components/inline_account.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'mastodon/selectors';
+import Avatar from 'mastodon/components/avatar';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+class InlineAccount extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <span className='inline-account'>
+        <Avatar size={13} account={account} /> <strong>{account.get('username')}</strong>
+      </span>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js
index d6a5adb6f..59f721c50 100644
--- a/app/javascript/mastodon/components/loading_indicator.js
+++ b/app/javascript/mastodon/components/loading_indicator.js
@@ -1,10 +1,31 @@
 import React from 'react';
-import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export const CircularProgress = ({ size, strokeWidth }) => {
+  const viewBox = `0 0 ${size} ${size}`;
+  const radius  = (size - strokeWidth) / 2;
+
+  return (
+    <svg width={size} heigh={size} viewBox={viewBox} className='circular-progress' role='progressbar'>
+      <circle
+        fill='none'
+        cx={size / 2}
+        cy={size / 2}
+        r={radius}
+        strokeWidth={`${strokeWidth}px`}
+      />
+    </svg>
+  );
+};
+
+CircularProgress.propTypes = {
+  size: PropTypes.number.isRequired,
+  strokeWidth: PropTypes.number.isRequired,
+};
 
 const LoadingIndicator = () => (
   <div className='loading-indicator'>
-    <div className='loading-indicator__figure' />
-    <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
+    <CircularProgress size={50} strokeWidth={6} />
   </div>
 );
 
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
index 711181dcd..512480339 100644
--- a/app/javascript/mastodon/components/relative_timestamp.js
+++ b/app/javascript/mastodon/components/relative_timestamp.js
@@ -5,10 +5,15 @@ import PropTypes from 'prop-types';
 const messages = defineMessages({
   today: { id: 'relative_time.today', defaultMessage: 'today' },
   just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+  just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' },
   seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+  seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' },
   minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+  minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' },
   hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+  hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' },
   days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+  days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' },
   moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
   seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
   minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
@@ -66,7 +71,7 @@ const getUnitDelay = units => {
   }
 };
 
-export const timeAgoString = (intl, date, now, year, timeGiven = true) => {
+export const timeAgoString = (intl, date, now, year, timeGiven, short) => {
   const delta = now - date.getTime();
 
   let relativeTime;
@@ -74,16 +79,16 @@ export const timeAgoString = (intl, date, now, year, timeGiven = true) => {
   if (delta < DAY && !timeGiven) {
     relativeTime = intl.formatMessage(messages.today);
   } else if (delta < 10 * SECOND) {
-    relativeTime = intl.formatMessage(messages.just_now);
+    relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full);
   } else if (delta < 7 * DAY) {
     if (delta < MINUTE) {
-      relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
+      relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) });
     } else if (delta < HOUR) {
-      relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
+      relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) });
     } else if (delta < DAY) {
-      relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
+      relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) });
     } else {
-      relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
+      relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) });
     }
   } else if (date.getFullYear() === year) {
     relativeTime = intl.formatDate(date, shortDateFormatOptions);
@@ -124,6 +129,7 @@ class RelativeTimestamp extends React.Component {
     timestamp: PropTypes.string.isRequired,
     year: PropTypes.number.isRequired,
     futureDate: PropTypes.bool,
+    short: PropTypes.bool,
   };
 
   state = {
@@ -132,6 +138,7 @@ class RelativeTimestamp extends React.Component {
 
   static defaultProps = {
     year: (new Date()).getFullYear(),
+    short: true,
   };
 
   shouldComponentUpdate (nextProps, nextState) {
@@ -176,11 +183,11 @@ class RelativeTimestamp extends React.Component {
   }
 
   render () {
-    const { timestamp, intl, year, futureDate } = this.props;
+    const { timestamp, intl, year, futureDate, short } = this.props;
 
     const timeGiven    = timestamp.includes('T');
     const date         = new Date(timestamp);
-    const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven);
+    const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short);
 
     return (
       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index ee4a6b989..c99e01f73 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
 import StatusContent from '../../../components/status_content';
 import MediaGallery from '../../../components/media_gallery';
 import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl';
+import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from '../../video';
@@ -16,6 +16,7 @@ import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 import AnimatedNumber from 'mastodon/components/animated_number';
 import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
+import EditedTimestamp from 'mastodon/components/edited_timestamp';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@@ -242,7 +243,7 @@ class DetailedStatus extends ImmutablePureComponent {
       edited = (
         <React.Fragment>
           <React.Fragment> · </React.Fragment>
-          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
+          <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
         </React.Fragment>
       );
     }
diff --git a/app/javascript/mastodon/features/ui/components/compare_history_modal.js b/app/javascript/mastodon/features/ui/components/compare_history_modal.js
new file mode 100644
index 000000000..40cfba335
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/compare_history_modal.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { closeModal } from 'mastodon/actions/modal';
+import emojify from 'mastodon/features/emoji/emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import InlineAccount from 'mastodon/components/inline_account';
+import IconButton from 'mastodon/components/icon_button';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+
+const mapStateToProps = (state, { statusId }) => ({
+  versions: state.getIn(['history', statusId, 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClose() {
+    dispatch(closeModal());
+  },
+
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+class CompareHistoryModal extends React.PureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    index: PropTypes.number.isRequired,
+    statusId: PropTypes.string.isRequired,
+    versions: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { index, versions, onClose } = this.props;
+    const currentVersion = versions.get(index);
+
+    const emojiMap = currentVersion.get('emojis').reduce((obj, emoji) => {
+      obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
+      return obj;
+    }, {});
+
+    const content = { __html: emojify(currentVersion.get('content'), emojiMap) };
+    const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) };
+
+    const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
+    const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
+
+    const label = currentVersion.get('original') ? (
+      <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} />
+    ) : (
+      <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} />
+    );
+
+    return (
+      <div className='modal-root__modal compare-history-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' icon='times' onClick={onClose} size={20} />
+          {label}
+        </div>
+
+        <div className='compare-history-modal__container'>
+          <div className='status__content'>
+            {currentVersion.get('spoiler_text').length > 0 && (
+              <React.Fragment>
+                <div className='translate' dangerouslySetInnerHTML={spoilerContent} />
+                <hr />
+              </React.Fragment>
+            )}
+
+            <div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 377cccda5..7b14fe5ca 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -19,7 +19,8 @@ import {
   EmbedModal,
   ListEditor,
   ListAdder,
-} from '../../../features/ui/util/async-components';
+  CompareHistoryModal,
+} from 'mastodon/features/ui/util/async-components';
 
 const MODAL_COMPONENTS = {
   'MEDIA': () => Promise.resolve({ default: MediaModal }),
@@ -34,7 +35,8 @@ const MODAL_COMPONENTS = {
   'EMBED': EmbedModal,
   'LIST_EDITOR': ListEditor,
   'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
-  'LIST_ADDER':ListAdder,
+  'LIST_ADDER': ListAdder,
+  'COMPARE_HISTORY': CompareHistoryModal,
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index aa90b226a..5349bd656 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -157,3 +157,7 @@ export function Directory () {
 export function FollowRecommendations () {
   return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
 }
+
+export function CompareHistoryModal () {
+  return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
+}
diff --git a/app/javascript/mastodon/reducers/history.js b/app/javascript/mastodon/reducers/history.js
new file mode 100644
index 000000000..00b01aa6e
--- /dev/null
+++ b/app/javascript/mastodon/reducers/history.js
@@ -0,0 +1,28 @@
+import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL } from 'mastodon/actions/history';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialHistory = ImmutableMap({
+  loading: false,
+  items: ImmutableList(),
+});
+
+const initialState = ImmutableMap();
+
+export default function history(state = initialState, action) {
+  switch(action.type) {
+  case HISTORY_FETCH_REQUEST:
+    return state.update(action.statusId, initialHistory, history => history.withMutations(map => {
+      map.set('loading', true);
+      map.set('items', ImmutableList());
+    }));
+  case HISTORY_FETCH_SUCCESS:
+    return state.update(action.statusId, initialHistory, history => history.withMutations(map => {
+      map.set('loading', false);
+      map.set('items', fromJS(action.history.map((x, i) => ({ ...x, account: x.account.id, original: i === 0 })).reverse()));
+    }));
+  case HISTORY_FETCH_FAIL:
+    return state.update(action.statusId, initialHistory, history => history.set('loading', false));
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 53e2dd681..af2ef595e 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -38,6 +38,7 @@ import announcements from './announcements';
 import markers from './markers';
 import picture_in_picture from './picture_in_picture';
 import accounts_map from './accounts_map';
+import history from './history';
 
 const reducers = {
   announcements,
@@ -79,6 +80,7 @@ const reducers = {
   missed_updates,
   markers,
   picture_in_picture,
+  history,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 919480e7e..5304bec34 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1889,8 +1889,47 @@ a.account__display-name {
   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
   z-index: 9999;
 
-  ul {
-    list-style: none;
+  &__text-button {
+    display: inline;
+    color: inherit;
+    background: transparent;
+    border: 0;
+    margin: 0;
+    padding: 0;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+
+    &:focus {
+      outline: 1px dotted;
+    }
+  }
+
+  &__container {
+    &__header {
+      border-bottom: 1px solid darken($ui-secondary-color, 8%);
+      padding: 4px 14px;
+      padding-bottom: 8px;
+      font-size: 13px;
+      line-height: 18px;
+      color: $inverted-text-color;
+    }
+
+    &__list {
+      list-style: none;
+
+      &--scrollable {
+        max-height: 300px;
+        overflow-y: scroll;
+      }
+    }
+
+    &--loading {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 30px 45px;
+    }
   }
 
   &.left {
@@ -1946,18 +1985,29 @@ a.account__display-name {
 }
 
 .dropdown-menu__item {
-  a {
-    font-size: 13px;
-    line-height: 18px;
+  font-size: 13px;
+  line-height: 18px;
+  display: block;
+  color: $inverted-text-color;
+
+  a,
+  button {
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
     display: block;
+    width: 100%;
     padding: 4px 14px;
+    border: 0;
+    margin: 0;
     box-sizing: border-box;
     text-decoration: none;
     background: $ui-secondary-color;
-    color: $inverted-text-color;
+    color: inherit;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
+    text-align: inherit;
 
     &:focus,
     &:hover,
@@ -1969,6 +2019,42 @@ a.account__display-name {
   }
 }
 
+.dropdown-menu__item--text {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  padding: 4px 14px;
+}
+
+.dropdown-menu__item.edited-timestamp__history__item {
+  border-bottom: 1px solid darken($ui-secondary-color, 8%);
+
+  &:last-child {
+    border-bottom: 0;
+  }
+
+  &.dropdown-menu__item--text,
+  a,
+  button {
+    padding: 8px 14px;
+  }
+}
+
+.inline-account {
+  display: inline-flex;
+  align-items: center;
+  vertical-align: top;
+
+  .account__avatar {
+    margin-right: 5px;
+    border-radius: 50%;
+  }
+
+  strong {
+    font-weight: 600;
+  }
+}
+
 .dropdown--active .dropdown__content {
   display: block;
   line-height: 18px;
@@ -3631,36 +3717,48 @@ a.status-card.compact:hover {
   top: 50%;
   left: 50%;
   transform: translate(-50%, -50%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
 
-  span {
-    display: block;
-    float: left;
-    transform: translateX(-50%);
-    margin: 82px 0 0 50%;
-    white-space: nowrap;
+.circular-progress {
+  color: lighten($ui-base-color, 26%);
+  animation: 1.4s linear 0s infinite normal none running simple-rotate;
+
+  circle {
+    stroke: currentColor;
+    stroke-dasharray: 80px, 200px;
+    stroke-dashoffset: 0;
+    animation: circular-progress 1.4s ease-in-out infinite;
   }
 }
 
-.loading-indicator__figure {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  width: 42px;
-  height: 42px;
-  box-sizing: border-box;
-  background-color: transparent;
-  border: 0 solid lighten($ui-base-color, 26%);
-  border-width: 6px;
-  border-radius: 50%;
-}
+@keyframes circular-progress {
+  0% {
+    stroke-dasharray: 1px, 200px;
+    stroke-dashoffset: 0;
+  }
 
-.no-reduce-motion .loading-indicator span {
-  animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
+  50% {
+    stroke-dasharray: 100px, 200px;
+    stroke-dashoffset: -15px;
+  }
+
+  100% {
+    stroke-dasharray: 100px, 200px;
+    stroke-dashoffset: -125px;
+  }
 }
 
-.no-reduce-motion .loading-indicator__figure {
-  animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
+@keyframes simple-rotate {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  100% {
+    transform: rotate(360deg);
+  }
 }
 
 @keyframes spring-rotate-in {
@@ -3707,40 +3805,6 @@ a.status-card.compact:hover {
   }
 }
 
-@keyframes loader-figure {
-  0% {
-    width: 0;
-    height: 0;
-    background-color: lighten($ui-base-color, 26%);
-  }
-
-  29% {
-    background-color: lighten($ui-base-color, 26%);
-  }
-
-  30% {
-    width: 42px;
-    height: 42px;
-    background-color: transparent;
-    border-width: 21px;
-    opacity: 1;
-  }
-
-  100% {
-    width: 42px;
-    height: 42px;
-    border-width: 0;
-    opacity: 0;
-    background-color: transparent;
-  }
-}
-
-@keyframes loader-label {
-  0% { opacity: 0.25; }
-  30% { opacity: 1; }
-  100% { opacity: 0.25; }
-}
-
 .video-error-cover {
   align-items: center;
   background: $base-overlay-background;
@@ -4940,7 +5004,8 @@ a.status-card.compact:hover {
 .report-modal,
 .actions-modal,
 .mute-modal,
-.block-modal {
+.block-modal,
+.compare-history-modal {
   background: lighten($ui-secondary-color, 8%);
   color: $inverted-text-color;
   border-radius: 8px;
@@ -5342,6 +5407,41 @@ a.status-card.compact:hover {
   }
 }
 
+.compare-history-modal {
+  .report-modal__target {
+    border-bottom: 1px solid $ui-secondary-color;
+  }
+
+  &__container {
+    padding: 30px;
+    pointer-events: all;
+  }
+
+  .status__content {
+    color: $inverted-text-color;
+    font-size: 19px;
+    line-height: 24px;
+
+    .emojione {
+      width: 24px;
+      height: 24px;
+      margin: -1px 0 0;
+    }
+
+    a {
+      color: $highlight-text-color;
+    }
+
+    hr {
+      height: 0.25rem;
+      padding: 0;
+      background-color: $ui-secondary-color;
+      border: 0;
+      margin: 20px 0;
+    }
+  }
+}
+
 .loading-bar {
   background-color: $highlight-text-color;
   height: 3px;
diff --git a/app/lib/admin/metrics/dimension/languages_dimension.rb b/app/lib/admin/metrics/dimension/languages_dimension.rb
index a6aaf5d21..1cc5f4120 100644
--- a/app/lib/admin/metrics/dimension/languages_dimension.rb
+++ b/app/lib/admin/metrics/dimension/languages_dimension.rb
@@ -20,6 +20,6 @@ class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension:
 
     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
 
-    rows.map { |row| { key: row['locale'], human_key: human_locale(row['locale']), value: row['value'].to_s } }
+    rows.map { |row| { key: row['locale'], human_key: standard_locale_name(row['locale']), value: row['value'].to_s } }
   end
 end
diff --git a/app/lib/admin/metrics/dimension/tag_languages_dimension.rb b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
index 1cfa07478..afbc8cde8 100644
--- a/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
+++ b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
@@ -25,7 +25,7 @@ class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimensi
 
     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
 
-    rows.map { |row| { key: row['language'], human_key: human_locale(row['language']), value: row['value'].to_s } }
+    rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
   end
 
   private
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index f2c4beed5..94d149da3 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -32,7 +32,7 @@ class Formatter
   include ActionView::Helpers::TextHelper
 
   def format(status, **options)
-    if status.reblog?
+    if status.respond_to?(:reblog?) && status.reblog?
       prepend_reblog = status.reblog.account.acct
       status         = status.proper
     else
@@ -53,7 +53,7 @@ class Formatter
       return html.html_safe # rubocop:disable Rails/OutputSafety
     end
 
-    linkable_accounts = status.active_mentions.map(&:account)
+    linkable_accounts = status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []
     linkable_accounts << status.account
 
     html = raw_content
diff --git a/app/models/account_suggestions/global_source.rb b/app/models/account_suggestions/global_source.rb
index 03ed1b6c2..7bca530d4 100644
--- a/app/models/account_suggestions/global_source.rb
+++ b/app/models/account_suggestions/global_source.rb
@@ -6,7 +6,7 @@ class AccountSuggestions::GlobalSource < AccountSuggestions::Source
   end
 
   def get(account, skip_account_ids: [], limit: 40)
-    account_ids = account_ids_for_locale(I18n.locale.to_str.split(/[_-]/).first) - [account.id] - skip_account_ids
+    account_ids = account_ids_for_locale(I18n.locale.to_s.split(/[_-]/).first) - [account.id] - skip_account_ids
 
     as_ordered_suggestions(
       scope(account).where(id: account_ids),
diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb
index a89df86c5..6e88864e8 100644
--- a/app/models/status_edit.rb
+++ b/app/models/status_edit.rb
@@ -20,4 +20,9 @@ class StatusEdit < ApplicationRecord
   default_scope { order(id: :asc) }
 
   delegate :local?, to: :status
+
+  def emojis
+    return @emojis if defined?(@emojis)
+    @emojis = CustomEmoji.from_text([spoiler_text, text].join(' '), status.account.domain)
+  end
 end
diff --git a/app/serializers/rest/status_edit_serializer.rb b/app/serializers/rest/status_edit_serializer.rb
index b123b4e09..a1f9e824e 100644
--- a/app/serializers/rest/status_edit_serializer.rb
+++ b/app/serializers/rest/status_edit_serializer.rb
@@ -1,6 +1,14 @@
 # frozen_string_literal: true
 
 class REST::StatusEditSerializer < ActiveModel::Serializer
-  attributes :text, :spoiler_text, :media_attachments_changed,
-             :created_at
+  has_one :account, serializer: REST::AccountSerializer
+
+  attributes :content, :spoiler_text,
+             :media_attachments_changed, :created_at
+
+  has_many :emojis, serializer: REST::CustomEmojiSerializer
+
+  def content
+    Formatter.instance.format(object)
+  end
 end
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 3867d1b19..f3853d629 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -147,7 +147,7 @@
 
           %tr
             %th= t('simple_form.labels.defaults.locale')
-            %td= @account.user_locale
+            %td= standard_locale_name(@account.user_locale)
             %td
 
           %tr
diff --git a/app/views/admin/follow_recommendations/show.html.haml b/app/views/admin/follow_recommendations/show.html.haml
index d1c160bd2..85dee210a 100644
--- a/app/views/admin/follow_recommendations/show.html.haml
+++ b/app/views/admin/follow_recommendations/show.html.haml
@@ -10,7 +10,7 @@
     .filter-subset.filter-subset--with-select
       %strong= t('admin.follow_recommendations.language')
       .input.select.optional
-        = select_tag :language, options_for_select(I18n.available_locales.map { |key| key.to_s.split(/[_-]/).first.to_sym }.uniq.map { |key| [human_locale(key), key]}, @language)
+        = select_tag :language, options_for_select(I18n.available_locales.map { |key| key.to_s.split(/[_-]/).first.to_sym }.uniq.map { |key| [standard_locale_name(key), key]}, @language)
 
     .filter-subset
       %strong= t('admin.follow_recommendations.status')
diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml
index b88c1be2f..d88e06bfd 100644
--- a/app/views/admin/trends/links/_preview_card.html.haml
+++ b/app/views/admin/trends/links/_preview_card.html.haml
@@ -13,7 +13,7 @@

 
       - if preview_card.language.present?
-        = human_locale(preview_card.language)
+        = standard_locale_name(preview_card.language)

 
       = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index ccea2e9b7..89bd4f459 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -6,7 +6,7 @@
 
 = simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f|
   .fields-group
-    = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale, hint: false
+    = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, selected: I18n.locale, hint: false
 
   - unless I18n.locale == :en
     .flash-message.translation-prompt
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 76ff2bcbc..8159efb55 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -27,7 +27,7 @@
       = f.input :setting_default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false
 
     .fields-group.fields-row__column.fields-row__column-6
-      = f.input :setting_default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : human_locale(locale) }, required: false, include_blank: false, hint: false
+      = f.input :setting_default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false
 
   .fields-group
     = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label
@@ -41,7 +41,7 @@
   %h4= t 'preferences.public_timelines'
 
   .fields-group
-    = f.input :chosen_languages, collection: filterable_languages, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+    = f.input :chosen_languages, collection: filterable_languages, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
 
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/spec/helpers/languages_helper_spec.rb b/spec/helpers/languages_helper_spec.rb
index b455cee33..5587fc261 100644
--- a/spec/helpers/languages_helper_spec.rb
+++ b/spec/helpers/languages_helper_spec.rb
@@ -9,9 +9,15 @@ describe LanguagesHelper do
     end
   end
 
-  describe 'human_locale' do
-    it 'finds the human readable local description from a key' do
-      expect(helper.human_locale(:en)).to eq('English')
+  describe 'native_locale_name' do
+    it 'finds the human readable native name from a key' do
+      expect(helper.native_locale_name(:en)).to eq('English')
+    end
+  end
+
+  describe 'standard_locale_name' do
+    it 'finds the human readable standard name from a key' do
+      expect(helper.standard_locale_name(:de)).to eq('German')
     end
   end
 end