mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-18 15:48:22 +00:00
Add edit history to web UI (#17390)
* Add edit history to web UI * Change history reducer to store items per status * Fix missing loading prop
This commit is contained in:
parent
2adcad04ff
commit
fd3a45e348
|
@ -7,7 +7,7 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController
|
||||||
before_action :set_status
|
before_action :set_status
|
||||||
|
|
||||||
def show
|
def show
|
||||||
render json: @status.edits, each_serializer: REST::StatusEditSerializer
|
render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
37
app/javascript/mastodon/actions/history.js
Normal file
37
app/javascript/mastodon/actions/history.js
Normal file
|
@ -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,
|
||||||
|
});
|
|
@ -6,6 +6,8 @@ import Overlay from 'react-overlays/lib/Overlay';
|
||||||
import Motion from '../features/ui/util/optional_motion';
|
import Motion from '../features/ui/util/optional_motion';
|
||||||
import spring from 'react-motion/lib/spring';
|
import spring from 'react-motion/lib/spring';
|
||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { CircularProgress } from 'mastodon/components/loading_indicator';
|
||||||
|
|
||||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||||
let id = 0;
|
let id = 0;
|
||||||
|
@ -17,13 +19,18 @@ class DropdownMenu extends React.PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
items: PropTypes.array.isRequired,
|
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
scrollable: PropTypes.bool,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
style: PropTypes.object,
|
style: PropTypes.object,
|
||||||
placement: PropTypes.string,
|
placement: PropTypes.string,
|
||||||
arrowOffsetLeft: PropTypes.string,
|
arrowOffsetLeft: PropTypes.string,
|
||||||
arrowOffsetTop: PropTypes.string,
|
arrowOffsetTop: PropTypes.string,
|
||||||
openedViaKeyboard: PropTypes.bool,
|
openedViaKeyboard: PropTypes.bool,
|
||||||
|
renderItem: PropTypes.func,
|
||||||
|
renderHeader: PropTypes.func,
|
||||||
|
onItemClick: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -45,9 +52,11 @@ class DropdownMenu extends React.PureComponent {
|
||||||
document.addEventListener('click', this.handleDocumentClick, false);
|
document.addEventListener('click', this.handleDocumentClick, false);
|
||||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||||
|
|
||||||
if (this.focusedItem && this.props.openedViaKeyboard) {
|
if (this.focusedItem && this.props.openedViaKeyboard) {
|
||||||
this.focusedItem.focus({ preventScroll: true });
|
this.focusedItem.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ mounted: true });
|
this.setState({ mounted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,7 +75,7 @@ class DropdownMenu extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown = e => {
|
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);
|
const index = items.indexOf(document.activeElement);
|
||||||
let element = null;
|
let element = null;
|
||||||
|
|
||||||
|
@ -109,21 +118,11 @@ class DropdownMenu extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick = e => {
|
handleClick = e => {
|
||||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
const { onItemClick } = this.props;
|
||||||
const { action, to } = this.props.items[i];
|
onItemClick(e);
|
||||||
|
|
||||||
this.props.onClose();
|
|
||||||
|
|
||||||
if (typeof action === 'function') {
|
|
||||||
e.preventDefault();
|
|
||||||
action(e);
|
|
||||||
} else if (to) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.context.router.history.push(to);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderItem (option, i) {
|
renderItem = (option, i) => {
|
||||||
if (option === null) {
|
if (option === null) {
|
||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
@ -140,9 +139,11 @@ class DropdownMenu extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop, scrollable, renderHeader, loading } = this.props;
|
||||||
const { mounted } = this.state;
|
const { mounted } = this.state;
|
||||||
|
|
||||||
|
let renderItem = this.props.renderItem || this.renderItem;
|
||||||
|
|
||||||
return (
|
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 }) }}>
|
<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 }) => (
|
{({ 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 ${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 }} />
|
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||||
|
|
||||||
<ul>
|
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })}>
|
||||||
{items.map((option, i) => this.renderItem(option, i))}
|
{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>
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Motion>
|
</Motion>
|
||||||
|
@ -170,11 +185,14 @@ export default class Dropdown extends React.PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
icon: PropTypes.string.isRequired,
|
children: PropTypes.node,
|
||||||
items: PropTypes.array.isRequired,
|
icon: PropTypes.string,
|
||||||
size: PropTypes.number.isRequired,
|
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
size: PropTypes.number,
|
||||||
title: PropTypes.string,
|
title: PropTypes.string,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
|
scrollable: PropTypes.bool,
|
||||||
status: ImmutablePropTypes.map,
|
status: ImmutablePropTypes.map,
|
||||||
isUserTouching: PropTypes.func,
|
isUserTouching: PropTypes.func,
|
||||||
onOpen: PropTypes.func.isRequired,
|
onOpen: PropTypes.func.isRequired,
|
||||||
|
@ -182,6 +200,9 @@ export default class Dropdown extends React.PureComponent {
|
||||||
dropdownPlacement: PropTypes.string,
|
dropdownPlacement: PropTypes.string,
|
||||||
openDropdownId: PropTypes.number,
|
openDropdownId: PropTypes.number,
|
||||||
openedViaKeyboard: PropTypes.bool,
|
openedViaKeyboard: PropTypes.bool,
|
||||||
|
renderItem: PropTypes.func,
|
||||||
|
renderHeader: PropTypes.func,
|
||||||
|
onItemClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -237,17 +258,21 @@ export default class Dropdown extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleItemClick = e => {
|
handleItemClick = e => {
|
||||||
|
const { onItemClick } = this.props;
|
||||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||||
const { action, to } = this.props.items[i];
|
const item = this.props.items[i];
|
||||||
|
|
||||||
this.handleClose();
|
this.handleClose();
|
||||||
|
|
||||||
if (typeof action === 'function') {
|
if (typeof onItemClick === 'function') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
action();
|
onItemClick(item, i);
|
||||||
} else if (to) {
|
} else if (item && typeof item.action === 'function') {
|
||||||
e.preventDefault();
|
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,12 +290,36 @@ export default class Dropdown extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
close = () => {
|
||||||
|
this.handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
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 open = this.state.id === openDropdownId;
|
||||||
|
|
||||||
return (
|
const button = children ? React.cloneElement(React.Children.only(children), {
|
||||||
<div>
|
ref: this.setTargetRef,
|
||||||
|
onClick: this.handleClick,
|
||||||
|
onMouseDown: this.handleMouseDown,
|
||||||
|
onKeyDown: this.handleButtonKeyDown,
|
||||||
|
onKeyPress: this.handleKeyPress,
|
||||||
|
}) : (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={icon}
|
icon={icon}
|
||||||
title={title}
|
title={title}
|
||||||
|
@ -283,11 +332,25 @@ export default class Dropdown extends React.PureComponent {
|
||||||
onKeyDown={this.handleButtonKeyDown}
|
onKeyDown={this.handleButtonKeyDown}
|
||||||
onKeyPress={this.handleKeyPress}
|
onKeyPress={this.handleKeyPress}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{button}
|
||||||
|
|
||||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
<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>
|
</Overlay>
|
||||||
</div>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
app/javascript/mastodon/components/edited_timestamp/index.js
Normal file
70
app/javascript/mastodon/components/edited_timestamp/index.js
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
34
app/javascript/mastodon/components/inline_account.js
Normal file
34
app/javascript/mastodon/components/inline_account.js
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,10 +1,31 @@
|
||||||
import React from 'react';
|
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 = () => (
|
const LoadingIndicator = () => (
|
||||||
<div className='loading-indicator'>
|
<div className='loading-indicator'>
|
||||||
<div className='loading-indicator__figure' />
|
<CircularProgress size={50} strokeWidth={6} />
|
||||||
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,15 @@ import PropTypes from 'prop-types';
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
today: { id: 'relative_time.today', defaultMessage: 'today' },
|
today: { id: 'relative_time.today', defaultMessage: 'today' },
|
||||||
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
|
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: { 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: { 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: { 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: { 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' },
|
moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
|
||||||
seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
|
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' },
|
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();
|
const delta = now - date.getTime();
|
||||||
|
|
||||||
let relativeTime;
|
let relativeTime;
|
||||||
|
@ -74,16 +79,16 @@ export const timeAgoString = (intl, date, now, year, timeGiven = true) => {
|
||||||
if (delta < DAY && !timeGiven) {
|
if (delta < DAY && !timeGiven) {
|
||||||
relativeTime = intl.formatMessage(messages.today);
|
relativeTime = intl.formatMessage(messages.today);
|
||||||
} else if (delta < 10 * SECOND) {
|
} 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) {
|
} else if (delta < 7 * DAY) {
|
||||||
if (delta < MINUTE) {
|
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) {
|
} 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) {
|
} 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 {
|
} 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) {
|
} else if (date.getFullYear() === year) {
|
||||||
relativeTime = intl.formatDate(date, shortDateFormatOptions);
|
relativeTime = intl.formatDate(date, shortDateFormatOptions);
|
||||||
|
@ -124,6 +129,7 @@ class RelativeTimestamp extends React.Component {
|
||||||
timestamp: PropTypes.string.isRequired,
|
timestamp: PropTypes.string.isRequired,
|
||||||
year: PropTypes.number.isRequired,
|
year: PropTypes.number.isRequired,
|
||||||
futureDate: PropTypes.bool,
|
futureDate: PropTypes.bool,
|
||||||
|
short: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -132,6 +138,7 @@ class RelativeTimestamp extends React.Component {
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
year: (new Date()).getFullYear(),
|
year: (new Date()).getFullYear(),
|
||||||
|
short: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
shouldComponentUpdate (nextProps, nextState) {
|
shouldComponentUpdate (nextProps, nextState) {
|
||||||
|
@ -176,11 +183,11 @@ class RelativeTimestamp extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { timestamp, intl, year, futureDate } = this.props;
|
const { timestamp, intl, year, futureDate, short } = this.props;
|
||||||
|
|
||||||
const timeGiven = timestamp.includes('T');
|
const timeGiven = timestamp.includes('T');
|
||||||
const date = new Date(timestamp);
|
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 (
|
return (
|
||||||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
|
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
|
||||||
import StatusContent from '../../../components/status_content';
|
import StatusContent from '../../../components/status_content';
|
||||||
import MediaGallery from '../../../components/media_gallery';
|
import MediaGallery from '../../../components/media_gallery';
|
||||||
import { Link } from 'react-router-dom';
|
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 Card from './card';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Video from '../../video';
|
import Video from '../../video';
|
||||||
|
@ -16,6 +16,7 @@ import classNames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import AnimatedNumber from 'mastodon/components/animated_number';
|
import AnimatedNumber from 'mastodon/components/animated_number';
|
||||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||||
|
import EditedTimestamp from 'mastodon/components/edited_timestamp';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
|
@ -242,7 +243,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
edited = (
|
edited = (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<React.Fragment> · </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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -19,7 +19,8 @@ import {
|
||||||
EmbedModal,
|
EmbedModal,
|
||||||
ListEditor,
|
ListEditor,
|
||||||
ListAdder,
|
ListAdder,
|
||||||
} from '../../../features/ui/util/async-components';
|
CompareHistoryModal,
|
||||||
|
} from 'mastodon/features/ui/util/async-components';
|
||||||
|
|
||||||
const MODAL_COMPONENTS = {
|
const MODAL_COMPONENTS = {
|
||||||
'MEDIA': () => Promise.resolve({ default: MediaModal }),
|
'MEDIA': () => Promise.resolve({ default: MediaModal }),
|
||||||
|
@ -34,7 +35,8 @@ const MODAL_COMPONENTS = {
|
||||||
'EMBED': EmbedModal,
|
'EMBED': EmbedModal,
|
||||||
'LIST_EDITOR': ListEditor,
|
'LIST_EDITOR': ListEditor,
|
||||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
||||||
'LIST_ADDER':ListAdder,
|
'LIST_ADDER': ListAdder,
|
||||||
|
'COMPARE_HISTORY': CompareHistoryModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ModalRoot extends React.PureComponent {
|
export default class ModalRoot extends React.PureComponent {
|
||||||
|
|
|
@ -157,3 +157,7 @@ export function Directory () {
|
||||||
export function FollowRecommendations () {
|
export function FollowRecommendations () {
|
||||||
return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
|
return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CompareHistoryModal () {
|
||||||
|
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
|
||||||
|
}
|
||||||
|
|
28
app/javascript/mastodon/reducers/history.js
Normal file
28
app/javascript/mastodon/reducers/history.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,6 +38,7 @@ import announcements from './announcements';
|
||||||
import markers from './markers';
|
import markers from './markers';
|
||||||
import picture_in_picture from './picture_in_picture';
|
import picture_in_picture from './picture_in_picture';
|
||||||
import accounts_map from './accounts_map';
|
import accounts_map from './accounts_map';
|
||||||
|
import history from './history';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
announcements,
|
announcements,
|
||||||
|
@ -79,6 +80,7 @@ const reducers = {
|
||||||
missed_updates,
|
missed_updates,
|
||||||
markers,
|
markers,
|
||||||
picture_in_picture,
|
picture_in_picture,
|
||||||
|
history,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -1889,8 +1889,47 @@ a.account__display-name {
|
||||||
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
|
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
|
|
||||||
ul {
|
&__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;
|
list-style: none;
|
||||||
|
|
||||||
|
&--scrollable {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px 45px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.left {
|
&.left {
|
||||||
|
@ -1946,18 +1985,29 @@ a.account__display-name {
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu__item {
|
.dropdown-menu__item {
|
||||||
a {
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
display: block;
|
display: block;
|
||||||
|
color: $inverted-text-color;
|
||||||
|
|
||||||
|
a,
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
padding: 4px 14px;
|
padding: 4px 14px;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: $ui-secondary-color;
|
background: $ui-secondary-color;
|
||||||
color: $inverted-text-color;
|
color: inherit;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: inherit;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:hover,
|
&: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 {
|
.dropdown--active .dropdown__content {
|
||||||
display: block;
|
display: block;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
|
@ -3631,36 +3717,48 @@ a.status-card.compact:hover {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
.circular-progress {
|
||||||
display: block;
|
color: lighten($ui-base-color, 26%);
|
||||||
float: left;
|
animation: 1.4s linear 0s infinite normal none running simple-rotate;
|
||||||
transform: translateX(-50%);
|
|
||||||
margin: 82px 0 0 50%;
|
circle {
|
||||||
white-space: nowrap;
|
stroke: currentColor;
|
||||||
|
stroke-dasharray: 80px, 200px;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
animation: circular-progress 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-indicator__figure {
|
@keyframes circular-progress {
|
||||||
position: absolute;
|
0% {
|
||||||
top: 50%;
|
stroke-dasharray: 1px, 200px;
|
||||||
left: 50%;
|
stroke-dashoffset: 0;
|
||||||
transform: translate(-50%, -50%);
|
}
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
50% {
|
||||||
box-sizing: border-box;
|
stroke-dasharray: 100px, 200px;
|
||||||
background-color: transparent;
|
stroke-dashoffset: -15px;
|
||||||
border: 0 solid lighten($ui-base-color, 26%);
|
}
|
||||||
border-width: 6px;
|
|
||||||
border-radius: 50%;
|
100% {
|
||||||
|
stroke-dasharray: 100px, 200px;
|
||||||
|
stroke-dashoffset: -125px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-reduce-motion .loading-indicator span {
|
@keyframes simple-rotate {
|
||||||
animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
0% {
|
||||||
}
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
.no-reduce-motion .loading-indicator__figure {
|
100% {
|
||||||
animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spring-rotate-in {
|
@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 {
|
.video-error-cover {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: $base-overlay-background;
|
background: $base-overlay-background;
|
||||||
|
@ -4940,7 +5004,8 @@ a.status-card.compact:hover {
|
||||||
.report-modal,
|
.report-modal,
|
||||||
.actions-modal,
|
.actions-modal,
|
||||||
.mute-modal,
|
.mute-modal,
|
||||||
.block-modal {
|
.block-modal,
|
||||||
|
.compare-history-modal {
|
||||||
background: lighten($ui-secondary-color, 8%);
|
background: lighten($ui-secondary-color, 8%);
|
||||||
color: $inverted-text-color;
|
color: $inverted-text-color;
|
||||||
border-radius: 8px;
|
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 {
|
.loading-bar {
|
||||||
background-color: $highlight-text-color;
|
background-color: $highlight-text-color;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
|
|
|
@ -9,7 +9,7 @@ class Formatter
|
||||||
include ActionView::Helpers::TextHelper
|
include ActionView::Helpers::TextHelper
|
||||||
|
|
||||||
def format(status, **options)
|
def format(status, **options)
|
||||||
if status.reblog?
|
if status.respond_to?(:reblog?) && status.reblog?
|
||||||
prepend_reblog = status.reblog.account.acct
|
prepend_reblog = status.reblog.account.acct
|
||||||
status = status.proper
|
status = status.proper
|
||||||
else
|
else
|
||||||
|
@ -30,7 +30,7 @@ class Formatter
|
||||||
return html.html_safe # rubocop:disable Rails/OutputSafety
|
return html.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
end
|
end
|
||||||
|
|
||||||
linkable_accounts = status.active_mentions.map(&:account)
|
linkable_accounts = status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []
|
||||||
linkable_accounts << status.account
|
linkable_accounts << status.account
|
||||||
|
|
||||||
html = raw_content
|
html = raw_content
|
||||||
|
|
|
@ -20,4 +20,9 @@ class StatusEdit < ApplicationRecord
|
||||||
default_scope { order(id: :asc) }
|
default_scope { order(id: :asc) }
|
||||||
|
|
||||||
delegate :local?, to: :status
|
delegate :local?, to: :status
|
||||||
|
|
||||||
|
def emojis
|
||||||
|
return @emojis if defined?(@emojis)
|
||||||
|
@emojis = CustomEmoji.from_text([spoiler_text, text].join(' '), status.account.domain)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class REST::StatusEditSerializer < ActiveModel::Serializer
|
class REST::StatusEditSerializer < ActiveModel::Serializer
|
||||||
attributes :text, :spoiler_text, :media_attachments_changed,
|
has_one :account, serializer: REST::AccountSerializer
|
||||||
:created_at
|
|
||||||
|
attributes :content, :spoiler_text,
|
||||||
|
:media_attachments_changed, :created_at
|
||||||
|
|
||||||
|
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
||||||
|
|
||||||
|
def content
|
||||||
|
Formatter.instance.format(object)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue