diff --git a/app/assets/javascripts/discourse/components/date-picker-future.js.es6 b/app/assets/javascripts/discourse/components/date-picker-future.js.es6 index 4cf37cd30e7..b69e8dc510f 100644 --- a/app/assets/javascripts/discourse/components/date-picker-future.js.es6 +++ b/app/assets/javascripts/discourse/components/date-picker-future.js.es6 @@ -10,8 +10,7 @@ export default DatePicker.extend({ moment() .add(1, "day") .toDate(), - setDefaultDate: !!this.get("defaultDate"), - minDate: new Date() + setDefaultDate: !!this.get("defaultDate") }; } }); diff --git a/app/assets/javascripts/discourse/components/future-date-input.js.es6 b/app/assets/javascripts/discourse/components/future-date-input.js.es6 index 42ac342f48e..9d6a470ab1b 100644 --- a/app/assets/javascripts/discourse/components/future-date-input.js.es6 +++ b/app/assets/javascripts/discourse/components/future-date-input.js.es6 @@ -10,11 +10,13 @@ export default Ember.Component.extend({ selection: null, date: null, time: null, + includeDateTime: true, isCustom: Ember.computed.equal("selection", "pick_date_and_time"), isBasedOnLastPost: Ember.computed.equal( "selection", "set_based_on_last_post" ), + displayDateAndTimePicker: Ember.computed.and("includeDateTime", "isCustom"), displayLabel: null, init() { diff --git a/app/assets/javascripts/discourse/controllers/ignore-duration.js.es6 b/app/assets/javascripts/discourse/controllers/ignore-duration.js.es6 new file mode 100644 index 00000000000..e734e7764b2 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/ignore-duration.js.es6 @@ -0,0 +1,31 @@ +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Ember.Controller.extend(ModalFunctionality, { + loading: false, + ignoredUntil: null, + actions: { + ignore() { + if (!this.get("ignoredUntil")) { + this.flash( + I18n.t("user.user_notifications.ignore_duration_time_frame_required"), + "alert-error" + ); + return; + } + this.set("loading", true); + this.get("model") + .updateNotificationLevel("ignore", this.get("ignoredUntil")) + .then(() => { + this.set("model.ignored", true); + this.set("model.muted", false); + if (this.get("onSuccess")) { + this.get("onSuccess")(); + } + this.send("closeModal"); + }) + .catch(popupAjaxError) + .finally(() => this.set("loading", false)); + } + } +}); diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index 72da24ddb03..c8c0db76f4d 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -615,10 +615,10 @@ const User = RestModel.extend({ } }, - updateNotificationLevel(level) { + updateNotificationLevel(level, expiringAt) { return ajax(`${userPath(this.get("username"))}/notification_level.json`, { type: "PUT", - data: { notification_level: level } + data: { notification_level: level, expiring_at: expiringAt } }); }, diff --git a/app/assets/javascripts/discourse/templates/components/future-date-input.hbs b/app/assets/javascripts/discourse/templates/components/future-date-input.hbs index 645fbc4f6bc..336fabec67a 100644 --- a/app/assets/javascripts/discourse/templates/components/future-date-input.hbs +++ b/app/assets/javascripts/discourse/templates/components/future-date-input.hbs @@ -6,13 +6,15 @@ statusType=statusType value=selection input=input + includeDateTime=includeDateTime includeWeekend=includeWeekend includeFarFuture=includeFarFuture + includeMidFuture=includeMidFuture clearable=clearable none="topic.auto_update_input.none"}} - {{#if isCustom}} + {{#if displayDateAndTimePicker}}
{{i18n "user.user_notifications.ignore_duration_note"}}
+{{/d-modal-body}} + + diff --git a/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 b/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 index 5454e36cf00..a9c325abe0f 100644 --- a/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 +++ b/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 @@ -90,10 +90,22 @@ export const TIMEFRAMES = [ .minute(0), icon: "briefcase" }), + buildTimeframe({ + id: "two_months", + format: "MMM D", + enabled: opts => opts.includeMidFuture, + when: (time, timeOfDay) => + time + .add(2, "month") + .startOf("month") + .hour(timeOfDay) + .minute(0), + icon: "briefcase" + }), buildTimeframe({ id: "three_months", format: "MMM D", - enabled: opts => opts.includeFarFuture, + enabled: opts => opts.includeMidFuture, when: (time, timeOfDay) => time .add(3, "month") @@ -102,6 +114,18 @@ export const TIMEFRAMES = [ .minute(0), icon: "briefcase" }), + buildTimeframe({ + id: "four_months", + format: "MMM D", + enabled: opts => opts.includeMidFuture, + when: (time, timeOfDay) => + time + .add(4, "month") + .startOf("month") + .hour(timeOfDay) + .minute(0), + icon: "briefcase" + }), buildTimeframe({ id: "six_months", format: "MMM D", @@ -139,6 +163,7 @@ export const TIMEFRAMES = [ }), buildTimeframe({ id: "pick_date_and_time", + enabled: opts => opts.includeDateTime, icon: "far-calendar-plus" }), buildTimeframe({ @@ -192,7 +217,9 @@ export default ComboBoxComponent.extend(DatetimeMixin, { now, day: now.day(), includeWeekend: this.get("includeWeekend"), + includeMidFuture: this.get("includeMidFuture") || true, includeFarFuture: this.get("includeFarFuture"), + includeDateTime: this.get("includeDateTime"), includeBasedOnLastPost: this.get("statusType") === CLOSE_STATUS_TYPE, canScheduleToday: 24 - now.hour() > 6 }; 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 index 4f90a2e5b63..0d31bfc1fe9 100644 --- a/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 +++ b/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 @@ -1,85 +1,99 @@ import DropdownSelectBox from "select-kit/components/dropdown-select-box"; import { popupAjaxError } from "discourse/lib/ajax-error"; +import showModal from "discourse/lib/show-modal"; export default DropdownSelectBox.extend({ classNames: ["user-notifications", "user-notifications-dropdown"], nameProperty: "label", - allowInitialValueMutation: false, - - computeHeaderContent() { - let content = this._super(...arguments); + init() { + this._super(...arguments); if (this.get("user.ignored")) { this.set("headerIcon", "eye-slash"); - content.name = `${I18n.t("user.user_notifications_ignore_option")}`; + this.set("value", "changeToIgnored"); } else if (this.get("user.muted")) { this.set("headerIcon", "times-circle"); - content.name = `${I18n.t("user.user_notifications_mute_option")}`; + this.set("value", "changeToMuted"); } else { this.set("headerIcon", "user"); - content.name = `${I18n.t("user.user_notifications_normal_option")}`; + this.set("value", "changeToNormal"); } - 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") + id: "changeToNormal", + description: I18n.t("user.user_notifications.normal_option_title"), + 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") + id: "changeToMuted", + description: I18n.t("user.user_notifications.mute_option_title"), + 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") + id: "changeToIgnored", + description: I18n.t("user.user_notifications.ignore_option_title"), + label: I18n.t("user.user_notifications.ignore_option") }); } return content; }, + changeToNormal() { + this.get("updateNotificationLevel")("normal") + .then(() => { + this.set("user.ignored", false); + this.set("user.muted", false); + this.set("headerIcon", "user"); + }) + .catch(popupAjaxError); + }, + changeToMuted() { + this.get("updateNotificationLevel")("mute") + .then(() => { + this.set("user.ignored", false); + this.set("user.muted", true); + this.set("headerIcon", "times-circle"); + }) + .catch(popupAjaxError); + }, + changeToIgnored() { + const controller = showModal("ignore-duration", { + model: this.get("user") + }); + controller.setProperties({ + onSuccess: () => { + this.set("headerIcon", "eye-slash"); + }, + onClose: () => { + if (this.get("user.muted")) { + this.set("headerIcon", "times-circle"); + this._select("changeToMuted"); + } else if (!this.get("user.muted") && !this.get("user.ignored")) { + this.set("headerIcon", "user"); + this._select("changeToNormal"); + } + } + }); + }, + + _select(id) { + this.select( + this.collectionComputedContent.find(c => c.originalContent.id === id) + ); + }, + 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); + onSelect(level) { + this[level](); } } }); diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 012dc86983b..9dbf412bab7 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -999,7 +999,12 @@ class UsersController < ApplicationController 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) + ignored_user = IgnoredUser.find_by(user: current_user, ignored_user: user) + if ignored_user.present? + ignored_user.update(expiring_at: DateTime.parse(params[:expiring_at])) + else + IgnoredUser.create!(user: current_user, ignored_user: user, expiring_at: Time.parse(params[:expiring_at])) + end elsif params[:notification_level] == "mute" guardian.ensure_can_mute_user!(user.id) IgnoredUser.where(user: current_user, ignored_user: user).delete_all diff --git a/app/jobs/scheduled/purge_expired_ignored_users.rb b/app/jobs/scheduled/purge_expired_ignored_users.rb index e898e3296a3..06e872e21c6 100644 --- a/app/jobs/scheduled/purge_expired_ignored_users.rb +++ b/app/jobs/scheduled/purge_expired_ignored_users.rb @@ -3,7 +3,7 @@ module Jobs every 1.day def execute(args) - IgnoredUser.where("created_at <= ?", 4.months.ago).delete_all + IgnoredUser.where("created_at <= ? OR expiring_at <= ?", 4.months.ago, Time.zone.now).delete_all end end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 22abf10ba6c..e971636a38b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -702,12 +702,18 @@ en: new_private_message: "New Message" private_message: "Message" private_messages: "Messages" - 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." - 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, quotes you, or mentions you." + user_notifications: + ignore_duration_title: "Ignore Timer" + ignore_duration_when: "Duration:" + ignore_duration_save: "Ignore" + ignore_duration_note: "Please note that all ignores are automatically removed after the ignore duration expires." + ignore_duration_time_frame_required: "Please select a time frame" + ignore_option: "Ignored" + ignore_option_title: "You will not receive notifications related to this user and all of their topics and replies will be hidden." + mute_option: "Muted" + mute_option_title: "You will not receive any notifications related to this user." + normal_option: "Normal" + normal_option_title: "You will be notified if this user replies to you, quotes you, or mentions you." activity_stream: "Activity" preferences: "Preferences" profile_hidden: "This user's public profile is hidden." @@ -1895,7 +1901,9 @@ en: next_week: "Next week" two_weeks: "Two Weeks" next_month: "Next month" + two_months: "Two Months" three_months: "Three Months" + four_months: "Four Months" six_months: "Six Months" one_year: "One Year" forever: "Forever" diff --git a/db/migrate/20190327090918_add_expiring_at_column_to_ignored_users_table.rb b/db/migrate/20190327090918_add_expiring_at_column_to_ignored_users_table.rb new file mode 100644 index 00000000000..d6b91ce1013 --- /dev/null +++ b/db/migrate/20190327090918_add_expiring_at_column_to_ignored_users_table.rb @@ -0,0 +1,5 @@ +class AddExpiringAtColumnToIgnoredUsersTable < ActiveRecord::Migration[5.2] + def change + add_column :ignored_users, :expiring_at, :datetime + end +end diff --git a/spec/jobs/purge_expired_ignored_users_spec.rb b/spec/jobs/purge_expired_ignored_users_spec.rb index 1816c9590a9..3fac279adfa 100644 --- a/spec/jobs/purge_expired_ignored_users_spec.rb +++ b/spec/jobs/purge_expired_ignored_users_spec.rb @@ -39,5 +39,18 @@ describe Jobs::PurgeExpiredIgnoredUsers do expect(IgnoredUser.find_by(ignored_user: fred)).to be_nil end end + + context "when there are expired ignored users by expiring_at" do + let(:fred) { Fabricate(:user, username: "fred") } + + it "purges expired ignored users" do + Fabricate(:ignored_user, user: tarek, ignored_user: fred, expiring_at: 1.month.from_now) + + freeze_time(2.months.from_now) do + subject + expect(IgnoredUser.find_by(ignored_user: fred)).to be_nil + end + end + end end end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index ccff1761278..6473b25a23f 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -2065,11 +2065,25 @@ describe UsersController do end context 'when changing notification level to ignore' do - it 'changes notification level to mute' do + it 'changes notification level to ignore' 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 + + context 'when expiring_at param is set' do + it 'changes notification level to ignore' do + freeze_time(Time.now) do + expiring_at = 3.days.from_now + put "/u/#{another_user.username}/notification_level.json", params: { notification_level: "ignore", expiring_at: expiring_at } + + ignored_user = IgnoredUser.find_by(user_id: user.id, ignored_user_id: another_user.id) + expect(ignored_user).to be_present + expect(ignored_user.expiring_at.to_i).to eq(expiring_at.to_i) + expect(MutedUser.count).to eq(0) + end + end + end end end end