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"