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
--- /dev/null
+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,
+});
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;
};
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 = {
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 });
}
}
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;
}
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' />;
}
}
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 }) => (
<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>
};
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,
dropdownPlacement: PropTypes.string,
openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool,
+ renderItem: PropTypes.func,
+ renderHeader: PropTypes.func,
+ onItemClick: PropTypes.func,
};
static defaultProps = {
}
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);
}
}
}
}
+ 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>
);
}
--- /dev/null
+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);
--- /dev/null
+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>
+ );
+ }
+
+}
--- /dev/null
+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>
+ );
+ }
+
+}
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>
);
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' },
}
};
-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;
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);
timestamp: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
futureDate: PropTypes.bool,
+ short: PropTypes.bool,
};
state = {
static defaultProps = {
year: (new Date()).getFullYear(),
+ short: true,
};
shouldComponentUpdate (nextProps, nextState) {
}
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)}>
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';
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' },
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>
);
}
--- /dev/null
+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>
+ );
+ }
+
+}
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 }),
'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 {
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');
+}
--- /dev/null
+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;
+ }
+}
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,
missed_updates,
markers,
picture_in_picture,
+ history,
};
export default combineReducers(reducers);
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 {
}
.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,
}
}
+.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;
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 {
}
}
-@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;
.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;
}
}
+.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;
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
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
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
# 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