diff options
Diffstat (limited to 'app/javascript')
35 files changed, 1910 insertions, 613 deletions
diff --git a/app/javascript/flavours/glitch/actions/reports.js b/app/javascript/flavours/glitch/actions/reports.js index 80c3b3280..333bc71f4 100644 --- a/app/javascript/flavours/glitch/actions/reports.js +++ b/app/javascript/flavours/glitch/actions/reports.js @@ -1,89 +1,38 @@ import api from 'flavours/glitch/util/api'; -import { openModal, closeModal } from './modal'; - -export const REPORT_INIT = 'REPORT_INIT'; -export const REPORT_CANCEL = 'REPORT_CANCEL'; +import { openModal } from './modal'; export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; -export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; -export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; -export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; - -export function initReport(account, status) { - return dispatch => { - dispatch({ - type: REPORT_INIT, - account, - status, - }); - - dispatch(openModal('REPORT')); - }; -}; - -export function cancelReport() { - return { - type: REPORT_CANCEL, - }; -}; - -export function toggleStatusReport(statusId, checked) { - return { - type: REPORT_STATUS_TOGGLE, - statusId, - checked, - }; -}; - -export function submitReport() { - return (dispatch, getState) => { - dispatch(submitReportRequest()); - - api(getState).post('/api/v1/reports', { - account_id: getState().getIn(['reports', 'new', 'account_id']), - status_ids: getState().getIn(['reports', 'new', 'status_ids']), - comment: getState().getIn(['reports', 'new', 'comment']), - forward: getState().getIn(['reports', 'new', 'forward']), - }).then(response => { - dispatch(closeModal()); - dispatch(submitReportSuccess(response.data)); - }).catch(error => dispatch(submitReportFail(error))); - }; -}; - -export function submitReportRequest() { - return { - type: REPORT_SUBMIT_REQUEST, - }; -}; - -export function submitReportSuccess(report) { - return { - type: REPORT_SUBMIT_SUCCESS, - report, - }; -}; - -export function submitReportFail(error) { - return { - type: REPORT_SUBMIT_FAIL, - error, - }; -}; - -export function changeReportComment(comment) { - return { - type: REPORT_COMMENT_CHANGE, - comment, - }; -}; - -export function changeReportForward(forward) { - return { - type: REPORT_FORWARD_CHANGE, - forward, - }; -}; +export const initReport = (account, status) => dispatch => + dispatch(openModal('REPORT', { + accountId: account.get('id'), + statusId: status?.get('id'), + })); + +export const submitReport = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(submitReportRequest()); + + api(getState).post('/api/v1/reports', params).then(response => { + dispatch(submitReportSuccess(response.data)); + if (onSuccess) onSuccess(); + }).catch(error => { + dispatch(submitReportFail(error)); + if (onFail) onFail(); + }); +}; + +export const submitReportRequest = () => ({ + type: REPORT_SUBMIT_REQUEST, +}); + +export const submitReportSuccess = report => ({ + type: REPORT_SUBMIT_SUCCESS, + report, +}); + +export const submitReportFail = error => ({ + type: REPORT_SUBMIT_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/rules.js b/app/javascript/flavours/glitch/actions/rules.js new file mode 100644 index 000000000..b95045e81 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/rules.js @@ -0,0 +1,27 @@ +import api from 'flavours/glitch/util/api'; + +export const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST'; +export const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS'; +export const RULES_FETCH_FAIL = 'RULES_FETCH_FAIL'; + +export const fetchRules = () => (dispatch, getState) => { + dispatch(fetchRulesRequest()); + + api(getState) + .get('/api/v1/instance').then(({ data }) => dispatch(fetchRulesSuccess(data.rules))) + .catch(err => dispatch(fetchRulesFail(err))); +}; + +const fetchRulesRequest = () => ({ + type: RULES_FETCH_REQUEST, +}); + +const fetchRulesSuccess = rules => ({ + type: RULES_FETCH_SUCCESS, + rules, +}); + +const fetchRulesFail = error => ({ + type: RULES_FETCH_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/components/check.js b/app/javascript/flavours/glitch/components/check.js new file mode 100644 index 000000000..ee2ef1595 --- /dev/null +++ b/app/javascript/flavours/glitch/components/check.js @@ -0,0 +1,9 @@ +import React from 'react'; + +const Check = () => ( + <svg width='14' height='11' viewBox='0 0 14 11'> + <path d='M11.264 0L5.26 6.004 2.103 2.847 0 4.95l5.26 5.26 8.108-8.107L11.264 0' fill='currentColor' fillRule='evenodd' /> + </svg> +); + +export default Check; diff --git a/app/javascript/flavours/glitch/features/report/category.js b/app/javascript/flavours/glitch/features/report/category.js new file mode 100644 index 000000000..ddbc82563 --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/category.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; +import Option from './components/option'; + +const messages = defineMessages({ + dislike: { id: 'report.reasons.dislike', defaultMessage: 'I don\'t like it' }, + dislike_description: { id: 'report.reasons.dislike_description', defaultMessage: 'It is not something you want to see' }, + spam: { id: 'report.reasons.spam', defaultMessage: 'It\'s spam' }, + spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetetive replies' }, + violation: { id: 'report.reasons.violation', defaultMessage: 'It violates server rules' }, + violation_description: { id: 'report.reasons.violation_description', defaultMessage: 'You are aware that it breaks specific rules' }, + other: { id: 'report.reasons.other', defaultMessage: 'It\'s something else' }, + other_description: { id: 'report.reasons.other_description', defaultMessage: 'The issue does not fit into other categories' }, + status: { id: 'report.category.title_status', defaultMessage: 'post' }, + account: { id: 'report.category.title_account', defaultMessage: 'profile' }, +}); + +export default @injectIntl +class Category extends React.PureComponent { + + static propTypes = { + onNextStep: PropTypes.func.isRequired, + category: PropTypes.string, + onChangeCategory: PropTypes.func.isRequired, + startedFrom: PropTypes.oneOf(['status', 'account']), + intl: PropTypes.object.isRequired, + }; + + handleNextClick = () => { + const { onNextStep, category } = this.props; + + switch(category) { + case 'dislike': + onNextStep('thanks'); + break; + case 'violation': + onNextStep('rules'); + break; + default: + onNextStep('statuses'); + break; + } + }; + + handleCategoryToggle = (value, checked) => { + const { onChangeCategory } = this.props; + + if (checked) { + onChangeCategory(value); + } + }; + + render () { + const { category, startedFrom, intl } = this.props; + + const options = [ + 'dislike', + 'spam', + 'violation', + 'other', + ]; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.category.title' defaultMessage="Tell us what's going on with this {type}" values={{ type: intl.formatMessage(messages[startedFrom]) }} /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.category.subtitle' defaultMessage='Choose the best match' /></p> + + <div> + {options.map(item => ( + <Option + key={item} + name='category' + value={item} + checked={category === item} + onToggle={this.handleCategoryToggle} + label={intl.formatMessage(messages[item])} + description={intl.formatMessage(messages[`${item}_description`])} + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick} disabled={category === null}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/comment.js b/app/javascript/flavours/glitch/features/report/comment.js new file mode 100644 index 000000000..b2663bbf2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/comment.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; +import Toggle from 'react-toggle'; + +const messages = defineMessages({ + placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' }, +}); + +export default @injectIntl +class Comment extends React.PureComponent { + + static propTypes = { + onSubmit: PropTypes.func.isRequired, + comment: PropTypes.string.isRequired, + onChangeComment: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + isSubmitting: PropTypes.bool, + forward: PropTypes.bool, + isRemote: PropTypes.bool, + domain: PropTypes.string, + onChangeForward: PropTypes.func.isRequired, + }; + + handleClick = () => { + const { onSubmit } = this.props; + onSubmit(); + }; + + handleChange = e => { + const { onChangeComment } = this.props; + onChangeComment(e.target.value); + }; + + handleKeyDown = e => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleClick(); + } + }; + + handleForwardChange = e => { + const { onChangeForward } = this.props; + onChangeForward(e.target.checked); + }; + + render () { + const { comment, isRemote, forward, domain, isSubmitting, intl } = this.props; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3> + + <textarea + className='report-dialog-modal__textarea' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + disabled={isSubmitting} + /> + + {isRemote && ( + <React.Fragment> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p> + + <label className='report-dialog-modal__toggle'> + <Toggle checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} /> + <FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /> + </label> + </React.Fragment> + )} + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleClick}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/components/option.js b/app/javascript/flavours/glitch/features/report/components/option.js new file mode 100644 index 000000000..7e94f0654 --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/components/option.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Check from 'flavours/glitch/components/check'; + +export default class Option extends React.PureComponent { + + static propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + label: PropTypes.node, + description: PropTypes.node, + onToggle: PropTypes.func, + multiple: PropTypes.bool, + labelComponent: PropTypes.node, + }; + + handleKeyPress = e => { + const { value, checked, onToggle } = this.props; + + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + e.preventDefault(); + onToggle(value, !checked); + } + } + + handleChange = e => { + const { value, onToggle } = this.props; + onToggle(value, e.target.checked); + } + + render () { + const { name, value, checked, label, labelComponent, description, multiple } = this.props; + + return ( + <label className='dialog-option poll__option selectable'> + <input type={multiple ? 'checkbox' : 'radio'} name={name} value={value} checked={checked} onChange={this.handleChange} /> + + <span + className={classNames('poll__input', { active: checked, checkbox: multiple })} + tabIndex='0' + role='radio' + onKeyPress={this.handleKeyPress} + aria-checked={checked} + aria-label={label} + >{checked && <Check />}</span> + + {labelComponent ? labelComponent : ( + <span className='poll__option__text'> + <strong>{label}</strong> + {description} + </span> + )} + </label> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/components/status_check_box.js b/app/javascript/flavours/glitch/features/report/components/status_check_box.js index cc49042fc..adb5e77a7 100644 --- a/app/javascript/flavours/glitch/features/report/components/status_check_box.js +++ b/app/javascript/flavours/glitch/features/report/components/status_check_box.js @@ -1,23 +1,32 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Toggle from 'react-toggle'; import noop from 'lodash/noop'; import StatusContent from 'flavours/glitch/components/status_content'; import { MediaGallery, Video } from 'flavours/glitch/util/async-components'; import Bundle from 'flavours/glitch/features/ui/components/bundle'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import Option from './option'; export default class StatusCheckBox extends React.PureComponent { static propTypes = { + id: PropTypes.string.isRequired, status: ImmutablePropTypes.map.isRequired, checked: PropTypes.bool, onToggle: PropTypes.func.isRequired, - disabled: PropTypes.bool, + }; + + handleStatusesToggle = (value, checked) => { + const { onToggle } = this.props; + onToggle(value, checked); }; render () { - const { status, checked, onToggle, disabled } = this.props; + const { status, checked } = this.props; + let media = null; if (status.get('reblog')) { @@ -51,26 +60,45 @@ export default class StatusCheckBox extends React.PureComponent { } else { media = ( <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > - {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} revealed={false} height={110} onOpenMedia={noop} />} + {Component => ( + <Component + media={status.get('media_attachments')} + sensitive={status.get('sensitive')} + revealed={false} + height={110} + onOpenMedia={noop} + /> + )} </Bundle> ); } } - return ( - <div className='status-check-box'> - <div className='status-check-box__status'> - <StatusContent - status={status} - media={media} - /> - </div> + const labelComponent = ( + <div className='status-check-box__status poll__option__text'> + <div className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'> + <Avatar account={status.get('account')} size={46} /> + </div> - <div className='status-check-box-toggle'> - <Toggle checked={checked} onChange={onToggle} disabled={disabled} /> + <div><DisplayName account={status.get('account')} /> · <RelativeTimestamp timestamp={status.get('created_at')} /></div> </div> + + <StatusContent status={status} media={media} /> </div> ); + + return ( + <Option + name='status_ids' + value={status.get('id')} + checked={checked} + onToggle={this.handleStatusesToggle} + label={status.get('search_index')} + labelComponent={labelComponent} + multiple + /> + ); } } diff --git a/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js b/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js index 9bfd41ffc..aa34b3efd 100644 --- a/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js +++ b/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js @@ -1,19 +1,15 @@ import { connect } from 'react-redux'; import StatusCheckBox from '../components/status_check_box'; -import { toggleStatusReport } from 'flavours/glitch/actions/reports'; -import { Set as ImmutableSet } from 'immutable'; +import { makeGetStatus } from 'flavours/glitch/selectors'; -const mapStateToProps = (state, { id }) => ({ - status: state.getIn(['statuses', id]), - checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id), -}); +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); -const mapDispatchToProps = (dispatch, { id }) => ({ + const mapStateToProps = (state, { id }) => ({ + status: getStatus(state, { id }), + }); - onToggle (e) { - dispatch(toggleStatusReport(id, e.target.checked)); - }, + return mapStateToProps; +}; -}); - -export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); +export default connect(makeMapStateToProps)(StatusCheckBox); diff --git a/app/javascript/flavours/glitch/features/report/rules.js b/app/javascript/flavours/glitch/features/report/rules.js new file mode 100644 index 000000000..4772e04a2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/rules.js @@ -0,0 +1,64 @@ +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 Button from 'flavours/glitch/components/button'; +import Option from './components/option'; + +const mapStateToProps = state => ({ + rules: state.get('rules'), +}); + +export default @connect(mapStateToProps) +class Rules extends React.PureComponent { + + static propTypes = { + onNextStep: PropTypes.func.isRequired, + rules: ImmutablePropTypes.list, + selectedRuleIds: ImmutablePropTypes.set.isRequired, + onToggle: PropTypes.func.isRequired, + }; + + handleNextClick = () => { + const { onNextStep } = this.props; + onNextStep('statuses'); + }; + + handleRulesToggle = (value, checked) => { + const { onToggle } = this.props; + onToggle(value, checked); + }; + + render () { + const { rules, selectedRuleIds } = this.props; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.rules.title' defaultMessage='Which rules are being violated?' /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.rules.subtitle' defaultMessage='Select all that apply' /></p> + + <div> + {rules.map(item => ( + <Option + key={item.get('id')} + name='rule_ids' + value={item.get('id')} + checked={selectedRuleIds.includes(item.get('id'))} + onToggle={this.handleRulesToggle} + label={item.get('text')} + multiple + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick} disabled={selectedRuleIds.size < 1}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/statuses.js b/app/javascript/flavours/glitch/features/report/statuses.js new file mode 100644 index 000000000..69cfbb3e5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/statuses.js @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import StatusCheckBox from 'flavours/glitch/features/report/containers/status_check_box_container'; +import { OrderedSet } from 'immutable'; +import { FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; + +const mapStateToProps = (state, { accountId }) => ({ + availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])), +}); + +export default @connect(mapStateToProps) +class Statuses extends React.PureComponent { + + static propTypes = { + onNextStep: PropTypes.func.isRequired, + accountId: PropTypes.string.isRequired, + availableStatusIds: ImmutablePropTypes.set.isRequired, + selectedStatusIds: ImmutablePropTypes.set.isRequired, + onToggle: PropTypes.func.isRequired, + }; + + handleNextClick = () => { + const { onNextStep } = this.props; + onNextStep('comment'); + }; + + render () { + const { availableStatusIds, selectedStatusIds, onToggle } = this.props; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.statuses.title' defaultMessage='Are there any posts that back up this report?' /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.statuses.subtitle' defaultMessage='Select all that apply' /></p> + + <div className='report-dialog-modal__statuses'> + {availableStatusIds.union(selectedStatusIds).map(statusId => ( + <StatusCheckBox + id={statusId} + key={statusId} + checked={selectedStatusIds.includes(statusId)} + onToggle={onToggle} + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/thanks.js b/app/javascript/flavours/glitch/features/report/thanks.js new file mode 100644 index 000000000..9c41baa7f --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/thanks.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; +import { connect } from 'react-redux'; +import { + unfollowAccount, + muteAccount, + blockAccount, +} from 'mastodon/actions/accounts'; + +const mapStateToProps = () => ({}); + +export default @connect(mapStateToProps) +class Thanks extends React.PureComponent { + + static propTypes = { + submitted: PropTypes.bool, + onClose: PropTypes.func.isRequired, + account: ImmutablePropTypes.map.isRequired, + dispatch: PropTypes.func.isRequired, + }; + + handleCloseClick = () => { + const { onClose } = this.props; + onClose(); + }; + + handleUnfollowClick = () => { + const { dispatch, account, onClose } = this.props; + dispatch(unfollowAccount(account.get('id'))); + onClose(); + }; + + handleMuteClick = () => { + const { dispatch, account, onClose } = this.props; + dispatch(muteAccount(account.get('id'))); + onClose(); + }; + + handleBlockClick = () => { + const { dispatch, account, onClose } = this.props; + dispatch(blockAccount(account.get('id'))); + onClose(); + }; + + render () { + const { account, submitted } = this.props; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'>{submitted ? <FormattedMessage id='report.thanks.title_actionable' defaultMessage="Thanks for reporting, we'll look into this." /> : <FormattedMessage id='report.thanks.title' defaultMessage="Don't want to see this?" />}</h3> + <p className='report-dialog-modal__lead'>{submitted ? <FormattedMessage id='report.thanks.take_action_actionable' defaultMessage='While we review this, you can take action against @{name}:' values={{ name: account.get('username') }} /> : <FormattedMessage id='report.thanks.take_action' defaultMessage='Here are your options for controlling what you see on Mastodon:' />}</p> + + {account.getIn(['relationship', 'following']) && ( + <React.Fragment> + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='report.unfollow' defaultMessage='Unfollow @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.unfollow_explanation' defaultMessage='You are following this account. To not see their posts in your home feed anymore, unfollow them.' /></p> + <Button secondary onClick={this.handleUnfollowClick}><FormattedMessage id='account.unfollow' defaultMessage='Unfollow' /></Button> + <hr /> + </React.Fragment> + )} + + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='account.mute' defaultMessage='Mute @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.mute_explanation' defaultMessage='You will not see their posts. They can still follow you and see your posts and will not know that they are muted.' /></p> + <Button secondary onClick={this.handleMuteClick}>{!account.getIn(['relationship', 'muting']) ? <FormattedMessage id='report.mute' defaultMessage='Mute' /> : <FormattedMessage id='account.muted' defaultMessage='Muted' />}</Button> + + <hr /> + + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='account.block' defaultMessage='Block @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.block_explanation' defaultMessage='You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.' /></p> + <Button secondary onClick={this.handleBlockClick}>{!account.getIn(['relationship', 'blocking']) ? <FormattedMessage id='report.block' defaultMessage='Block' /> : <FormattedMessage id='account.blocked' defaultMessage='Blocked' />}</Button> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js index 5cb7c5d07..dcbe94929 100644 --- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js @@ -1,38 +1,32 @@ import React from 'react'; import { connect } from 'react-redux'; -import { changeReportComment, changeReportForward, submitReport } from 'flavours/glitch/actions/reports'; +import { submitReport } from 'flavours/glitch/actions/reports'; import { expandAccountTimeline } from 'flavours/glitch/actions/timelines'; +import { fetchRules } from 'flavours/glitch/actions/rules'; +import { fetchRelationships } from 'flavours/glitch/actions/accounts'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { makeGetAccount } from 'flavours/glitch/selectors'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import StatusCheckBox from 'flavours/glitch/features/report/containers/status_check_box_container'; import { OrderedSet } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import Button from 'flavours/glitch/components/button'; -import Toggle from 'react-toggle'; -import IconButton from '../../../components/icon_button'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Category from 'flavours/glitch/features/report/category'; +import Statuses from 'flavours/glitch/features/report/statuses'; +import Rules from 'flavours/glitch/features/report/rules'; +import Comment from 'flavours/glitch/features/report/comment'; +import Thanks from 'flavours/glitch/features/report/thanks'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, - placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, - submit: { id: 'report.submit', defaultMessage: 'Submit' }, }); const makeMapStateToProps = () => { const getAccount = makeGetAccount(); - const mapStateToProps = state => { - const accountId = state.getIn(['reports', 'new', 'account_id']); - - return { - isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), - account: getAccount(state, accountId), - comment: state.getIn(['reports', 'new', 'comment']), - forward: state.getIn(['reports', 'new', 'forward']), - statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), - }; - }; + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); return mapStateToProps; }; @@ -42,92 +36,183 @@ export default @connect(makeMapStateToProps) class ReportModal extends ImmutablePureComponent { static propTypes = { - isSubmitting: PropTypes.bool, - account: ImmutablePropTypes.map, - statusIds: ImmutablePropTypes.orderedSet.isRequired, - comment: PropTypes.string.isRequired, - forward: PropTypes.bool, + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string, dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + account: ImmutablePropTypes.map.isRequired, }; - handleCommentChange = e => { - this.props.dispatch(changeReportComment(e.target.value)); - } - - handleForwardChange = e => { - this.props.dispatch(changeReportForward(e.target.checked)); - } + state = { + step: 'category', + selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []), + comment: '', + category: null, + selectedRuleIds: OrderedSet(), + forward: true, + isSubmitting: false, + isSubmitted: false, + }; handleSubmit = () => { - this.props.dispatch(submitReport()); - } + const { dispatch, accountId } = this.props; + const { selectedStatusIds, comment, category, selectedRuleIds, forward } = this.state; + + this.setState({ isSubmitting: true }); + + dispatch(submitReport({ + account_id: accountId, + status_ids: selectedStatusIds.toArray(), + comment, + forward, + category, + rule_ids: selectedRuleIds.toArray(), + }, this.handleSuccess, this.handleFail)); + }; + + handleSuccess = () => { + this.setState({ isSubmitting: false, isSubmitted: true, step: 'thanks' }); + }; + + handleFail = () => { + this.setState({ isSubmitting: false }); + }; + + handleStatusToggle = (statusId, checked) => { + const { selectedStatusIds } = this.state; + + if (checked) { + this.setState({ selectedStatusIds: selectedStatusIds.add(statusId) }); + } else { + this.setState({ selectedStatusIds: selectedStatusIds.remove(statusId) }); + } + }; - handleKeyDown = e => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); + handleRuleToggle = (ruleId, checked) => { + const { selectedRuleIds } = this.state; + + if (checked) { + this.setState({ selectedRuleIds: selectedRuleIds.add(ruleId) }); + } else { + this.setState({ selectedRuleIds: selectedRuleIds.remove(ruleId) }); } } + handleChangeCategory = category => { + this.setState({ category }); + }; + + handleChangeComment = comment => { + this.setState({ comment }); + }; + + handleChangeForward = forward => { + this.setState({ forward }); + }; + + handleNextStep = step => { + this.setState({ step }); + }; + componentDidMount () { - this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true })); - } + const { dispatch, accountId } = this.props; - componentWillReceiveProps (nextProps) { - if (this.props.account !== nextProps.account && nextProps.account) { - this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true })); - } + dispatch(fetchRelationships([accountId])); + dispatch(expandAccountTimeline(accountId, { withReplies: true })); + dispatch(fetchRules()); } render () { - const { account, comment, intl, statusIds, isSubmitting, forward, onClose } = this.props; + const { + accountId, + account, + intl, + onClose, + } = this.props; if (!account) { return null; } - const domain = account.get('acct').split('@')[1]; + const { + step, + selectedStatusIds, + selectedRuleIds, + comment, + forward, + category, + isSubmitting, + isSubmitted, + } = this.state; + + const domain = account.get('acct').split('@')[1]; + const isRemote = !!domain; + + let stepComponent; + + switch(step) { + case 'category': + stepComponent = ( + <Category + onNextStep={this.handleNextStep} + startedFrom={this.props.statusId ? 'status' : 'account'} + category={category} + onChangeCategory={this.handleChangeCategory} + /> + ); + break; + case 'rules': + stepComponent = ( + <Rules + onNextStep={this.handleNextStep} + selectedRuleIds={selectedRuleIds} + onToggle={this.handleRuleToggle} + /> + ); + break; + case 'statuses': + stepComponent = ( + <Statuses + onNextStep={this.handleNextStep} + accountId={accountId} + selectedStatusIds={selectedStatusIds} + onToggle={this.handleStatusToggle} + /> + ); + break; + case 'comment': + stepComponent = ( + <Comment + onSubmit={this.handleSubmit} + isSubmitting={isSubmitting} + isRemote={isRemote} + comment={comment} + forward={forward} + domain={domain} + onChangeComment={this.handleChangeComment} + onChangeForward={this.handleChangeForward} + /> + ); + break; + case 'thanks': + stepComponent = ( + <Thanks + submitted={isSubmitted} + account={account} + onClose={onClose} + /> + ); + } return ( - <div className='modal-root__modal report-modal'> + <div className='modal-root__modal report-dialog-modal'> <div className='report-modal__target'> <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} /> </div> - <div className='report-modal__container'> - <div className='report-modal__comment'> - <p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:' /></p> - - <textarea - className='setting-text light' - placeholder={intl.formatMessage(messages.placeholder)} - value={comment} - onChange={this.handleCommentChange} - onKeyDown={this.handleKeyDown} - disabled={isSubmitting} - autoFocus - /> - - {domain && ( - <div> - <p><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p> - - <div className='setting-toggle'> - <Toggle id='report-forward' checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} /> - <label htmlFor='report-forward' className='setting-toggle__label'><FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /></label> - </div> - </div> - )} - - <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /> - </div> - - <div className='report-modal__statuses'> - <div> - {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} - </div> - </div> + <div className='report-dialog-modal__container'> + {stepComponent} </div> </div> ); diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index d9123b103..92348c0c5 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -17,7 +17,7 @@ import push_notifications from './push_notifications'; import status_lists from './status_lists'; import mutes from './mutes'; import blocks from './blocks'; -import reports from './reports'; +import rules from './rules'; import boosts from './boosts'; import contexts from './contexts'; import compose from './compose'; @@ -64,7 +64,7 @@ const reducers = { push_notifications, mutes, blocks, - reports, + rules, boosts, contexts, compose, diff --git a/app/javascript/flavours/glitch/reducers/reports.js b/app/javascript/flavours/glitch/reducers/reports.js deleted file mode 100644 index 1f7f3f273..000000000 --- a/app/javascript/flavours/glitch/reducers/reports.js +++ /dev/null @@ -1,77 +0,0 @@ -import { - REPORT_INIT, - REPORT_SUBMIT_REQUEST, - REPORT_SUBMIT_SUCCESS, - REPORT_SUBMIT_FAIL, - REPORT_CANCEL, - REPORT_STATUS_TOGGLE, - REPORT_COMMENT_CHANGE, - REPORT_FORWARD_CHANGE, -} from 'flavours/glitch/actions/reports'; -import { - TIMELINE_DELETE, -} from 'flavours/glitch/actions/timelines'; -import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; - -const initialState = ImmutableMap({ - new: ImmutableMap({ - isSubmitting: false, - account_id: null, - status_ids: ImmutableSet(), - comment: '', - forward: false, - }), -}); - -const deleteStatus = (state, id, references) => { - references.forEach(ref => { - state = deleteStatus(state, ref[0], []); - }); - - return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.remove(id)); -}; - -export default function reports(state = initialState, action) { - switch(action.type) { - case REPORT_INIT: - return state.withMutations(map => { - map.setIn(['new', 'isSubmitting'], false); - map.setIn(['new', 'account_id'], action.account.get('id')); - - if (state.getIn(['new', 'account_id']) !== action.account.get('id')) { - map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet()); - map.setIn(['new', 'comment'], ''); - } else if (action.status) { - map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id')))); - } - }); - case REPORT_STATUS_TOGGLE: - return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => { - if (action.checked) { - return set.add(action.statusId); - } - - return set.remove(action.statusId); - }); - case REPORT_COMMENT_CHANGE: - return state.setIn(['new', 'comment'], action.comment); - case REPORT_FORWARD_CHANGE: - return state.setIn(['new', 'forward'], action.forward); - case REPORT_SUBMIT_REQUEST: - return state.setIn(['new', 'isSubmitting'], true); - case REPORT_SUBMIT_FAIL: - return state.setIn(['new', 'isSubmitting'], false); - case REPORT_CANCEL: - case REPORT_SUBMIT_SUCCESS: - return state.withMutations(map => { - map.setIn(['new', 'account_id'], null); - map.setIn(['new', 'status_ids'], ImmutableSet()); - map.setIn(['new', 'comment'], ''); - map.setIn(['new', 'isSubmitting'], false); - }); - case TIMELINE_DELETE: - return deleteStatus(state, action.id, action.references); - default: - return state; - } -}; diff --git a/app/javascript/flavours/glitch/reducers/rules.js b/app/javascript/flavours/glitch/reducers/rules.js new file mode 100644 index 000000000..6cc2230bc --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/rules.js @@ -0,0 +1,13 @@ +import { RULES_FETCH_SUCCESS } from 'flavours/glitch/actions/rules'; +import { List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableList(); + +export default function rules(state = initialState, action) { + switch (action.type) { + case RULES_FETCH_SUCCESS: + return fromJS(action.rules); + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index fd62bb651..937751d00 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -652,14 +652,14 @@ & > .primary { display: inline-block; margin: 0; - padding: 0 10px; + padding: 7px 10px; text-align: center; } & > .side_arm { display: inline-block; margin: 0 2px; - padding: 0; + padding: 7px 0; width: 36px; text-align: center; } diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 656d8f25d..55abd6e1e 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -41,16 +41,14 @@ cursor: pointer; display: inline-block; font-family: inherit; - font-size: 14px; + font-size: 17px; font-weight: 500; - height: 36px; letter-spacing: 0; - line-height: 36px; + line-height: 22px; overflow: hidden; - padding: 0 16px; + padding: 7px 18px; position: relative; text-align: center; - text-transform: uppercase; text-decoration: none; text-overflow: ellipsis; transition: all 100ms ease-in; @@ -82,17 +80,6 @@ cursor: default; } - &.button-primary, - &.button-alternative, - &.button-secondary, - &.button-alternative-2 { - font-size: 16px; - line-height: 36px; - height: auto; - text-transform: none; - padding: 4px 16px; - } - &.button-alternative { color: $inverted-text-color; background: $ui-primary-color; @@ -121,8 +108,7 @@ color: $darker-text-color; text-transform: none; background: transparent; - padding: 3px 15px; - border-radius: 4px; + padding: 6px 17px; border: 1px solid $ui-primary-color; &:active, diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index fb2445a17..ae1afc320 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -537,6 +537,192 @@ max-width: 700px; } +.report-dialog-modal { + max-width: 90vw; + width: 480px; + height: 80vh; + background: lighten($ui-secondary-color, 8%); + color: $inverted-text-color; + border-radius: 8px; + overflow: hidden; + position: relative; + flex-direction: column; + display: flex; + + &__container { + box-sizing: border-box; + border-top: 1px solid $ui-secondary-color; + padding: 20px; + flex-grow: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + } + + &__title { + font-size: 28px; + line-height: 33px; + font-weight: 700; + margin-bottom: 15px; + + @media screen and (max-height: 800px) { + font-size: 22px; + } + } + + &__subtitle { + font-size: 17px; + font-weight: 600; + line-height: 22px; + margin-bottom: 4px; + } + + &__lead { + font-size: 17px; + line-height: 22px; + color: lighten($inverted-text-color, 16%); + margin-bottom: 30px; + } + + &__actions { + margin-top: 30px; + display: flex; + + .button { + flex: 1 1 auto; + } + } + + &__statuses { + flex-grow: 1; + min-height: 0; + overflow: auto; + } + + .status__content a { + color: $highlight-text-color; + } + + .status__content, + .status__content p { + color: $inverted-text-color; + } + + .dialog-option .poll__input { + border-color: $inverted-text-color; + color: $ui-secondary-color; + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + width: 8px; + height: auto; + } + + &:active, + &:focus, + &:hover { + border-color: lighten($inverted-text-color, 15%); + border-width: 4px; + } + + &.active { + border-color: $inverted-text-color; + background: $inverted-text-color; + } + } + + .poll__option.dialog-option { + padding: 15px 0; + flex: 0 0 auto; + border-bottom: 1px solid $ui-secondary-color; + + &:last-child { + border-bottom: 0; + } + + & > .poll__option__text { + font-size: 13px; + color: lighten($inverted-text-color, 16%); + + strong { + font-size: 17px; + font-weight: 500; + line-height: 22px; + color: $inverted-text-color; + display: block; + margin-bottom: 4px; + + &:last-child { + margin-bottom: 0; + } + } + } + } + + .flex-spacer { + background: transparent; + } + + &__textarea { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + color: $inverted-text-color; + background: $simple-background-color; + padding: 10px; + font-family: inherit; + font-size: 17px; + line-height: 22px; + resize: vertical; + border: 0; + outline: 0; + border-radius: 4px; + margin: 20px 0; + + &::placeholder { + color: $dark-text-color; + } + + &:focus { + outline: 0; + } + } + + &__toggle { + display: flex; + align-items: center; + + & > span { + font-size: 17px; + font-weight: 500; + margin-left: 10px; + } + } + + .button.button-secondary { + border-color: $inverted-text-color; + color: $inverted-text-color; + flex: 0 0 auto; + + &:hover, + &:focus, + &:active { + border-color: lighten($inverted-text-color, 15%); + color: lighten($inverted-text-color, 15%); + } + } + + hr { + border: 0; + background: transparent; + margin: 15px 0; + } +} + .report-modal__container { display: flex; border-top: 1px solid $ui-secondary-color; diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 013b1bd25..a49299af1 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -517,42 +517,39 @@ justify-content: flex-start; } -.status-check-box { - border-bottom: 1px solid $ui-secondary-color; - display: flex; +.status-check-box__status { + display: block; + box-sizing: border-box; + width: 100%; + padding: 0 10px; - .status-check-box__status { - margin: 10px 0 10px 10px; - flex: 1; - overflow: hidden; + .detailed-status__display-name { + color: lighten($inverted-text-color, 16%); - .media-gallery { - max-width: 250px; + span { + display: inline; } - .status__content { - padding: 0; - white-space: normal; + &:hover strong { + text-decoration: none; } + } - .video-player, - .audio-player { - margin-top: 8px; - max-width: 250px; - } + .media-gallery, + .audio-player, + .video-player { + margin-top: 8px; + max-width: 250px; + } - .media-gallery__item-thumbnail { - cursor: default; - } + .status__content { + padding: 0; + white-space: normal; } -} -.status-check-box-toggle { - align-items: center; - display: flex; - flex: 0 0 auto; - justify-content: center; - padding: 10px; + .media-gallery__item-thumbnail { + cursor: default; + } } .status__prepend { diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js index afa0c3412..fbe5b3791 100644 --- a/app/javascript/mastodon/actions/reports.js +++ b/app/javascript/mastodon/actions/reports.js @@ -1,89 +1,38 @@ import api from '../api'; -import { openModal, closeModal } from './modal'; - -export const REPORT_INIT = 'REPORT_INIT'; -export const REPORT_CANCEL = 'REPORT_CANCEL'; +import { openModal } from './modal'; export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; -export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; -export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; -export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; - -export function initReport(account, status) { - return dispatch => { - dispatch({ - type: REPORT_INIT, - account, - status, - }); - - dispatch(openModal('REPORT')); - }; -}; - -export function cancelReport() { - return { - type: REPORT_CANCEL, - }; -}; - -export function toggleStatusReport(statusId, checked) { - return { - type: REPORT_STATUS_TOGGLE, - statusId, - checked, - }; -}; - -export function submitReport() { - return (dispatch, getState) => { - dispatch(submitReportRequest()); - - api(getState).post('/api/v1/reports', { - account_id: getState().getIn(['reports', 'new', 'account_id']), - status_ids: getState().getIn(['reports', 'new', 'status_ids']), - comment: getState().getIn(['reports', 'new', 'comment']), - forward: getState().getIn(['reports', 'new', 'forward']), - }).then(response => { - dispatch(closeModal()); - dispatch(submitReportSuccess(response.data)); - }).catch(error => dispatch(submitReportFail(error))); - }; -}; - -export function submitReportRequest() { - return { - type: REPORT_SUBMIT_REQUEST, - }; -}; - -export function submitReportSuccess(report) { - return { - type: REPORT_SUBMIT_SUCCESS, - report, - }; -}; - -export function submitReportFail(error) { - return { - type: REPORT_SUBMIT_FAIL, - error, - }; -}; - -export function changeReportComment(comment) { - return { - type: REPORT_COMMENT_CHANGE, - comment, - }; -}; - -export function changeReportForward(forward) { - return { - type: REPORT_FORWARD_CHANGE, - forward, - }; -}; +export const initReport = (account, status) => dispatch => + dispatch(openModal('REPORT', { + accountId: account.get('id'), + statusId: status?.get('id'), + })); + +export const submitReport = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(submitReportRequest()); + + api(getState).post('/api/v1/reports', params).then(response => { + dispatch(submitReportSuccess(response.data)); + if (onSuccess) onSuccess(); + }).catch(error => { + dispatch(submitReportFail(error)); + if (onFail) onFail(); + }); +}; + +export const submitReportRequest = () => ({ + type: REPORT_SUBMIT_REQUEST, +}); + +export const submitReportSuccess = report => ({ + type: REPORT_SUBMIT_SUCCESS, + report, +}); + +export const submitReportFail = error => ({ + type: REPORT_SUBMIT_FAIL, + error, +}); diff --git a/app/javascript/mastodon/actions/rules.js b/app/javascript/mastodon/actions/rules.js new file mode 100644 index 000000000..34e60a121 --- /dev/null +++ b/app/javascript/mastodon/actions/rules.js @@ -0,0 +1,27 @@ +import api from '../api'; + +export const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST'; +export const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS'; +export const RULES_FETCH_FAIL = 'RULES_FETCH_FAIL'; + +export const fetchRules = () => (dispatch, getState) => { + dispatch(fetchRulesRequest()); + + api(getState) + .get('/api/v1/instance').then(({ data }) => dispatch(fetchRulesSuccess(data.rules))) + .catch(err => dispatch(fetchRulesFail(err))); +}; + +const fetchRulesRequest = () => ({ + type: RULES_FETCH_REQUEST, +}); + +const fetchRulesSuccess = rules => ({ + type: RULES_FETCH_SUCCESS, + rules, +}); + +const fetchRulesFail = error => ({ + type: RULES_FETCH_FAIL, + error, +}); diff --git a/app/javascript/mastodon/components/check.js b/app/javascript/mastodon/components/check.js new file mode 100644 index 000000000..ee2ef1595 --- /dev/null +++ b/app/javascript/mastodon/components/check.js @@ -0,0 +1,9 @@ +import React from 'react'; + +const Check = () => ( + <svg width='14' height='11' viewBox='0 0 14 11'> + <path d='M11.264 0L5.26 6.004 2.103 2.847 0 4.95l5.26 5.26 8.108-8.107L11.264 0' fill='currentColor' fillRule='evenodd' /> + </svg> +); + +export default Check; diff --git a/app/javascript/mastodon/features/report/category.js b/app/javascript/mastodon/features/report/category.js new file mode 100644 index 000000000..122b51c7c --- /dev/null +++ b/app/javascript/mastodon/features/report/category.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'mastodon/components/button'; +import Option from './components/option'; + +const messages = defineMessages({ + dislike: { id: 'report.reasons.dislike', defaultMessage: 'I don\'t like it' }, + dislike_description: { id: 'report.reasons.dislike_description', defaultMessage: 'It is not something you want to see' }, + spam: { id: 'report.reasons.spam', defaultMessage: 'It\'s spam' }, + spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetetive replies' }, + violation: { id: 'report.reasons.violation', defaultMessage: 'It violates server rules' }, + violation_description: { id: 'report.reasons.violation_description', defaultMessage: 'You are aware that it breaks specific rules' }, + other: { id: 'report.reasons.other', defaultMessage: 'It\'s something else' }, + other_description: { id: 'report.reasons.other_description', defaultMessage: 'The issue does not fit into other categories' }, + status: { id: 'report.category.title_status', defaultMessage: 'post' }, + account: { id: 'report.category.title_account', defaultMessage: 'profile' }, +}); + +export default @injectIntl +class Category extends React.PureComponent { + + static propTypes = { + onNextStep: PropTypes.func.isRequired, + category: PropTypes.string, + onChangeCategory: PropTypes.func.isRequired, + startedFrom: PropTypes.oneOf(['status', 'account']), + intl: PropTypes.object.isRequired, + }; + + handleNextClick = () => { + const { onNextStep, category } = this.props; + + switch(category) { + case 'dislike': + onNextStep('thanks'); + break; + case 'violation': + onNextStep('rules'); + break; + default: + onNextStep('statuses'); + break; + } + }; + + handleCategoryToggle = (value, checked) => { + const { onChangeCategory } = this.props; + + if (checked) { + onChangeCategory(value); + } + }; + + render () { + const { category, startedFrom, intl } = this.props; + + const options = [ + 'dislike', + 'spam', + 'violation', + 'other', + ]; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.category.title' defaultMessage="Tell us what's going on with this {type}" values={{ type: intl.formatMessage(messages[startedFrom]) }} /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.category.subtitle' defaultMessage='Choose the best match' /></p> + + <div> + {options.map(item => ( + <Option + key={item} + name='category' + value={item} + checked={category === item} + onToggle={this.handleCategoryToggle} + label={intl.formatMessage(messages[item])} + description={intl.formatMessage(messages[`${item}_description`])} + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick} disabled={category === null}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/mastodon/features/report/comment.js b/app/javascript/mastodon/features/report/comment.js new file mode 100644 index 000000000..8d1a4e21f --- /dev/null +++ b/app/javascript/mastodon/features/report/comment.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; +import Button from 'mastodon/components/button'; +import Toggle from 'react-toggle'; + +const messages = defineMessages({ + placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' }, +}); + +export default @injectIntl +class Comment extends React.PureComponent { + + static propTypes = { + onSubmit: PropTypes.func.isRequired, + comment: PropTypes.string.isRequired, + onChangeComment: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + isSubmitting: PropTypes.bool, + forward: PropTypes.bool, + isRemote: PropTypes.bool, + domain: PropTypes.string, + onChangeForward: PropTypes.func.isRequired, + }; + + handleClick = () => { + const { onSubmit } = this.props; + onSubmit(); + }; + + handleChange = e => { + const { onChangeComment } = this.props; + onChangeComment(e.target.value); + }; + + handleKeyDown = e => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleClick(); + } + }; + + handleForwardChange = e => { + const { onChangeForward } = this.props; + onChangeForward(e.target.checked); + }; + + render () { + const { comment, isRemote, forward, domain, isSubmitting, intl } = this.props; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3> + + <textarea + className='report-dialog-modal__textarea' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + disabled={isSubmitting} + /> + + {isRemote && ( + <React.Fragment> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p> + + <label className='report-dialog-modal__toggle'> + <Toggle checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} /> + <FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /> + </label> + </React.Fragment> + )} + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleClick}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/mastodon/features/report/components/option.js b/app/javascript/mastodon/features/report/components/option.js new file mode 100644 index 000000000..744d85268 --- /dev/null +++ b/app/javascript/mastodon/features/report/components/option.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Check from 'mastodon/components/check'; + +export default class Option extends React.PureComponent { + + static propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + label: PropTypes.node, + description: PropTypes.node, + onToggle: PropTypes.func, + multiple: PropTypes.bool, + labelComponent: PropTypes.node, + }; + + handleKeyPress = e => { + const { value, checked, onToggle } = this.props; + + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + e.preventDefault(); + onToggle(value, !checked); + } + } + + handleChange = e => { + const { value, onToggle } = this.props; + onToggle(value, e.target.checked); + } + + render () { + const { name, value, checked, label, labelComponent, description, multiple } = this.props; + + return ( + <label className='dialog-option poll__option selectable'> + <input type={multiple ? 'checkbox' : 'radio'} name={name} value={value} checked={checked} onChange={this.handleChange} /> + + <span + className={classNames('poll__input', { active: checked, checkbox: multiple })} + tabIndex='0' + role='radio' + onKeyPress={this.handleKeyPress} + aria-checked={checked} + aria-label={label} + >{checked && <Check />}</span> + + {labelComponent ? labelComponent : ( + <span className='poll__option__text'> + <strong>{label}</strong> + {description} + </span> + )} + </label> + ); + } + +} diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js index c29e517da..a2eb3d6f5 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.js +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -1,23 +1,32 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Toggle from 'react-toggle'; import noop from 'lodash/noop'; -import StatusContent from '../../../components/status_content'; -import { MediaGallery, Video } from '../../ui/util/async-components'; -import Bundle from '../../ui/components/bundle'; +import StatusContent from 'mastodon/components/status_content'; +import { MediaGallery, Video } from 'mastodon/features/ui/util/async-components'; +import Bundle from 'mastodon/features/ui/components/bundle'; +import Avatar from 'mastodon/components/avatar'; +import DisplayName from 'mastodon/components/display_name'; +import RelativeTimestamp from 'mastodon/components/relative_timestamp'; +import Option from './option'; export default class StatusCheckBox extends React.PureComponent { static propTypes = { + id: PropTypes.string.isRequired, status: ImmutablePropTypes.map.isRequired, checked: PropTypes.bool, onToggle: PropTypes.func.isRequired, - disabled: PropTypes.bool, + }; + + handleStatusesToggle = (value, checked) => { + const { onToggle } = this.props; + onToggle(value, checked); }; render () { - const { status, checked, onToggle, disabled } = this.props; + const { status, checked } = this.props; + let media = null; if (status.get('reblog')) { @@ -50,24 +59,46 @@ export default class StatusCheckBox extends React.PureComponent { } else { media = ( <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > - {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={noop} />} + {Component => ( + <Component + media={status.get('media_attachments')} + sensitive={status.get('sensitive')} + height={110} + onOpenMedia={noop} + /> + )} </Bundle> ); } } - return ( - <div className='status-check-box'> - <div className='status-check-box__status'> - <StatusContent status={status} /> - {media} - </div> + const labelComponent = ( + <div className='status-check-box__status poll__option__text'> + <div className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'> + <Avatar account={status.get('account')} size={46} /> + </div> - <div className='status-check-box-toggle'> - <Toggle checked={checked} onChange={onToggle} disabled={disabled} /> + <div><DisplayName account={status.get('account')} /> · <RelativeTimestamp timestamp={status.get('created_at')} /></div> </div> + + <StatusContent status={status} /> + + {media} </div> ); + + return ( + <Option + name='status_ids' + value={status.get('id')} + checked={checked} + onToggle={this.handleStatusesToggle} + label={status.get('search_index')} + labelComponent={labelComponent} + multiple + /> + ); } } diff --git a/app/javascript/mastodon/features/report/containers/status_check_box_container.js b/app/javascript/mastodon/features/report/containers/status_check_box_container.js index 48cd0319b..65a7c11fd 100644 --- a/app/javascript/mastodon/features/report/containers/status_check_box_container.js +++ b/app/javascript/mastodon/features/report/containers/status_check_box_container.js @@ -1,19 +1,15 @@ import { connect } from 'react-redux'; import StatusCheckBox from '../components/status_check_box'; -import { toggleStatusReport } from '../../../actions/reports'; -import { Set as ImmutableSet } from 'immutable'; +import { makeGetStatus } from 'mastodon/selectors'; -const mapStateToProps = (state, { id }) => ({ - status: state.getIn(['statuses', id]), - checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id), -}); +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); -const mapDispatchToProps = (dispatch, { id }) => ({ + const mapStateToProps = (state, { id }) => ({ + status: getStatus(state, { id }), + }); - onToggle (e) { - dispatch(toggleStatusReport(id, e.target.checked)); - }, + return mapStateToProps; +}; -}); - -export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); +export default connect(makeMapStateToProps)(StatusCheckBox); diff --git a/app/javascript/mastodon/features/report/rules.js b/app/javascript/mastodon/features/report/rules.js new file mode 100644 index 000000000..f2db0d9e4 --- /dev/null +++ b/app/javascript/mastodon/features/report/rules.js @@ -0,0 +1,64 @@ +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 Button from 'mastodon/components/button'; +import Option from './components/option'; + +const mapStateToProps = state => ({ + rules: state.get('rules'), +}); + +export default @connect(mapStateToProps) +class Rules extends React.PureComponent { + + static propTypes = { + onNextStep: PropTypes.func.isRequired, + rules: ImmutablePropTypes.list, + selectedRuleIds: ImmutablePropTypes.set.isRequired, + onToggle: PropTypes.func.isRequired, + }; + + handleNextClick = () => { + const { onNextStep } = this.props; + onNextStep('statuses'); + }; + + handleRulesToggle = (value, checked) => { + const { onToggle } = this.props; + onToggle(value, checked); + }; + + render () { + const { rules, selectedRuleIds } = this.props; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.rules.title' defaultMessage='Which rules are being violated?' /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.rules.subtitle' defaultMessage='Select all that apply' /></p> + + <div> + {rules.map(item => ( + <Option + key={item.get('id')} + name='rule_ids' + value={item.get('id')} + checked={selectedRuleIds.includes(item.get('id'))} + onToggle={this.handleRulesToggle} + label={item.get('text')} + multiple + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick} disabled={selectedRuleIds.size < 1}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/mastodon/features/report/statuses.js b/app/javascript/mastodon/features/report/statuses.js new file mode 100644 index 000000000..5999a0e06 --- /dev/null +++ b/app/javascript/mastodon/features/report/statuses.js @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import StatusCheckBox from 'mastodon/features/report/containers/status_check_box_container'; +import { OrderedSet } from 'immutable'; +import { FormattedMessage } from 'react-intl'; +import Button from 'mastodon/components/button'; + +const mapStateToProps = (state, { accountId }) => ({ + availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])), +}); + +export default @connect(mapStateToProps) +class Statuses extends React.PureComponent { + + static propTypes = { + onNextStep: PropTypes.func.isRequired, + accountId: PropTypes.string.isRequired, + availableStatusIds: ImmutablePropTypes.set.isRequired, + selectedStatusIds: ImmutablePropTypes.set.isRequired, + onToggle: PropTypes.func.isRequired, + }; + + handleNextClick = () => { + const { onNextStep } = this.props; + onNextStep('comment'); + }; + + render () { + const { availableStatusIds, selectedStatusIds, onToggle } = this.props; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'><FormattedMessage id='report.statuses.title' defaultMessage='Are there any posts that back up this report?' /></h3> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.statuses.subtitle' defaultMessage='Select all that apply' /></p> + + <div className='report-dialog-modal__statuses'> + {availableStatusIds.union(selectedStatusIds).map(statusId => ( + <StatusCheckBox + id={statusId} + key={statusId} + checked={selectedStatusIds.includes(statusId)} + onToggle={onToggle} + /> + ))} + </div> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleNextClick}><FormattedMessage id='report.next' defaultMessage='Next' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/mastodon/features/report/thanks.js b/app/javascript/mastodon/features/report/thanks.js new file mode 100644 index 000000000..d169b1e32 --- /dev/null +++ b/app/javascript/mastodon/features/report/thanks.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import Button from 'mastodon/components/button'; +import { connect } from 'react-redux'; +import { + unfollowAccount, + muteAccount, + blockAccount, +} from 'mastodon/actions/accounts'; + +const mapStateToProps = () => ({}); + +export default @connect(mapStateToProps) +class Thanks extends React.PureComponent { + + static propTypes = { + submitted: PropTypes.bool, + onClose: PropTypes.func.isRequired, + account: ImmutablePropTypes.map.isRequired, + dispatch: PropTypes.func.isRequired, + }; + + handleCloseClick = () => { + const { onClose } = this.props; + onClose(); + }; + + handleUnfollowClick = () => { + const { dispatch, account, onClose } = this.props; + dispatch(unfollowAccount(account.get('id'))); + onClose(); + }; + + handleMuteClick = () => { + const { dispatch, account, onClose } = this.props; + dispatch(muteAccount(account.get('id'))); + onClose(); + }; + + handleBlockClick = () => { + const { dispatch, account, onClose } = this.props; + dispatch(blockAccount(account.get('id'))); + onClose(); + }; + + render () { + const { account, submitted } = this.props; + + return ( + <React.Fragment> + <h3 className='report-dialog-modal__title'>{submitted ? <FormattedMessage id='report.thanks.title_actionable' defaultMessage="Thanks for reporting, we'll look into this." /> : <FormattedMessage id='report.thanks.title' defaultMessage="Don't want to see this?" />}</h3> + <p className='report-dialog-modal__lead'>{submitted ? <FormattedMessage id='report.thanks.take_action_actionable' defaultMessage='While we review this, you can take action against @{name}:' values={{ name: account.get('username') }} /> : <FormattedMessage id='report.thanks.take_action' defaultMessage='Here are your options for controlling what you see on Mastodon:' />}</p> + + {account.getIn(['relationship', 'following']) && ( + <React.Fragment> + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='report.unfollow' defaultMessage='Unfollow @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.unfollow_explanation' defaultMessage='You are following this account. To not see their posts in your home feed anymore, unfollow them.' /></p> + <Button secondary onClick={this.handleUnfollowClick}><FormattedMessage id='account.unfollow' defaultMessage='Unfollow' /></Button> + <hr /> + </React.Fragment> + )} + + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='account.mute' defaultMessage='Mute @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.mute_explanation' defaultMessage='You will not see their posts. They can still follow you and see your posts and will not know that they are muted.' /></p> + <Button secondary onClick={this.handleMuteClick}>{!account.getIn(['relationship', 'muting']) ? <FormattedMessage id='report.mute' defaultMessage='Mute' /> : <FormattedMessage id='account.muted' defaultMessage='Muted' />}</Button> + + <hr /> + + <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='account.block' defaultMessage='Block @{name}' values={{ name: account.get('username') }} /></h4> + <p className='report-dialog-modal__lead'><FormattedMessage id='report.block_explanation' defaultMessage='You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.' /></p> + <Button secondary onClick={this.handleBlockClick}>{!account.getIn(['relationship', 'blocking']) ? <FormattedMessage id='report.block' defaultMessage='Block' /> : <FormattedMessage id='account.blocked' defaultMessage='Blocked' />}</Button> + + <div className='flex-spacer' /> + + <div className='report-dialog-modal__actions'> + <Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button> + </div> + </React.Fragment> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index f4f0a3884..744dd248b 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -1,38 +1,31 @@ import React from 'react'; import { connect } from 'react-redux'; -import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports'; -import { expandAccountTimeline } from '../../../actions/timelines'; +import { submitReport } from 'mastodon/actions/reports'; +import { expandAccountTimeline } from 'mastodon/actions/timelines'; +import { fetchRules } from 'mastodon/actions/rules'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { makeGetAccount } from '../../../selectors'; +import { makeGetAccount } from 'mastodon/selectors'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import StatusCheckBox from '../../report/containers/status_check_box_container'; import { OrderedSet } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import Button from '../../../components/button'; -import Toggle from 'react-toggle'; -import IconButton from '../../../components/icon_button'; +import IconButton from 'mastodon/components/icon_button'; +import Category from 'mastodon/features/report/category'; +import Statuses from 'mastodon/features/report/statuses'; +import Rules from 'mastodon/features/report/rules'; +import Comment from 'mastodon/features/report/comment'; +import Thanks from 'mastodon/features/report/thanks'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, - placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, - submit: { id: 'report.submit', defaultMessage: 'Submit' }, }); const makeMapStateToProps = () => { const getAccount = makeGetAccount(); - const mapStateToProps = state => { - const accountId = state.getIn(['reports', 'new', 'account_id']); - - return { - isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), - account: getAccount(state, accountId), - comment: state.getIn(['reports', 'new', 'comment']), - forward: state.getIn(['reports', 'new', 'forward']), - statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), - }; - }; + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); return mapStateToProps; }; @@ -42,92 +35,182 @@ export default @connect(makeMapStateToProps) class ReportModal extends ImmutablePureComponent { static propTypes = { - isSubmitting: PropTypes.bool, - account: ImmutablePropTypes.map, - statusIds: ImmutablePropTypes.orderedSet.isRequired, - comment: PropTypes.string.isRequired, - forward: PropTypes.bool, + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string, dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + account: ImmutablePropTypes.map.isRequired, }; - handleCommentChange = e => { - this.props.dispatch(changeReportComment(e.target.value)); - } - - handleForwardChange = e => { - this.props.dispatch(changeReportForward(e.target.checked)); - } + state = { + step: 'category', + selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []), + comment: '', + category: null, + selectedRuleIds: OrderedSet(), + forward: true, + isSubmitting: false, + isSubmitted: false, + }; handleSubmit = () => { - this.props.dispatch(submitReport()); - } + const { dispatch, accountId } = this.props; + const { selectedStatusIds, comment, category, selectedRuleIds, forward } = this.state; + + this.setState({ isSubmitting: true }); + + dispatch(submitReport({ + account_id: accountId, + status_ids: selectedStatusIds.toArray(), + comment, + forward, + category, + rule_ids: selectedRuleIds.toArray(), + }, this.handleSuccess, this.handleFail)); + }; + + handleSuccess = () => { + this.setState({ isSubmitting: false, isSubmitted: true, step: 'thanks' }); + }; + + handleFail = () => { + this.setState({ isSubmitting: false }); + }; + + handleStatusToggle = (statusId, checked) => { + const { selectedStatusIds } = this.state; + + if (checked) { + this.setState({ selectedStatusIds: selectedStatusIds.add(statusId) }); + } else { + this.setState({ selectedStatusIds: selectedStatusIds.remove(statusId) }); + } + }; - handleKeyDown = e => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); + handleRuleToggle = (ruleId, checked) => { + const { selectedRuleIds } = this.state; + + if (checked) { + this.setState({ selectedRuleIds: selectedRuleIds.add(ruleId) }); + } else { + this.setState({ selectedRuleIds: selectedRuleIds.remove(ruleId) }); } } + handleChangeCategory = category => { + this.setState({ category }); + }; + + handleChangeComment = comment => { + this.setState({ comment }); + }; + + handleChangeForward = forward => { + this.setState({ forward }); + }; + + handleNextStep = step => { + this.setState({ step }); + }; + componentDidMount () { - this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true })); - } + const { dispatch, accountId } = this.props; - componentWillReceiveProps (nextProps) { - if (this.props.account !== nextProps.account && nextProps.account) { - this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true })); - } + dispatch(expandAccountTimeline(accountId, { withReplies: true })); + dispatch(fetchRules()); } render () { - const { account, comment, intl, statusIds, isSubmitting, forward, onClose } = this.props; + const { + accountId, + account, + intl, + onClose, + } = this.props; if (!account) { return null; } - const domain = account.get('acct').split('@')[1]; + const { + step, + selectedStatusIds, + selectedRuleIds, + comment, + forward, + category, + isSubmitting, + isSubmitted, + } = this.state; + + const domain = account.get('acct').split('@')[1]; + const isRemote = !!domain; + + let stepComponent; + + switch(step) { + case 'category': + stepComponent = ( + <Category + onNextStep={this.handleNextStep} + startedFrom={this.props.statusId ? 'status' : 'account'} + category={category} + onChangeCategory={this.handleChangeCategory} + /> + ); + break; + case 'rules': + stepComponent = ( + <Rules + onNextStep={this.handleNextStep} + selectedRuleIds={selectedRuleIds} + onToggle={this.handleRuleToggle} + /> + ); + break; + case 'statuses': + stepComponent = ( + <Statuses + onNextStep={this.handleNextStep} + accountId={accountId} + selectedStatusIds={selectedStatusIds} + onToggle={this.handleStatusToggle} + /> + ); + break; + case 'comment': + stepComponent = ( + <Comment + onSubmit={this.handleSubmit} + isSubmitting={isSubmitting} + isRemote={isRemote} + comment={comment} + forward={forward} + domain={domain} + onChangeComment={this.handleChangeComment} + onChangeForward={this.handleChangeForward} + /> + ); + break; + case 'thanks': + stepComponent = ( + <Thanks + submitted={isSubmitted} + account={account} + onClose={onClose} + /> + ); + } return ( - <div className='modal-root__modal report-modal'> + <div className='modal-root__modal report-dialog-modal'> <div className='report-modal__target'> <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} /> </div> - <div className='report-modal__container'> - <div className='report-modal__comment'> - <p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:' /></p> - - <textarea - className='setting-text light' - placeholder={intl.formatMessage(messages.placeholder)} - value={comment} - onChange={this.handleCommentChange} - onKeyDown={this.handleKeyDown} - disabled={isSubmitting} - autoFocus - /> - - {domain && ( - <div> - <p><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p> - - <div className='setting-toggle'> - <Toggle id='report-forward' checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} /> - <label htmlFor='report-forward' className='setting-toggle__label'><FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /></label> - </div> - </div> - )} - - <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /> - </div> - - <div className='report-modal__statuses'> - <div> - {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} - </div> - </div> + <div className='report-dialog-modal__container'> + {stepComponent} </div> </div> ); diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index af2ef595e..0219d8a5e 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -17,7 +17,7 @@ import status_lists from './status_lists'; import mutes from './mutes'; import blocks from './blocks'; import boosts from './boosts'; -import reports from './reports'; +import rules from './rules'; import contexts from './contexts'; import compose from './compose'; import search from './search'; @@ -61,7 +61,7 @@ const reducers = { mutes, blocks, boosts, - reports, + rules, contexts, compose, search, diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js deleted file mode 100644 index 21ae6f93f..000000000 --- a/app/javascript/mastodon/reducers/reports.js +++ /dev/null @@ -1,64 +0,0 @@ -import { - REPORT_INIT, - REPORT_SUBMIT_REQUEST, - REPORT_SUBMIT_SUCCESS, - REPORT_SUBMIT_FAIL, - REPORT_CANCEL, - REPORT_STATUS_TOGGLE, - REPORT_COMMENT_CHANGE, - REPORT_FORWARD_CHANGE, -} from '../actions/reports'; -import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; - -const initialState = ImmutableMap({ - new: ImmutableMap({ - isSubmitting: false, - account_id: null, - status_ids: ImmutableSet(), - comment: '', - forward: false, - }), -}); - -export default function reports(state = initialState, action) { - switch(action.type) { - case REPORT_INIT: - return state.withMutations(map => { - map.setIn(['new', 'isSubmitting'], false); - map.setIn(['new', 'account_id'], action.account.get('id')); - - if (state.getIn(['new', 'account_id']) !== action.account.get('id')) { - map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet()); - map.setIn(['new', 'comment'], ''); - } else if (action.status) { - map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id')))); - } - }); - case REPORT_STATUS_TOGGLE: - return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => { - if (action.checked) { - return set.add(action.statusId); - } - - return set.remove(action.statusId); - }); - case REPORT_COMMENT_CHANGE: - return state.setIn(['new', 'comment'], action.comment); - case REPORT_FORWARD_CHANGE: - return state.setIn(['new', 'forward'], action.forward); - case REPORT_SUBMIT_REQUEST: - return state.setIn(['new', 'isSubmitting'], true); - case REPORT_SUBMIT_FAIL: - return state.setIn(['new', 'isSubmitting'], false); - case REPORT_CANCEL: - case REPORT_SUBMIT_SUCCESS: - return state.withMutations(map => { - map.setIn(['new', 'account_id'], null); - map.setIn(['new', 'status_ids'], ImmutableSet()); - map.setIn(['new', 'comment'], ''); - map.setIn(['new', 'isSubmitting'], false); - }); - default: - return state; - } -}; diff --git a/app/javascript/mastodon/reducers/rules.js b/app/javascript/mastodon/reducers/rules.js new file mode 100644 index 000000000..c1180b520 --- /dev/null +++ b/app/javascript/mastodon/reducers/rules.js @@ -0,0 +1,13 @@ +import { RULES_FETCH_SUCCESS } from 'mastodon/actions/rules'; +import { List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableList(); + +export default function rules(state = initialState, action) { + switch (action.type) { + case RULES_FETCH_SUCCESS: + return fromJS(action.rules); + default: + return state; + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5304bec34..108bf68a5 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -50,16 +50,14 @@ cursor: pointer; display: inline-block; font-family: inherit; - font-size: 14px; + font-size: 17px; font-weight: 500; - height: 36px; letter-spacing: 0; - line-height: 36px; + line-height: 22px; overflow: hidden; - padding: 0 16px; + padding: 7px 18px; position: relative; text-align: center; - text-transform: uppercase; text-decoration: none; text-overflow: ellipsis; transition: all 100ms ease-in; @@ -100,17 +98,6 @@ outline: 0 !important; } - &.button-primary, - &.button-alternative, - &.button-secondary, - &.button-alternative-2 { - font-size: 16px; - line-height: 36px; - height: auto; - text-transform: none; - padding: 4px 16px; - } - &.button-alternative { color: $inverted-text-color; background: $ui-primary-color; @@ -135,7 +122,7 @@ &.button-secondary { color: $darker-text-color; background: transparent; - padding: 3px 15px; + padding: 6px 17px; border: 1px solid $ui-primary-color; &:active, @@ -1114,42 +1101,39 @@ font-size: 15px; } -.status-check-box { - border-bottom: 1px solid $ui-secondary-color; - display: flex; +.status-check-box__status { + display: block; + box-sizing: border-box; + width: 100%; + padding: 0 10px; - .status-check-box__status { - margin: 10px 0 10px 10px; - flex: 1; - overflow: hidden; + .detailed-status__display-name { + color: lighten($inverted-text-color, 16%); - .media-gallery { - max-width: 250px; + span { + display: inline; } - .status__content { - padding: 0; - white-space: normal; + &:hover strong { + text-decoration: none; } + } - .video-player, - .audio-player { - margin-top: 8px; - max-width: 250px; - } + .media-gallery, + .audio-player, + .video-player { + margin-top: 8px; + max-width: 250px; + } - .media-gallery__item-thumbnail { - cursor: default; - } + .status__content { + padding: 0; + white-space: normal; } -} -.status-check-box-toggle { - align-items: center; - display: flex; - flex: 0 0 auto; - justify-content: center; - padding: 10px; + .media-gallery__item-thumbnail { + cursor: default; + } } .status__prepend { @@ -5103,6 +5087,192 @@ a.status-card.compact:hover { max-width: 700px; } +.report-dialog-modal { + max-width: 90vw; + width: 480px; + height: 80vh; + background: lighten($ui-secondary-color, 8%); + color: $inverted-text-color; + border-radius: 8px; + overflow: hidden; + position: relative; + flex-direction: column; + display: flex; + + &__container { + box-sizing: border-box; + border-top: 1px solid $ui-secondary-color; + padding: 20px; + flex-grow: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + } + + &__title { + font-size: 28px; + line-height: 33px; + font-weight: 700; + margin-bottom: 15px; + + @media screen and (max-height: 800px) { + font-size: 22px; + } + } + + &__subtitle { + font-size: 17px; + font-weight: 600; + line-height: 22px; + margin-bottom: 4px; + } + + &__lead { + font-size: 17px; + line-height: 22px; + color: lighten($inverted-text-color, 16%); + margin-bottom: 30px; + } + + &__actions { + margin-top: 30px; + display: flex; + + .button { + flex: 1 1 auto; + } + } + + &__statuses { + flex-grow: 1; + min-height: 0; + overflow: auto; + } + + .status__content a { + color: $highlight-text-color; + } + + .status__content, + .status__content p { + color: $inverted-text-color; + } + + .dialog-option .poll__input { + border-color: $inverted-text-color; + color: $ui-secondary-color; + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + width: 8px; + height: auto; + } + + &:active, + &:focus, + &:hover { + border-color: lighten($inverted-text-color, 15%); + border-width: 4px; + } + + &.active { + border-color: $inverted-text-color; + background: $inverted-text-color; + } + } + + .poll__option.dialog-option { + padding: 15px 0; + flex: 0 0 auto; + border-bottom: 1px solid $ui-secondary-color; + + &:last-child { + border-bottom: 0; + } + + & > .poll__option__text { + font-size: 13px; + color: lighten($inverted-text-color, 16%); + + strong { + font-size: 17px; + font-weight: 500; + line-height: 22px; + color: $inverted-text-color; + display: block; + margin-bottom: 4px; + + &:last-child { + margin-bottom: 0; + } + } + } + } + + .flex-spacer { + background: transparent; + } + + &__textarea { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + color: $inverted-text-color; + background: $simple-background-color; + padding: 10px; + font-family: inherit; + font-size: 17px; + line-height: 22px; + resize: vertical; + border: 0; + outline: 0; + border-radius: 4px; + margin: 20px 0; + + &::placeholder { + color: $dark-text-color; + } + + &:focus { + outline: 0; + } + } + + &__toggle { + display: flex; + align-items: center; + + & > span { + font-size: 17px; + font-weight: 500; + margin-left: 10px; + } + } + + .button.button-secondary { + border-color: $inverted-text-color; + color: $inverted-text-color; + flex: 0 0 auto; + + &:hover, + &:focus, + &:active { + border-color: lighten($inverted-text-color, 15%); + color: lighten($inverted-text-color, 15%); + } + } + + hr { + border: 0; + background: transparent; + margin: 15px 0; + } +} + .report-modal__container { display: flex; border-top: 1px solid $ui-secondary-color; |