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"