diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4a97f0251e75a47cfafd446a53b740ba05a6c8d9..3e66ff212eba9b9127f38ab25453a4b2ae900b77 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -42,7 +42,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications)) + MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), duration: (params[:duration] || 0)) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 805d0dee2abcb5211ca09c940d78aaa63291635a..fd52511d7eb0497d018551d61233a7a5b1d33666 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -7,7 +7,7 @@ class Api::V1::MutesController < Api::BaseController def index @accounts = load_accounts - render json: @accounts, each_serializer: REST::AccountSerializer + render json: @accounts, each_serializer: REST::MutedAccountSerializer end private diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 723c04e554d96a3772c9080a0683dadadf3c0cf3..58b636602609718a41453f3ec0a310ba20b280c4 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -257,11 +257,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id, notifications) { +export function muteAccount(id, notifications, duration=0) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index 9f645faee17bbf50579e26c7b001f057e0e68543..d8874f353f42c43cd9a88aa9aecd3089977eb4d8 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; export function fetchMutes() { return (dispatch, getState) => { @@ -104,3 +105,12 @@ export function toggleHideNotifications() { dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); }; } + +export function changeMuteDuration(duration) { + return dispatch => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; +} diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 2705a6001341bff0ad06bbfd060fe0c42f5248ca..0e40ee1d6a5543d6e28f1fdeae28fb0e898a1978 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -8,6 +8,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from '../initial_state'; +import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -107,11 +108,17 @@ class Account extends ImmutablePureComponent { } } + let mute_expires_at; + if (account.get('mute_expires_at')) { + mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>; + } + return ( <div className='account'> <div className='account__wrapper'> <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + {mute_expires_at} <DisplayName account={account} /> </Permalink> diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js index 852830c3c2c93fd7c87de88e029db557bda9b3e1..51228b532e2bf3349b831e2f0f6c75fdacd6a828 100644 --- a/app/javascript/mastodon/features/ui/components/mute_modal.js +++ b/app/javascript/mastodon/features/ui/components/mute_modal.js @@ -1,25 +1,31 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; import Button from '../../../components/button'; import { closeModal } from '../../../actions/modal'; import { muteAccount } from '../../../actions/accounts'; -import { toggleHideNotifications } from '../../../actions/mutes'; +import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes'; +const messages = defineMessages({ + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, +}); const mapStateToProps = state => { return { account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), + muteDuration: state.getIn(['mutes', 'new', 'duration']), }; }; const mapDispatchToProps = dispatch => { return { - onConfirm(account, notifications) { - dispatch(muteAccount(account.get('id'), notifications)); + onConfirm(account, notifications, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, muteDuration)); }, onClose() { @@ -29,6 +35,10 @@ const mapDispatchToProps = dispatch => { onToggleNotifications() { dispatch(toggleHideNotifications()); }, + + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, }; }; @@ -43,6 +53,8 @@ class MuteModal extends React.PureComponent { onConfirm: PropTypes.func.isRequired, onToggleNotifications: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + muteDuration: PropTypes.number.isRequired, + onChangeMuteDuration: PropTypes.func.isRequired, }; componentDidMount() { @@ -51,7 +63,7 @@ class MuteModal extends React.PureComponent { handleClick = () => { this.props.onClose(); - this.props.onConfirm(this.props.account, this.props.notifications); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); } handleCancel = () => { @@ -66,8 +78,12 @@ class MuteModal extends React.PureComponent { this.props.onToggleNotifications(); } + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + } + render () { - const { account, notifications } = this.props; + const { account, notifications, muteDuration, intl } = this.props; return ( <div className='modal-root__modal mute-modal'> @@ -91,6 +107,21 @@ class MuteModal extends React.PureComponent { <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> </label> </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage({ id: 'mute_modal.indefinite' })}</option> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> </div> <div className='mute-modal__action-bar'> diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 9c8b3d11ba6ac456f0e16a86733092f043ba7774..b53731340872111d06e5d5311c4e09507406b6a1 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -276,6 +276,8 @@ "missing_indicator.label": "Not found", "missing_indicator.sublabel": "This resource could not be found", "mute_modal.hide_notifications": "Hide notifications from this user?", + "mute_modal.duration": "Duration", + "mute_modal.indefinite": "Indefinite", "navigation_bar.apps": "Mobile apps", "navigation_bar.blocks": "Blocked users", "navigation_bar.bookmarks": "Bookmarks", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index ec3d0ee59a559336ffe162a326a24354afa04994..2a1df987c18e910062418cf8b8cee3f2bec36b15 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -268,6 +268,8 @@ "missing_indicator.label": "見ã¤ã‹ã‚Šã¾ã›ã‚“", "missing_indicator.sublabel": "見ã¤ã‹ã‚Šã¾ã›ã‚“ã§ã—ãŸ", "mute_modal.hide_notifications": "ã“ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã‹ã‚‰ã®é€šçŸ¥ã‚’éš ã—ã¾ã™ã‹ï¼Ÿ", + "mute_modal.duration": "ミュートã™ã‚‹æœŸé–“", + "mute_modal.indefinite": "無期é™", "navigation_bar.apps": "アプリ", "navigation_bar.blocks": "ブãƒãƒƒã‚¯ã—ãŸãƒ¦ãƒ¼ã‚¶ãƒ¼", "navigation_bar.bookmarks": "ブックマーク", diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js index 4672e50974eaa2a1394b5df9a6c51bc15548e0fa..a9eb61ff834cbcff296c6ad0d1ec1d541a0ba3cf 100644 --- a/app/javascript/mastodon/reducers/mutes.js +++ b/app/javascript/mastodon/reducers/mutes.js @@ -3,12 +3,14 @@ import Immutable from 'immutable'; import { MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, } from '../actions/mutes'; const initialState = Immutable.Map({ new: Immutable.Map({ account: null, notifications: true, + duration: 0, }), }); @@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) { }); case MUTES_TOGGLE_HIDE_NOTIFICATIONS: return state.updateIn(['new', 'notifications'], (old) => !old); + case MUTES_CHANGE_DURATION: + return state.setIn(['new', 'duration'], Number(action.duration)); default: return state; } diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 6b81e762370b43bb9e4c751fac10f1a4682fea7d..64f4adb848c3b2fcf148b0dc8cd8733b022ee27f 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -759,3 +759,8 @@ html { .compose-form .compose-form__warning { box-shadow: none; } + +.mute-modal select { + border: 1px solid lighten($ui-base-color, 8%); + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px; +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 8a8f20baa540c35ce0b3eb2bfdc1200a9726b9db..378d1640fdb183497924574e862b8f8acb5207bd 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5074,6 +5074,22 @@ a.status-card.compact:hover { } } } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-right: 30px; + } } .confirmation-modal__container, diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 427ebdae29c36bb8a84baed701db6897a9383c37..6a0ad5aa9825521ffa37162d11b0d2ea97de17f7 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -131,9 +131,12 @@ module AccountInteractions .find_or_create_by!(target_account: other_account) end - def mute!(other_account, notifications: nil) + def mute!(other_account, notifications: nil, duration: 0) notifications = true if notifications.nil? - mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account) + mute = mute_relationships.create_with(hide_notifications: notifications).find_or_initialize_by(target_account: other_account) + mute.expires_in = duration.zero? ? nil : duration + mute.save! + remove_potential_friendship(other_account) # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. diff --git a/app/models/mute.rb b/app/models/mute.rb index 0e00c2278f6f6be1a83e620d41730e0f7c6488aa..578345ef644ad4459a90fde1be38c540d7e5cb82 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -9,11 +9,13 @@ # account_id :bigint(8) not null # target_account_id :bigint(8) not null # hide_notifications :boolean default(TRUE), not null +# expires_at :datetime # class Mute < ApplicationRecord include Paginable include RelationshipCacheable + include Expireable belongs_to :account belongs_to :target_account, class_name: 'Account' diff --git a/app/serializers/rest/muted_account_serializer.rb b/app/serializers/rest/muted_account_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..3ddd706dcd327b0f28c81f878e052745c442ecb8 --- /dev/null +++ b/app/serializers/rest/muted_account_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class REST::MutedAccountSerializer < REST::AccountSerializer + attribute :mute_expires_at + + def mute_expires_at + mute = current_user.account.mute_relationships.find_by(target_account_id: object.id) + mute && !mute.expired? ? mute.expires_at : nil + end +end diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 676804cb991d97fa718f9b5554c47ea339daf4da..9ae9afd623498d3a6b0baaa7d6cb7d91a88fea6f 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account, notifications: nil) + def call(account, target_account, notifications: nil, duration: 0) return if account.id == target_account.id - mute = account.mute!(target_account, notifications: notifications) + mute = account.mute!(target_account, notifications: notifications, duration: duration) if mute.hide_notifications? BlockWorker.perform_async(account.id, target_account.id) @@ -12,6 +12,8 @@ class MuteService < BaseService MuteWorker.perform_async(account.id, target_account.id) end + DeleteMuteWorker.perform_at(duration.seconds, mute.id) if duration != 0 + mute end end diff --git a/app/workers/delete_mute_worker.rb b/app/workers/delete_mute_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..eb031020e1b2cfea2cad1b1dcd0d966161a25b4c --- /dev/null +++ b/app/workers/delete_mute_worker.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class DeleteMuteWorker + include Sidekiq::Worker + + def perform(mute_id) + mute = Mute.find_by(id: mute_id) + UnmuteService.new.call(mute.account, mute.target_account) if mute&.expired? + end +end diff --git a/db/migrate/20200317021758_add_expires_at_to_mutes.rb b/db/migrate/20200317021758_add_expires_at_to_mutes.rb new file mode 100644 index 0000000000000000000000000000000000000000..eaae8319d7c11080081c20c5bc5bdd399186b6b0 --- /dev/null +++ b/db/migrate/20200317021758_add_expires_at_to_mutes.rb @@ -0,0 +1,5 @@ +class AddExpiresAtToMutes < ActiveRecord::Migration[5.2] + def change + add_column :mutes, :expires_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 5805f31050ff228b15148b5f79b0def8abac1acc..262e25b3bab1a5593a30127876c87ca14994d45c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -545,6 +545,7 @@ ActiveRecord::Schema.define(version: 2020_10_08_220312) do t.boolean "hide_notifications", default: true, null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.datetime "expires_at" t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true t.index ["target_account_id"], name: "index_mutes_on_target_account_id" end