diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 4fa2c1158c950cb5548469b9bcc2ee91f368bf83..bd3bb16bbc3665c21ab00c0c86c08ea089cba047 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -1,5 +1,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import IconButton from './icon_button'; import DropdownMenuContainer from '../containers/dropdown_menu_container'; @@ -24,6 +25,7 @@ const messages = defineMessages({ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, @@ -34,6 +36,10 @@ const messages = defineMessages({ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, + blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, }); const obfuscatedCount = count => { @@ -46,7 +52,12 @@ const obfuscatedCount = count => { } }; -export default @injectIntl +const mapStateToProps = (state, { status }) => ({ + relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), +}); + +export default @connect(mapStateToProps) +@injectIntl class StatusActionBar extends ImmutablePureComponent { static contextTypes = { @@ -55,6 +66,7 @@ class StatusActionBar extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + relationship: ImmutablePropTypes.map, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, @@ -62,7 +74,11 @@ class StatusActionBar extends ImmutablePureComponent { onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, + onUnmute: PropTypes.func, onBlock: PropTypes.func, + onUnblock: PropTypes.func, + onBlockDomain: PropTypes.func, + onUnblockDomain: PropTypes.func, onReport: PropTypes.func, onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, @@ -76,6 +92,7 @@ class StatusActionBar extends ImmutablePureComponent { // evaluate to false. See react-immutable-pure-component for usage. updateOnProps = [ 'status', + 'relationship', 'withDismiss', ] @@ -141,11 +158,39 @@ class StatusActionBar extends ImmutablePureComponent { } handleMuteClick = () => { - this.props.onMute(this.props.status.get('account')); + const { status, relationship, onMute, onUnmute } = this.props; + const account = status.get('account'); + + if (relationship && relationship.get('muting')) { + onUnmute(account); + } else { + onMute(account); + } } handleBlockClick = () => { - this.props.onBlock(this.props.status); + const { status, relationship, onBlock, onUnblock } = this.props; + const account = status.get('account'); + + if (relationship && relationship.get('blocking')) { + onBlock(status); + } else { + onUnblock(account); + } + } + + handleBlockDomain = () => { + const { status, onBlockDomain } = this.props; + const account = status.get('account'); + + onBlockDomain(account.get('acct').split('@')[1]); + } + + handleUnblockDomain = () => { + const { status, onUnblockDomain } = this.props; + const account = status.get('account'); + + onUnblockDomain(account.get('acct').split('@')[1]); } handleOpen = () => { @@ -184,11 +229,12 @@ class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, intl, withDismiss } = this.props; + const { status, relationship, intl, withDismiss } = this.props; const mutingConversation = status.get('muted'); const anonymousAccess = !me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const account = status.get('account'); let menu = []; let reblogIcon = 'retweet'; @@ -202,6 +248,7 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); } + menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); menu.push(null); if (status.getIn(['account', 'id']) === me || withDismiss) { @@ -221,16 +268,39 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { - menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); - menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); menu.push(null); - menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); - menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); - menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + + if (relationship && relationship.get('muting')) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick }); + } + + if (relationship && relationship.get('blocking')) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport }); + + if (account.get('acct') !== account.get('username')) { + const domain = account.get('acct').split('@')[1]; + + menu.push(null); + + if (relationship && relationship.get('domain_blocking')) { + menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain }); + } else { + menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain }); + } + } if (isStaff) { menu.push(null); - menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); } } @@ -259,7 +329,6 @@ class StatusActionBar extends ImmutablePureComponent { <IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> {shareButton} - <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /> <div className='status__action-bar-dropdown'> <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} /> diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index f79b192029e5de0d666c1682a81ce8d604e8f35d..1427f85289a5a1811da70f0a3271386d07842719 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -1,4 +1,5 @@ import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu'; +import { fetchRelationships } from 'mastodon/actions/accounts'; import { openModal, closeModal } from '../actions/modal'; import { connect } from 'react-redux'; import DropdownMenu from '../components/dropdown_menu'; @@ -13,12 +14,15 @@ const mapStateToProps = state => ({ const mapDispatchToProps = (dispatch, { status, items }) => ({ onOpen(id, onItemClick, dropdownPlacement, keyboard) { + dispatch(fetchRelationships([status.getIn(['account', 'id'])])); + dispatch(isUserTouching() ? openModal('ACTIONS', { status, actions: items, onClick: onItemClick, }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, + onClose(id) { dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 16ba02e120fe1def4fcceba355306f3e163c9256..35c16a20ca68f24da978f1663f13acedf0981386 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,3 +1,4 @@ +import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; import { makeGetStatus } from '../selectors'; @@ -23,11 +24,19 @@ import { hideStatus, revealStatus, } from '../actions/statuses'; +import { + unmuteAccount, + unblockAccount, +} from '../actions/accounts'; +import { + blockDomain, + unblockDomain, +} from '../actions/domain_blocks'; import { initMuteModal } from '../actions/mutes'; import { initBlockModal } from '../actions/blocks'; import { initReport } from '../actions/reports'; import { openModal } from '../actions/modal'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { boostModal, deleteModal } from '../initial_state'; import { showAlertForError } from '../actions/alerts'; @@ -38,6 +47,7 @@ const messages = defineMessages({ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); const makeMapStateToProps = () => { @@ -148,6 +158,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(initBlockModal(account)); }, + onUnblock (account) { + dispatch(unblockAccount(account.get('id'))); + }, + onReport (status) { dispatch(initReport(status.get('account'), status)); }, @@ -156,6 +170,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(initMuteModal(account)); }, + onUnmute (account) { + dispatch(unmuteAccount(account.get('id'))); + }, + onMuteConversation (status) { if (status.get('muted')) { dispatch(unmuteStatus(status.get('id'))); @@ -172,6 +190,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onBlockDomain (domain) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain)), + })); + }, + + onUnblockDomain (domain) { + dispatch(unblockDomain(domain)); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 1b81cd245d0f7dbe3900db62db6d5f58815e8065..76334de69b35d364fc61fa18f6b814134a5c3914 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import IconButton from '../../../components/icon_button'; import ImmutablePropTypes from 'react-immutable-proptypes'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; @@ -30,9 +31,18 @@ const messages = defineMessages({ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, + blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, }); -export default @injectIntl +const mapStateToProps = (state, { status }) => ({ + relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), +}); + +export default @connect(mapStateToProps) +@injectIntl class ActionBar extends React.PureComponent { static contextTypes = { @@ -41,6 +51,7 @@ class ActionBar extends React.PureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + relationship: ImmutablePropTypes.map, onReply: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired, @@ -49,8 +60,12 @@ class ActionBar extends React.PureComponent { onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onMute: PropTypes.func, - onMuteConversation: PropTypes.func, + onUnmute: PropTypes.func, onBlock: PropTypes.func, + onUnblock: PropTypes.func, + onBlockDomain: PropTypes.func, + onUnblockDomain: PropTypes.func, + onMuteConversation: PropTypes.func, onReport: PropTypes.func, onPin: PropTypes.func, onEmbed: PropTypes.func, @@ -90,15 +105,43 @@ class ActionBar extends React.PureComponent { } handleMuteClick = () => { - this.props.onMute(this.props.status.get('account')); - } + const { status, relationship, onMute, onUnmute } = this.props; + const account = status.get('account'); - handleConversationMuteClick = () => { - this.props.onMuteConversation(this.props.status); + if (relationship && relationship.get('muting')) { + onUnmute(account); + } else { + onMute(account); + } } handleBlockClick = () => { - this.props.onBlock(this.props.status); + const { status, relationship, onBlock, onUnblock } = this.props; + const account = status.get('account'); + + if (relationship && relationship.get('blocking')) { + onBlock(status); + } else { + onUnblock(account); + } + } + + handleBlockDomain = () => { + const { status, onBlockDomain } = this.props; + const account = status.get('account'); + + onBlockDomain(account.get('acct').split('@')[1]); + } + + handleUnblockDomain = () => { + const { status, onUnblockDomain } = this.props; + const account = status.get('account'); + + onUnblockDomain(account.get('acct').split('@')[1]); + } + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); } handleReport = () => { @@ -140,10 +183,11 @@ class ActionBar extends React.PureComponent { } render () { - const { status, intl } = this.props; + const { status, relationship, intl } = this.props; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const mutingConversation = status.get('muted'); + const account = status.get('account'); let menu = []; @@ -171,9 +215,33 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); menu.push(null); - menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); - menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); + + if (relationship && relationship.get('muting')) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick }); + } + + if (relationship && relationship.get('blocking')) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick }); + } + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + + if (account.get('acct') !== account.get('username')) { + const domain = account.get('acct').split('@')[1]; + + menu.push(null); + + if (relationship && relationship.get('domain_blocking')) { + menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain }); + } else { + menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain }); + } + } + if (isStaff) { menu.push(null); menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); @@ -207,7 +275,7 @@ class ActionBar extends React.PureComponent { <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div> <div className='detailed-status__action-bar-dropdown'> - <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title='More' /> + <DropdownMenuContainer size={18} icon='ellipsis-h' status={status} items={menu} direction='left' title='More' /> </div> </div> ); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 9fb3fe305c32fe8a0e3a9bc3c02e7517899a4205..55bd99886579daef1f7b17f54b1e2ed3911bebc2 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -32,6 +32,14 @@ import { hideStatus, revealStatus, } from '../../actions/statuses'; +import { + unblockAccount, + unmuteAccount, +} from '../../actions/accounts'; +import { + blockDomain, + unblockDomain, +} from '../../actions/domain_blocks'; import { initMuteModal } from '../../actions/mutes'; import { initBlockModal } from '../../actions/blocks'; import { initReport } from '../../actions/reports'; @@ -41,7 +49,7 @@ import ColumnBackButton from '../../components/column_back_button'; import ColumnHeader from '../../components/column_header'; import StatusContainer from '../../containers/status_container'; import { openModal } from '../../actions/modal'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { HotKeys } from 'react-hotkeys'; import { boostModal, deleteModal } from '../../initial_state'; @@ -59,6 +67,7 @@ const messages = defineMessages({ detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); const makeMapStateToProps = () => { @@ -317,6 +326,27 @@ class Status extends ImmutablePureComponent { this.props.dispatch(openModal('EMBED', { url: status.get('url') })); } + handleUnmuteClick = account => { + this.props.dispatch(unmuteAccount(account.get('id'))); + } + + handleUnblockClick = account => { + this.props.dispatch(unblockAccount(account.get('id'))); + } + + handleBlockDomainClick = domain => { + this.props.dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />, + confirm: this.props.intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => this.props.dispatch(blockDomain(domain)), + })); + } + + handleUnblockDomainClick = domain => { + this.props.dispatch(unblockDomain(domain)); + } + + handleHotkeyMoveUp = () => { this.handleMoveUp(this.props.status.get('id')); } @@ -514,8 +544,12 @@ class Status extends ImmutablePureComponent { onDirect={this.handleDirectClick} onMention={this.handleMentionClick} onMute={this.handleMuteClick} + onUnmute={this.handleUnmuteClick} onMuteConversation={this.handleConversationMuteClick} onBlock={this.handleBlockClick} + onUnblock={this.handleUnblockClick} + onBlockDomain={this.handleBlockDomainClick} + onUnblockDomain={this.handleUnblockDomainClick} onReport={this.handleReport} onPin={this.handlePin} onEmbed={this.handleEmbed}