diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index 578a4a4e47f..0a6b8aa4f0e 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -6,6 +6,7 @@ import PanEvents, { import { cancel, later, schedule } from "@ember/runloop"; import Docking from "discourse/mixins/docking"; import MountWidget from "discourse/components/mount-widget"; +import { isTesting } from "discourse-common/config/environment"; import { observes } from "discourse-common/utils/decorators"; import { topicTitleDecorators } from "discourse/components/topic-title"; @@ -13,6 +14,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { widget: "header", docAt: null, dockedHeader: null, + _listenToDoNotDisturbLoop: null, _animate: false, _isPanning: false, _panMenuOrigin: "right", @@ -194,12 +196,32 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { } }, + listenForDoNotDisturbChanges() { + if (this.currentUser && !this.currentUser.isInDoNotDisturb()) { + this.queueRerender(); + } else { + cancel(this._listenToDoNotDisturbLoop); + this._listenToDoNotDisturbLoop = later( + this, + () => { + this.listenForDoNotDisturbChanges(); + }, + 10000 + ); + } + }, + didInsertElement() { this._super(...arguments); $(window).on("resize.discourse-menu-panel", () => this.afterRender()); this.appEvents.on("header:show-topic", this, "setTopic"); this.appEvents.on("header:hide-topic", this, "setTopic"); + this.appEvents.on("do-not-disturb:changed", () => this.queueRerender()); + + if (!isTesting()) { + this.listenForDoNotDisturbChanges(); + } this.dispatch("notifications:changed", "user-notifications"); this.dispatch("header:keyboard-trigger", "header"); @@ -250,6 +272,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { this.appEvents.off("dom:clean", this, "_cleanDom"); cancel(this._scheduledRemoveAnimate); + cancel(this._listenToDoNotDisturbLoop); window.cancelAnimationFrame(this._scheduledMovingAnimation); document.removeEventListener("click", this._dismissFirstNotification); diff --git a/app/assets/javascripts/discourse/app/controllers/do-not-disturb.js b/app/assets/javascripts/discourse/app/controllers/do-not-disturb.js new file mode 100644 index 00000000000..1bf02bafdda --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/do-not-disturb.js @@ -0,0 +1,36 @@ +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); + }, + + @action + save() { + this.set("saving", true); + this.currentUser + .enterDoNotDisturbFor(this.duration) + .then(() => { + this.send("closeModal"); + }) + .catch((e) => { + this.flash(extractError(e), "error"); + }) + .finally(() => { + this.set("saving", false); + }); + }, +}); diff --git a/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js b/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js index 4559df7e551..2e93d643ea3 100644 --- a/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js +++ b/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js @@ -113,6 +113,10 @@ export default { user.notification_channel_position ); + bus.subscribe(`/do-not-disturb/${user.get("id")}`, (data) => { + user.updateDoNotDisturbStatus(data.ends_at); + }); + const site = container.lookup("site:main"); const siteSettings = container.lookup("site-settings:main"); const router = container.lookup("router:main"); @@ -130,7 +134,7 @@ export default { if (!isTesting()) { bus.subscribe(alertChannel(user), (data) => - onNotification(data, siteSettings) + onNotification(data, siteSettings, user) ); initDesktopNotifications(bus, appEvents); diff --git a/app/assets/javascripts/discourse/app/lib/desktop-notifications.js b/app/assets/javascripts/discourse/app/lib/desktop-notifications.js index 5513b021ee0..5e6053b43bc 100644 --- a/app/assets/javascripts/discourse/app/lib/desktop-notifications.js +++ b/app/assets/javascripts/discourse/app/lib/desktop-notifications.js @@ -136,7 +136,7 @@ function isIdle() { } // Call-in point from message bus -function onNotification(data, siteSettings) { +function onNotification(data, siteSettings, user) { if (!liveEnabled) { return; } @@ -146,6 +146,9 @@ function onNotification(data, siteSettings) { if (!isIdle()) { return; } + if (user.isInDoNotDisturb()) { + return; + } if (keyValueStore.getItem("notifications-disabled")) { return; } diff --git a/app/assets/javascripts/discourse/app/lib/push-notifications.js b/app/assets/javascripts/discourse/app/lib/push-notifications.js index 4c21b86e3f1..b9ed09fe9b2 100644 --- a/app/assets/javascripts/discourse/app/lib/push-notifications.js +++ b/app/assets/javascripts/discourse/app/lib/push-notifications.js @@ -75,6 +75,7 @@ export function isPushNotificationsSupported(mobileView) { export function isPushNotificationsEnabled(user, mobileView) { return ( user && + !user.isInDoNotDisturb() && isPushNotificationsSupported(mobileView) && keyValueStore.getItem(userSubscriptionKey(user)) ); diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index 0a1a94e422f..8e9a844ebe5 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -958,6 +958,37 @@ const User = RestModel.extend({ return muted_ids.filter((existing_id) => existing_id !== id); } }, + + enterDoNotDisturbFor(duration) { + return ajax({ + url: "/do-not-disturb.json", + type: "POST", + data: { duration }, + }).then((response) => { + return this.updateDoNotDisturbStatus(response.ends_at); + }); + }, + + leaveDoNotDisturb() { + return ajax({ + url: "/do-not-disturb.json", + type: "DELETE", + }).then(() => { + this.updateDoNotDisturbStatus(null); + }); + }, + + updateDoNotDisturbStatus(ends_at) { + this.set("do_not_disturb_until", ends_at); + this.appEvents.trigger("do-not-disturb:changed", this.do_not_disturb_until); + }, + + isInDoNotDisturb() { + return ( + this.do_not_disturb_until && + new Date(this.do_not_disturb_until) >= new Date() + ); + }, }); User.reopenClass(Singleton, { diff --git a/app/assets/javascripts/discourse/app/templates/components/tap-tile.hbs b/app/assets/javascripts/discourse/app/templates/components/tap-tile.hbs index df17a2fd982..1789711e151 100644 --- a/app/assets/javascripts/discourse/app/templates/components/tap-tile.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/tap-tile.hbs @@ -1,2 +1,4 @@ -{{d-icon icon}} +{{#if icon}} + {{d-icon icon}} +{{/if}} {{ yield }} diff --git a/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs b/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs index 14f3bc88c9e..ba19e49d429 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs @@ -38,7 +38,7 @@ {{#if userHasTimezoneSet}} {{#tap-tile-grid activeTile=selectedReminderType as |grid|}} {{#if showLaterToday}} - {{#tap-tile icon="far-moon" tileId=reminderTypes.LATER_TODAY activeTile=grid.activeTile onChange=(action "selectReminderType")}} + {{#tap-tile icon="angle-right" tileId=reminderTypes.LATER_TODAY activeTile=grid.activeTile onChange=(action "selectReminderType")}}
{{i18n "bookmarks.reminders.later_today"}}
{{laterTodayFormatted}}
{{/tap-tile}} 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 new file mode 100644 index 00000000000..ab1e378d777 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/do-not-disturb.hbs @@ -0,0 +1,24 @@ +{{#d-modal-body title="do_not_disturb.title"}} + {{#tap-tile-grid activeTile=duration as |grid|}} + {{#tap-tile class="do-not-disturb-tile" tileId="30" activeTile=grid.activeTile onChange=(action "setDuration")}} + {{i18n "do_not_disturb.options.half_hour"}} + {{/tap-tile}} + {{#tap-tile class="do-not-disturb-tile" tileId="60" activeTile=grid.activeTile onChange=(action "setDuration")}} + {{i18n "do_not_disturb.options.one_hour"}} + {{/tap-tile}} + {{#tap-tile class="do-not-disturb-tile" tileId="120" activeTile=grid.activeTile onChange=(action "setDuration")}} + {{i18n "do_not_disturb.options.two_hours"}} + {{/tap-tile}} + {{#tap-tile class="do-not-disturb-tile" tileId="tomorrow" activeTile=grid.activeTile onChange=(action "setDuration")}} + {{i18n "do_not_disturb.options.tomorrow"}} + {{/tap-tile}} + {{/tap-tile-grid}} +{{/d-modal-body}} + diff --git a/app/assets/javascripts/discourse/app/widgets/do-not-disturb.js b/app/assets/javascripts/discourse/app/widgets/do-not-disturb.js new file mode 100644 index 00000000000..57b153f5494 --- /dev/null +++ b/app/assets/javascripts/discourse/app/widgets/do-not-disturb.js @@ -0,0 +1,54 @@ +import I18n from "I18n"; +import { createWidget } from "discourse/widgets/widget"; +import { h } from "virtual-dom"; +import { iconNode } from "discourse-common/lib/icon-library"; +import showModal from "discourse/lib/show-modal"; + +export default createWidget("do-not-disturb", { + tagName: "div.btn.do-not-disturb-btn", + saving: false, + + html() { + if (this.currentUser.isInDoNotDisturb()) { + let remainingTime = moment() + .to(moment(this.currentUser.do_not_disturb_until)) + .split(" ") + .slice(1) + .join(" "); // The first word is "in" and we don't want that. + return [ + h("div.do-not-disturb-inner-container", [ + h("div.do-not-disturb-background", iconNode("moon")), + + h("span.do-not-disturb-label", [ + h("span", I18n.t("do_not_disturb.label")), + h( + "span.time-remaining", + I18n.t("do_not_disturb.remaining", { remaining: remainingTime }) + ), + ]), + ]), + ]; + } else { + return [ + iconNode("far-moon"), + h("span.do-not-disturb-label", I18n.t("do_not_disturb.label")), + ]; + } + }, + + click() { + if (this.saving) { + return; + } + + this.saving = true; + if (this.currentUser.do_not_disturb_until) { + return this.currentUser.leaveDoNotDisturb().then(() => { + this.saving = false; + }); + } else { + this.saving = false; + return showModal("do-not-disturb"); + } + }, +}); diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js index a76108cb5b8..f8747fa552c 100644 --- a/app/assets/javascripts/discourse/app/widgets/header.js +++ b/app/assets/javascripts/discourse/app/widgets/header.js @@ -64,66 +64,69 @@ createWidget("header-notifications", { ), ]; - const unreadNotifications = user.get("unread_notifications"); - if (!!unreadNotifications) { - contents.push( - this.attach("link", { - action: attrs.action, - className: "badge-notification unread-notifications", - rawLabel: unreadNotifications, - omitSpan: true, - title: "notifications.tooltip.regular", - titleOptions: { count: unreadNotifications }, - }) - ); - } - - const unreadHighPriority = user.get("unread_high_priority_notifications"); - if (!!unreadHighPriority) { - // highlight the avatar if the first ever PM is not read - if ( - !user.get("read_first_notification") && - !user.get("enforcedSecondFactor") - ) { - if (!attrs.active && attrs.ringBackdrop) { - contents.push(h("span.ring")); - contents.push(h("span.ring-backdrop-spotlight")); - contents.push( - h( - "span.ring-backdrop", - {}, - h("h1.ring-first-notification", {}, [ - h("span", {}, I18n.t("user.first_notification")), - h("span", {}, [ - I18n.t("user.skip_new_user_tips.not_first_time"), - " ", - this.attach("link", { - action: "skipNewUserTips", - className: "skip-new-user-tips", - label: "user.skip_new_user_tips.skip_link", - title: "user.skip_new_user_tips.description", - omitSpan: true, - }), - ]), - ]) - ) - ); - } + if (user.isInDoNotDisturb()) { + contents.push(h("div.do-not-disturb-background", iconNode("moon"))); + } else { + const unreadNotifications = user.get("unread_notifications"); + if (!!unreadNotifications) { + contents.push( + this.attach("link", { + action: attrs.action, + className: "badge-notification unread-notifications", + rawLabel: unreadNotifications, + omitSpan: true, + title: "notifications.tooltip.regular", + titleOptions: { count: unreadNotifications }, + }) + ); } - // add the counter for the unread high priority - contents.push( - this.attach("link", { - action: attrs.action, - className: "badge-notification unread-high-priority-notifications", - rawLabel: unreadHighPriority, - omitSpan: true, - title: "notifications.tooltip.high_priority", - titleOptions: { count: unreadHighPriority }, - }) - ); - } + const unreadHighPriority = user.get("unread_high_priority_notifications"); + if (!!unreadHighPriority) { + // highlight the avatar if the first ever PM is not read + if ( + !user.get("read_first_notification") && + !user.get("enforcedSecondFactor") + ) { + if (!attrs.active && attrs.ringBackdrop) { + contents.push(h("span.ring")); + contents.push(h("span.ring-backdrop-spotlight")); + contents.push( + h( + "span.ring-backdrop", + {}, + h("h1.ring-first-notification", {}, [ + h("span", {}, I18n.t("user.first_notification")), + h("span", {}, [ + I18n.t("user.skip_new_user_tips.not_first_time"), + " ", + this.attach("link", { + action: "skipNewUserTips", + className: "skip-new-user-tips", + label: "user.skip_new_user_tips.skip_link", + title: "user.skip_new_user_tips.description", + omitSpan: true, + }), + ]), + ]) + ) + ); + } + } + // add the counter for the unread high priority + contents.push( + this.attach("link", { + action: attrs.action, + className: "badge-notification unread-high-priority-notifications", + rawLabel: unreadHighPriority, + omitSpan: true, + title: "notifications.tooltip.high_priority", + titleOptions: { count: unreadHighPriority }, + }) + ); + } + } return contents; }, }); diff --git a/app/assets/javascripts/discourse/app/widgets/quick-access-notifications.js b/app/assets/javascripts/discourse/app/widgets/quick-access-notifications.js index 98a1c1b7d3d..490fde0402d 100644 --- a/app/assets/javascripts/discourse/app/widgets/quick-access-notifications.js +++ b/app/assets/javascripts/discourse/app/widgets/quick-access-notifications.js @@ -5,6 +5,7 @@ import { createWidgetFrom } from "discourse/widgets/widget"; createWidgetFrom(QuickAccessPanel, "quick-access-notifications", { buildKey: () => "quick-access-notifications", emptyStatePlaceholderItemKey: "notifications.empty", + showDoNotDisturb: true, markReadRequest() { return ajax("/notifications/mark-read", { type: "PUT" }); diff --git a/app/assets/javascripts/discourse/app/widgets/quick-access-panel.js b/app/assets/javascripts/discourse/app/widgets/quick-access-panel.js index 18feb59dd1f..907615529c4 100644 --- a/app/assets/javascripts/discourse/app/widgets/quick-access-panel.js +++ b/app/assets/javascripts/discourse/app/widgets/quick-access-panel.js @@ -95,12 +95,17 @@ export default createWidget("quick-access-panel", { return [h("div.spinner-container", h("div.spinner"))]; } - let bottomItems = []; const items = this.getItems().length ? this.getItems().map((item) => this.itemHtml(item)) : [this.emptyStatePlaceholderItem()]; + let bottomItems = []; + if (!this.hideBottomItems()) { + if (this.showDoNotDisturb) { + bottomItems.push(this.attach("do-not-disturb")); + } + bottomItems.push( // intentionally a link so it can be ctrl clicked this.attach("link", { 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 new file mode 100644 index 00000000000..c16a8de9dc8 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js @@ -0,0 +1,71 @@ +import { + acceptance, + exists, + queryAll, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +acceptance("Do not disturb", function (needs) { + needs.user(); + needs.pretender((server, helper) => { + server.post("/do-not-disturb.json", () => { + let now = new Date(); + now.setHours(now.getHours() + 1); + return helper.response({ ends_at: now }); + }); + server.delete("/do-not-disturb.json", () => + helper.response({ success: true }) + ); + }); + + test("when turned off, it is turned on from modal", async function (assert) { + updateCurrentUser({ do_not_disturb_until: null }); + + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + await click(".do-not-disturb-btn"); + + assert.ok(exists(".do-not-disturb-modal"), "modal to choose time appears"); + + let tiles = queryAll(".do-not-disturb-tile"); + 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", + "modal is hidden" + ); + + assert.ok( + exists(".header-dropdown-toggle .do-not-disturb-background"), + "moon icon is present in header" + ); + }); + + test("when turned on, it can be turned off", async function (assert) { + let now = new Date(); + now.setHours(now.getHours() + 1); + updateCurrentUser({ do_not_disturb_until: now }); + + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + assert.ok( + queryAll(".do-not-disturb-btn .time-remaining")[0].textContent === + "an hour remaining", + "The remaining time is displayed" + ); + + await click(".do-not-disturb-btn"); + + assert.ok( + queryAll(".do-not-disturb-background").length === 0, + "The active moon icons are removed" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 64a66c7774a..a38e25dac47 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -205,6 +205,11 @@ export function acceptance(name, optionsOrCallback) { getApplication().reset(); this.container = getOwner(this); + if (loggedIn) { + updateCurrentUser({ + appEvents: this.container.lookup("service:app-events"), + }); + } setURLContainer(this.container); setDefaultOwner(this.container); diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss index 1690bb0312c..50e82535dbb 100644 --- a/app/assets/stylesheets/common/base/discourse.scss +++ b/app/assets/stylesheets/common/base/discourse.scss @@ -416,6 +416,85 @@ table { } } +.d-header .header-dropdown-toggle .do-not-disturb-background { + position: absolute; + left: 2px; + bottom: -1px; + z-index: 1002; +} + +.do-not-disturb-background { + display: flex; + align-items: center; + justify-content: center; + width: 1.25em; + background-color: var(--tertiary-med-or-tertiary); + border-radius: 50%; + height: 1.25em; + + .d-icon.d-icon-moon { + color: var(--tertiary-or-white) !important; + line-height: unset; + font-size: 0.875em; + margin: 0; + } +} + +.do-not-disturb-btn { + display: flex; + flex: 0 0 100%; + flex-wrap: wrap; + align-items: center; + justify-content: center; + margin-bottom: 0.5em; + + .do-not-disturb-inner-container { + display: flex; + align-items: center; + + .do-not-disturb-label { + display: flex; + flex-direction: column; + padding-top: 2px; + margin-left: 0.6em; + } + + .time-remaining { + text-align: left; + font-size: $font-down-3; + margin-top: 0.3em; + color: var(--primary-medium); + } + .do-not-disturb-background { + width: 1.75em; + height: 1.75em; + + .d-icon-far-moon { + margin-right: 0; + } + } + } +} + +.do-not-disturb-modal { + .do-not-disturb-choice { + display: grid; + grid-template-columns: 2em 1fr auto; + grid-template-rows: auto auto; + align-items: center; + cursor: pointer; + padding: 0.5em 0; + + &:hover { + background-color: var(--tertiary-low); + } + + label { + margin-bottom: 0; + } + } +} + .ring-backdrop-spotlight { position: absolute; width: 80px; diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index 2122876ae9a..8126f872fcc 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -65,6 +65,8 @@ display: flex; flex: 1 0 0%; // safari height fix margin-top: 0.5em; + flex-wrap: wrap; + .show-all { flex: 1 1 auto; button { @@ -236,7 +238,7 @@ color: var(--primary); } - span.double-user, + span.double-user, // e.g., "username, username2" span.multi-user // e.g., "username, username2, and n others" @@ -247,7 +249,7 @@ white-space: nowrap; } - span.multi-user + span.multi-user // e.g., "username, username2, and n others" { span.multi-username:nth-of-type(2) { diff --git a/app/controllers/do_not_disturb_controller.rb b/app/controllers/do_not_disturb_controller.rb new file mode 100644 index 00000000000..cd22eb033f5 --- /dev/null +++ b/app/controllers/do_not_disturb_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class DoNotDisturbController < ApplicationController + requires_login + + def create + raise Discourse::InvalidParameters.new(:duration) if params[:duration].blank? + + duration_minutes = (Integer(params[:duration]) rescue false) + + ends_at = duration_minutes ? + ends_at_from_minutes(duration_minutes) : + ends_at_from_string(params[:duration]) + + new_timing = current_user.do_not_disturb_timings.new(starts_at: Time.zone.now, ends_at: ends_at) + + if new_timing.save + current_user.publish_do_not_disturb(ends_at: ends_at) + render json: { ends_at: ends_at } + else + render_json_error(new_timing) + end + end + + def destroy + current_user.active_do_not_disturb_timings.destroy_all + current_user.publish_do_not_disturb(ends_at: nil) + render json: success_json + end + + private + + def ends_at_from_minutes(duration) + duration.minutes.from_now + end + + def ends_at_from_string(string) + if string == 'tomorrow' + Time.now.end_of_day.utc + else + raise Discourse::InvalidParameters.new(:duration) + end + end +end diff --git a/app/models/do_not_disturb_timing.rb b/app/models/do_not_disturb_timing.rb new file mode 100644 index 00000000000..38119c5a87e --- /dev/null +++ b/app/models/do_not_disturb_timing.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DoNotDisturbTiming < ActiveRecord::Base + belongs_to :user + + validate :ends_at_greater_thans_starts_at + + def ends_at_greater_thans_starts_at + if starts_at > ends_at + errors.add(:ends_at, :invalid) + end + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb index 8b731c5a48c..3fd94b7335c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -282,7 +282,8 @@ class Notification < ActiveRecord::Base end def send_email - NotificationEmailer.process_notification(self) if !skip_send_email + return if skip_send_email || user.do_not_disturb? # TODO: 'shelve' emails rather than skipping them entirely + NotificationEmailer.process_notification(self) end end diff --git a/app/models/user.rb b/app/models/user.rb index dd1be3bb501..279d11a4fbf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,6 +59,7 @@ class User < ActiveRecord::Base has_many :group_requests, dependent: :delete_all has_many :muted_user_records, class_name: 'MutedUser', dependent: :delete_all has_many :ignored_user_records, class_name: 'IgnoredUser', dependent: :delete_all + has_many :do_not_disturb_timings, dependent: :delete_all # dependent deleting handled via before_destroy (special cases) has_many :user_actions @@ -635,6 +636,10 @@ class User < ActiveRecord::Base MessageBus.publish("/notification/#{id}", payload, user_ids: [id]) end + def publish_do_not_disturb(ends_at: nil) + MessageBus.publish("/do-not-disturb/#{id}", { ends_at: ends_at }, user_ids: [id]) + end + def password=(password) # special case for passwordless accounts unless password.blank? @@ -1365,6 +1370,15 @@ class User < ActiveRecord::Base UrlHelper.encode_component(lower ? username_lower : username) end + def do_not_disturb? + active_do_not_disturb_timings.exists? + end + + def active_do_not_disturb_timings + now = Time.zone.now + do_not_disturb_timings.where('starts_at <= ? AND ends_at > ?', now, now) + end + protected def badge_grant diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 1eafd222212..4b7c1bcf6fe 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -49,7 +49,8 @@ class CurrentUserSerializer < BasicUserSerializer :title_count_mode, :timezone, :featured_topic, - :skip_new_user_tips + :skip_new_user_tips, + :do_not_disturb_until, def groups object.visible_groups.pluck(:id, :name).map { |id, name| { id: id, name: name } } @@ -237,4 +238,8 @@ class CurrentUserSerializer < BasicUserSerializer def featured_topic object.user_profile.featured_topic end + + def do_not_disturb_until + object.active_do_not_disturb_timings.maximum(:ends_at) + end end diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 9817abebd1c..01d9d18fadd 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -464,6 +464,8 @@ class PostAlerter end def push_notification(user, payload) + return if user.do_not_disturb? + if user.push_subscriptions.exists? Jobs.enqueue(:send_push_notification, user_id: user.id, payload: payload) end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index deb85f13133..5d9a663ddf3 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -932,7 +932,7 @@ en: perm_denied_expl: "You denied permission for notifications. Allow notifications via your browser settings." disable: "Disable Notifications" enable: "Enable Notifications" - each_browser_note: "Note: You have to change this setting on every browser you use." + each_browser_note: 'Note: You have to change this setting on every browser you use. All notifications will be disabled when in "do not disturb", regardless of this setting.' consent_prompt: "Do you want live notifications when people reply to your posts?" dismiss: "Dismiss" dismiss_notifications: "Dismiss All" @@ -3538,6 +3538,19 @@ en: image_removed: "(image removed)" + do_not_disturb: + title: "Do not disturb for..." + save: "Save" + label: "Do not disturb" + remaining: "%{remaining} remaining" + options: + half_hour: "30 minutes" + one_hour: "1 hour" + two_hours: "2 hours" + tomorrow: "Until tomorrow" + custom: "Custom" + + # This section is exported to the javascript for i18n in the admin section admin_js: type_to_filter: "type to filter..." diff --git a/config/routes.rb b/config/routes.rb index 897c35f6ac7..e30b0a7d2c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -960,6 +960,9 @@ Discourse::Application.routes.draw do get "/permalink-check", to: 'permalinks#check' + post "/do-not-disturb" => "do_not_disturb#create" + delete "/do-not-disturb" => "do_not_disturb#destroy" + get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new end end diff --git a/db/migrate/20201210151635_create_do_not_disturb_timings.rb b/db/migrate/20201210151635_create_do_not_disturb_timings.rb new file mode 100644 index 00000000000..af308bc050d --- /dev/null +++ b/db/migrate/20201210151635_create_do_not_disturb_timings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateDoNotDisturbTimings < ActiveRecord::Migration[6.0] + def change + create_table :do_not_disturb_timings do |t| + t.integer :user_id, null: false + t.datetime :starts_at, null: false + t.datetime :ends_at, null: false + end + add_index :do_not_disturb_timings, [:user_id], unique: false + add_index :do_not_disturb_timings, [:starts_at], unique: false + add_index :do_not_disturb_timings, [:ends_at], unique: false + end +end diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 33d15da2cbc..1d93f77857c 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -148,6 +148,7 @@ module SvgSprite "minus", "minus-circle", "mobile-alt", + "moon", "paint-brush", "paper-plane", "pencil-alt", diff --git a/spec/fabricators/do_not_disturb_fabricator.rb b/spec/fabricators/do_not_disturb_fabricator.rb new file mode 100644 index 00000000000..3aafa6fa86c --- /dev/null +++ b/spec/fabricators/do_not_disturb_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:do_not_disturb_timing) do + user + starts_at { Time.zone.now } + ends_at { 1.hour.from_now } +end diff --git a/spec/models/do_not_disturb_timing_spec.rb b/spec/models/do_not_disturb_timing_spec.rb new file mode 100644 index 00000000000..306c2c82bdc --- /dev/null +++ b/spec/models/do_not_disturb_timing_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe DoNotDisturbTiming do + fab!(:user) { Fabricate(:user) } + + describe "validations" do + it 'is invalid when ends_at is before starts_at' do + freeze_time + timing = DoNotDisturbTiming.new(user: user, starts_at: Time.zone.now, ends_at: 1.hour.ago) + timing.valid? + expect(timing.errors[:ends_at]).to be_present + end + end +end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index 1bde8f4e058..e87bff865c4 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -373,6 +373,24 @@ describe Notification do end end + describe "do not disturb" do + it "calls NotificationEmailer.process_notification when user is not in 'do not disturb'" do + user = Fabricate(:user) + notification = Notification.new(read: false, user_id: user.id, topic_id: 2, post_number: 1, data: '{}', notification_type: 1) + NotificationEmailer.expects(:process_notification).with(notification) + notification.save! + end + + it "doesn't call NotificationEmailer.process_notification when user is in 'do not disturb'" do + freeze_time + user = Fabricate(:user) + Fabricate(:do_not_disturb_timing, user: user, starts_at: Time.zone.now, ends_at: 1.day.from_now) + + notification = Notification.new(read: false, user_id: user.id, topic_id: 2, post_number: 1, data: '{}', notification_type: 1) + NotificationEmailer.expects(:process_notification).with(notification).never + notification.save! + end + end end # pulling this out cause I don't want an observer diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 3e69f08afa3..3a7a609765f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2481,4 +2481,16 @@ describe User do end end end + + describe "#do_not_disturb?" do + it "is true when a dnd timing is present for the current time" do + Fabricate(:do_not_disturb_timing, user: user, starts_at: Time.zone.now, ends_at: 1.day.from_now) + expect(user.do_not_disturb?).to eq(true) + end + + it "is false when no dnd timing is present for the current time" do + Fabricate(:do_not_disturb_timing, user: user, starts_at: Time.zone.now - 2.day, ends_at: 1.minute.ago) + expect(user.do_not_disturb?).to eq(false) + end + end end diff --git a/spec/requests/do_not_disturb_controller_spec.rb b/spec/requests/do_not_disturb_controller_spec.rb new file mode 100644 index 00000000000..9893daafb0c --- /dev/null +++ b/spec/requests/do_not_disturb_controller_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe DoNotDisturbController do + it 'requires you to be logged in' do + post "/do-not-disturb.json", params: { duration: 30 } + expect(response.status).to eq(403) + end + + describe 'logged in' do + fab!(:user) { Fabricate(:user) } + + before do + sign_in(user) + end + + it 'returns a 400 when a duration is not passed in' do + post "/do-not-disturb.json" + expect(response.status).to eq(400) + end + + it 'works properly with integer minute durations' do + freeze_time + post "/do-not-disturb.json", params: { duration: 30 } + + expect(response.status).to eq(200) + expect(user.do_not_disturb_timings.last.ends_at).to eq_time(30.minutes.from_now) + end + + it 'works properly with integer minute durations' do + post "/do-not-disturb.json", params: { duration: -30 } + expect(response.status).to eq(422) + expect(response.parsed_body).to eq({ "errors" => ["Ends at is invalid"] }) + end + + include ActiveSupport::Testing::TimeHelpers + it "works properly with duration of 'tomorrow'" do + travel_to Time.new(2020, 11, 24, 01, 04, 44) do + post "/do-not-disturb.json", params: { duration: 'tomorrow' } + expect(response.status).to eq(200) + expect(user.do_not_disturb_timings.last.ends_at.to_i).to eq(Time.new(2020, 11, 24, 23, 59, 59).utc.to_i) + end + end + end +end diff --git a/spec/services/post_alerter_spec.rb b/spec/services/post_alerter_spec.rb index 0b8ab126d9c..ebed546c447 100644 --- a/spec/services/post_alerter_spec.rb +++ b/spec/services/post_alerter_spec.rb @@ -731,6 +731,21 @@ describe PostAlerter do expect { mention_post }.to_not change { Jobs::PushNotification.jobs.count } end + it "pushes nothing when the user is in 'do not disturb'" do + SiteSetting.allowed_user_api_push_urls = "https://site.com/push|https://site2.com/push" + 2.times do |i| + UserApiKey.create!(user_id: evil_trout.id, + client_id: "xxx#{i}", + application_name: "iPhone#{i}", + scopes: ['notifications'].map { |name| UserApiKeyScope.new(name: name) }, + push_url: "https://site2.com/push") + end + + Fabricate(:do_not_disturb_timing, user: evil_trout, starts_at: Time.zone.now, ends_at: 1.day.from_now) + + expect { mention_post }.to_not change { Jobs::PushNotification.jobs.count } + end + it "correctly pushes notifications if configured correctly" do Jobs.run_immediately! SiteSetting.allowed_user_api_push_urls = "https://site.com/push|https://site2.com/push"