diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index 47c0d9f851a..8af0b15d856 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; +export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST'; export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS'; export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL'; @@ -328,6 +336,76 @@ export function unblockAccountFail(error) { }; }; + +export function muteAccount(id) { + return (dispatch, getState) => { + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/mute`).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 => { + dispatch(muteAccountFail(id, error)); + }); + }; +}; + +export function unmuteAccount(id) { + return (dispatch, getState) => { + dispatch(unmuteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { + dispatch(unmuteAccountSuccess(response.data)); + }).catch(error => { + dispatch(unmuteAccountFail(id, error)); + }); + }; +}; + +export function muteAccountRequest(id) { + return { + type: ACCOUNT_MUTE_REQUEST, + id + }; +}; + +export function muteAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses + }; +}; + +export function muteAccountFail(error) { + return { + type: ACCOUNT_MUTE_FAIL, + error + }; +}; + +export function unmuteAccountRequest(id) { + return { + type: ACCOUNT_UNMUTE_REQUEST, + id + }; +}; + +export function unmuteAccountSuccess(relationship) { + return { + type: ACCOUNT_UNMUTE_SUCCESS, + relationship + }; +}; + +export function unmuteAccountFail(error) { + return { + type: ACCOUNT_UNMUTE_FAIL, + error + }; +}; + + export function fetchFollowers(id) { return (dispatch, getState) => { dispatch(fetchFollowersRequest(id)); diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx index 889c0ac4c04..3c30be7152e 100644 --- a/app/assets/javascripts/components/containers/account_container.jsx +++ b/app/assets/javascripts/components/containers/account_container.jsx @@ -5,7 +5,9 @@ import { followAccount, unfollowAccount, blockAccount, - unblockAccount + unblockAccount, + muteAccount, + unmuteAccount, } from '../actions/accounts'; const makeMapStateToProps = () => { @@ -34,6 +36,14 @@ const mapDispatchToProps = (dispatch) => ({ } else { dispatch(blockAccount(account.get('id'))); } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(muteAccount(account.get('id'))); + } } }); diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index 81265bc50f6..e7543bc3979 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -11,7 +11,10 @@ import { unreblog, unfavourite } from '../actions/interactions'; -import { blockAccount } from '../actions/accounts'; +import { + blockAccount, + muteAccount +} from '../actions/accounts'; import { deleteStatus } from '../actions/statuses'; import { initReport } from '../actions/reports'; import { openMedia } from '../actions/modal'; @@ -69,7 +72,11 @@ const mapDispatchToProps = (dispatch) => ({ onReport (status) { dispatch(initReport(status.get('account'), status)); - } + }, + + onMute (account) { + dispatch(muteAccount(account.get('id'))); + }, }); diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx index 93ca9711941..80a32d3e288 100644 --- a/app/assets/javascripts/components/features/account/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx @@ -9,7 +9,9 @@ const messages = defineMessages({ edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, report: { id: 'account.report', defaultMessage: 'Report @{name}' }, disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' } @@ -35,6 +37,7 @@ const ActionBar = React.createClass({ onBlock: React.PropTypes.func.isRequired, onMention: React.PropTypes.func.isRequired, onReport: React.PropTypes.func.isRequired, + onMute: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, @@ -51,15 +54,19 @@ const ActionBar = React.createClass({ if (account.get('id') === me) { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); - } else if (account.getIn(['relationship', 'blocking'])) { - menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); - } else if (account.getIn(['relationship', 'following'])) { - menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); } else { - menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); - } + if (account.getIn(['relationship', 'muting'])) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); + } + + if (account.getIn(['relationship', 'blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); + } - if (account.get('id') !== me) { menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); } diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx index 2dd3ca7b156..99a10562e73 100644 --- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx +++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx @@ -15,7 +15,8 @@ const Header = React.createClass({ onFollow: React.PropTypes.func.isRequired, onBlock: React.PropTypes.func.isRequired, onMention: React.PropTypes.func.isRequired, - onReport: React.PropTypes.func.isRequired + onReport: React.PropTypes.func.isRequired, + onMute: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], @@ -37,6 +38,10 @@ const Header = React.createClass({ this.context.router.push('/report'); }, + handleMute() { + this.props.onMute(this.props.account); + }, + render () { const { account, me } = this.props; @@ -58,6 +63,7 @@ const Header = React.createClass({ onBlock={this.handleBlock} onMention={this.handleMention} onReport={this.handleReport} + onMute={this.handleMute} /> ); diff --git a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx index e4ce905fe74..8472d25a5fd 100644 --- a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx +++ b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx @@ -5,7 +5,9 @@ import { followAccount, unfollowAccount, blockAccount, - unblockAccount + unblockAccount, + muteAccount, + unmuteAccount } from '../../../actions/accounts'; import { mentionCompose } from '../../../actions/compose'; import { initReport } from '../../../actions/reports'; @@ -44,6 +46,14 @@ const mapDispatchToProps = dispatch => ({ onReport (account) { dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(muteAccount(account.get('id'))); + } } }); diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx index 6419ff08a23..3a454a5fbd0 100644 --- a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx @@ -36,7 +36,7 @@ const EmojiPickerDropdown = React.createClass({ return ( - + diff --git a/app/assets/javascripts/components/reducers/relationships.jsx b/app/assets/javascripts/components/reducers/relationships.jsx index e4af1f0282c..591f8034be9 100644 --- a/app/assets/javascripts/components/reducers/relationships.jsx +++ b/app/assets/javascripts/components/reducers/relationships.jsx @@ -3,6 +3,8 @@ import { ACCOUNT_UNFOLLOW_SUCCESS, ACCOUNT_BLOCK_SUCCESS, ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNMUTE_SUCCESS, RELATIONSHIPS_FETCH_SUCCESS } from '../actions/accounts'; import Immutable from 'immutable'; @@ -25,6 +27,8 @@ export default function relationships(state = initialState, action) { case ACCOUNT_UNFOLLOW_SUCCESS: case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_UNBLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + case ACCOUNT_UNMUTE_SUCCESS: return normalizeRelationship(state, action.relationship); case RELATIONSHIPS_FETCH_SUCCESS: return normalizeRelationships(state, action.relationships); diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 6472ac6a013..c67d054235d 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -22,7 +22,8 @@ import { ACCOUNT_TIMELINE_EXPAND_REQUEST, ACCOUNT_TIMELINE_EXPAND_SUCCESS, ACCOUNT_TIMELINE_EXPAND_FAIL, - ACCOUNT_BLOCK_SUCCESS + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS } from '../actions/accounts'; import { CONTEXT_FETCH_SUCCESS @@ -295,6 +296,7 @@ export default function timelines(state = initialState, action) { case ACCOUNT_TIMELINE_EXPAND_SUCCESS: return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: return filterTimelines(state, action.relationship, action.statuses); case TIMELINE_SCROLL_TOP: return updateTop(state, action.timeline, action.top); diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 94dba1d038d..d691ac98775 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class Api::V1::AccountsController < ApiController - before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock] - before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock] + before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock, :mute, :unmute] + before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute] before_action :require_user!, except: [:show, :following, :followers, :statuses] before_action :set_account, except: [:verify_credentials, :suggestions, :search] @@ -86,10 +86,17 @@ class Api::V1::AccountsController < ApiController @followed_by = { @account.id => false } @blocking = { @account.id => true } @requested = { @account.id => false } + @muting = { @account.id => current_user.account.muting?(@account.id) } render action: :relationship end + def mute + MuteService.new.call(current_user.account, @account) + set_relationship + render action: :relationship + end + def unfollow UnfollowService.new.call(current_user.account, @account) set_relationship @@ -102,6 +109,12 @@ class Api::V1::AccountsController < ApiController render action: :relationship end + def unmute + UnmuteService.new.call(current_user.account, @account) + set_relationship + render action: :relationship + end + def relationships ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i] @@ -109,6 +122,7 @@ class Api::V1::AccountsController < ApiController @following = Account.following_map(ids, current_user.account_id) @followed_by = Account.followed_by_map(ids, current_user.account_id) @blocking = Account.blocking_map(ids, current_user.account_id) + @muting = Account.muting_map(ids, current_user.account_id) @requested = Account.requested_map(ids, current_user.account_id) end @@ -130,6 +144,7 @@ class Api::V1::AccountsController < ApiController @following = Account.following_map([@account.id], current_user.account_id) @followed_by = Account.followed_by_map([@account.id], current_user.account_id) @blocking = Account.blocking_map([@account.id], current_user.account_id) + @muting = Account.muting_map([@account.id], current_user.account_id) @requested = Account.requested_map([@account.id], current_user.account_id) end end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb new file mode 100644 index 00000000000..42a9ed412cc --- /dev/null +++ b/app/controllers/api/v1/mutes_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::MutesController < ApiController + before_action -> { doorkeeper_authorize! :follow } + before_action :require_user! + + respond_to :json + + def index + results = Mute.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + accounts = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h + @accounts = results.map { |f| accounts[f.target_account_id] } + + set_account_counters_maps(@accounts) + + next_path = api_v1_mutes_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + prev_path = api_v1_mutes_url(since_id: results.first.id) unless results.empty? + + set_pagination_headers(next_path, prev_path) + end +end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 016ef02356e..3a26c5c056f 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -95,6 +95,8 @@ class FeedManager end def filter_from_home?(status, receiver) + return true if receiver.muting?(status.account) + should_filter = false if status.reply? && status.in_reply_to_id.nil? @@ -105,6 +107,7 @@ class FeedManager should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply elsif status.reblog? # Filter out a reblog should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person + should_filter ||= receiver.muting?(status.reblog.account) # or muting that person end should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked diff --git a/app/models/account.rb b/app/models/account.rb index a93a0668a55..2fa6bab7181 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -46,6 +46,10 @@ class Account < ApplicationRecord has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account + # Mute relationships + has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy + has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account + # Media has_many :media_attachments, dependent: :destroy @@ -73,6 +77,10 @@ class Account < ApplicationRecord block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) end + def mute!(other_account) + mute_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) + end + def unfollow!(other_account) follow = active_relationships.find_by(target_account: other_account) follow&.destroy @@ -83,6 +91,11 @@ class Account < ApplicationRecord block&.destroy end + def unmute!(other_account) + mute = mute_relationships.find_by(target_account: other_account) + mute&.destroy + end + def following?(other_account) following.include?(other_account) end @@ -91,6 +104,10 @@ class Account < ApplicationRecord blocking.include?(other_account) end + def muting?(other_account) + muting.include?(other_account) + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end @@ -188,6 +205,10 @@ class Account < ApplicationRecord follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end + def muting_map(target_account_ids, account_id) + follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + end + def requested_map(target_account_ids, account_id) follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end diff --git a/app/models/mute.rb b/app/models/mute.rb new file mode 100644 index 00000000000..a5b334c859e --- /dev/null +++ b/app/models/mute.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Mute < ApplicationRecord + include Paginable + + belongs_to :account + belongs_to :target_account, class_name: 'Account' + + validates :account, :target_account, presence: true + validates :account_id, uniqueness: { scope: :target_account_id } +end diff --git a/app/models/status.rb b/app/models/status.rb index 1b40897f36c..e5e740360aa 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -168,9 +168,9 @@ class Status < ApplicationRecord private def filter_timeline(query, account) - blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) - query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty? - query = query.where('accounts.silenced = TRUE') if account.silenced? + blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) + Mute.where(account: account).pluck(:target_account_id) + query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty? # Only give us statuses from people we haven't blocked, or muted, or that have blocked us + query = query.where('accounts.silenced = TRUE') if account.silenced? # and if we're hellbanned, only people who are also hellbanned query end @@ -192,6 +192,6 @@ class Status < ApplicationRecord private def filter_from_context?(status, account) - account&.blocking?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account) + account&.blocking?(status.account_id) || account&.muting?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account) end end diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb new file mode 100644 index 00000000000..0050cfc8d02 --- /dev/null +++ b/app/services/mute_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class MuteService < BaseService + def call(account, target_account) + return if account.id == target_account.id + clear_home_timeline(account, target_account) + account.mute!(target_account) + end + + private + + def clear_home_timeline(account, target_account) + home_key = FeedManager.instance.key(:home, account.id) + + target_account.statuses.select('id').find_each do |status| + redis.zrem(home_key, status.id) + end + end + + def redis + Redis.current + end +end diff --git a/app/services/unmute_service.rb b/app/services/unmute_service.rb new file mode 100644 index 00000000000..6aeea358f75 --- /dev/null +++ b/app/services/unmute_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class UnmuteService < BaseService + def call(account, target_account) + return unless account.muting?(target_account) + + account.unmute!(target_account) + + MergeWorker.perform_async(target_account.id, account.id) if account.following?(target_account) + end +end diff --git a/app/views/api/v1/accounts/relationship.rabl b/app/views/api/v1/accounts/relationship.rabl index 22b37586e59..d6f1dd48ab6 100644 --- a/app/views/api/v1/accounts/relationship.rabl +++ b/app/views/api/v1/accounts/relationship.rabl @@ -4,4 +4,5 @@ attribute :id node(:following) { |account| @following[account.id] || false } node(:followed_by) { |account| @followed_by[account.id] || false } node(:blocking) { |account| @blocking[account.id] || false } +node(:muting) { |account| @muting[account.id] || false } node(:requested) { |account| @requested[account.id] || false } diff --git a/app/views/api/v1/mutes/index.rabl b/app/views/api/v1/mutes/index.rabl new file mode 100644 index 00000000000..9f3b13a53d9 --- /dev/null +++ b/app/views/api/v1/mutes/index.rabl @@ -0,0 +1,2 @@ +collection @accounts +extends 'api/v1/accounts/show' diff --git a/config/routes.rb b/config/routes.rb index 870d8afd4eb..f6e2dce5cdc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -127,6 +127,7 @@ Rails.application.routes.draw do resources :media, only: [:create] resources :apps, only: [:create] resources :blocks, only: [:index] + resources :mutes, only: [:index] resources :favourites, only: [:index] resources :reports, only: [:index, :create] @@ -160,6 +161,8 @@ Rails.application.routes.draw do post :unfollow post :block post :unblock + post :mute + post :unmute end end end diff --git a/db/migrate/20170301222600_create_mutes.rb b/db/migrate/20170301222600_create_mutes.rb new file mode 100644 index 00000000000..8f1bb22f5b9 --- /dev/null +++ b/db/migrate/20170301222600_create_mutes.rb @@ -0,0 +1,12 @@ +class CreateMutes < ActiveRecord::Migration[5.0] + def change + create_table :mutes do |t| + t.integer :account_id, null: false + t.integer :target_account_id, null: false + t.timestamps null: false + end + + add_index :mutes, [:account_id, :target_account_id], unique: true + + end +end diff --git a/db/schema.rb b/db/schema.rb index fa5c40774c0..c2d88ac13b6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170217012631) do +ActiveRecord::Schema.define(version: 20170301222600) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -110,6 +110,14 @@ ActiveRecord::Schema.define(version: 20170217012631) do t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree end + create_table "mutes", force: :cascade do |t| + t.integer "account_id", null: false + t.integer "target_account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true, using: :btree + end + create_table "notifications", force: :cascade do |t| t.integer "account_id" t.integer "activity_id" diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 98b284f7a4e..5d36b01591d 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -116,6 +116,44 @@ RSpec.describe Api::V1::AccountsController, type: :controller do end end + describe 'POST #mute' do + let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + user.account.follow!(other_account) + post :mute, params: {id: other_account.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'does not remove the following relation between user and target user' do + expect(user.account.following?(other_account)).to be true + end + + it 'creates a muting relation' do + expect(user.account.muting?(other_account)).to be true + end + end + + describe 'POST #unmute' do + let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + user.account.mute!(other_account) + post :unmute, params: { id: other_account.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'removes the muting relation between user and target user' do + expect(user.account.muting?(other_account)).to be false + end + end + describe 'GET #relationships' do let(:simon) { Fabricate(:user, email: 'simon@example.com', account: Fabricate(:account, username: 'simon')).account } let(:lewis) { Fabricate(:user, email: 'lewis@example.com', account: Fabricate(:account, username: 'lewis')).account } diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb new file mode 100644 index 00000000000..be8a5e7dd2b --- /dev/null +++ b/spec/controllers/api/v1/mutes_controller_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe Api::V1::MutesController, type: :controller do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:token) { double acceptable?: true, resource_owner_id: user.id } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + it 'returns http success' do + get :index + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/fabricators/mute_fabricator.rb b/spec/fabricators/mute_fabricator.rb new file mode 100644 index 00000000000..fc150c1d6b3 --- /dev/null +++ b/spec/fabricators/mute_fabricator.rb @@ -0,0 +1,3 @@ +Fabricator(:mute) do + +end diff --git a/spec/models/mute_spec.rb b/spec/models/mute_spec.rb new file mode 100644 index 00000000000..83ba793b2b1 --- /dev/null +++ b/spec/models/mute_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Mute, type: :model do + +end diff --git a/spec/services/mute_service_spec.rb b/spec/services/mute_service_spec.rb new file mode 100644 index 00000000000..39736841692 --- /dev/null +++ b/spec/services/mute_service_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe MuteService do + subject { MuteService.new } +end diff --git a/spec/services/unmute_service_spec.rb b/spec/services/unmute_service_spec.rb new file mode 100644 index 00000000000..5dc971fb164 --- /dev/null +++ b/spec/services/unmute_service_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe UnmuteService do + subject { UnmuteService.new } +end diff --git a/streaming/index.js b/streaming/index.js index 125b35bb44f..0f838e411a1 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -164,7 +164,7 @@ const streamFrom = (id, req, output, attachCloseHandler, needsFiltering = false) const unpackedPayload = JSON.parse(payload) const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id)).concat(unpackedPayload.reblog ? [unpackedPayload.reblog.account.id] : []) - client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => { + client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)}) UNION SELECT target_account_id FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => { done() if (err) {