FEATURE: Introduce ignore duration selection (#7266)

* FEATURE: Introducing new UI for tracking User's ignored or muted states
This commit is contained in:
Tarek Khalil
2019-03-29 10:14:53 +00:00
committed by GitHub
parent 961fb2c70e
commit b1cb95fc23
14 changed files with 201 additions and 62 deletions

View File

@ -10,8 +10,7 @@ export default DatePicker.extend({
moment() moment()
.add(1, "day") .add(1, "day")
.toDate(), .toDate(),
setDefaultDate: !!this.get("defaultDate"), setDefaultDate: !!this.get("defaultDate")
minDate: new Date()
}; };
} }
}); });

View File

@ -10,11 +10,13 @@ export default Ember.Component.extend({
selection: null, selection: null,
date: null, date: null,
time: null, time: null,
includeDateTime: true,
isCustom: Ember.computed.equal("selection", "pick_date_and_time"), isCustom: Ember.computed.equal("selection", "pick_date_and_time"),
isBasedOnLastPost: Ember.computed.equal( isBasedOnLastPost: Ember.computed.equal(
"selection", "selection",
"set_based_on_last_post" "set_based_on_last_post"
), ),
displayDateAndTimePicker: Ember.computed.and("includeDateTime", "isCustom"),
displayLabel: null, displayLabel: null,
init() { init() {

View File

@ -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));
}
}
});

View File

@ -615,10 +615,10 @@ const User = RestModel.extend({
} }
}, },
updateNotificationLevel(level) { updateNotificationLevel(level, expiringAt) {
return ajax(`${userPath(this.get("username"))}/notification_level.json`, { return ajax(`${userPath(this.get("username"))}/notification_level.json`, {
type: "PUT", type: "PUT",
data: { notification_level: level } data: { notification_level: level, expiring_at: expiringAt }
}); });
}, },

View File

@ -6,13 +6,15 @@
statusType=statusType statusType=statusType
value=selection value=selection
input=input input=input
includeDateTime=includeDateTime
includeWeekend=includeWeekend includeWeekend=includeWeekend
includeFarFuture=includeFarFuture includeFarFuture=includeFarFuture
includeMidFuture=includeMidFuture
clearable=clearable clearable=clearable
none="topic.auto_update_input.none"}} none="topic.auto_update_input.none"}}
</div> </div>
{{#if isCustom}} {{#if displayDateAndTimePicker}}
<div class="control-group"> <div class="control-group">
{{d-icon "calendar-alt"}} {{date-picker-future value=date defaultDate=date}} {{d-icon "calendar-alt"}} {{date-picker-future value=date defaultDate=date}}
</div> </div>

View File

@ -0,0 +1,19 @@
{{#d-modal-body title="user.user_notifications.ignore_duration_title" autoFocus="false"}}
{{future-date-input
label="user.user_notifications.ignore_duration_when"
input=ignoredUntil
includeWeekend=true
includeDateTime=false
includeMidFuture=true
includeFarFuture=false}}
<p>{{i18n "user.user_notifications.ignore_duration_note"}}</p>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button class="btn-primary"
disabled=saveDisabled
label="user.user_notifications.ignore_duration_save"
action=(action "ignore")}}
{{conditional-loading-spinner size="small" condition=loading}}
</div>

View File

@ -90,10 +90,22 @@ export const TIMEFRAMES = [
.minute(0), .minute(0),
icon: "briefcase" 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({ buildTimeframe({
id: "three_months", id: "three_months",
format: "MMM D", format: "MMM D",
enabled: opts => opts.includeFarFuture, enabled: opts => opts.includeMidFuture,
when: (time, timeOfDay) => when: (time, timeOfDay) =>
time time
.add(3, "month") .add(3, "month")
@ -102,6 +114,18 @@ export const TIMEFRAMES = [
.minute(0), .minute(0),
icon: "briefcase" 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({ buildTimeframe({
id: "six_months", id: "six_months",
format: "MMM D", format: "MMM D",
@ -139,6 +163,7 @@ export const TIMEFRAMES = [
}), }),
buildTimeframe({ buildTimeframe({
id: "pick_date_and_time", id: "pick_date_and_time",
enabled: opts => opts.includeDateTime,
icon: "far-calendar-plus" icon: "far-calendar-plus"
}), }),
buildTimeframe({ buildTimeframe({
@ -192,7 +217,9 @@ export default ComboBoxComponent.extend(DatetimeMixin, {
now, now,
day: now.day(), day: now.day(),
includeWeekend: this.get("includeWeekend"), includeWeekend: this.get("includeWeekend"),
includeMidFuture: this.get("includeMidFuture") || true,
includeFarFuture: this.get("includeFarFuture"), includeFarFuture: this.get("includeFarFuture"),
includeDateTime: this.get("includeDateTime"),
includeBasedOnLastPost: this.get("statusType") === CLOSE_STATUS_TYPE, includeBasedOnLastPost: this.get("statusType") === CLOSE_STATUS_TYPE,
canScheduleToday: 24 - now.hour() > 6 canScheduleToday: 24 - now.hour() > 6
}; };

View File

@ -1,85 +1,99 @@
import DropdownSelectBox from "select-kit/components/dropdown-select-box"; import DropdownSelectBox from "select-kit/components/dropdown-select-box";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal";
export default DropdownSelectBox.extend({ export default DropdownSelectBox.extend({
classNames: ["user-notifications", "user-notifications-dropdown"], classNames: ["user-notifications", "user-notifications-dropdown"],
nameProperty: "label", nameProperty: "label",
allowInitialValueMutation: false, init() {
this._super(...arguments);
computeHeaderContent() {
let content = this._super(...arguments);
if (this.get("user.ignored")) { if (this.get("user.ignored")) {
this.set("headerIcon", "eye-slash"); this.set("headerIcon", "eye-slash");
content.name = `${I18n.t("user.user_notifications_ignore_option")}`; this.set("value", "changeToIgnored");
} else if (this.get("user.muted")) { } else if (this.get("user.muted")) {
this.set("headerIcon", "times-circle"); this.set("headerIcon", "times-circle");
content.name = `${I18n.t("user.user_notifications_mute_option")}`; this.set("value", "changeToMuted");
} else { } else {
this.set("headerIcon", "user"); this.set("headerIcon", "user");
content.name = `${I18n.t("user.user_notifications_normal_option")}`; this.set("value", "changeToNormal");
} }
return content;
}, },
computeContent() { computeContent() {
const content = []; const content = [];
content.push({ content.push({
icon: "user", icon: "user",
id: "change-to-normal", id: "changeToNormal",
description: I18n.t("user.user_notifications_normal_option_title"), description: I18n.t("user.user_notifications.normal_option_title"),
action: () => this.send("reset"), label: I18n.t("user.user_notifications.normal_option")
label: I18n.t("user.user_notifications_normal_option")
}); });
content.push({ content.push({
icon: "times-circle", icon: "times-circle",
id: "change-to-muted", id: "changeToMuted",
description: I18n.t("user.user_notifications_mute_option_title"), description: I18n.t("user.user_notifications.mute_option_title"),
action: () => this.send("mute"), label: I18n.t("user.user_notifications.mute_option")
label: I18n.t("user.user_notifications_mute_option")
}); });
if (this.get("user.can_ignore_user")) { if (this.get("user.can_ignore_user")) {
content.push({ content.push({
icon: "eye-slash", icon: "eye-slash",
id: "change-to-ignored", id: "changeToIgnored",
description: I18n.t("user.user_notifications_ignore_option_title"), description: I18n.t("user.user_notifications.ignore_option_title"),
action: () => this.send("ignore"), label: I18n.t("user.user_notifications.ignore_option")
label: I18n.t("user.user_notifications_ignore_option")
}); });
} }
return content; return content;
}, },
actions: { changeToNormal() {
reset() {
this.get("updateNotificationLevel")("normal") this.get("updateNotificationLevel")("normal")
.then(() => { .then(() => {
this.set("user.ignored", false); this.set("user.ignored", false);
this.set("user.muted", false); this.set("user.muted", false);
this.computeHeaderContent(); this.set("headerIcon", "user");
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
}, },
mute() { changeToMuted() {
this.get("updateNotificationLevel")("mute") this.get("updateNotificationLevel")("mute")
.then(() => { .then(() => {
this.set("user.ignored", false); this.set("user.ignored", false);
this.set("user.muted", true); this.set("user.muted", true);
this.computeHeaderContent(); this.set("headerIcon", "times-circle");
}) })
.catch(popupAjaxError); .catch(popupAjaxError);
}, },
ignore() { changeToIgnored() {
this.get("updateNotificationLevel")("ignore") const controller = showModal("ignore-duration", {
.then(() => { model: this.get("user")
this.set("user.ignored", true); });
this.set("user.muted", false); controller.setProperties({
this.computeHeaderContent(); onSuccess: () => {
}) this.set("headerIcon", "eye-slash");
.catch(popupAjaxError); },
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: {
onSelect(level) {
this[level]();
} }
} }
}); });

View File

@ -999,7 +999,12 @@ class UsersController < ApplicationController
if params[:notification_level] == "ignore" if params[:notification_level] == "ignore"
guardian.ensure_can_ignore_user!(user.id) guardian.ensure_can_ignore_user!(user.id)
MutedUser.where(user: current_user, muted_user: user).delete_all 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" elsif params[:notification_level] == "mute"
guardian.ensure_can_mute_user!(user.id) guardian.ensure_can_mute_user!(user.id)
IgnoredUser.where(user: current_user, ignored_user: user).delete_all IgnoredUser.where(user: current_user, ignored_user: user).delete_all

View File

@ -3,7 +3,7 @@ module Jobs
every 1.day every 1.day
def execute(args) 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 end
end end

View File

@ -702,12 +702,18 @@ en:
new_private_message: "New Message" new_private_message: "New Message"
private_message: "Message" private_message: "Message"
private_messages: "Messages" private_messages: "Messages"
user_notifications_ignore_option: "Ignored" user_notifications:
user_notifications_ignore_option_title: "You will not receive notifications related to this user and all of their topics and replies will be hidden." ignore_duration_title: "Ignore Timer"
user_notifications_mute_option: "Muted" ignore_duration_when: "Duration:"
user_notifications_mute_option_title: "You will not receive any notifications related to this user." ignore_duration_save: "Ignore"
user_notifications_normal_option: "Normal" ignore_duration_note: "Please note that all ignores are automatically removed after the ignore duration expires."
user_notifications_normal_option_title: "You will be notified if this user replies to you, quotes you, or mentions you." 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" 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."
@ -1895,7 +1901,9 @@ en:
next_week: "Next week" next_week: "Next week"
two_weeks: "Two Weeks" two_weeks: "Two Weeks"
next_month: "Next month" next_month: "Next month"
two_months: "Two Months"
three_months: "Three Months" three_months: "Three Months"
four_months: "Four Months"
six_months: "Six Months" six_months: "Six Months"
one_year: "One Year" one_year: "One Year"
forever: "Forever" forever: "Forever"

View File

@ -0,0 +1,5 @@
class AddExpiringAtColumnToIgnoredUsersTable < ActiveRecord::Migration[5.2]
def change
add_column :ignored_users, :expiring_at, :datetime
end
end

View File

@ -39,5 +39,18 @@ describe Jobs::PurgeExpiredIgnoredUsers do
expect(IgnoredUser.find_by(ignored_user: fred)).to be_nil expect(IgnoredUser.find_by(ignored_user: fred)).to be_nil
end end
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
end end

View File

@ -2065,11 +2065,25 @@ describe UsersController do
end end
context 'when changing notification level to ignore' do 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" } put "/u/#{another_user.username}/notification_level.json", params: { notification_level: "ignore" }
expect(MutedUser.count).to eq(0) expect(MutedUser.count).to eq(0)
expect(IgnoredUser.find_by(user_id: user.id, ignored_user_id: another_user.id)).to be_present expect(IgnoredUser.find_by(user_id: user.id, ignored_user_id: another_user.id)).to be_present
end 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 end
end end