FEATURE: integrate DnD with user status (#19410)

This PR adds a new "Pause notifications" checkbox to the user status modal. This checkbox allows enabling the Do-Not-Disturb mode together with user status. Note that we don't remove and don't rename the existing DnD menu item in this PR, so the old way of entering the DnD mode is still available.

Also, we're not making DnD mode a part of user status on backend and in database. The reason is that the DnD mode should still be available on sites with disabled user status, having them separated helps keep the implementation simple.
This commit is contained in:
Andrei Prigorshnev 2022-12-16 16:35:39 +04:00 committed by GitHub
parent c358151a6c
commit 4908a669e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 237 additions and 9 deletions

View File

@ -77,7 +77,9 @@
<span class="item-label">
{{#if this.isInDoNotDisturb}}
<span>{{i18n "do_not_disturb.label"}}</span>
{{format-age this.doNotDisturbDateTime}}
{{#if this.showDoNotDisturbEndDate}}
{{format-age this.doNotDisturbDateTime}}
{{/if}}
{{else}}
{{i18n "do_not_disturb.label"}}
{{/if}}

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
import DoNotDisturb from "discourse/lib/do-not-disturb";
export default class UserMenuProfileTabContent extends Component {
@service currentUser;
@ -27,6 +28,12 @@ export default class UserMenuProfileTabContent extends Component {
return this.#doNotDisturbUntilDate.getTime();
}
get showDoNotDisturbEndDate() {
return !DoNotDisturb.isEternal(
this.currentUser.get("do_not_disturb_until")
);
}
get #doNotDisturbUntilDate() {
if (!this.currentUser.get("do_not_disturb_until")) {
return;
@ -63,7 +70,9 @@ export default class UserMenuProfileTabContent extends Component {
modalClass: "user-status",
model: {
status: this.currentUser.status,
saveAction: (status) => this.userStatus.set(status),
pauseNotifications: this.currentUser.isInDoNotDisturb(),
saveAction: (status, pauseNotifications) =>
this.userStatus.set(status, pauseNotifications),
deleteAction: () => this.userStatus.clear(),
},
});

View File

@ -157,6 +157,7 @@ export default Controller.extend(CanCheckEmails, {
modalClass: "user-status",
model: {
status,
hidePauseNotifications: true,
saveAction: (s) => this.set("newStatus", s),
deleteAction: () => this.set("newStatus", null),
},

View File

@ -19,6 +19,8 @@ export default Controller.extend(ModalFunctionality, {
const currentStatus = { ...this.model.status };
this.setProperties({
status: currentStatus,
hidePauseNotifications: this.model.hidePauseNotifications,
pauseNotifications: this.model.pauseNotifications,
showDeleteButton: !!this.model.status,
timeShortcuts: this._buildTimeShortcuts(),
prefilledDateTime: currentStatus?.ends_at,
@ -70,7 +72,7 @@ export default Controller.extend(ModalFunctionality, {
ends_at: this.status.endsAt?.toISOString(),
};
Promise.resolve(this.model.saveAction(newStatus))
Promise.resolve(this.model.saveAction(newStatus, this.pauseNotifications))
.then(() => this.send("closeModal"))
.catch((e) => this._handleError(e));
},

View File

@ -0,0 +1,7 @@
export default class DoNotDisturb {
static forever = "3000-01-01T00:00:00.000Z";
static isEternal(until) {
return moment.utc(until).isSame(DoNotDisturb.forever, "day");
}
}

View File

@ -1,10 +1,11 @@
import Service, { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import DoNotDisturb from "discourse/lib/do-not-disturb";
export default class UserStatusService extends Service {
@service appEvents;
async set(status) {
async set(status, pauseNotifications) {
await ajax({
url: "/user-status.json",
type: "PUT",
@ -12,6 +13,11 @@ export default class UserStatusService extends Service {
});
this.currentUser.set("status", status);
if (pauseNotifications) {
this.#enterDoNotDisturb(status.ends_at);
} else {
this.#leaveDoNotDisturb();
}
}
async clear() {
@ -21,5 +27,23 @@ export default class UserStatusService extends Service {
});
this.currentUser.set("status", null);
this.#leaveDoNotDisturb();
}
#enterDoNotDisturb(endsAt) {
const duration = this.#duration(endsAt ?? DoNotDisturb.forever);
this.currentUser.enterDoNotDisturbFor(duration);
}
#leaveDoNotDisturb() {
if (!this.currentUser.isInDoNotDisturb()) {
return;
}
this.currentUser.leaveDoNotDisturb();
}
#duration(endsAt) {
return moment.utc(endsAt).diff(moment.utc(), "minutes");
}
}

View File

@ -3,6 +3,14 @@
<div class="control-group">
<UserStatusPicker @status={{this.status}} />
</div>
{{#unless this.hidePauseNotifications}}
<div class="control-group pause-notifications">
<label class="checkbox-label">
<Input @type="checkbox" @checked={{this.pauseNotifications}} />
{{i18n "user_status.pause_notifications"}}
</label>
</div>
{{/unless}}
<div class="control-group control-group-remove-status">
<label class="control-label">
{{i18n "user_status.remove_status"}}

View File

@ -4,6 +4,7 @@ import { dateNode } from "discourse/helpers/node";
import { h } from "virtual-dom";
import { iconNode } from "discourse-common/lib/icon-library";
import showModal from "discourse/lib/show-modal";
import DoNotDisturb from "discourse/lib/do-not-disturb";
export default createWidget("do-not-disturb", {
tagName: "li.do-not-disturb",
@ -14,10 +15,7 @@ export default createWidget("do-not-disturb", {
return [
h("button.btn-flat.do-not-disturb-inner-container", [
iconNode("toggle-on"),
h("span.do-not-disturb-label", [
h("span", I18n.t("do_not_disturb.label")),
dateNode(this.currentUser.do_not_disturb_until),
]),
this.label(),
]),
];
} else {
@ -45,4 +43,15 @@ export default createWidget("do-not-disturb", {
return showModal("do-not-disturb");
}
},
label() {
const content = [h("span", I18n.t("do_not_disturb.label"))];
const until = this.currentUser.do_not_disturb_until;
if (!DoNotDisturb.isEternal(until)) {
content.push(dateNode(until));
}
return h("span.do-not-disturb-label", content);
},
});

View File

@ -44,7 +44,9 @@ createWidgetFrom(QuickAccessItem, "user-status-item", {
modalClass: "user-status",
model: {
status: this.currentUser.status,
saveAction: (status) => this.userStatus.set(status),
pauseNotifications: this.currentUser.isInDoNotDisturb(),
saveAction: (status, pauseNotifications) =>
this.userStatus.set(status, pauseNotifications),
deleteAction: () => this.userStatus.clear(),
},
});

View File

@ -8,6 +8,7 @@ import {
} from "discourse/tests/helpers/qunit-helpers";
import { click, triggerKeyEvent, visit } from "@ember/test-helpers";
import { test } from "qunit";
import DoNotDisturb from "discourse/lib/do-not-disturb";
acceptance("Do not disturb", function (needs) {
needs.user();
@ -99,6 +100,16 @@ acceptance("Do not disturb", function (needs) {
"The active moon icons are removed"
);
});
test("doesn't show the end date for eternal DnD", async function (assert) {
updateCurrentUser({ do_not_disturb_until: DoNotDisturb.forever });
await visit("/");
await click(".header-dropdown-toggle.current-user");
await click(".menu-links-row .user-preferences-link");
assert.dom(".do-not-disturb .relative-date").doesNotExist();
});
});
acceptance("Do not disturb - new user menu", function (needs) {
@ -220,4 +231,14 @@ acceptance("Do not disturb - new user menu", function (needs) {
assert.notOk(exists(".user-menu"));
});
test("doesn't show the end date for eternal DnD", async function (assert) {
updateCurrentUser({ do_not_disturb_until: DoNotDisturb.forever });
await visit("/");
await click(".header-dropdown-toggle.current-user");
await click("#user-menu-button-profile");
assert.dom(".do-not-disturb .relative-date").doesNotExist();
});
});

View File

@ -61,6 +61,15 @@ acceptance("User Profile - Account - User Status", function (needs) {
);
});
test("doesn't show the pause notifications control group on the user status modal", async function (assert) {
this.siteSettings.enable_user_status = true;
await visit(`/u/${username}/preferences/account`);
await openUserStatusModal();
assert.dom(".pause-notifications").doesNotExist();
});
test("the status modal sets status", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({ status: null });

View File

@ -20,6 +20,10 @@ async function pickEmoji(emoji) {
await click(".results .emoji");
}
async function setDoNotDisturbMode() {
await click(".pause-notifications input[type=checkbox]");
}
acceptance("User Status", function (needs) {
const userStatus = "off to dentist";
const userStatusEmoji = "tooth";
@ -40,6 +44,9 @@ acceptance("User Status", function (needs) {
publishToMessageBus(`/user-status/${userId}`, null);
return helper.response({ success: true });
});
server.delete("/do-not-disturb.json", () =>
helper.response({ success: true })
);
});
test("doesn't show the user status button on the menu by default", async function (assert) {
@ -345,6 +352,128 @@ acceptance("User Status", function (needs) {
});
});
acceptance(
"User Status - pause notifications (do not disturb mode)",
function (needs) {
const userStatus = "off to dentist";
const userStatusEmoji = "tooth";
const userId = 1;
const userTimezone = "UTC";
needs.user({ id: userId, "user_option.timezone": userTimezone });
needs.pretender((server, helper) => {
server.put("/user-status.json", () => {
return helper.response({ success: true });
});
server.delete("/user-status.json", () => {
return helper.response({ success: true });
});
server.post("/do-not-disturb.json", (request) => {
const duration = request.requestBody.match(/(?<=duration=)\d+/g)[0]; // body is something like "duration=134"
const endsAt = moment.utc().add(duration, "minutes").toISOString();
return helper.response({ ends_at: endsAt });
});
server.delete("/do-not-disturb.json", () =>
helper.response({ success: true })
);
});
test("shows the pause notifications control group", async function (assert) {
this.siteSettings.enable_user_status = true;
await visit("/");
await openUserStatusModal();
assert.dom(".pause-notifications").exists();
});
test("sets do-not-disturb mode", async function (assert) {
this.siteSettings.enable_user_status = true;
await visit("/");
await openUserStatusModal();
await fillIn(".user-status-description", userStatus);
await pickEmoji(userStatusEmoji);
await click("#tap_tile_one_hour");
await setDoNotDisturbMode();
await click(".btn-primary"); // save
assert
.dom(".do-not-disturb-background .d-icon-moon")
.exists("the DnD mode indicator on the menu is shown");
});
test("sets do-not-disturb mode even if ends at time wasn't chosen", async function (assert) {
this.siteSettings.enable_user_status = true;
await visit("/");
await openUserStatusModal();
await fillIn(".user-status-description", userStatus);
await pickEmoji(userStatusEmoji);
await setDoNotDisturbMode();
await click(".btn-primary"); // save
assert
.dom(".do-not-disturb-background .d-icon-moon")
.exists("the DnD mode indicator on the menu is shown");
});
test("unsets do-not-disturb mode when removing status", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({ status: { description: userStatus } });
updateCurrentUser({ do_not_disturb_until: "2100-01-01T08:00:00.000Z" });
await visit("/");
await openUserStatusModal();
await click(".btn.delete-status");
assert
.dom(".do-not-disturb-background .d-icon-moon")
.doesNotExist("there is no DnD mode indicator on the menu");
});
test("unsets do-not-disturb mode when updating status", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({
status: { emoji: userStatusEmoji, description: userStatus },
});
updateCurrentUser({ do_not_disturb_until: "2100-01-01T08:00:00.000Z" });
await visit("/");
await openUserStatusModal();
await click(".pause-notifications input[type=checkbox]");
await click(".btn-primary"); // save
assert
.dom(".do-not-disturb-background .d-icon-moon")
.doesNotExist("there is no DnD mode indicator on the menu");
});
test("if user isn't in DnD mode the user status modal shows it", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({ do_not_disturb_until: null });
await visit("/");
await openUserStatusModal();
assert.dom(".pause-notifications input").isNotChecked();
});
test("if user is in DnD mode the user status modal shows it", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({ do_not_disturb_until: "2100-01-01T08:00:00.000Z" });
await visit("/");
await openUserStatusModal();
assert.dom(".pause-notifications input").isChecked();
});
}
);
acceptance("User Status - new user menu", function (needs) {
const userStatus = "off to dentist";
const userStatusEmoji = "tooth";

View File

@ -27,6 +27,10 @@
margin-top: 25px;
}
.pause-notifications {
margin-top: 1.5em;
}
.control-label {
font-weight: 700;
}

View File

@ -1868,6 +1868,7 @@ en:
save: "Save"
set_custom_status: "Set custom status"
what_are_you_doing: "What are you doing?"
pause_notifications: "Pause notifications"
remove_status: "Remove status"
user_tips: