From ef2362a30fe1a1cb5e7c15e70c5332adf14c7d0b Mon Sep 17 00:00:00 2001 From: Tarek Khalil <45508821+khalilovcmded@users.noreply.github.com> Date: Wed, 27 Mar 2019 09:41:50 +0000 Subject: [PATCH] FEATURE: Introducing new UI for changing User's notification levels (#7248) * FEATURE: Introducing new UI for tracking User's ignored or muted states --- .../discourse/controllers/user.js.es6 | 14 ++- .../javascripts/discourse/models/user.js.es6 | 13 +-- .../javascripts/discourse/templates/user.hbs | 14 +-- .../user-notifications-dropdown.js.es6 | 85 +++++++++++++++++++ app/assets/stylesheets/common.scss | 1 + app/assets/stylesheets/common/base/user.scss | 1 - .../user-notifications-dropdown.scss | 20 +++++ app/controllers/users_controller.rb | 25 +++--- app/serializers/user_serializer.rb | 10 +++ app/serializers/web_hook_user_serializer.rb | 3 + config/locales/client.en.yml | 8 +- config/routes.rb | 3 +- lib/guardian.rb | 11 +++ spec/components/guardian_spec.rb | 56 ++++++++++++ spec/requests/users_controller_spec.rb | 77 +++++++---------- .../web_hook_user_serializer_spec.rb | 2 +- 16 files changed, 251 insertions(+), 92 deletions(-) create mode 100644 app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 create mode 100644 app/assets/stylesheets/common/select-kit/user-notifications-dropdown.scss diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index 48d271183eb..038dfccfbd0 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -33,7 +33,10 @@ export default Ember.Controller.extend(CanCheckEmails, { } return (!indexStream || viewingSelf) && !forceExpand; }, - + canMuteOrIgnoreUser: Ember.computed.or( + "model.can_ignore_user", + "model.can_mute_user" + ), hasGivenFlags: Ember.computed.gt("model.number_of_flags_given", 0), hasFlaggedPosts: Ember.computed.gt("model.number_of_flagged_posts", 0), hasDeletedPosts: Ember.computed.gt("model.number_of_deleted_posts", 0), @@ -147,14 +150,9 @@ export default Ember.Controller.extend(CanCheckEmails, { this.get("adminTools").deleteUser(this.get("model.id")); }, - ignoreUser() { + updateNotificationLevel(level) { const user = this.get("model"); - user.ignore().then(() => user.set("ignored", true)); - }, - - unignoreUser() { - const user = this.get("model"); - user.unignore().then(() => user.set("ignored", false)); + return user.updateNotificationLevel(level); } } }); diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index 1405295b730..72da24ddb03 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -615,17 +615,10 @@ const User = RestModel.extend({ } }, - ignore() { - return ajax(`${userPath(this.get("username"))}/ignore.json`, { + updateNotificationLevel(level) { + return ajax(`${userPath(this.get("username"))}/notification_level.json`, { type: "PUT", - data: { ignored_user_id: this.get("id") } - }); - }, - - unignore() { - return ajax(`${userPath(this.get("username"))}/ignore.json`, { - type: "DELETE", - data: { ignored_user_id: this.get("id") } + data: { notification_level: level } }); }, diff --git a/app/assets/javascripts/discourse/templates/user.hbs b/app/assets/javascripts/discourse/templates/user.hbs index 0c3669d7899..1a7afc307d8 100644 --- a/app/assets/javascripts/discourse/templates/user.hbs +++ b/app/assets/javascripts/discourse/templates/user.hbs @@ -48,19 +48,9 @@ icon="envelope" label="user.private_message"}} - {{#if model.can_ignore_user}} + {{#if canMuteOrIgnoreUser}}
  • - {{#if model.ignored}} - {{d-button class="btn-default" - action=(action "unignoreUser") - icon="far-eye" - label="user.unignore"}} - {{else}} - {{d-button class="btn-default" - action=(action "ignoreUser") - icon="eye-slash" - label="user.ignore"}} - {{/if}} + {{user-notifications-dropdown user=model updateNotificationLevel=(action "updateNotificationLevel")}}
  • {{/if}} {{/if}} diff --git a/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 b/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 new file mode 100644 index 00000000000..4f90a2e5b63 --- /dev/null +++ b/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 @@ -0,0 +1,85 @@ +import DropdownSelectBox from "select-kit/components/dropdown-select-box"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default DropdownSelectBox.extend({ + classNames: ["user-notifications", "user-notifications-dropdown"], + nameProperty: "label", + allowInitialValueMutation: false, + + computeHeaderContent() { + let content = this._super(...arguments); + if (this.get("user.ignored")) { + this.set("headerIcon", "eye-slash"); + content.name = `${I18n.t("user.user_notifications_ignore_option")}`; + } else if (this.get("user.muted")) { + this.set("headerIcon", "times-circle"); + content.name = `${I18n.t("user.user_notifications_mute_option")}`; + } else { + this.set("headerIcon", "user"); + content.name = `${I18n.t("user.user_notifications_normal_option")}`; + } + return content; + }, + + computeContent() { + const content = []; + + content.push({ + icon: "user", + id: "change-to-normal", + description: I18n.t("user.user_notifications_normal_option_title"), + action: () => this.send("reset"), + label: I18n.t("user.user_notifications_normal_option") + }); + + content.push({ + icon: "times-circle", + id: "change-to-muted", + description: I18n.t("user.user_notifications_mute_option_title"), + action: () => this.send("mute"), + label: I18n.t("user.user_notifications_mute_option") + }); + + if (this.get("user.can_ignore_user")) { + content.push({ + icon: "eye-slash", + id: "change-to-ignored", + description: I18n.t("user.user_notifications_ignore_option_title"), + action: () => this.send("ignore"), + label: I18n.t("user.user_notifications_ignore_option") + }); + } + + return content; + }, + + actions: { + reset() { + this.get("updateNotificationLevel")("normal") + .then(() => { + this.set("user.ignored", false); + this.set("user.muted", false); + this.computeHeaderContent(); + }) + .catch(popupAjaxError); + }, + mute() { + this.get("updateNotificationLevel")("mute") + .then(() => { + this.set("user.ignored", false); + this.set("user.muted", true); + this.computeHeaderContent(); + }) + .catch(popupAjaxError); + }, + ignore() { + this.get("updateNotificationLevel")("ignore") + .then(() => { + this.set("user.ignored", true); + this.set("user.muted", false); + this.computeHeaderContent(); + }) + .catch(popupAjaxError); + } + } +}); diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index 050d1c394b4..c23f1800d6e 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -27,6 +27,7 @@ @import "common/select-kit/tag-drop"; @import "common/select-kit/toolbar-popup-menu-options"; @import "common/select-kit/topic-notifications-button"; +@import "common/select-kit/user-notifications-dropdown"; @import "common/select-kit/color-palettes"; @import "common/components/*"; @import "common/input_tip"; diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 62120489435..050a3ade44f 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -97,7 +97,6 @@ .user-main { .about { - overflow: hidden; width: 100%; margin-bottom: 15px; diff --git a/app/assets/stylesheets/common/select-kit/user-notifications-dropdown.scss b/app/assets/stylesheets/common/select-kit/user-notifications-dropdown.scss new file mode 100644 index 00000000000..a529f38d419 --- /dev/null +++ b/app/assets/stylesheets/common/select-kit/user-notifications-dropdown.scss @@ -0,0 +1,20 @@ +.select-kit { + &.dropdown-select-box { + &.user-notifications-dropdown { + text-align: left; + + .select-kit-body { + width: 485px; + max-width: 485px; + } + + .select-kit-header { + justify-content: center; + } + + .dropdown-select-box-header { + align-items: center; + } + } + } +} diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 239388c2072..d55d07a99b3 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -14,7 +14,7 @@ class UsersController < ApplicationController :pick_avatar, :destroy_user_image, :destroy, :check_emails, :topic_tracking_state, :preferences, :create_second_factor, :update_second_factor, :create_second_factor_backup, :select_avatar, - :ignore, :unignore, :revoke_auth_token + :notification_level, :revoke_auth_token ] skip_before_action :check_xhr, only: [ @@ -995,18 +995,23 @@ class UsersController < ApplicationController render json: success_json end - def ignore + def notification_level raise Discourse::NotFound unless SiteSetting.ignore_user_enabled - guardian.ensure_can_ignore_user!(params[:ignored_user_id]) + user = fetch_user_from_params - IgnoredUser.find_or_create_by!(user: current_user, ignored_user_id: params[:ignored_user_id]) - render json: success_json - end + if params[:notification_level] == "ignore" + guardian.ensure_can_ignore_user!(user.id) + MutedUser.where(user: current_user, muted_user: user).delete_all + IgnoredUser.find_or_create_by!(user: current_user, ignored_user: user) + elsif params[:notification_level] == "mute" + guardian.ensure_can_mute_user!(user.id) + IgnoredUser.where(user: current_user, ignored_user: user).delete_all + MutedUser.find_or_create_by!(user: current_user, muted_user: user) + elsif params[:notification_level] == "normal" + MutedUser.where(user: current_user, muted_user: user).delete_all + IgnoredUser.where(user: current_user, ignored_user: user).delete_all + end - def unignore - 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 diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index ca80c051122..74ac617e861 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -50,7 +50,9 @@ class UserSerializer < BasicUserSerializer :can_edit_name, :stats, :ignored, + :muted, :can_ignore_user, + :can_mute_user, :can_send_private_messages, :can_send_private_message_to_user, :bio_excerpt, @@ -281,6 +283,14 @@ class UserSerializer < BasicUserSerializer IgnoredUser.where(user_id: scope.user&.id, ignored_user_id: object.id).exists? end + def muted + MutedUser.where(user_id: scope.user&.id, muted_user_id: object.id).exists? + end + + def can_mute_user + scope.can_mute_user?(object.id) + end + def can_ignore_user scope.can_ignore_user?(object.id) end diff --git a/app/serializers/web_hook_user_serializer.rb b/app/serializers/web_hook_user_serializer.rb index 1e89fc34d21..183e4cb66bd 100644 --- a/app/serializers/web_hook_user_serializer.rb +++ b/app/serializers/web_hook_user_serializer.rb @@ -12,6 +12,9 @@ class WebHookUserSerializer < UserSerializer can_edit_name can_send_private_messages can_send_private_message_to_user + can_ignore_user + can_mute_user + ignored uploaded_avatar_id has_title_badges bio_cooked diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 42942883094..0b6f2719dba 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -637,8 +637,12 @@ en: new_private_message: "New Message" private_message: "Message" private_messages: "Messages" - ignore: "Ignore" - unignore: "Unignore" + user_notifications_ignore_option: "Ignored" + user_notifications_ignore_option_title: "You will not receive notifications related to this user and all of their topics and replies will be hidden from you." + user_notifications_mute_option: "Muted" + user_notifications_mute_option_title: "You will not receive any notifications related to this user." + user_notifications_normal_option: "Normal" + user_notifications_normal_option_title: "You will be notified if this user replies to you or mentions you, and you will see their topics." activity_stream: "Activity" preferences: "Preferences" profile_hidden: "This user's public profile is hidden." diff --git a/config/routes.rb b/config/routes.rb index 2ba524acba6..64bb07a8c54 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -426,8 +426,7 @@ Discourse::Application.routes.draw do 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/summary" => "users#summary", constraints: { username: RouteFormat.username } - put "#{root_path}/:username/ignore" => "users#ignore", constraints: { username: RouteFormat.username } - delete "#{root_path}/:username/ignore" => "users#unignore", constraints: { username: RouteFormat.username } + put "#{root_path}/:username/notification_level" => "users#notification_level", 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/:filter" => "users#invited", constraints: { username: RouteFormat.username } diff --git a/lib/guardian.rb b/lib/guardian.rb index c582668628f..c5fe976c2f7 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -390,6 +390,17 @@ class Guardian UserExport.where(user_id: @user.id, created_at: (Time.zone.now.beginning_of_day..Time.zone.now.end_of_day)).count == 0 end + def can_mute_user?(user_id) + can_mute_users? && + @user.id != user_id && + User.where(id: user_id, admin: false, moderator: false).exists? + end + + def can_mute_users? + return false if anonymous? + @user.staff? || @user.trust_level >= TrustLevel.levels[:basic] + end + def can_ignore_user?(user_id) can_ignore_users? && @user.id != user_id && User.where(id: user_id, admin: false, moderator: false).exists? end diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index ac827e01ee8..0ceee2a822d 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -9,6 +9,7 @@ describe Guardian do let(:user) { Fabricate(:user) } let(:moderator) { Fabricate(:moderator) } let(:admin) { Fabricate(:admin) } + let(:trust_level_1) { build(:user, trust_level: 1) } let(:trust_level_2) { build(:user, trust_level: 2) } let(:trust_level_3) { build(:user, trust_level: 3) } let(:trust_level_4) { build(:user, trust_level: 4) } @@ -2698,6 +2699,61 @@ describe Guardian do end end + describe '#can_mute_user?' do + + let(:guardian) { Guardian.new(trust_level_1) } + + context "when muted user is the same as guardian user" do + it 'does not allow muting user' do + expect(guardian.can_mute_user?(trust_level_1.id)).to eq(false) + end + end + + context "when muted user is a staff user" do + let!(:admin) { Fabricate(:user, admin: true) } + + it 'does not allow muting user' do + expect(guardian.can_mute_user?(admin.id)).to eq(false) + end + end + + context "when muted user is a normal user" do + let!(:another_user) { Fabricate(:user) } + + it 'allows muting user' do + expect(guardian.can_mute_user?(another_user.id)).to eq(true) + end + end + + context "when muter's trust level is below tl1" do + let(:guardian) { Guardian.new(trust_level_0) } + let!(:another_user) { Fabricate(:user) } + let!(:trust_level_0) { build(:user, trust_level: 0) } + + it 'does not allow muting user' do + expect(guardian.can_mute_user?(another_user.id)).to eq(false) + end + end + + context "when muter is staff" do + let(:guardian) { Guardian.new(admin) } + let!(:another_user) { Fabricate(:user) } + + it 'allows muting user' do + expect(guardian.can_mute_user?(another_user.id)).to eq(true) + end + end + + context "when muters's trust level is tl1" do + let(:guardian) { Guardian.new(trust_level_1) } + let!(:another_user) { Fabricate(:user) } + + it 'allows muting user' do + expect(guardian.can_mute_user?(another_user.id)).to eq(true) + end + end + end + describe "#allow_themes?" do let(:theme) { Fabricate(:theme) } let(:theme2) { Fabricate(:theme) } diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index fe25b3a04e3..ccff1761278 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -2022,7 +2022,7 @@ describe UsersController do describe '#ignore' do it 'raises an error when not logged in' do - put "/u/#{user.username}/ignore.json", params: { ignored_user_id: "" } + put "/u/#{user.username}/notification_level.json", params: { notification_level: "" } expect(response.status).to eq(403) end @@ -2033,58 +2033,43 @@ describe UsersController 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" + context 'when ignore_user_enable is OFF' do + it 'raises an error when not logged in' do + put "/u/#{another_user.username}/notification_level.json", params: { notification_level: "" } 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 + context 'when ignore_user_enable is ON' do before do - Fabricate(:ignored_user, user_id: user.id, ignored_user_id: another_user.id) + SiteSetting.ignore_user_enabled = true 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 + let!(:ignored_user) { Fabricate(:ignored_user, user: user, ignored_user: another_user) } + let!(:muted_user) { Fabricate(:muted_user, user: user, muted_user: another_user) } + + context 'when changing notification level to normal' do + it 'changes notification level to normal' do + put "/u/#{another_user.username}/notification_level.json", params: { notification_level: "normal" } + expect(IgnoredUser.count).to eq(0) + expect(MutedUser.count).to eq(0) + end + end + + context 'when changing notification level to mute' do + it 'changes notification level to mute' do + put "/u/#{another_user.username}/notification_level.json", params: { notification_level: "mute" } + expect(IgnoredUser.count).to eq(0) + expect(MutedUser.find_by(user_id: user.id, muted_user_id: another_user.id)).to be_present + end + end + + context 'when changing notification level to ignore' do + it 'changes notification level to mute' do + put "/u/#{another_user.username}/notification_level.json", params: { notification_level: "ignore" } + expect(MutedUser.count).to eq(0) + expect(IgnoredUser.find_by(user_id: user.id, ignored_user_id: another_user.id)).to be_present + end end end end diff --git a/spec/serializers/web_hook_user_serializer_spec.rb b/spec/serializers/web_hook_user_serializer_spec.rb index f74d5cfce71..65b5c8d876d 100644 --- a/spec/serializers/web_hook_user_serializer_spec.rb +++ b/spec/serializers/web_hook_user_serializer_spec.rb @@ -21,7 +21,7 @@ RSpec.describe WebHookUserSerializer do it 'should only include the required keys' do count = serializer.as_json.keys.count - difference = count - 46 + difference = count - 45 expect(difference).to eq(0), lambda { message = ""