diff --git a/app/assets/javascripts/discourse/app/components/user-notification-schedule-day.js b/app/assets/javascripts/discourse/app/components/user-notification-schedule-day.js
new file mode 100644
index 00000000000..4dc0ea0076c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-notification-schedule-day.js
@@ -0,0 +1,7 @@
+import Component from "@ember/component";
+import { i18n } from "discourse/lib/computed";
+
+export default Component.extend({
+ tagName: "",
+ dayLabel: i18n("day", "user.notification_schedule.%@"),
+});
diff --git a/app/assets/javascripts/discourse/app/components/user-notification-schedule.js b/app/assets/javascripts/discourse/app/components/user-notification-schedule.js
new file mode 100644
index 00000000000..45d72d7c117
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-notification-schedule.js
@@ -0,0 +1,139 @@
+import EmberObject, { action } from "@ember/object";
+import Component from "@ember/component";
+import I18n from "I18n";
+import discourseComputed from "discourse-common/utils/decorators";
+
+const DAYS = [
+ "monday",
+ "tuesday",
+ "wednesday",
+ "thursday",
+ "friday",
+ "saturday",
+ "sunday",
+];
+
+const Day = EmberObject.extend({
+ id: null,
+ startTimeOptions: null,
+ model: null,
+
+ @action
+ onChangeStartTime(val) {
+ this.startingTimeChangedForDay(val);
+ },
+
+ @action
+ onChangeEndTime(val) {
+ this.set(`model.user_notification_schedule.day_${this.id}_end_time`, val);
+ },
+
+ @discourseComputed(
+ "model.user_notification_schedule.day_{0,1,2,3,4,5,6}_start_time"
+ )
+ startTimeValue(schedule) {
+ return schedule[`day_${this.id}_start_time`];
+ },
+
+ @discourseComputed(
+ "model.user_notification_schedule.day_{0,1,2,3,4,5,6}_start_time"
+ )
+ endTimeOptions(schedule) {
+ return this.buildEndTimeOptionsFor(schedule[`day_${this.id}_start_time`]);
+ },
+
+ @discourseComputed(
+ "model.user_notification_schedule.day_{0,1,2,3,4,5,6}_end_time"
+ )
+ endTimeValue(schedule) {
+ return schedule[`day_${this.id}_end_time`];
+ },
+
+ startingTimeChangedForDay(val) {
+ val = parseInt(val, 10);
+ this.model.set(`user_notification_schedule.day_${this.id}_start_time`, val);
+ if (
+ val !== "-1" &&
+ this.model.user_notification_schedule[`day_${this.id}_end_time`] <= val
+ ) {
+ this.model.set(
+ `user_notification_schedule.day_${this.id}_end_time`,
+ val + 30
+ );
+ }
+ },
+
+ buildEndTimeOptionsFor(startTime) {
+ startTime = parseInt(startTime, 10);
+ if (startTime === -1) {
+ return null;
+ }
+ return this.buildTimeOptions(startTime + 30, {
+ includeNone: false,
+ showMidnight: true,
+ });
+ },
+});
+
+export default Component.extend({
+ days: null,
+
+ didInsertElement() {
+ this._super(...arguments);
+ this.set(
+ "startTimeOptions",
+ this.buildTimeOptions(0, {
+ includeNone: true,
+ showMidnight: false,
+ })
+ );
+
+ this.set("days", []);
+
+ DAYS.forEach((day, index) => {
+ this.days.pushObject(
+ Day.create({
+ id: index,
+ day,
+ model: this.model,
+ buildTimeOptions: this.buildTimeOptions,
+ startTimeOptions: this.startTimeOptions,
+ })
+ );
+ });
+ },
+
+ buildTimeOptions(startAt, opts = { includeNone: false, showMidnight: true }) {
+ let timeOptions = [];
+
+ if (opts.includeNone) {
+ timeOptions.push({
+ name: I18n.t("user.notification_schedule.none"),
+ value: -1,
+ });
+ }
+
+ for (let timeInMin = startAt; timeInMin <= 1440; timeInMin += 30) {
+ let hours = Math.floor(timeInMin / 60);
+ let minutes = timeInMin % 60;
+
+ if (minutes === 0) {
+ minutes = "00";
+ }
+ if (hours === 24) {
+ if (opts.showMidnight) {
+ timeOptions.push({
+ name: I18n.t("user.notification_schedule.midnight"),
+ value: 1440,
+ });
+ }
+ break;
+ }
+ timeOptions.push({
+ name: moment().set("hour", hours).set("minute", minutes).format("LT"),
+ value: timeInMin,
+ });
+ }
+ return timeOptions;
+ },
+});
diff --git a/app/assets/javascripts/discourse/app/controllers/do-not-disturb.js b/app/assets/javascripts/discourse/app/controllers/do-not-disturb.js
index 1bf02bafdda..cafa0483ef3 100644
--- a/app/assets/javascripts/discourse/app/controllers/do-not-disturb.js
+++ b/app/assets/javascripts/discourse/app/controllers/do-not-disturb.js
@@ -1,26 +1,18 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { action } from "@ember/object";
-import discourseComputed from "discourse-common/utils/decorators";
import { extractError } from "discourse/lib/ajax-error";
export default Controller.extend(ModalFunctionality, {
duration: null,
- saving: false,
-
- @discourseComputed("saving", "duration")
- saveDisabled(saving, duration) {
- return saving || !duration;
- },
@action
setDuration(duration) {
this.set("duration", duration);
+ this.save();
},
- @action
save() {
- this.set("saving", true);
this.currentUser
.enterDoNotDisturbFor(this.duration)
.then(() => {
@@ -28,9 +20,12 @@ export default Controller.extend(ModalFunctionality, {
})
.catch((e) => {
this.flash(extractError(e), "error");
- })
- .finally(() => {
- this.set("saving", false);
});
},
+
+ @action
+ navigateToNotificationSchedule() {
+ this.transitionToRoute("preferences.notifications", this.currentUser);
+ this.send("closeModal");
+ },
});
diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/notifications.js b/app/assets/javascripts/discourse/app/controllers/preferences/notifications.js
index 52e8b03655f..55b6304d354 100644
--- a/app/assets/javascripts/discourse/app/controllers/preferences/notifications.js
+++ b/app/assets/javascripts/discourse/app/controllers/preferences/notifications.js
@@ -15,6 +15,7 @@ export default Controller.extend({
"like_notification_frequency",
"allow_private_messages",
"enable_allowed_pm_users",
+ "user_notification_schedule",
];
this.likeNotificationFrequencies = [
diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js
index 8e9a844ebe5..7a0b562b43a 100644
--- a/app/assets/javascripts/discourse/app/models/user.js
+++ b/app/assets/javascripts/discourse/app/models/user.js
@@ -59,6 +59,7 @@ let userFields = [
"watching_first_post_tags",
"date_of_birth",
"primary_group_id",
+ "user_notification_schedule",
];
export function addSaveableUserField(fieldName) {
diff --git a/app/assets/javascripts/discourse/app/templates/components/user-notification-schedule-day.hbs b/app/assets/javascripts/discourse/app/templates/components/user-notification-schedule-day.hbs
new file mode 100644
index 00000000000..b03f1672287
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/components/user-notification-schedule-day.hbs
@@ -0,0 +1,22 @@
+
+ {{dayLabel}} |
+
+ {{combo-box
+ valueProperty="value"
+ content=startTimeOptions
+ value=startTimeValue
+ onChange=onChangeStartTime
+ }}
+ |
+ {{#if endTimeOptions}}
+ {{i18n "user.notification_schedule.to"}} |
+
+ {{combo-box
+ valueProperty="value"
+ content=endTimeOptions
+ value=endTimeValue
+ onChange=onChangeEndTime
+ }}
+ |
+ {{/if}}
+
diff --git a/app/assets/javascripts/discourse/app/templates/components/user-notification-schedule.hbs b/app/assets/javascripts/discourse/app/templates/components/user-notification-schedule.hbs
new file mode 100644
index 00000000000..acc1486e76c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/components/user-notification-schedule.hbs
@@ -0,0 +1,26 @@
+
+
+ {{preference-checkbox
+ labelKey="user.notification_schedule.label"
+ checked=model.user_notification_schedule.enabled}}
+
+ {{#if model.user_notification_schedule.enabled}}
+
{{i18n "user.notification_schedule.tip"}}
+
+
+
+ {{#each days as |day|}}
+ {{user-notification-schedule-day
+ day=day.day
+ startTimeOptions=day.startTimeOptions
+ startTimeValue=day.startTimeValue
+ onChangeStartTime=day.onChangeStartTime
+ endTimeOptions=day.endTimeOptions
+ endTimeValue=day.endTimeValue
+ onChangeEndTime=day.onChangeEndTime
+ }}
+ {{/each}}
+
+
+ {{/if}}
+
diff --git a/app/assets/javascripts/discourse/app/templates/modal/do-not-disturb.hbs b/app/assets/javascripts/discourse/app/templates/modal/do-not-disturb.hbs
index ab1e378d777..6a31d7d5596 100644
--- a/app/assets/javascripts/discourse/app/templates/modal/do-not-disturb.hbs
+++ b/app/assets/javascripts/discourse/app/templates/modal/do-not-disturb.hbs
@@ -13,12 +13,8 @@
{{i18n "do_not_disturb.options.tomorrow"}}
{{/tap-tile}}
{{/tap-tile-grid}}
+
+
+ {{i18n "do_not_disturb.set_schedule"}}
+
{{/d-modal-body}}
-
diff --git a/app/assets/javascripts/discourse/app/templates/preferences/notifications.hbs b/app/assets/javascripts/discourse/app/templates/preferences/notifications.hbs
index ad3b52e8a33..7cf61081b9c 100644
--- a/app/assets/javascripts/discourse/app/templates/preferences/notifications.hbs
+++ b/app/assets/javascripts/discourse/app/templates/preferences/notifications.hbs
@@ -52,6 +52,8 @@
{{/unless}}
+{{user-notification-schedule model=model}}
+
{{#if siteSettings.enable_personal_messages}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js
index 51e798734c8..fea8383deed 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js
@@ -35,7 +35,6 @@ acceptance("Do not disturb", function (needs) {
assert.ok(tiles.length === 4, "There are 4 duration choices");
await click(tiles[0]);
- await click(".modal-footer .btn.btn-primary");
assert.ok(
queryAll(".do-not-disturb-modal")[0].style.display === "none",
diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-notifications-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-notifications-test.js
new file mode 100644
index 00000000000..10dd6b8e8ce
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-notifications-test.js
@@ -0,0 +1,108 @@
+import {
+ acceptance,
+ exists,
+ queryAll,
+} from "discourse/tests/helpers/qunit-helpers";
+import { click, visit } from "@ember/test-helpers";
+import selectKit from "discourse/tests/helpers/select-kit-helper";
+import { test } from "qunit";
+
+acceptance("User notification schedule", function (needs) {
+ needs.user();
+
+ test("the schedule interface is hidden until enabled", async function (assert) {
+ await visit("/u/eviltrout/preferences/notifications");
+
+ assert.ok(
+ !exists(".notification-schedule-table"),
+ "notification schedule is hidden"
+ );
+ await click(".control-group.notification-schedule input");
+ assert.ok(
+ exists(".notification-schedule-table"),
+ "notification schedule is visible"
+ );
+ });
+
+ test("By default every day is selected 8:00am - 5:00pm", async function (assert) {
+ await visit("/u/eviltrout/preferences/notifications");
+ await click(".control-group.notification-schedule input");
+
+ [
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday",
+ ].forEach((day) => {
+ assert.equal(
+ selectKit(`.day.${day} .starts-at .combobox`).header().label(),
+ "8:00 AM",
+ "8am is selected"
+ );
+ assert.equal(
+ selectKit(`.day.${day} .starts-at .combobox`).header().value(),
+ "480",
+ "8am is 480"
+ );
+ assert.equal(
+ selectKit(`.day.${day} .ends-at .combobox`).header().label(),
+ "5:00 PM",
+ "5am is selected"
+ );
+ assert.equal(
+ selectKit(`.day.${day} .ends-at .combobox`).header().value(),
+ "1020",
+ "5pm is 1020"
+ );
+ });
+ });
+
+ test("If 'none' is selected for the start time, end time dropdown is removed", async function (assert) {
+ await visit("/u/eviltrout/preferences/notifications");
+ await click(".control-group.notification-schedule input");
+
+ await selectKit(".day.Monday .combobox").expand();
+ await selectKit(".day.Monday .combobox").selectRowByValue(-1);
+
+ assert.equal(
+ selectKit(".day.Monday .starts-at .combobox").header().value(),
+ "-1",
+ "set monday input to none"
+ );
+ assert.equal(
+ selectKit(".day.Monday .starts-at .combobox").header().label(),
+ "None",
+ "set monday label to none"
+ );
+ assert.equal(
+ queryAll(".day.Monday .select-kit.single-select").length,
+ 1,
+ "The end time input is hidden"
+ );
+ });
+
+ test("If start time is after end time, end time gets bumped 30 minutes past start time", async function (assert) {
+ await visit("/u/eviltrout/preferences/notifications");
+ await click(".control-group.notification-schedule input");
+
+ await selectKit(".day.Tuesday .starts-at .combobox").expand();
+ await selectKit(".day.Tuesday .starts-at .combobox").selectRowByValue(
+ "1350"
+ );
+
+ assert.equal(
+ selectKit(".day.Tuesday .ends-at .combobox").header().value(),
+ "1380",
+ "End time is 30 past start time"
+ );
+
+ await selectKit(".day.Tuesday .ends-at .combobox").expand();
+ assert.ok(
+ !selectKit(".day.Tuesday .ends-at .combobox").rowByValue(1350).exists(),
+ "End time options are limited to + 30 past start time"
+ );
+ });
+});
diff --git a/app/assets/javascripts/discourse/tests/fixtures/user-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/user-fixtures.js
index d88adf77926..6d78f895a22 100644
--- a/app/assets/javascripts/discourse/tests/fixtures/user-fixtures.js
+++ b/app/assets/javascripts/discourse/tests/fixtures/user-fixtures.js
@@ -276,7 +276,24 @@ export default {
seen_at: "2018-09-08T21:44:42.209Z",
is_active: false
}
- ]
+ ],
+ user_notification_schedule: {
+ enabled: false,
+ day_0_start_time: 480,
+ day_0_end_time: 1020,
+ day_1_start_time: 480,
+ day_1_end_time: 1020,
+ day_2_start_time: 480,
+ day_2_end_time: 1020,
+ day_3_start_time: 480,
+ day_3_end_time: 1020,
+ day_4_start_time: 480,
+ day_4_end_time: 1020,
+ day_5_start_time: 480,
+ day_5_end_time: 1020,
+ day_6_start_time: 480,
+ day_6_end_time: 1020,
+ }
}
},
"/u/eviltrout/card.json": {
diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss
index 272ce23aadd..dfe6c338257 100644
--- a/app/assets/stylesheets/common/base/user.scss
+++ b/app/assets/stylesheets/common/base/user.scss
@@ -789,3 +789,24 @@
grid-column: 1 / span 2;
}
}
+
+.notification-schedule {
+ .instruction {
+ margin-top: 12px;
+ margin-bottom: 10px;
+ }
+
+ .notification-schedule-table {
+ .notification-schedule-tbody {
+ border-top-width: 1px;
+ .day {
+ .day-label {
+ padding: 1em 1em 1em 0;
+ }
+ .to {
+ padding: 0 0.5em;
+ }
+ }
+ }
+ }
+}
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index f5301bdd872..2c4163e986a 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1624,6 +1624,7 @@ class UsersController < ApplicationController
permitted.concat UserUpdater::OPTION_ATTR
permitted.concat UserUpdater::CATEGORY_IDS.keys.map { |k| { k => [] } }
permitted.concat UserUpdater::TAG_NAMES.keys
+ permitted << UserUpdater::NOTIFICATION_SCHEDULE_ATTRS
result = params
.permit(permitted, theme_ids: [])
diff --git a/app/jobs/scheduled/process_user_notification_schedules.rb b/app/jobs/scheduled/process_user_notification_schedules.rb
new file mode 100644
index 00000000000..a82db6e2d59
--- /dev/null
+++ b/app/jobs/scheduled/process_user_notification_schedules.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Jobs
+ class ProcessUserNotificationSchedules < ::Jobs::Scheduled
+ every 1.day
+
+ def execute(args)
+ UserNotificationSchedule.enabled.includes(:user).each do |schedule|
+ begin
+ schedule.create_do_not_disturb_timings
+ rescue
+ Rails.logger.warn("Failed to process user_notification_schedule with ID #{schedule.id}")
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 0fa842fc8e2..8b6f58d13cb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -47,6 +47,7 @@ class User < ActiveRecord::Base
has_one :anonymous_user_master, class_name: 'AnonymousUser', dependent: :destroy
has_one :anonymous_user_shadow, ->(record) { where(active: true) }, foreign_key: :master_user_id, class_name: 'AnonymousUser', dependent: :destroy
has_one :invited_user, dependent: :destroy
+ has_one :user_notification_schedule, dependent: :destroy
# delete all is faster but bypasses callbacks
has_many :bookmarks, dependent: :delete_all
diff --git a/app/models/user_notification_schedule.rb b/app/models/user_notification_schedule.rb
new file mode 100644
index 00000000000..18892506db3
--- /dev/null
+++ b/app/models/user_notification_schedule.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class UserNotificationSchedule < ActiveRecord::Base
+ belongs_to :user
+
+ DEFAULT = -> {
+ attrs = { enabled: false }
+ 7.times do |n|
+ attrs["day_#{n}_start_time".to_sym] = 480
+ attrs["day_#{n}_end_time".to_sym] = 1020
+ end
+ attrs
+ }.call
+
+ validate :has_valid_times
+ validates :enabled, inclusion: { in: [ true, false ] }
+
+ scope :enabled, -> { where(enabled: true) }
+
+ def create_do_not_disturb_timings(delete_existing: false)
+ user.do_not_disturb_timings.where(scheduled: true).destroy_all if delete_existing
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(self)
+ end
+
+ private
+
+ def has_valid_times
+ 7.times do |n|
+ start_key = "day_#{n}_start_time"
+ end_key = "day_#{n}_end_time"
+
+ if self[start_key].nil? || self[start_key] > 1410 || self[start_key] < -1
+ errors.add(start_key, "is invalid")
+ end
+
+ if self[end_key].nil? || self[end_key] > 1440
+ errors.add(end_key, "is invalid")
+ end
+
+ if self[start_key] && self[end_key] && self[start_key] > self[end_key]
+ errors.add(start_key, "is after end time")
+ end
+ end
+ end
+end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index 3935ce20522..08766faf5b5 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -58,7 +58,8 @@ class UserSerializer < UserCardSerializer
:can_change_location,
:can_change_website,
:user_api_keys,
- :user_auth_tokens
+ :user_auth_tokens,
+ :user_notification_schedule
untrusted_attributes :bio_raw,
:bio_cooked,
@@ -67,6 +68,10 @@ class UserSerializer < UserCardSerializer
###
### ATTRIBUTES
###
+ #
+ def user_notification_schedule
+ object.user_notification_schedule || UserNotificationSchedule::DEFAULT
+ end
def mailing_list_posts_per_day
val = Post.estimate_posts_per_day
diff --git a/app/services/user_notification_schedule_processor.rb b/app/services/user_notification_schedule_processor.rb
new file mode 100644
index 00000000000..7b924b22d43
--- /dev/null
+++ b/app/services/user_notification_schedule_processor.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+class UserNotificationScheduleProcessor
+
+ attr_accessor :schedule, :user, :timezone_name
+
+ def initialize(schedule)
+ @schedule = schedule
+ @user = schedule.user
+ @timezone_name = user.user_option.timezone
+ end
+
+ def create_do_not_disturb_timings
+ local_time = Time.now.in_time_zone(timezone_name)
+
+ create_timings_for(local_time, days: 2)
+ end
+
+ def self.create_do_not_disturb_timings_for(schedule)
+ processor = UserNotificationScheduleProcessor.new(schedule)
+ processor.create_do_not_disturb_timings
+ end
+
+ private
+
+ def create_timings_for(local_time, days: 0, previous_timing: nil)
+ weekday = transform_wday(local_time.wday)
+ start_minute = schedule["day_#{weekday}_start_time"]
+ end_minute = schedule["day_#{weekday}_end_time"]
+
+ previous_timing = find_previous_timing(local_time) if previous_timing.nil? && start_minute != 0
+
+ if start_minute > 0
+ previous_timing.ends_at = utc_time_at_minute(local_time, start_minute - 1)
+ if previous_timing.id
+ previous_timing.save
+ else
+ user.do_not_disturb_timings.find_or_create_by(previous_timing.attributes.except("id"))
+ end
+
+ next_timing = user.do_not_disturb_timings.new(
+ starts_at: utc_time_at_minute(local_time, end_minute),
+ scheduled: true
+ )
+ save_timing_and_continue(local_time, next_timing, days)
+ else
+ save_timing_and_continue(local_time, previous_timing, days)
+ end
+ end
+
+ private
+
+ def find_previous_timing(local_time)
+ # Try and find a previously scheduled dnd timing that we can extend if the
+ # ends_at is at the previous midnight. fallback to a new timing if not.
+ previous = user.do_not_disturb_timings.find_by(
+ ends_at: (local_time - 1.day).end_of_day.utc,
+ scheduled: true
+ )
+ previous || user.do_not_disturb_timings.new(
+ starts_at: local_time.beginning_of_day.utc,
+ scheduled: true
+ )
+ end
+
+ def save_timing_and_continue(local_time, timing, days)
+ if days == 0
+ if timing
+ timing.ends_at = local_time.end_of_day.utc
+ user.do_not_disturb_timings.find_or_create_by(timing.attributes.except("id"))
+ end
+ user.publish_do_not_disturb(ends_at: user.do_not_disturb_until)
+ else
+ create_timings_for(local_time + 1.day, days: days - 1, previous_timing: timing)
+ end
+ end
+
+ def utc_time_at_minute(base_time, total_minutes)
+ hour = total_minutes / 60
+ minute = total_minutes % 60
+ Time.new(base_time.year, base_time.month, base_time.day, hour, minute, 0, base_time.formatted_offset).utc
+ end
+
+ def transform_wday(wday)
+ wday == 0 ? 6 : wday - 1
+ end
+end
diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb
index 9471f0c8d86..41593b84db1 100644
--- a/app/services/user_updater.rb
+++ b/app/services/user_updater.rb
@@ -49,6 +49,15 @@ class UserUpdater
:skip_new_user_tips
]
+ NOTIFICATION_SCHEDULE_ATTRS = -> {
+ attrs = [:enabled]
+ 7.times do |n|
+ attrs.push("day_#{n}_start_time".to_sym)
+ attrs.push("day_#{n}_end_time".to_sym)
+ end
+ { user_notification_schedule: attrs }
+ }.call
+
def initialize(actor, user)
@user = user
@guardian = Guardian.new(actor)
@@ -82,6 +91,11 @@ class UserUpdater
user_profile.card_background_upload_id = upload.id
end
+ if attributes[:user_notification_schedule]
+ user_notification_schedule = user.user_notification_schedule || UserNotificationSchedule.new(user: user)
+ user_notification_schedule.assign_attributes(attributes[:user_notification_schedule])
+ end
+
old_user_name = user.name.present? ? user.name : ""
user.name = attributes.fetch(:name) { user.name }
@@ -170,7 +184,7 @@ class UserUpdater
end
name_changed = user.name_changed?
- if (saved = (!save_options || user.user_option.save) && user_profile.save && user.save) &&
+ if (saved = (!save_options || user.user_option.save) && (user_notification_schedule.nil? || user_notification_schedule.save) && user_profile.save && user.save) &&
(name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0)
StaffActionLogger.new(@actor).log_name_change(
@@ -184,7 +198,13 @@ class UserUpdater
return saved
end
- DiscourseEvent.trigger(:user_updated, user) if saved
+ if saved
+ if user_notification_schedule
+ user_notification_schedule.create_do_not_disturb_timings(delete_existing: true)
+ end
+ DiscourseEvent.trigger(:user_updated, user)
+ end
+
saved
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index f427e96a591..5c36c52cc8e 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -907,6 +907,20 @@ en:
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."
+ notification_schedule:
+ title: "Notification Schedule"
+ label: "Enable custom notification schedule"
+ tip: "Outside of these hours you will be put in 'do not disturb' automatically."
+ midnight: "Midnight"
+ none: "None"
+ monday: "Monday"
+ tuesday: "Tuesday"
+ wednesday: "Wednesday"
+ thursday: "Thursday"
+ friday: "Friday"
+ saturday: "Saturday"
+ sunday: "Sunday"
+ to: "to"
activity_stream: "Activity"
preferences: "Preferences"
feature_topic_on_profile:
@@ -3546,7 +3560,6 @@ en:
do_not_disturb:
title: "Do not disturb for..."
- save: "Save"
label: "Do not disturb"
remaining: "%{remaining} remaining"
options:
@@ -3555,6 +3568,7 @@ en:
two_hours: "2 hours"
tomorrow: "Until tomorrow"
custom: "Custom"
+ set_schedule: "Set a notification schedule"
# This section is exported to the javascript for i18n in the admin section
admin_js:
diff --git a/db/migrate/20210106181418_create_user_notification_schedules.rb b/db/migrate/20210106181418_create_user_notification_schedules.rb
new file mode 100644
index 00000000000..4e561841fd6
--- /dev/null
+++ b/db/migrate/20210106181418_create_user_notification_schedules.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class CreateUserNotificationSchedules < ActiveRecord::Migration[6.0]
+ def change
+ create_table :user_notification_schedules do |t|
+ t.integer :user_id, null: false
+ t.boolean :enabled, null: false, default: false
+ t.integer :day_0_start_time, null: false
+ t.integer :day_0_end_time, null: false
+ t.integer :day_1_start_time, null: false
+ t.integer :day_1_end_time, null: false
+ t.integer :day_2_start_time, null: false
+ t.integer :day_2_end_time, null: false
+ t.integer :day_3_start_time, null: false
+ t.integer :day_3_end_time, null: false
+ t.integer :day_4_start_time, null: false
+ t.integer :day_4_end_time, null: false
+ t.integer :day_5_start_time, null: false
+ t.integer :day_5_end_time, null: false
+ t.integer :day_6_start_time, null: false
+ t.integer :day_6_end_time, null: false
+ end
+
+ add_index :user_notification_schedules, [:user_id]
+ add_index :user_notification_schedules, [:enabled]
+
+ add_column :do_not_disturb_timings, :scheduled, :boolean, default: false
+ add_index :do_not_disturb_timings, [:scheduled]
+ end
+end
diff --git a/spec/models/user_notification_schedule_spec.rb b/spec/models/user_notification_schedule_spec.rb
new file mode 100644
index 00000000000..63eecb149d9
--- /dev/null
+++ b/spec/models/user_notification_schedule_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe UserNotificationSchedule do
+ fab!(:user) { Fabricate(:user) }
+
+ describe "validations" do
+ it 'is invalid when no times are specified' do
+ schedule = UserNotificationSchedule.create({
+ user: user,
+ enabled: true
+ })
+ expect(schedule.errors.keys).to eq([
+ :day_0_start_time,
+ :day_0_end_time,
+ :day_1_start_time,
+ :day_1_end_time,
+ :day_2_start_time,
+ :day_2_end_time,
+ :day_3_start_time,
+ :day_3_end_time,
+ :day_4_start_time,
+ :day_4_end_time,
+ :day_5_start_time,
+ :day_5_end_time,
+ :day_6_start_time,
+ :day_6_end_time,
+ ])
+ end
+
+ it 'is invalid when a start time is below -1' do
+ schedule = UserNotificationSchedule.new({
+ user: user,
+ }.merge(UserNotificationSchedule::DEFAULT))
+ schedule.day_0_start_time = -2
+ schedule.save
+ expect(schedule.errors.count).to eq(1)
+ expect(schedule.errors[:day_0_start_time]).to be_present
+ end
+
+ it 'invalid when an end time is greater than 1440' do
+ schedule = UserNotificationSchedule.new({
+ user: user,
+ }.merge(UserNotificationSchedule::DEFAULT))
+ schedule.day_0_end_time = 1441
+ schedule.save
+ expect(schedule.errors.count).to eq(1)
+ expect(schedule.errors[:day_0_end_time]).to be_present
+ end
+
+ it 'invalid when the start time is greater than the end time' do
+ schedule = UserNotificationSchedule.new({
+ user: user,
+ }.merge(UserNotificationSchedule::DEFAULT))
+ schedule.day_0_start_time = 1000
+ schedule.day_0_end_time = 800
+ schedule.save
+ expect(schedule.errors.count).to eq(1)
+ expect(schedule.errors[:day_0_start_time]).to be_present
+ end
+ end
+end
diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb
index a5d4af55c16..efe34e09174 100644
--- a/spec/requests/users_controller_spec.rb
+++ b/spec/requests/users_controller_spec.rb
@@ -1955,6 +1955,38 @@ describe UsersController do
end
end
+ context "with user_notification_schedule attributes" do
+ it "updates the user's notification schedule" do
+ params = {
+ user_notification_schedule: {
+ enabled: true,
+ day_0_start_time: 30,
+ day_0_end_time: 60,
+ day_1_start_time: 30,
+ day_1_end_time: 60,
+ day_2_start_time: 30,
+ day_2_end_time: 60,
+ day_3_start_time: 30,
+ day_3_end_time: 60,
+ day_4_start_time: 30,
+ day_4_end_time: 60,
+ day_5_start_time: 30,
+ day_5_end_time: 60,
+ day_6_start_time: 30,
+ day_6_end_time: 60,
+ }
+ }
+ put "/u/#{user.username}.json", params: params
+
+ user.reload
+ expect(user.user_notification_schedule.enabled).to eq(true)
+ expect(user.user_notification_schedule.day_0_start_time).to eq(30)
+ expect(user.user_notification_schedule.day_0_end_time).to eq(60)
+ expect(user.user_notification_schedule.day_6_start_time).to eq(30)
+ expect(user.user_notification_schedule.day_6_end_time).to eq(60)
+ end
+ end
+
context "uneditable field" do
let!(:user_field) { Fabricate(:user_field, editable: false) }
diff --git a/spec/serializers/web_hook_user_serializer_spec.rb b/spec/serializers/web_hook_user_serializer_spec.rb
index 75ca07f5b04..c26b58f7aa2 100644
--- a/spec/serializers/web_hook_user_serializer_spec.rb
+++ b/spec/serializers/web_hook_user_serializer_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe WebHookUserSerializer do
it 'should only include the required keys' do
count = serializer.as_json.keys.count
- difference = count - 50
+ difference = count - 51
expect(difference).to eq(0), lambda {
message = (difference < 0 ?
diff --git a/spec/services/user_notification_schedule_processor_spec.rb b/spec/services/user_notification_schedule_processor_spec.rb
new file mode 100644
index 00000000000..152667df941
--- /dev/null
+++ b/spec/services/user_notification_schedule_processor_spec.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe UserNotificationScheduleProcessor do
+ include ActiveSupport::Testing::TimeHelpers
+
+ fab!(:user) { Fabricate(:user) }
+ let(:standard_schedule) {
+ schedule = UserNotificationSchedule.create({
+ user: user
+ }.merge(UserNotificationSchedule::DEFAULT))
+ schedule.enabled = true
+ schedule.save
+ schedule
+ }
+
+ describe "#create_do_not_disturb_timings" do
+ [
+ { timezone: "UTC", offset: "+00:00" },
+ { timezone: "America/Chicago", offset: "-06:00" },
+ { timezone: "Australia/Sydney", offset: "+11:00" },
+ ].each do |timezone_info|
+ it 'creates dnd timings correctly for each timezone' do
+ user.user_option.update(timezone: timezone_info[:timezone])
+
+ travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
+
+ # The default schedule is 8am - 5pm.
+ # Expext DND timings to fill gaps before/after those times for 3 days.
+ dnd_timings = user.do_not_disturb_timings
+ offset = timezone_info[:offset]
+ expect(dnd_timings[0].starts_at).to eq_time(Time.new(2020, 1, 4, 0, 0, 0, offset))
+ expect(dnd_timings[0].ends_at).to eq_time(Time.new(2020, 1, 4, 7, 59, 0, offset))
+
+ expect(dnd_timings[1].starts_at).to eq_time(Time.new(2020, 1, 4, 17, 0, 0, offset))
+ expect(dnd_timings[1].ends_at).to eq_time(Time.new(2020, 1, 5, 7, 59, 0, offset))
+
+ expect(dnd_timings[2].starts_at).to eq_time(Time.new(2020, 1, 5, 17, 0, 0, offset))
+ expect(dnd_timings[2].ends_at).to eq_time(Time.new(2020, 1, 6, 7, 59, 0, offset))
+
+ expect(dnd_timings[3].starts_at).to eq_time(Time.new(2020, 1, 6, 17, 0, 0, offset))
+ expect(dnd_timings[3].ends_at).to be_within(1.second).of Time.new(2020, 1, 6, 23, 59, 59, offset)
+ end
+ end
+ end
+
+ it 'does not create duplicate record, but ensures the correct records exist' do
+ user.user_option.update(timezone: "UTC")
+
+ travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
+ expect(user.do_not_disturb_timings.count).to eq(4)
+ # All duplicates, so no new timings should be created
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
+ expect(user.do_not_disturb_timings.count).to eq(4)
+ end
+
+ travel_to Time.new(2020, 1, 5, 12, 0, 0, "+00:00") do
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
+ # There is 1 overlap, so expect only 3 more to be created
+ expect(user.do_not_disturb_timings.count).to eq(7)
+ end
+
+ travel_to Time.new(2020, 1, 10, 12, 0, 0, "+00:00") do
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
+ # There is no overlap, so expect only 4 more to be created
+ expect(user.do_not_disturb_timings.count).to eq(11)
+ end
+ end
+
+ it 'extends previously scheduled dnd timings to remove gaps' do
+ user.user_option.update(timezone: "UTC")
+
+ travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
+ existing_timing = user.do_not_disturb_timings.create(
+ scheduled: true,
+ starts_at: 1.day.ago,
+ ends_at: Time.new(2020, 1, 03, 11, 0, 0, "+00:00").end_of_day
+ )
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
+ expect(existing_timing.reload.ends_at).to eq_time(Time.new(2020, 1, 4, 7, 59, 0, "+00:00"))
+ end
+ end
+
+ it 'creates the correct timings when the whole schedule is DND (-1)' do
+ user.user_option.update(timezone: "UTC")
+ schedule = standard_schedule
+ schedule.update(
+ day_0_start_time: -1,
+ day_1_start_time: -1,
+ day_2_start_time: -1,
+ day_3_start_time: -1,
+ day_4_start_time: -1,
+ day_5_start_time: -1,
+ day_6_start_time: -1,
+ )
+
+ travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
+ expect(user.do_not_disturb_timings.count).to eq(1)
+ expect(user.do_not_disturb_timings.first.starts_at).to eq_time(Time.new(2020, 1, 4, 0, 0, 0, "+00:00"))
+ expect(user.do_not_disturb_timings.first.ends_at).to be_within(1.second).of Time.new(2020, 1, 6, 23, 59, 59, "+00:00")
+ end
+ end
+
+ it 'creates the correct timings at the end of a month and year' do
+ user.user_option.update(timezone: "UTC")
+ schedule = standard_schedule
+ schedule.update(
+ day_3_start_time: -1, # December 31, 2020 was a thursday. testing more cases.
+ )
+
+ travel_to Time.new(2020, 12, 31, 12, 0, 0, "+00:00") do
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
+ expect(user.do_not_disturb_timings[0].starts_at).to eq_time(Time.new(2020, 12, 31, 0, 0, 0, "+00:00"))
+ expect(user.do_not_disturb_timings[0].ends_at).to eq_time(Time.new(2021, 1, 1, 7, 59, 0, "+00:00"))
+ expect(user.do_not_disturb_timings[1].starts_at).to eq_time(Time.new(2021, 1, 1, 17, 0, 0, "+00:00"))
+ expect(user.do_not_disturb_timings[1].ends_at).to eq_time(Time.new(2021, 1, 2, 7, 59, 0, "+00:00"))
+ expect(user.do_not_disturb_timings[2].starts_at).to eq_time(Time.new(2021, 1, 2, 17, 0, 0, "+00:00"))
+ expect(user.do_not_disturb_timings[2].ends_at).to be_within(1.second).of Time.new(2021, 1, 2, 23, 59, 59, "+00:00")
+ end
+ end
+
+ it 'handles midnight to midnight for multiple days (no timings created)' do
+ user.user_option.update(timezone: "UTC")
+ schedule = standard_schedule
+ schedule.update(
+ day_0_start_time: 0,
+ day_0_end_time: 1440,
+ day_1_start_time: 0,
+ day_1_end_time: 1440,
+ day_2_start_time: 0,
+ day_2_end_time: 1440,
+ )
+ travel_to Time.new(2021, 1, 4, 12, 0, 0, "+00:00") do
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
+ expect(user.do_not_disturb_timings.count).to eq(0)
+ end
+
+ end
+
+ it 'publishes to message bus when the user should enter DND' do
+ user.user_option.update(timezone: "UTC")
+ schedule = standard_schedule
+ travel_to Time.new(2020, 12, 31, 1, 0, 0, "+00:00") do
+ MessageBus.expects(:publish).with(
+ "/do-not-disturb/#{user.id}",
+ { ends_at: Time.new(2020, 12, 31, 7, 59, 0, "+00:00").httpdate },
+ user_ids: [user.id]
+ )
+ UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
+ end
+ end
+ end
+end
diff --git a/spec/services/user_updater_spec.rb b/spec/services/user_updater_spec.rb
index b9163a2a8c6..abbaadf9359 100644
--- a/spec/services/user_updater_spec.rb
+++ b/spec/services/user_updater_spec.rb
@@ -194,6 +194,65 @@ describe UserUpdater do
expect(user.user_option.theme_ids).to eq([theme.id, child.id])
end
+ let(:schedule_attrs) {
+ {
+ enabled: true,
+ day_0_start_time: 30,
+ day_0_end_time: 60,
+ day_1_start_time: 30,
+ day_1_end_time: 60,
+ day_2_start_time: 30,
+ day_2_end_time: 60,
+ day_3_start_time: 30,
+ day_3_end_time: 60,
+ day_4_start_time: 30,
+ day_4_end_time: 60,
+ day_5_start_time: 30,
+ day_5_end_time: 60,
+ day_6_start_time: 30,
+ day_6_end_time: 60,
+ }
+ }
+
+ context 'with user_notification_schedule' do
+ fab!(:user) { Fabricate(:user) }
+
+ it "allows users to create their notification schedule when it doesn't exist previously" do
+ expect(user.user_notification_schedule).to be_nil
+ updater = UserUpdater.new(acting_user, user)
+
+ updater.update(user_notification_schedule: schedule_attrs)
+ user.reload
+ expect(user.user_notification_schedule.enabled).to eq(true)
+ expect(user.user_notification_schedule.day_0_start_time).to eq(30)
+ expect(user.user_notification_schedule.day_0_end_time).to eq(60)
+ expect(user.user_notification_schedule.day_6_start_time).to eq(30)
+ expect(user.user_notification_schedule.day_6_end_time).to eq(60)
+ end
+
+ it "allows users to update their notification schedule" do
+ UserNotificationSchedule.create({
+ user: user,
+ }.merge(UserNotificationSchedule::DEFAULT))
+ updater = UserUpdater.new(acting_user, user)
+ updater.update(user_notification_schedule: schedule_attrs)
+ user.reload
+ expect(user.user_notification_schedule.enabled).to eq(true)
+ expect(user.user_notification_schedule.day_0_start_time).to eq(30)
+ expect(user.user_notification_schedule.day_0_end_time).to eq(60)
+ expect(user.user_notification_schedule.day_6_start_time).to eq(30)
+ expect(user.user_notification_schedule.day_6_end_time).to eq(60)
+ end
+
+ it "processes the schedule and do_not_disturb_timings are created" do
+ updater = UserUpdater.new(acting_user, user)
+
+ expect {
+ updater.update(user_notification_schedule: schedule_attrs)
+ }.to change { user.do_not_disturb_timings.count }.by(4)
+ end
+ end
+
context 'when sso overrides bio' do
it 'does not change bio' do
SiteSetting.sso_url = "https://www.example.com/sso"