Skip to content

Commit

Permalink
Add edit history to web UI (mastodon#17390)
Browse files Browse the repository at this point in the history
* Add edit history to web UI

* Change history reducer to store items per status

* Fix missing loading prop
  • Loading branch information
Gargron authored Feb 9, 2022
1 parent 2adcad0 commit fd3a45e
Show file tree
Hide file tree
Showing 18 changed files with 615 additions and 127 deletions.
2 changes: 1 addition & 1 deletion app/controllers/api/v1/statuses/histories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions app/javascript/mastodon/actions/history.js
Original file line number Diff line number Diff line change
@@ -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,
});
149 changes: 106 additions & 43 deletions app/javascript/mastodon/components/dropdown_menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = {
Expand All @@ -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 });
}

Expand All @@ -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;

Expand Down Expand Up @@ -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' />;
}
Expand All @@ -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 }) => (
Expand All @@ -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>
Expand All @@ -170,18 +185,24 @@ 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,
onClose: PropTypes.func.isRequired,
dropdownPlacement: PropTypes.string,
openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func,
renderHeader: PropTypes.func,
onItemClick: PropTypes.func,
};

static defaultProps = {
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
70 changes: 70 additions & 0 deletions app/javascript/mastodon/components/edited_timestamp/index.js
Original file line number Diff line number Diff line change
@@ -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>
);
}

}
Loading

0 comments on commit fd3a45e

Please sign in to comment.