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