diff options
Diffstat (limited to 'app/javascript/flavours')
43 files changed, 1054 insertions, 415 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 261c72b2a..baa98e98f 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -75,6 +75,8 @@ export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL'; export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION'; export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; +export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; + const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, @@ -88,6 +90,15 @@ export const ensureComposeIsVisible = (getState, routerHistory) => { } }; +export function setComposeToStatus(status, text, spoiler_text) { + return{ + type: COMPOSE_SET_STATUS, + status, + text, + spoiler_text, + }; +}; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -150,8 +161,9 @@ export function directCompose(account, routerHistory) { export function submitCompose(routerHistory) { return function (dispatch, getState) { - let status = getState().getIn(['compose', 'text'], ''); - let media = getState().getIn(['compose', 'media_attachments']); + let status = getState().getIn(['compose', 'text'], ''); + const media = getState().getIn(['compose', 'media_attachments']); + const statusId = getState().getIn(['compose', 'id'], null); const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']); let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : ''; @@ -159,20 +171,25 @@ export function submitCompose(routerHistory) { return; } - dispatch(submitComposeRequest()); if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { status = status + ' 👁️'; } - api(getState).post('/api/v1/statuses', { - status, - content_type: getState().getIn(['compose', 'content_type']), - in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), - media_ids: media.map(item => item.get('id')), - sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), - spoiler_text: spoilerText, - visibility: getState().getIn(['compose', 'privacy']), - poll: getState().getIn(['compose', 'poll'], null), - }, { + + dispatch(submitComposeRequest()); + + api(getState).request({ + url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, + method: statusId === null ? 'post' : 'put', + data: { + status, + content_type: getState().getIn(['compose', 'content_type']), + in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), + media_ids: media.map(item => item.get('id')), + sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), + spoiler_text: spoilerText, + visibility: getState().getIn(['compose', 'privacy']), + poll: getState().getIn(['compose', 'poll'], null), + }, headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, @@ -202,14 +219,16 @@ export function submitCompose(routerHistory) { } }; - insertIfOnline('home'); + if (statusId === null) { + insertIfOnline('home'); + } - if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { + if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') { insertIfOnline('community'); if (!response.data.local_only) { insertIfOnline('public'); } - } else if (response.data.visibility === 'direct') { + } else if (statusId === null && response.data.visibility === 'direct') { insertIfOnline('direct'); } }).catch(function (error) { diff --git a/app/javascript/flavours/glitch/actions/history.js b/app/javascript/flavours/glitch/actions/history.js new file mode 100644 index 000000000..c47057261 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/history.js @@ -0,0 +1,37 @@ +import api from 'flavours/glitch/util/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/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 4b00ea632..40430102c 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -47,7 +47,6 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; - export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; @@ -136,7 +135,17 @@ const excludeTypesFromSettings = state => state.getIn(['settings', 'notification const excludeTypesFromFilter = filter => { - const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']); + const allTypes = ImmutableList([ + 'follow', + 'follow_request', + 'favourite', + 'reblog', + 'mention', + 'poll', + 'status', + 'update', + ]); + return allTypes.filterNot(item => item === filter).toJS(); }; diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 7db357df1..6ffcf181d 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -2,7 +2,7 @@ import api from 'flavours/glitch/util/api'; import { deleteFromTimelines } from './timelines'; import { importFetchedStatus, importFetchedStatuses } from './importer'; -import { ensureComposeIsVisible } from './compose'; +import { ensureComposeIsVisible, setComposeToStatus } from './compose'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -26,6 +26,10 @@ export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; export const REDRAFT = 'REDRAFT'; +export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; +export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS'; +export const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL'; + export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -81,6 +85,37 @@ export function redraft(status, raw_text, content_type) { }; }; +export const editStatus = (id, routerHistory) => (dispatch, getState) => { + let status = getState().getIn(['statuses', id]); + + if (status.get('poll')) { + status = status.set('poll', getState().getIn(['polls', status.get('poll')])); + } + + dispatch(fetchStatusSourceRequest()); + + api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { + dispatch(fetchStatusSourceSuccess()); + ensureComposeIsVisible(getState, routerHistory); + dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text)); + }).catch(error => { + dispatch(fetchStatusSourceFail(error)); + }); +}; + +export const fetchStatusSourceRequest = () => ({ + type: STATUS_FETCH_SOURCE_REQUEST, +}); + +export const fetchStatusSourceSuccess = () => ({ + type: STATUS_FETCH_SOURCE_SUCCESS, +}); + +export const fetchStatusSourceFail = error => ({ + type: STATUS_FETCH_SOURCE_FAIL, + error, +}); + export function deleteStatus(id, routerHistory, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index 023fecb9a..bd6bc4ffd 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -6,6 +6,8 @@ import Overlay from 'react-overlays/lib/Overlay'; import Motion from 'flavours/glitch/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,30 +118,20 @@ 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(); - } 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' />; } - const { text, href = '#' } = option; + const { text, href = '#', target = '_blank', method } = option; return ( <li className='dropdown-menu__item' key={`${text}-${i}`}> - <a href={href} target='_blank' rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}> + <a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}> {text} </a> </li> @@ -140,21 +139,37 @@ 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 }) => ( // It should not be transformed when mounting because the resulting // size will be used to determine the coordinate of the menu by // react-overlays - <div className='dropdown-menu' 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 }} /> - <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 = { @@ -236,17 +257,22 @@ export default class Dropdown extends React.PureComponent { } } - handleItemClick = (i, e) => { - const { action, to } = this.props.items[i]; + handleItemClick = e => { + const { onItemClick } = this.props; + const i = Number(e.currentTarget.getAttribute('data-index')); + 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); } } @@ -264,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/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js new file mode 100644 index 000000000..8b73663d4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/edited_timestamp/containers/dropdown_menu_container.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu'; +import { fetchHistory } from 'flavours/glitch/actions/history'; +import DropdownMenu from 'flavours/glitch/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/flavours/glitch/components/edited_timestamp/index.js b/app/javascript/flavours/glitch/components/edited_timestamp/index.js new file mode 100644 index 000000000..9648133af --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/components/icon'; +import DropdownMenu from './containers/dropdown_menu_container'; +import { connect } from 'react-redux'; +import { openModal } from 'flavours/glitch/actions/modal'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import InlineAccount from 'flavours/glitch/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/flavours/glitch/components/inline_account.js b/app/javascript/flavours/glitch/components/inline_account.js new file mode 100644 index 000000000..2ef1f52cc --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/selectors'; +import Avatar from 'flavours/glitch/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/flavours/glitch/components/loading_indicator.js b/app/javascript/flavours/glitch/components/loading_indicator.js index d6a5adb6f..59f721c50 100644 --- a/app/javascript/flavours/glitch/components/loading_indicator.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/relative_timestamp.js b/app/javascript/flavours/glitch/components/relative_timestamp.js index 711181dcd..512480339 100644 --- a/app/javascript/flavours/glitch/components/relative_timestamp.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index ae67c6116..68d93cd67 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -13,6 +13,7 @@ import classNames from 'classnames'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, @@ -126,6 +127,10 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status, this.context.router.history, true); } + handleEditClick = () => { + this.props.onEdit(this.props.status, this.context.router.history); + } + handlePinClick = () => { this.props.onPin(this.props.status); } @@ -225,6 +230,7 @@ class StatusActionBar extends ImmutablePureComponent { } if (writtenByMe) { + // menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js index 5a00f232e..1661ca8f5 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.js +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -88,6 +88,14 @@ export default class StatusPrepend extends React.PureComponent { /> ); } + case 'update': + return ( + <FormattedMessage + id='notification.update' + defaultMessage='{name} edited a post' + values={{ name: link }} + /> + ); } return null; } @@ -115,6 +123,9 @@ export default class StatusPrepend extends React.PureComponent { case 'status': iconId = 'bell'; break; + case 'update': + iconId = 'pencil'; + break; }; return !type ? null : ( diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js index 1c0385b74..0c4a2b50f 100644 --- a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js +++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js @@ -14,15 +14,11 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ onOpen(id, onItemClick, dropdownPlacement, keyboard) { dispatch(isUserTouching() ? openModal('ACTIONS', { status, - actions: items.map( - (item, i) => item ? { - ...item, - name: `${item.text}-${i}`, - onClick: item.action ? ((e) => { return onItemClick(i, e) }) : null, - } : null - ), + actions: items, + onClick: onItemClick, }) : openDropdownMenu(id, dropdownPlacement, keyboard, scrollKey)); }, + onClose(id) { dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index bc3c43d85..358b89ab9 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -17,7 +17,7 @@ import { pin, unpin, } from 'flavours/glitch/actions/interactions'; -import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; @@ -169,6 +169,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onEdit (status, history) { + dispatch(editStatus(status.get('id'), history)); + }, + onDirect (account, router) { dispatch(directCompose(account, router)); }, diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js index 5dfc119c1..c75906ce7 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -47,6 +47,7 @@ class ComposeForm extends ImmutablePureComponent { preselectDate: PropTypes.instanceOf(Date), isSubmitting: PropTypes.bool, isChangingUpload: PropTypes.bool, + isEditing: PropTypes.bool, isUploading: PropTypes.bool, onChange: PropTypes.func, onSubmit: PropTypes.func, @@ -293,6 +294,7 @@ class ComposeForm extends ImmutablePureComponent { spoilerText, suggestions, spoilersAlwaysOn, + isEditing, } = this.props; const countText = this.getFulltextForCharacterCounting(); @@ -353,6 +355,7 @@ class ComposeForm extends ImmutablePureComponent { onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness} onUpload={onPaste} privacy={privacy} + isEditing={isEditing} sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)} spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} /> @@ -364,6 +367,7 @@ class ComposeForm extends ImmutablePureComponent { <Publisher countText={countText} disabled={!this.canSubmit()} + isEditing={isEditing} onSecondarySubmit={handleSecondarySubmit} onSubmit={handleSubmit} privacy={privacy} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index abf7cbba1..9f70d6b79 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -21,22 +21,25 @@ export default class ComposerOptionsDropdown extends React.PureComponent { icon: PropTypes.string, items: PropTypes.arrayOf(PropTypes.shape({ icon: PropTypes.string, - meta: PropTypes.node, + meta: PropTypes.string, name: PropTypes.string.isRequired, - on: PropTypes.bool, - text: PropTypes.node, + text: PropTypes.string, })).isRequired, onModalOpen: PropTypes.func, onModalClose: PropTypes.func, title: PropTypes.string, value: PropTypes.string, onChange: PropTypes.func, - noModal: PropTypes.bool, container: PropTypes.func, + renderItemContents: PropTypes.func, + closeOnChange: PropTypes.bool, + }; + + static defaultProps = { + closeOnChange: true, }; state = { - needsModalUpdate: false, open: false, openedViaKeyboard: undefined, placement: 'bottom', @@ -44,10 +47,10 @@ export default class ComposerOptionsDropdown extends React.PureComponent { // Toggles opening and closing the dropdown. handleToggle = ({ target, type }) => { - const { onModalOpen, noModal } = this.props; + const { onModalOpen } = this.props; const { open } = this.state; - if (!noModal && isUserTouching()) { + if (isUserTouching()) { if (this.state.open) { this.props.onModalClose(); } else { @@ -107,9 +110,25 @@ export default class ComposerOptionsDropdown extends React.PureComponent { this.setState({ open: false }); } + handleItemClick = (e) => { + const { + items, + onChange, + onModalClose, + closeOnChange, + } = this.props; + + const i = Number(e.currentTarget.getAttribute('data-index')); + + const { name } = items[i]; + + e.preventDefault(); // Prevents focus from changing + if (closeOnChange) onModalClose(); + onChange(name); + }; + // Creates an action modal object. handleMakeModal = () => { - const component = this; const { items, onChange, @@ -125,6 +144,8 @@ export default class ComposerOptionsDropdown extends React.PureComponent { // The object. return { + renderItemContents: this.props.renderItemContents, + onClick: this.handleItemClick, actions: items.map( ({ name, @@ -133,48 +154,11 @@ export default class ComposerOptionsDropdown extends React.PureComponent { ...rest, active: value && name === value, name, - onClick (e) { - e.preventDefault(); // Prevents focus from changing - onModalClose(); - onChange(name); - }, - onPassiveClick (e) { - e.preventDefault(); // Prevents focus from changing - onChange(name); - component.setState({ needsModalUpdate: true }); - }, }) ), }; } - // If our modal is open and our props update, we need to also update - // the modal. - handleUpdate = () => { - const { onModalOpen } = this.props; - const { needsModalUpdate } = this.state; - - // Gets our modal object. - const modal = this.handleMakeModal(); - - // Reopens the modal with the new object. - if (needsModalUpdate && modal && onModalOpen) { - onModalOpen(modal); - } - } - - // Updates our modal as necessary. - componentDidUpdate (prevProps) { - const { items } = this.props; - const { needsModalUpdate } = this.state; - if (needsModalUpdate && items.find( - (item, i) => item.on !== prevProps.items[i].on - )) { - this.handleUpdate(); - this.setState({ needsModalUpdate: false }); - } - } - // Rendering. render () { const { @@ -186,6 +170,8 @@ export default class ComposerOptionsDropdown extends React.PureComponent { onChange, value, container, + renderItemContents, + closeOnChange, } = this.props; const { open, placement } = this.state; const computedClass = classNames('composer--options--dropdown', { @@ -226,10 +212,12 @@ export default class ComposerOptionsDropdown extends React.PureComponent { > <DropdownMenu items={items} + renderItemContents={renderItemContents} onChange={onChange} onClose={this.handleClose} value={value} openedViaKeyboard={this.state.openedViaKeyboard} + closeOnChange={closeOnChange} /> </Overlay> </div> diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js index bee06e64c..0649fe1ca 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -2,7 +2,6 @@ import PropTypes from 'prop-types'; import React from 'react'; import spring from 'react-motion/lib/spring'; -import Toggle from 'react-toggle'; import ImmutablePureComponent from 'react-immutable-pure-component'; import classNames from 'classnames'; @@ -28,18 +27,20 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent icon: PropTypes.string, meta: PropTypes.node, name: PropTypes.string.isRequired, - on: PropTypes.bool, text: PropTypes.node, })), onChange: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, style: PropTypes.object, value: PropTypes.string, + renderItemContents: PropTypes.func, openedViaKeyboard: PropTypes.bool, + closeOnChange: PropTypes.bool, }; static defaultProps = { style: {}, + closeOnChange: true, }; state = { @@ -77,16 +78,19 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent document.removeEventListener('touchend', this.handleDocumentClick, withPassive); } - handleClick = (name, e) => { + handleClick = (e) => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const { onChange, onClose, + closeOnChange, items, } = this.props; - const { on } = this.props.items.find(item => item.name === name); + const { name } = this.props.items[i]; e.preventDefault(); // Prevents change in focus on click - if ((on === null || typeof on === 'undefined')) { + if (closeOnChange) { onClose(); } onChange(name); @@ -101,11 +105,9 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent } } - handleKeyDown = (name, e) => { + handleKeyDown = (e) => { + const index = Number(e.currentTarget.getAttribute('data-index')); const { items } = this.props; - const index = items.findIndex(item => { - return (item.name === name); - }); let element = null; switch(e.key) { @@ -139,7 +141,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent if (element) { element.focus(); - this.handleChange(element.getAttribute('data-index')); + this.handleChange(this.props.items[Number(element.getAttribute('data-index'))].name); e.preventDefault(); e.stopPropagation(); } @@ -149,44 +151,40 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent this.focusedItem = c; } - renderItem = (item) => { - const { name, icon, meta, on, text } = item; + renderItem = (item, i) => { + const { name, icon, meta, text } = item; const active = (name === (this.props.value || this.state.value)); - const computedClass = classNames('composer--options--dropdown--content--item', { - active, - lengthy: meta, - 'toggled-off': !on && on !== null && typeof on !== 'undefined', - 'toggled-on': on, - 'with-icon': icon, - }); + const computedClass = classNames('composer--options--dropdown--content--item', { active }); + + let contents = this.props.renderItemContents && this.props.renderItemContents(item, i); - let prefix = null; + if (!contents) { + contents = ( + <React.Fragment> + {icon && <Icon className='icon' fixedWidth id={icon} />} - if (on !== null && typeof on !== 'undefined') { - prefix = <Toggle checked={on} onChange={this.handleClick.bind(this, name)} />; - } else if (icon) { - prefix = <Icon className='icon' fixedWidth id={icon} /> + <div className='content'> + <strong>{text}</strong> + {meta} + </div> + </React.Fragment> + ); } return ( <div className={computedClass} - onClick={this.handleClick.bind(this, name)} - onKeyDown={this.handleKeyDown.bind(this, name)} + onClick={this.handleClick} + onKeyDown={this.handleKeyDown} role='option' tabIndex='0' key={name} - data-index={name} + data-index={i} ref={active ? this.setFocusRef : null} > - {prefix} - - <div className='content'> - <strong>{text}</strong> - {meta} - </div> + {contents} </div> ); } @@ -229,7 +227,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, }} > - {!!items && items.map(item => this.renderItem(item))} + {!!items && items.map((item, i) => this.renderItem(item, i))} </div> )} </Motion> diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js index c6e4cc36b..95add2027 100644 --- a/app/javascript/flavours/glitch/features/compose/components/header.js +++ b/app/javascript/flavours/glitch/features/compose/components/header.js @@ -119,7 +119,7 @@ class Header extends ImmutablePureComponent { <a aria-label={intl.formatMessage(messages.settings)} onClick={onSettingsClick} - href='#' + href='/settings/preferences' title={intl.formatMessage(messages.settings)} ><Icon id='cogs' /></a> <a diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js index f9212bbae..3a31e214d 100644 --- a/app/javascript/flavours/glitch/features/compose/components/options.js +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -2,8 +2,10 @@ import PropTypes from 'prop-types'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import spring from 'react-motion/lib/spring'; +import Toggle from 'react-toggle'; +import { connect } from 'react-redux'; // Components. import IconButton from 'flavours/glitch/components/icon_button'; @@ -80,6 +82,36 @@ const messages = defineMessages({ }, }); +@connect((state, { name }) => ({ checked: state.getIn(['compose', 'advanced_options', name]) })) +class ToggleOption extends ImmutablePureComponent { + + static propTypes = { + name: PropTypes.string.isRequired, + checked: PropTypes.bool, + onChangeAdvancedOption: PropTypes.func.isRequired, + }; + + handleChange = () => { + this.props.onChangeAdvancedOption(this.props.name); + }; + + render() { + const { meta, text, checked } = this.props; + + return ( + <React.Fragment> + <Toggle checked={checked} onChange={this.handleChange} /> + + <div className='content'> + <strong>{text}</strong> + {meta} + </div> + </React.Fragment> + ); + } + +} + export default @injectIntl class ComposerOptions extends ImmutablePureComponent { @@ -106,6 +138,7 @@ class ComposerOptions extends ImmutablePureComponent { resetFileKey: PropTypes.number, spoiler: PropTypes.bool, showContentTypeChoice: PropTypes.bool, + isEditing: PropTypes.bool, }; // Handles file selection. @@ -141,6 +174,13 @@ class ComposerOptions extends ImmutablePureComponent { this.fileElement = fileElement; } + renderToggleItemContents = (item) => { + const { onChangeAdvancedOption } = this.props; + const { name, meta, text } = item; + + return <ToggleOption name={name} text={text} meta={meta} onChangeAdvancedOption={onChangeAdvancedOption} />; + }; + // Rendering. render () { const { @@ -152,7 +192,6 @@ class ComposerOptions extends ImmutablePureComponent { hasMedia, allowPoll, hasPoll, - intl, onChangeAdvancedOption, onChangeContentType, onChangeVisibility, @@ -164,23 +203,25 @@ class ComposerOptions extends ImmutablePureComponent { resetFileKey, spoiler, showContentTypeChoice, + isEditing, + intl: { formatMessage }, } = this.props; const contentTypeItems = { plain: { icon: 'file-text', name: 'text/plain', - text: <FormattedMessage {...messages.plain} />, + text: formatMessage(messages.plain), }, html: { icon: 'code', name: 'text/html', - text: <FormattedMessage {...messages.html} />, + text: formatMessage(messages.html), }, markdown: { icon: 'arrow-circle-down', name: 'text/markdown', - text: <FormattedMessage {...messages.markdown} />, + text: formatMessage(messages.markdown), }, }; @@ -204,18 +245,18 @@ class ComposerOptions extends ImmutablePureComponent { { icon: 'cloud-upload', name: 'upload', - text: <FormattedMessage {...messages.upload} />, + text: formatMessage(messages.upload), }, { icon: 'paint-brush', name: 'doodle', - text: <FormattedMessage {...messages.doodle} />, + text: formatMessage(messages.doodle), }, ]} onChange={this.handleClickAttach} onModalClose={onModalClose} onModalOpen={onModalOpen} - title={intl.formatMessage(messages.attach)} + title={formatMessage(messages.attach)} /> {!!pollLimits && ( <IconButton @@ -229,12 +270,12 @@ class ComposerOptions extends ImmutablePureComponent { height: null, lineHeight: null, }} - title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)} + title={formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)} /> )} <hr /> <PrivacyDropdown - disabled={disabled} + disabled={disabled || isEditing} onChange={onChangeVisibility} onModalClose={onModalClose} onModalOpen={onModalOpen} @@ -252,7 +293,7 @@ class ComposerOptions extends ImmutablePureComponent { onChange={onChangeContentType} onModalClose={onModalClose} onModalOpen={onModalOpen} - title={intl.formatMessage(messages.content_type)} + title={formatMessage(messages.content_type)} value={contentType} /> )} @@ -262,31 +303,31 @@ class ComposerOptions extends ImmutablePureComponent { ariaControls='glitch.composer.spoiler.input' label='CW' onClick={onToggleSpoiler} - title={intl.formatMessage(messages.spoiler)} + title={formatMessage(messages.spoiler)} /> )} <Dropdown active={advancedOptions && advancedOptions.some(value => !!value)} - disabled={disabled} + disabled={disabled || isEditing} icon='ellipsis-h' items={advancedOptions ? [ { - meta: <FormattedMessage {...messages.local_only_long} />, + meta: formatMessage(messages.local_only_long), name: 'do_not_federate', - on: advancedOptions.get('do_not_federate'), - text: <FormattedMessage {...messages.local_only_short} />, + text: formatMessage(messages.local_only_short), }, { - meta: <FormattedMessage {...messages.threaded_mode_long} />, + meta: formatMessage(messages.threaded_mode_long), name: 'threaded_mode', - on: advancedOptions.get('threaded_mode'), - text: <FormattedMessage {...messages.threaded_mode_short} />, + text: formatMessage(messages.threaded_mode_short), }, ] : null} onChange={onChangeAdvancedOption} + renderItemContents={this.renderToggleItemContents} onModalClose={onModalClose} onModalOpen={onModalOpen} - title={intl.formatMessage(messages.advanced_options_icon_title)} + title={formatMessage(messages.advanced_options_icon_title)} + closeOnChange={false} /> </div> ); diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js index 39f7c7bd1..c8e783d22 100644 --- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js @@ -1,46 +1,19 @@ import PropTypes from 'prop-types'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import Dropdown from './dropdown'; const messages = defineMessages({ - change_privacy: { - defaultMessage: 'Adjust status privacy', - id: 'privacy.change', - }, - direct_long: { - defaultMessage: 'Visible for mentioned users only', - id: 'privacy.direct.long', - }, - direct_short: { - defaultMessage: 'Direct', - id: 'privacy.direct.short', - }, - private_long: { - defaultMessage: 'Visible for followers only', - id: 'privacy.private.long', - }, - private_short: { - defaultMessage: 'Followers-only', - id: 'privacy.private.short', - }, - public_long: { - defaultMessage: 'Visible for all, shown in public timelines', - id: 'privacy.public.long', - }, - public_short: { - defaultMessage: 'Public', - id: 'privacy.public.short', - }, - unlisted_long: { - defaultMessage: 'Visible for all, but not in public timelines', - id: 'privacy.unlisted.long', - }, - unlisted_short: { - defaultMessage: 'Unlisted', - id: 'privacy.unlisted.short', - }, + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all, shown in public timelines' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but not in public timelines' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, + direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, }); export default @injectIntl @@ -53,40 +26,40 @@ class PrivacyDropdown extends React.PureComponent { value: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, noDirect: PropTypes.bool, - noModal: PropTypes.bool, container: PropTypes.func, + disabled: PropTypes.bool, intl: PropTypes.object.isRequired, }; render () { - const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, noModal, container, intl } = this.props; + const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, intl: { formatMessage } } = this.props; // We predefine our privacy items so that we can easily pick the // dropdown icon later. const privacyItems = { direct: { icon: 'envelope', - meta: <FormattedMessage {...messages.direct_long} />, + meta: formatMessage(messages.direct_long), name: 'direct', - text: <FormattedMessage {...messages.direct_short} />, + text: formatMessage(messages.direct_short), }, private: { icon: 'lock', - meta: <FormattedMessage {...messages.private_long} />, + meta: formatMessage(messages.private_long), name: 'private', - text: <FormattedMessage {...messages.private_short} />, + text: formatMessage(messages.private_short), }, public: { icon: 'globe', - meta: <FormattedMessage {...messages.public_long} />, + meta: formatMessage(messages.public_long), name: 'public', - text: <FormattedMessage {...messages.public_short} />, + text: formatMessage(messages.public_short), }, unlisted: { icon: 'unlock', - meta: <FormattedMessage {...messages.unlisted_long} />, + meta: formatMessage(messages.unlisted_long), name: 'unlisted', - text: <FormattedMessage {...messages.unlisted_short} />, + text: formatMessage(messages.unlisted_short), }, }; @@ -104,9 +77,8 @@ class PrivacyDropdown extends React.PureComponent { onChange={onChange} onModalClose={onModalClose} onModalOpen={onModalOpen} - title={intl.formatMessage(messages.change_privacy)} + title={formatMessage(messages.change_privacy)} container={container} - noModal={noModal} value={value} /> ); diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js index 1531dcaa9..9a8c0f510 100644 --- a/app/javascript/flavours/glitch/features/compose/components/publisher.js +++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js @@ -2,7 +2,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import { length } from 'stringz'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -23,6 +23,7 @@ const messages = defineMessages({ defaultMessage: '{publish}!', id: 'compose_form.publish_loud', }, + saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, }); export default @injectIntl @@ -36,6 +37,7 @@ class Publisher extends ImmutablePureComponent { onSubmit: PropTypes.func, privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), + isEditing: PropTypes.bool, }; handleSubmit = () => { @@ -43,7 +45,7 @@ class Publisher extends ImmutablePureComponent { }; render () { - const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props; + const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props; const diff = maxChars - length(countText || ''); const computedClass = classNames('composer--publisher', { @@ -51,63 +53,37 @@ class Publisher extends ImmutablePureComponent { over: diff < 0, }); + const privacyIcons = { direct: 'envelope', private: 'lock', public: 'globe', unlisted: 'unlock' }; + + let publishText; + if (isEditing) { + publishText = intl.formatMessage(messages.saveChanges); + } else if (privacy === 'private' || privacy === 'direct') { + const iconId = privacyIcons[privacy]; + publishText = ( + <span> + <Icon id={iconId} /> {intl.formatMessage(messages.publish)} + </span> + ); + } else { + publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + } + return ( <div className={computedClass}> - {sideArm && sideArm !== 'none' ? ( + {sideArm && !isEditing && sideArm !== 'none' ? ( <Button className='side_arm' disabled={disabled} onClick={onSecondarySubmit} style={{ padding: null }} - text={ - <span> - <Icon - id={{ - public: 'globe', - unlisted: 'unlock', - private: 'lock', - direct: 'envelope', - }[sideArm]} - /> - </span> - } + text={<Icon id={privacyIcons[sideArm]} />} title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`} /> ) : null} <Button className='primary' - text={function () { - switch (true) { - case !!sideArm && sideArm !== 'none': - case privacy === 'direct': - case privacy === 'private': - return ( - <span> - <Icon - id={{ - direct: 'envelope', - private: 'lock', - public: 'globe', - unlisted: 'unlock', - }[privacy]} - /> - {' '} - <FormattedMessage {...messages.publish} /> - </span> - ); - case privacy === 'public': - return ( - <span> - <FormattedMessage - {...messages.publishLoud} - values={{ publish: <FormattedMessage {...messages.publish} /> }} - /> - </span> - ); - default: - return <span><FormattedMessage {...messages.publish} /></span>; - } - }()} + text={publishText} title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`} onClick={this.handleSubmit} disabled={disabled} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js index 425b0fe5e..338bfca37 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -19,6 +19,7 @@ export default class Upload extends ImmutablePureComponent { media: ImmutablePropTypes.map.isRequired, onUndo: PropTypes.func.isRequired, onOpenFocalPoint: PropTypes.func.isRequired, + isEditingStatus: PropTypes.func.isRequired, }; handleUndoClick = e => { @@ -32,7 +33,7 @@ export default class Upload extends ImmutablePureComponent { } render () { - const { intl, media } = this.props; + const { intl, media, isEditingStatus } = this.props; const focusX = media.getIn(['meta', 'focus', 'x']); const focusY = media.getIn(['meta', 'focus', 'y']); const x = ((focusX / 2) + .5) * 100; @@ -45,7 +46,7 @@ export default class Upload extends ImmutablePureComponent { <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> <div className={classNames('composer--upload_form--actions', { active: true })}> <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> - <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> + {!isEditingStatus && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)} </div> </div> )} diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js index 8eff8a36b..a037bbbcc 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -51,6 +51,7 @@ function mapStateToProps (state) { focusDate: state.getIn(['compose', 'focusDate']), caretPosition: state.getIn(['compose', 'caretPosition']), isSubmitting: state.getIn(['compose', 'is_submitting']), + isEditing: state.getIn(['compose', 'id']) !== null, isChangingUpload: state.getIn(['compose', 'is_changing_upload']), isUploading: state.getIn(['compose', 'is_uploading']), layout: state.getIn(['local_settings', 'layout']), diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js index 395a9aa5b..dd6899be4 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js @@ -1,14 +1,24 @@ import { connect } from 'react-redux'; import { cancelReplyCompose } from 'flavours/glitch/actions/compose'; -import { makeGetStatus } from 'flavours/glitch/selectors'; import ReplyIndicator from '../components/reply_indicator'; -function makeMapStateToProps (state) { - const inReplyTo = state.getIn(['compose', 'in_reply_to']); +const makeMapStateToProps = () => { + const mapStateToProps = state => { + let statusId = state.getIn(['compose', 'id'], null); + let editing = true; - return { - status: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null, + if (statusId === null) { + statusId = state.getIn(['compose', 'in_reply_to']); + editing = false; + } + + return { + status: state.getIn(['statuses', statusId]), + editing, + }; }; + + return mapStateToProps; }; const mapDispatchToProps = dispatch => ({ diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js index f3ca4ce7b..d6256fe96 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js @@ -5,6 +5,7 @@ import { submitCompose } from 'flavours/glitch/actions/compose'; const mapStateToProps = (state, { id }) => ({ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), + isEditingStatus: state.getIn(['compose', 'id']) !== null, }); const mapDispatchToProps = dispatch => ({ diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js index 95250c6ed..569ba4db0 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -154,6 +154,17 @@ export default class ColumnSettings extends React.PureComponent { <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> </div> </div> + + <div role='group' aria-labelledby='notifications-update'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'update']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'update']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'update']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'update']} onChange={onChange} label={soundStr} /> + </div> + </div> </div> ); } diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js index e1d9fbd0a..1cf205898 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.js +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -171,6 +171,28 @@ export default class Notification extends ImmutablePureComponent { unread={this.props.unread} /> ); + case 'update': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='update' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + unread={this.props.unread} + /> + ); default: return null; } diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index eb4583026..a67a045da 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -11,6 +11,7 @@ import classNames from 'classnames'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, @@ -52,6 +53,7 @@ class ActionBar extends React.PureComponent { onMuteConversation: PropTypes.func, onBlock: PropTypes.func, onDelete: PropTypes.func.isRequired, + onEdit: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onReport: PropTypes.func, @@ -84,6 +86,10 @@ class ActionBar extends React.PureComponent { this.props.onDelete(this.props.status, this.context.router.history, true); } + handleEditClick = () => { + this.props.onEdit(this.props.status, this.context.router.history); + } + handleDirectClick = () => { this.props.onDirect(this.props.status.get('account'), this.context.router.history); } @@ -166,6 +172,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); + // menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 4b3a6aaaa..528d2eb73 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -7,7 +7,7 @@ import StatusContent from 'flavours/glitch/components/status_content'; import MediaGallery from 'flavours/glitch/components/media_gallery'; import AttachmentList from 'flavours/glitch/components/attachment_list'; import { Link } from 'react-router-dom'; -import { injectIntl, FormattedDate, FormattedMessage } from 'react-intl'; +import { injectIntl, FormattedDate } from 'react-intl'; import Card from './card'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Video from 'flavours/glitch/features/video'; @@ -19,6 +19,7 @@ import PollContainer from 'flavours/glitch/containers/poll_container'; import Icon from 'flavours/glitch/components/icon'; import AnimatedNumber from 'flavours/glitch/components/animated_number'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; +import EditedTimestamp from 'flavours/glitch/components/edited_timestamp'; export default @injectIntl class DetailedStatus extends ImmutablePureComponent { @@ -265,7 +266,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/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 12ea407ad..653fabeae 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -26,7 +26,7 @@ import { directCompose, } from 'flavours/glitch/actions/compose'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; -import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; @@ -307,6 +307,10 @@ class Status extends ImmutablePureComponent { } } + handleEditClick = (status, history) => { + this.props.dispatch(editStatus(status.get('id'), history)); + } + handleDirectClick = (account, router) => { this.props.dispatch(directCompose(account, router)); } @@ -585,6 +589,7 @@ class Status extends ImmutablePureComponent { onReblog={this.handleReblogClick} onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} + onEdit={this.handleEditClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} onMute={this.handleMuteClick} diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js index 24169036c..aae2e4426 100644 --- a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js @@ -7,24 +7,22 @@ import Avatar from 'flavours/glitch/components/avatar'; import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; import DisplayName from 'flavours/glitch/components/display_name'; import classNames from 'classnames'; -import Icon from 'flavours/glitch/components/icon'; -import Link from 'flavours/glitch/components/link'; -import Toggle from 'react-toggle'; +import IconButton from 'flavours/glitch/components/icon_button'; export default class ActionsModal extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, + onClick: PropTypes.func, actions: PropTypes.arrayOf(PropTypes.shape({ active: PropTypes.bool, href: PropTypes.string, icon: PropTypes.string, - meta: PropTypes.node, + meta: PropTypes.string, name: PropTypes.string, - on: PropTypes.bool, - onPassiveClick: PropTypes.func, - text: PropTypes.node, + text: PropTypes.string, })), + renderItemContents: PropTypes.func, }; renderAction = (action, i) => { @@ -32,57 +30,26 @@ export default class ActionsModal extends ImmutablePureComponent { return <li key={`sep-${i}`} className='dropdown-menu__separator' />; } - const { - active, - href, - icon, - meta, - name, - on, - onClick, - onPassiveClick, - text, - } = action; + const { icon = null, text, meta = null, active = false, href = '#' } = action; + let contents = this.props.renderItemContents && this.props.renderItemContents(action, i); - return ( - <li key={name || i}> - <Link - className={classNames('link', { active })} - href={href} - onClick={on !== null && typeof on !== 'undefined' && onPassiveClick || onClick} - role={onClick ? 'button' : null} - > - {function () { + if (!contents) { + contents = ( + <React.Fragment> + {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' inverted />} + <div> + <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div> + <div>{meta}</div> + </div> + </React.Fragment> + ); + } - // We render a `<Toggle>` if we were provided an `on` - // property, and otherwise show an `<Icon>` if available. - switch (true) { - case on !== null && typeof on !== 'undefined': - return ( - <Toggle - checked={on} - onChange={onPassiveClick || onClick} - /> - ); - case !!icon: - return ( - <Icon - className='icon' - fixedWidth - id={icon} - /> - ); - default: - return null; - } - }()} - {meta ? ( - <div> - <strong>{text}</strong> - {meta} - </div> - ) : <div>{text}</div>} - </Link> + return ( + <li key={`${text}-${i}`}> + <a href={href} target='_blank' rel='noopener noreferrer' onClick={this.props.onClick} data-index={i} className={classNames('link', { active })}> + {contents} + </a> </li> ); } diff --git a/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js new file mode 100644 index 000000000..198443221 --- /dev/null +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/modal'; +import emojify from 'flavours/glitch/util/emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import InlineAccount from 'flavours/glitch/components/inline_account'; +import IconButton from 'flavours/glitch/components/icon_button'; +import RelativeTimestamp from 'flavours/glitch/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/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js index 62bb167a0..1e065c171 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -24,6 +24,7 @@ import { ListEditor, ListAdder, PinnedAccountsEditor, + CompareHistoryModal, } from 'flavours/glitch/util/async-components'; const MODAL_COMPONENTS = { @@ -42,9 +43,10 @@ const MODAL_COMPONENTS = { 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, 'LIST_EDITOR': ListEditor, - 'LIST_ADDER':ListAdder, 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), + 'LIST_ADDER': ListAdder, 'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor, + 'COMPARE_HISTORY': CompareHistoryModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index d2ea0a924..f97c799e7 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -46,6 +46,7 @@ import { INIT_MEDIA_EDIT_MODAL, COMPOSE_CHANGE_MEDIA_DESCRIPTION, COMPOSE_CHANGE_MEDIA_FOCUS, + COMPOSE_SET_STATUS, } from 'flavours/glitch/actions/compose'; import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines'; import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; @@ -75,6 +76,7 @@ const initialState = ImmutableMap({ spoiler: false, spoiler_text: '', privacy: null, + id: null, content_type: defaultContentType || 'text/plain', text: '', focusDate: null, @@ -160,6 +162,7 @@ function apiStatusToTextHashtags (state, status) { function clearAll(state) { return state.withMutations(map => { + map.set('id', null); map.set('text', ''); if (defaultContentType) map.set('content_type', defaultContentType); map.set('spoiler', false); @@ -400,6 +403,7 @@ export default function compose(state = initialState, action) { .set('elefriend', (state.get('elefriend') + 1) % totalElefriends); case COMPOSE_REPLY: return state.withMutations(map => { + map.set('id', null); map.set('in_reply_to', action.status.get('id')); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); @@ -434,6 +438,7 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('id', null); map.set('poll', null); map.update( 'advanced_options', @@ -573,6 +578,34 @@ export default function compose(state = initialState, action) { })); } }); + case COMPOSE_SET_STATUS: + return state.withMutations(map => { + map.set('id', action.status.get('id')); + map.set('text', action.text); + map.set('in_reply_to', action.status.get('in_reply_to_id')); + map.set('privacy', action.status.get('visibility')); + map.set('media_attachments', action.status.get('media_attachments')); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + map.set('sensitive', action.status.get('sensitive')); + + if (action.spoiler_text.length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + + if (action.status.get('poll')) { + map.set('poll', ImmutableMap({ + options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])), + })); + } + }); case COMPOSE_POLL_ADD: return state.set('poll', initialPoll); case COMPOSE_POLL_REMOVE: diff --git a/app/javascript/flavours/glitch/reducers/history.js b/app/javascript/flavours/glitch/reducers/history.js new file mode 100644 index 000000000..04f5f2fd1 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/history.js @@ -0,0 +1,28 @@ +import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL } from 'flavours/glitch/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/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index 7d7fe6fd3..d9123b103 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -41,6 +41,7 @@ import markers from './markers'; import account_notes from './account_notes'; import picture_in_picture from './picture_in_picture'; import accounts_map from './accounts_map'; +import history from './history'; const reducers = { announcements, @@ -85,6 +86,7 @@ const reducers = { markers, account_notes, picture_in_picture, + history, }; export default combineReducers(reducers); diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index a53d34a83..48587ce64 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -40,6 +40,7 @@ const initialState = ImmutableMap({ mention: false, poll: false, status: false, + update: false, }), quickFilter: ImmutableMap({ @@ -59,6 +60,7 @@ const initialState = ImmutableMap({ mention: true, poll: true, status: true, + update: true, }), sounds: ImmutableMap({ @@ -69,6 +71,7 @@ const initialState = ImmutableMap({ mention: true, poll: true, status: true, + update: true, }), }), diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 92061585a..66ce92ce2 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -1203,6 +1203,10 @@ a.sparkline { } } } + + @media screen and (max-width: 930px) { + grid-template-columns: minmax(0, 1fr); + } } .account-card { @@ -1410,8 +1414,9 @@ a.sparkline { } &__button { + box-sizing: border-box; flex: 0 0 auto; - width: 100px; + width: 200px; padding: 15px; padding-right: 0; @@ -1427,4 +1432,38 @@ a.sparkline { color: $dark-text-color; } } + + @media screen and (max-width: 800px) { + border: 0; + + &__item { + flex-direction: column; + border: 0; + + &__button { + width: 100%; + padding: 15px 0; + } + + &__description { + padding: 0; + padding-bottom: 15px; + } + } + } +} + +.section-skip-link { + float: right; + + a { + color: $ui-highlight-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } } diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 2656890d7..656d8f25d 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -500,8 +500,47 @@ 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; + } } } @@ -541,18 +580,29 @@ } .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, @@ -564,6 +614,42 @@ } } +.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; @@ -1229,36 +1315,48 @@ button.icon-button.active i.fa-retweet { 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; + } + + 50% { + stroke-dasharray: 100px, 200px; + stroke-dashoffset: -15px; + } -.no-reduce-motion .loading-indicator span { - animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); + 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.610, 0.355, 1.000); +@keyframes simple-rotate { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } } @keyframes spring-rotate-in { @@ -1305,40 +1403,6 @@ button.icon-button.active i.fa-retweet { } } -@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; } -} - .spoiler-button { top: 0; left: 0; @@ -1508,6 +1572,41 @@ button.icon-button.active i.fa-retweet { filter: none; } +.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: $ui-highlight-color; height: 3px; diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index cb776e88f..fb2445a17 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -420,7 +420,8 @@ .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; diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml index ee2b699b2..f3c7fac7e 100644 --- a/app/javascript/flavours/glitch/theme.yml +++ b/app/javascript/flavours/glitch/theme.yml @@ -1,7 +1,9 @@ # (REQUIRED) The location of the pack files. pack: about: packs/about.js - admin: packs/admin.js + admin: + - packs/admin.js + - packs/public.js auth: packs/public.js common: filename: packs/common.js diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js index a6c6ab0ab..8c9630eea 100644 --- a/app/javascript/flavours/glitch/util/async-components.js +++ b/app/javascript/flavours/glitch/util/async-components.js @@ -173,3 +173,7 @@ export function Directory () { export function FollowRecommendations () { return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations'); } + +export function CompareHistoryModal () { + return import(/*webpackChunkName: "flavours/glitch/async/compare_history_modal" */'flavours/glitch/features/ui/components/compare_history_modal'); +} diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml index 3263fd7d4..9173d4ec9 100644 --- a/app/javascript/flavours/vanilla/theme.yml +++ b/app/javascript/flavours/vanilla/theme.yml @@ -1,7 +1,9 @@ # (REQUIRED) The location of the pack files inside `pack_directory`. pack: about: about.js - admin: admin.js + admin: + - admin.js + - public.js auth: public.js common: filename: common.js |