diff --git a/app/controllers/api/v1/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb
index c2c1fac5d5..7fe73a6f54 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 f3ed7b3140..3a65af6869 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 0000000000..c142aaf617
--- /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 7d0588901c..4b4ad83555 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
;
}
@@ -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 (
{({ opacity, scaleX, scaleY }) => (
@@ -152,9 +153,23 @@ class DropdownMenu extends React.PureComponent {
-
- {items.map((option, i) => this.renderItem(option, i))}
-
+
+ {loading && (
+
+ )}
+
+ {!loading && renderHeader && (
+
+ {renderHeader(items)}
+
+ )}
+
+ {!loading && (
+
+ {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
+
+ )}
+
)}
@@ -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();
- action();
- } else if (to) {
+ onItemClick(item, i);
+ } else if (item && typeof item.action === 'function') {
e.preventDefault();
- this.context.router.history.push(to);
+ item.action();
+ } else if (item && item.to) {
+ e.preventDefault();
+ 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,
+ }) : (
+
+ );
+
return (
-
-
+
+ {button}
-
+
-
+
);
}
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 0000000000..e30c18372e
--- /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 0000000000..bebf938865
--- /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 (
+
+ );
+ }
+
+ renderItem = (item, index, { onClick, onKeyPress }) => {
+ const formattedDate = ;
+ const formattedName = ;
+
+ const label = item.get('original') ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+ );
+ }
+
+ render () {
+ const { timestamp, intl, statusId } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/inline_account.js b/app/javascript/mastodon/components/inline_account.js
new file mode 100644
index 0000000000..a1b4955904
--- /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 (
+
+ {account.get('username')}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js
index d6a5adb6fe..59f721c50f 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 (
+
+ );
+};
+
+CircularProgress.propTypes = {
+ size: PropTypes.number.isRequired,
+ strokeWidth: PropTypes.number.isRequired,
+};
const LoadingIndicator = () => (
);
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
index 711181dcdb..5124803392 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 (