FEATURE: Introduce Ignore user (#7072)

This commit is contained in:
Tarek Khalil 2019-02-27 13:49:07 +00:00 committed by Régis Hanol
parent 5c476f639c
commit 986cc8a0fb
16 changed files with 233 additions and 25 deletions

View File

@ -195,6 +195,16 @@ export default Ember.Component.extend(
this._close(); this._close();
}, },
ignoreUser() {
this.get("user").ignore();
this._close();
},
watchUser() {
this.get("user").watch();
this._close();
},
showUser() { showUser() {
this.showUser(this.get("user")); this.showUser(this.get("user"));
this._close(); this._close();

View File

@ -145,6 +145,16 @@ export default Ember.Controller.extend(CanCheckEmails, {
adminDelete() { adminDelete() {
this.get("adminTools").deleteUser(this.get("model.id")); this.get("adminTools").deleteUser(this.get("model.id"));
},
ignoreUser() {
const user = this.get("model");
user.ignore().then(() => user.set("ignored", true));
},
watchUser() {
const user = this.get("model");
user.watch().then(() => user.set("ignored", false));
} }
} }
}); });

View File

@ -615,6 +615,20 @@ const User = RestModel.extend({
} }
}, },
ignore() {
return ajax(`${userPath(this.get("username"))}/ignore.json`, {
type: "PUT",
data: { ignored_user_id: this.get("id") }
});
},
watch() {
return ajax(`${userPath(this.get("username"))}/ignore.json`, {
type: "DELETE",
data: { ignored_user_id: this.get("id") }
});
},
dismissBanner(bannerKey) { dismissBanner(bannerKey) {
this.set("dismissed_banner_key", bannerKey); this.set("dismissed_banner_key", bannerKey);
ajax(userPath(this.get("username") + ".json"), { ajax(userPath(this.get("username") + ".json"), {

View File

@ -48,6 +48,21 @@
icon="envelope" icon="envelope"
label="user.private_message"}} label="user.private_message"}}
</li> </li>
{{#if user.can_ignore_user}}
<li>
{{#if user.ignored}}
{{d-button class="btn-default"
action=(action "watchUser")
icon="eye"
label="user.watch"}}
{{else}}
{{d-button class="btn-danger"
action=(action "ignoreUser")
icon="eye-slash"
label="user.ignore"}}
{{/if}}
</li>
{{/if}}
{{/if}} {{/if}}
{{#if showFilter}} {{#if showFilter}}

View File

@ -43,12 +43,27 @@
<section class='controls'> <section class='controls'>
<ul> <ul>
{{#if model.can_send_private_message_to_user}} {{#if model.can_send_private_message_to_user}}
<li> <li>
{{d-button class="btn btn-primary compose-pm" {{d-button class="btn-primary compose-pm"
action=(route-action "composePrivateMessage" model) action=(route-action "composePrivateMessage" model)
icon="envelope" icon="envelope"
label="user.private_message"}} label="user.private_message"}}
</li> </li>
{{#if model.can_ignore_user}}
<li>
{{#if model.ignored}}
{{d-button class="btn-default"
action=(action "watchUser")
icon="eye"
label="user.watch"}}
{{else}}
{{d-button class="btn-danger"
action=(action "ignoreUser")
icon="eye-slash"
label="user.ignore"}}
{{/if}}
</li>
{{/if}}
{{/if}} {{/if}}
{{#if currentUser.staff}} {{#if currentUser.staff}}
<li><a href={{model.adminPath}} class="btn btn-default">{{d-icon "wrench"}}{{i18n 'admin.user.show_admin_profile'}}</a></li> <li><a href={{model.adminPath}} class="btn btn-default">{{d-icon "wrench"}}{{i18n 'admin.user.show_admin_profile'}}</a></li>

View File

@ -14,7 +14,7 @@ class UsersController < ApplicationController
:pick_avatar, :destroy_user_image, :destroy, :check_emails, :pick_avatar, :destroy_user_image, :destroy, :check_emails,
:topic_tracking_state, :preferences, :create_second_factor, :topic_tracking_state, :preferences, :create_second_factor,
:update_second_factor, :create_second_factor_backup, :select_avatar, :update_second_factor, :create_second_factor_backup, :select_avatar,
:revoke_auth_token :ignore, :watch, :revoke_auth_token
] ]
skip_before_action :check_xhr, only: [ skip_before_action :check_xhr, only: [
@ -995,6 +995,22 @@ class UsersController < ApplicationController
render json: success_json render json: success_json
end end
def ignore
raise Discourse::NotFound unless SiteSetting.ignore_user_enabled
::IgnoredUser.find_or_create_by!(
user: current_user,
ignored_user_id: params[:ignored_user_id])
render json: success_json
end
def watch
raise Discourse::NotFound unless SiteSetting.ignore_user_enabled
IgnoredUser.where(user: current_user, ignored_user_id: params[:ignored_user_id]).delete_all
render json: success_json
end
def read_faq def read_faq
if user = current_user if user = current_user
user.user_stat.read_faq = 1.second.ago user.user_stat.read_faq = 1.second.ago

View File

@ -0,0 +1,20 @@
class IgnoredUser < ActiveRecord::Base
belongs_to :user
belongs_to :ignored_user, class_name: "User"
end
# == Schema Information
#
# Table name: ignored_users
#
# id :integer not null, primary key
# user_id :integer not null
# ignored_user_id :integer not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_ignored_users_on_ignored_user_id_and_user_id (ignored_user_id,user_id) UNIQUE
# index_ignored_users_on_user_id_and_ignored_user_id (user_id,ignored_user_id) UNIQUE
#

View File

@ -49,6 +49,8 @@ class UserSerializer < BasicUserSerializer
:can_edit_email, :can_edit_email,
:can_edit_name, :can_edit_name,
:stats, :stats,
:ignored,
:can_ignore_user,
:can_send_private_messages, :can_send_private_messages,
:can_send_private_message_to_user, :can_send_private_message_to_user,
:bio_excerpt, :bio_excerpt,
@ -274,6 +276,14 @@ class UserSerializer < BasicUserSerializer
UserAction.stats(object.id, scope) UserAction.stats(object.id, scope)
end end
def ignored
IgnoredUser.where(user_id: scope.user&.id, ignored_user_id: object.id).exists?
end
def can_ignore_user
SiteSetting.ignore_user_enabled
end
# Needed because 'send_private_message_to_user' will always return false # Needed because 'send_private_message_to_user' will always return false
# when the current user is being serialized # when the current user is being serialized
def can_send_private_messages def can_send_private_messages

View File

@ -636,6 +636,8 @@ en:
new_private_message: "New Message" new_private_message: "New Message"
private_message: "Message" private_message: "Message"
private_messages: "Messages" private_messages: "Messages"
ignore: "Ignore"
watch: "Watch"
activity_stream: "Activity" activity_stream: "Activity"
preferences: "Preferences" preferences: "Preferences"
profile_hidden: "This user's public profile is hidden." profile_hidden: "This user's public profile is hidden."

View File

@ -421,6 +421,8 @@ Discourse::Application.routes.draw do
post "#{root_path}/:username/preferences/revoke-auth-token" => "users#revoke_auth_token", constraints: { username: RouteFormat.username } post "#{root_path}/:username/preferences/revoke-auth-token" => "users#revoke_auth_token", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username } get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/summary" => "users#summary", constraints: { username: RouteFormat.username } get "#{root_path}/:username/summary" => "users#summary", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/ignore" => "users#ignore", constraints: { username: RouteFormat.username }
delete "#{root_path}/:username/ignore" => "users#watch", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/invited" => "users#invited", constraints: { username: RouteFormat.username } get "#{root_path}/:username/invited" => "users#invited", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/invited_count" => "users#invited_count", constraints: { username: RouteFormat.username } get "#{root_path}/:username/invited_count" => "users#invited_count", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/invited/:filter" => "users#invited", constraints: { username: RouteFormat.username } get "#{root_path}/:username/invited/:filter" => "users#invited", constraints: { username: RouteFormat.username }

View File

@ -529,6 +529,9 @@ users:
default: false default: false
client: true client: true
log_personal_messages_views: false log_personal_messages_views: false
ignore_user_enabled:
hidden: true
default: false
groups: groups:
enable_group_directory: enable_group_directory:

View File

@ -0,0 +1,12 @@
class AddIgnoredUsersTable < ActiveRecord::Migration[5.2]
def change
create_table :ignored_users do |t|
t.integer :user_id, null: false
t.integer :ignored_user_id, null: false
t.timestamps null: false
end
add_index :ignored_users, [:user_id, :ignored_user_id], unique: true
add_index :ignored_users, [:ignored_user_id, :user_id], unique: true
end
end

View File

@ -330,13 +330,13 @@ class TopicView
return {} if post_ids.blank? return {} if post_ids.blank?
sql = <<~SQL sql = <<~SQL
SELECT user_id, count(*) AS count_all SELECT user_id, count(*) AS count_all
FROM posts FROM posts
WHERE id in (:post_ids) WHERE id in (:post_ids)
AND user_id IS NOT NULL AND user_id IS NOT NULL
GROUP BY user_id GROUP BY user_id
ORDER BY count_all DESC ORDER BY count_all DESC
LIMIT #{MAX_PARTICIPANTS} LIMIT #{MAX_PARTICIPANTS}
SQL SQL
Hash[*DB.query_single(sql, post_ids: post_ids)] Hash[*DB.query_single(sql, post_ids: post_ids)]
@ -515,22 +515,22 @@ class TopicView
def get_sort_order(post_number) def get_sort_order(post_number)
sql = <<~SQL sql = <<~SQL
SELECT posts.sort_order SELECT posts.sort_order
FROM posts FROM posts
WHERE posts.post_number = #{post_number.to_i} WHERE posts.post_number = #{post_number.to_i}
AND posts.topic_id = #{@topic.id.to_i} AND posts.topic_id = #{@topic.id.to_i}
LIMIT 1 LIMIT 1
SQL SQL
sort_order = DB.query_single(sql).first sort_order = DB.query_single(sql).first
if !sort_order if !sort_order
sql = <<~SQL sql = <<~SQL
SELECT posts.sort_order SELECT posts.sort_order
FROM posts FROM posts
WHERE posts.topic_id = #{@topic.id.to_i} WHERE posts.topic_id = #{@topic.id.to_i}
ORDER BY @(post_number - #{post_number.to_i}) ORDER BY @(post_number - #{post_number.to_i})
LIMIT 1 LIMIT 1
SQL SQL
sort_order = DB.query_single(sql).first sort_order = DB.query_single(sql).first
@ -602,6 +602,12 @@ class TopicView
@contains_gaps = false @contains_gaps = false
@filtered_posts = unfiltered_posts @filtered_posts = unfiltered_posts
if SiteSetting.ignore_user_enabled
@filtered_posts = @filtered_posts.where.not("user_id IN (?) AND id <> ?",
IgnoredUser.where(user_id: @user.id).select(:ignored_user_id),
first_post_id)
end
# Filters # Filters
if @filter == 'summary' if @filter == 'summary'
@filtered_posts = @filtered_posts.summary(@topic.id) @filtered_posts = @filtered_posts.summary(@topic.id)

View File

@ -0,0 +1,3 @@
Fabricator(:ignored_user) do
user
end

View File

@ -2020,6 +2020,76 @@ describe UsersController do
end end
end end
describe '#ignore' do
it 'raises an error when not logged in' do
put "/u/#{user.username}/ignore.json", params: { ignored_user_id: "" }
expect(response.status).to eq(403)
end
context 'while logged in' do
let(:user) { Fabricate(:user) }
let(:another_user) { Fabricate(:user) }
before do
sign_in(user)
end
describe 'when SiteSetting.ignore_user_enabled is false' do
it 'raises an error' do
SiteSetting.ignore_user_enabled = false
put "/u/#{user.username}/ignore.json"
expect(response.status).to eq(404)
end
end
describe 'when SiteSetting.ignore_user_enabled is true' do
it 'creates IgnoredUser record' do
SiteSetting.ignore_user_enabled = true
put "/u/#{user.username}/ignore.json", params: { ignored_user_id: another_user.id }
expect(response.status).to eq(200)
expect(IgnoredUser.find_by(user_id: user.id,
ignored_user_id: another_user.id)).to be_present
end
end
end
end
describe '#watch' do
it 'raises an error when not logged in' do
delete "/u/#{user.username}/ignore.json"
expect(response.status).to eq(403)
end
context 'while logged in' do
let(:user) { Fabricate(:user) }
let(:another_user) { Fabricate(:user) }
before do
sign_in(user)
end
describe 'when SiteSetting.ignore_user_enabled is false' do
it 'raises an error' do
SiteSetting.ignore_user_enabled = false
delete "/u/#{user.username}/ignore.json", params: { ignored_user_id: another_user.id }
expect(response.status).to eq(404)
end
end
describe 'when SiteSetting.ignore_user_enabled is true' do
before do
Fabricate(:ignored_user, user_id: user.id, ignored_user_id: another_user.id)
end
it 'destroys IgnoredUser record' do
SiteSetting.ignore_user_enabled = true
delete "/u/#{user.username}/ignore.json", params: { ignored_user_id: another_user.id }
expect(response.status).to eq(200)
expect(IgnoredUser.find_by(user_id: user.id,
ignored_user_id: another_user.id)).to be_blank
end
end
end
end
describe "for user with period in username" do describe "for user with period in username" do
let(:user_with_period) { Fabricate(:user, username: "myname.test") } let(:user_with_period) { Fabricate(:user, username: "myname.test") }

View File

@ -21,7 +21,7 @@ RSpec.describe WebHookUserSerializer do
it 'should only include the required keys' do it 'should only include the required keys' do
count = serializer.as_json.keys.count count = serializer.as_json.keys.count
difference = count - 43 difference = count - 45
expect(difference).to eq(0), lambda { expect(difference).to eq(0), lambda {
message = "" message = ""