FEATURE: auto remove user status after predefined period (#17236)

This commit is contained in:
Andrei Prigorshnev 2022-07-05 19:12:22 +04:00 committed by GitHub
parent 4acf2394e6
commit c59f1729a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 423 additions and 52 deletions

View File

@ -238,7 +238,9 @@ const SiteHeaderComponent = MountWidget.extend(
this.appEvents.on("dom:clean", this, "_cleanDom");
this.appEvents.on("user-status:changed", () => this.queueRerender());
if (this.currentUser) {
this.currentUser.on("status-changed", this, "queueRerender");
}
if (
this.currentUser &&
@ -310,6 +312,10 @@ const SiteHeaderComponent = MountWidget.extend(
this.appEvents.off("header:hide-topic", this, "setTopic");
this.appEvents.off("dom:clean", this, "_cleanDom");
if (this.currentUser) {
this.currentUser.off("status-changed", this, "queueRerender");
}
cancel(this._scheduledRemoveAnimate);
this._itsatrap?.destroy();

View File

@ -196,6 +196,7 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
);
}
this.setProperties({ user });
this.user.trackStatus();
return user;
})
.catch(() => this._close())
@ -203,6 +204,10 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
},
_close() {
if (this.user) {
this.user.stopTrackingStatus();
}
this.setProperties({
user: null,
topicPostCount: null,

View File

@ -5,21 +5,42 @@ import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import bootbox from "bootbox";
import discourseComputed from "discourse-common/utils/decorators";
import ItsATrap from "@discourse/itsatrap";
import {
TIME_SHORTCUT_TYPES,
timeShortcuts,
} from "discourse/lib/time-shortcut";
export default Controller.extend(ModalFunctionality, {
userStatusService: service("user-status"),
emoji: null,
description: null,
endsAt: null,
showDeleteButton: false,
prefilledDateTime: null,
timeShortcuts: null,
_itsatrap: null,
onShow() {
const status = this.currentUser.status;
this.setProperties({
emoji: status?.emoji,
description: status?.description,
endsAt: status?.ends_at,
showDeleteButton: !!status,
timeShortcuts: this._buildTimeShortcuts(),
prefilledDateTime: status?.ends_at,
});
this.set("_itsatrap", new ItsATrap());
},
onClose() {
this._itsatrap.destroy();
this.set("_itsatrap", null);
this.set("timeShortcuts", null);
},
@discourseComputed("emoji", "description")
@ -27,6 +48,18 @@ export default Controller.extend(ModalFunctionality, {
return !!emoji && !!description;
},
@discourseComputed
customTimeShortcutLabels() {
const labels = {};
labels[TIME_SHORTCUT_TYPES.NONE] = "time_shortcut.never";
return labels;
},
@discourseComputed
hiddenTimeShortcutOptions() {
return [TIME_SHORTCUT_TYPES.LAST_CUSTOM];
},
@action
delete() {
this.userStatusService
@ -35,9 +68,18 @@ export default Controller.extend(ModalFunctionality, {
.catch((e) => this._handleError(e));
},
@action
onTimeSelected(_, time) {
this.set("endsAt", time);
},
@action
saveAndClose() {
const status = { description: this.description, emoji: this.emoji };
const status = {
description: this.description,
emoji: this.emoji,
ends_at: this.endsAt?.toISOString(),
};
this.userStatusService
.set(status)
.then(() => {
@ -53,4 +95,10 @@ export default Controller.extend(ModalFunctionality, {
popupAjaxError(e);
}
},
_buildTimeShortcuts() {
const timezone = this.currentUser.timezone;
const shortcuts = timeShortcuts(timezone);
return [shortcuts.oneHour(), shortcuts.twoHours(), shortcuts.tomorrow()];
},
});

View File

@ -117,8 +117,7 @@ export default {
});
bus.subscribe(`/user-status/${user.id}`, (data) => {
user.set("status", data);
appEvents.trigger("user-status:changed");
appEvents.trigger("current-user-status:changed", data);
});
const site = container.lookup("site:main");

View File

@ -11,6 +11,7 @@ import {
nextBusinessWeekStart,
nextMonth,
now,
oneHour,
oneYear,
sixMonths,
thisWeekend,
@ -18,12 +19,15 @@ import {
threeMonths,
tomorrow,
twoDays,
twoHours,
twoMonths,
twoWeeks,
} from "discourse/lib/time-utils";
import I18n from "I18n";
export const TIME_SHORTCUT_TYPES = {
ONE_HOUR: "one_hour",
TWO_HOURS: "two_hours",
LATER_TODAY: "later_today",
TOMORROW: "tomorrow",
THIS_WEEKEND: "this_weekend",
@ -77,6 +81,24 @@ export function specialShortcutOptions() {
export function timeShortcuts(timezone) {
return {
oneHour() {
return {
id: TIME_SHORTCUT_TYPES.ONE_HOUR,
icon: "angle-right",
label: "time_shortcut.in_one_hour",
time: oneHour(timezone),
timeFormatKey: "dates.time",
};
},
twoHours() {
return {
id: TIME_SHORTCUT_TYPES.TWO_HOURS,
icon: "angle-right",
label: "time_shortcut.in_two_hours",
time: twoHours(timezone),
timeFormatKey: "dates.time",
};
},
laterToday() {
return {
id: TIME_SHORTCUT_TYPES.LATER_TODAY,

View File

@ -19,6 +19,14 @@ export function startOfDay(momentDate, startOfDayHour = START_OF_DAY_HOUR) {
return momentDate.hour(startOfDayHour).startOf("hour");
}
export function oneHour(timezone) {
return now(timezone).add(1, "hours");
}
export function twoHours(timezone) {
return now(timezone).add(2, "hours");
}
export function tomorrow(timezone) {
return startOfDay(now(timezone).add(1, "day"));
}

View File

@ -112,6 +112,9 @@ RestModel.reopenClass({
if (!args.siteSettings) {
args.siteSettings = owner.lookup("site-settings:main");
}
if (!args.appEvents) {
args.appEvents = owner.lookup("service:appEvents");
}
args.__munge = this.munge;
return this._super(this.munge(args, args.store));

View File

@ -31,6 +31,9 @@ import { longDate } from "discourse/lib/formatter";
import { url } from "discourse/lib/computed";
import { userPath } from "discourse/lib/url";
import { htmlSafe } from "@ember/template";
import Evented from "@ember/object/evented";
import { cancel, later } from "@ember/runloop";
import { isTesting } from "discourse-common/config/environment";
export const SECOND_FACTOR_METHODS = {
TOTP: 1,
@ -1072,6 +1075,8 @@ User.reopenClass(Singleton, {
createCurrent() {
const userJson = PreloadStore.get("currentUser");
if (userJson) {
userJson.isCurrent = true;
if (userJson.primary_group_id) {
const primaryGroup = userJson.groups.find(
(group) => group.id === userJson.primary_group_id
@ -1087,7 +1092,9 @@ User.reopenClass(Singleton, {
}
const store = getOwner(this).lookup("service:store");
return store.createRecord("user", userJson);
const currentUser = store.createRecord("user", userJson);
currentUser.trackStatus();
return currentUser;
}
return null;
@ -1170,6 +1177,78 @@ User.reopenClass(Singleton, {
},
});
// user status tracking
User.reopen(Evented, {
_clearStatusTimerId: null,
// always call stopTrackingStatus() when done with a user
trackStatus() {
this.addObserver("status", this, "_statusChanged");
if (this.isCurrent) {
this.appEvents.on(
"current-user-status:changed",
this,
this._updateStatus
);
}
if (this.status && this.status.ends_at) {
this._scheduleStatusClearing(this.status.ends_at);
}
},
stopTrackingStatus() {
this.removeObserver("status", this, "_statusChanged");
if (this.isCurrent) {
this.appEvents.off(
"current-user-status:changed",
this,
this._updateStatus
);
}
this._unscheduleStatusClearing();
},
_statusChanged(sender, key) {
this.trigger("status-changed");
const status = this.get(key);
if (status && status.ends_at) {
this._scheduleStatusClearing(status.ends_at);
} else {
this._unscheduleStatusClearing();
}
},
_scheduleStatusClearing(endsAt) {
if (isTesting()) {
return;
}
if (this._clearStatusTimerId) {
this._unscheduleStatusClearing();
}
const utcNow = moment.utc();
const remaining = moment.utc(endsAt).diff(utcNow, "milliseconds");
this._clearStatusTimerId = later(this, "_autoClearStatus", remaining);
},
_unscheduleStatusClearing() {
cancel(this._clearStatusTimerId);
this._clearStatusTimerId = null;
},
_autoClearStatus() {
this.set("status", null);
},
_updateStatus(status) {
this.set("status", status);
},
});
if (typeof Discourse !== "undefined") {
let warned = false;
// eslint-disable-next-line no-undef

View File

@ -63,7 +63,11 @@
<h2 class="staged">{{i18n "user.staged"}}</h2>
{{/if}}
{{#if this.hasStatus}}
<h3 class="user-status">{{html-safe this.userStatusEmoji}} {{this.user.status.description}}</h3>
<h3 class="user-status">
{{html-safe this.userStatusEmoji}}
{{this.user.status.description}}
{{format-date this.user.status.ends_at format="tiny"}}
</h3>
{{/if}}
<PluginOutlet @name="user-card-post-names" @connectorTagName="div" @args={{hash user=this.user}} @tagName="div" />
</div>

View File

@ -3,8 +3,24 @@
<div class="control-group">
<UserStatusPicker @emoji={{emoji}} @description={{description}} />
</div>
<div class="control-group control-group-remove-status">
<label class="control-label">
{{i18n "user_status.remove_status"}}
</label>
<TimeShortcutPicker
@timeShortcuts={{timeShortcuts}}
@hiddenOptions={{hiddenTimeShortcutOptions}}
@customLabels={{customTimeShortcutLabels}}
@prefilledDatetime={{prefilledDateTime}}
@onTimeSelected={{action "onTimeSelected"}}
@_itsatrap={{_itsatrap}} />
</div>
<div class="modal-footer control-group">
<DButton @label="user_status.save" @class="btn-primary" @disabled={{not statusIsSet}} @action={{action "saveAndClose"}} />
<DButton
@label="user_status.save"
@class="btn-primary"
@disabled={{not statusIsSet}}
@action={{action "saveAndClose"}} />
<DModalCancel @close={{action "closeModal"}} />
{{#if showDeleteButton}}
<DButton @icon="trash-alt" @class="delete-status btn-danger" @action={{action "delete"}} />

View File

@ -4,6 +4,7 @@ import QuickAccessItem from "discourse/widgets/quick-access-item";
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
import { createWidgetFrom } from "discourse/widgets/widget";
import showModal from "discourse/lib/show-modal";
import { dateNode } from "discourse/helpers/node";
const _extraItems = [];
@ -27,20 +28,11 @@ createWidgetFrom(QuickAccessItem, "user-status-item", {
tagName: "li.user-status",
html() {
const action = "hideMenuAndSetStatus";
const userStatus = this.currentUser.status;
if (userStatus) {
return this.attach("flat-button", {
action,
emoji: userStatus.emoji,
translatedLabel: userStatus.description,
});
const status = this.currentUser.status;
if (status) {
return this._editStatusButton(status);
} else {
return this.attach("flat-button", {
action,
icon: "plus-circle",
label: "user_status.set_custom_status",
});
return this._setStatusButton();
}
},
@ -51,6 +43,28 @@ createWidgetFrom(QuickAccessItem, "user-status-item", {
modalClass: "user-status",
});
},
_setStatusButton() {
return this.attach("flat-button", {
action: "hideMenuAndSetStatus",
icon: "plus-circle",
label: "user_status.set_custom_status",
});
},
_editStatusButton(status) {
const menuButton = {
action: "hideMenuAndSetStatus",
emoji: status.emoji,
translatedLabel: status.description,
};
if (status.ends_at) {
menuButton.contents = dateNode(status.ends_at);
}
return this.attach("flat-button", menuButton);
},
});
createWidgetFrom(QuickAccessPanel, "quick-access-profile", {

View File

@ -24,8 +24,9 @@ acceptance("User Status", function (needs) {
const userStatus = "off to dentist";
const userStatusEmoji = "tooth";
const userId = 1;
const userTimezone = "UTC";
needs.user({ id: userId });
needs.user({ id: userId, timezone: userTimezone });
needs.pretender((server, helper) => {
server.put("/user-status.json", () => {
@ -102,7 +103,11 @@ acceptance("User Status", function (needs) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({
status: { description: userStatus, emoji: userStatusEmoji },
status: {
description: userStatus,
emoji: userStatusEmoji,
ends_at: "2100-02-01T09:35:00.000Z",
},
});
await visit("/");
@ -118,6 +123,16 @@ acceptance("User Status", function (needs) {
userStatus,
"status description is shown"
);
assert.equal(
query(".date-picker").value,
"2100-02-01",
"date of auto removing of status is shown"
);
assert.equal(
query(".time-input").value,
"09:35",
"time of auto removing of status is shown"
);
});
test("emoji picking", async function (assert) {
@ -213,6 +228,28 @@ acceptance("User Status", function (needs) {
assert.notOk(exists(".header-dropdown-toggle .user-status-background"));
});
test("setting user status with auto removing timer", 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 click(".btn-primary"); // save
await click(".header-dropdown-toggle.current-user");
await click(".menu-links-row .user-preferences-link");
assert.equal(
query("div.quick-access-panel li.user-status span.relative-date")
.innerText,
"1h",
"shows user status timer on the menu"
);
});
test("it's impossible to set status without description", async function (assert) {
this.siteSettings.enable_user_status = true;
@ -237,7 +274,7 @@ acceptance("User Status", function (needs) {
);
});
test("shows actual status on the modal after canceling the modal", async function (assert) {
test("shows actual status on the modal after canceling the modal and opening it again", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({

View File

@ -289,6 +289,9 @@ export function acceptance(name, optionsOrCallback) {
if (userChanges) {
updateCurrentUser(userChanges);
}
User.current().appEvents = getOwner(this).lookup("service:appEvents");
User.current().trackStatus();
}
if (settingChanges) {
@ -313,6 +316,9 @@ export function acceptance(name, optionsOrCallback) {
resetMobile();
let app = getApplication();
options?.afterEach?.call(this);
if (loggedIn) {
User.current().stopTrackingStatus();
}
testCleanup(this.container, app);
// We do this after reset so that the willClearRender will have already fired

View File

@ -5,7 +5,13 @@ import PreloadStore from "discourse/lib/preload-store";
import sinon from "sinon";
import { settled } from "@ember/test-helpers";
module("Unit | Model | user", function () {
module("Unit | Model | user", function (hooks) {
hooks.afterEach(function () {
if (this.clock) {
this.clock.restore();
}
});
test("staff", function (assert) {
let user = User.create({ id: 1, username: "eviltrout" });

View File

@ -446,6 +446,14 @@ table {
}
}
.user-menu .quick-access-panel li.user-status .relative-date {
text-align: left;
font-size: var(--font-down-3);
padding-top: 0.45em;
margin-left: 0.75em;
color: var(--primary-medium);
}
.user-menu .quick-access-panel li.do-not-disturb {
display: flex;
flex: 0 0 100%;

View File

@ -302,7 +302,18 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards
}
h3.user-status {
display: flex;
img.emoji {
margin-bottom: 1px;
margin-right: 0.3em;
}
.relative-date {
text-align: left;
font-size: var(--font-down-3);
padding-top: 0.5em;
margin-left: 0.6em;
color: var(--primary-medium);
}
}

View File

@ -22,5 +22,13 @@
@media (max-width: 600px) {
width: 100%;
}
.control-group-remove-status {
margin-top: 25px;
}
.control-label {
font-weight: 700;
}
}
}

View File

@ -8,7 +8,7 @@ class UserStatusController < ApplicationController
description = params.require(:description)
emoji = params.require(:emoji)
current_user.set_status!(description, emoji)
current_user.set_status!(description, emoji, params[:ends_at])
render json: success_json
end

View File

@ -666,9 +666,15 @@ class User < ActiveRecord::Base
end
def publish_user_status(status)
payload = status ?
{ description: status.description, emoji: status.emoji } :
nil
if status
payload = {
description: status.description,
emoji: status.emoji,
ends_at: status.ends_at&.iso8601
}
else
payload = nil
end
MessageBus.publish("/user-status/#{id}", payload, user_ids: [id])
end
@ -1526,25 +1532,27 @@ class User < ActiveRecord::Base
publish_user_status(nil)
end
def set_status!(description, emoji)
now = Time.zone.now
def set_status!(description, emoji, ends_at)
status = {
description: description,
emoji: emoji,
set_at: Time.zone.now,
ends_at: ends_at
}
if user_status
user_status.update!(
description: description,
emoji: emoji,
set_at: now)
user_status.update!(status)
else
self.user_status = UserStatus.create!(
user_id: id,
description: description,
emoji: emoji,
set_at: now
)
self.user_status = UserStatus.create!(status)
end
publish_user_status(user_status)
end
def has_status?
user_status && !user_status.expired?
end
protected
def badge_grant

View File

@ -6,6 +6,10 @@ class UserStatus < ActiveRecord::Base
validate :ends_at_greater_than_set_at,
if: Proc.new { |t| t.will_save_change_to_set_at? || t.will_save_change_to_ends_at? }
def expired?
ends_at && ends_at < Time.zone.now
end
def ends_at_greater_than_set_at
if ends_at && set_at > ends_at
errors.add(:ends_at, I18n.t("user_status.errors.ends_at_should_be_greater_than_set_at"))

View File

@ -332,7 +332,7 @@ class CurrentUserSerializer < BasicUserSerializer
end
def include_status?
SiteSetting.enable_user_status
SiteSetting.enable_user_status && object.has_status?
end
def status

View File

@ -224,7 +224,7 @@ class UserCardSerializer < BasicUserSerializer
end
def include_status?
SiteSetting.enable_user_status
SiteSetting.enable_user_status && user.has_status?
end
def status

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true
class UserStatusSerializer < ApplicationSerializer
attributes :description, :emoji
attributes :description, :emoji, :ends_at
end

View File

@ -644,6 +644,8 @@ en:
time_shortcut:
now: "Now"
in_one_hour: "In one hour"
in_two_hours: "In two hours"
later_today: "Later today"
two_days: "Two days"
next_business_day: "Next business day"
@ -664,6 +666,7 @@ en:
forever: "Forever"
relative: "Relative time"
none: "None needed"
never: "Never"
last_custom: "Last custom datetime"
custom: "Custom date and time"
select_timeframe: "Select a timeframe"
@ -1796,6 +1799,7 @@ en:
save: "Save"
set_custom_status: "Set custom status"
what_are_you_doing: "What are you doing?"
remove_status: "Remove status"
loading: "Loading..."
errors:

View File

@ -38,38 +38,72 @@ describe UserStatusController do
it "sets user status" do
status = "off to dentist"
status_emoji = "tooth"
put "/user-status.json", params: { description: status, emoji: status_emoji }
ends_at = DateTime.parse("2100-01-01 18:00")
put "/user-status.json", params: {
description: status,
emoji: status_emoji,
ends_at: ends_at
}
expect(response.status).to eq(200)
expect(user.user_status.description).to eq(status)
expect(user.user_status.emoji).to eq(status_emoji)
expect(user.user_status.ends_at).to eq_time(ends_at)
end
it "following calls update status" do
status = "off to dentist"
status_emoji = "tooth"
put "/user-status.json", params: { description: status, emoji: status_emoji }
ends_at = DateTime.parse("2100-01-01 18:00")
put "/user-status.json", params: {
description: status,
emoji: status_emoji,
ends_at: ends_at
}
expect(response.status).to eq(200)
user.reload
expect(user.user_status.description).to eq(status)
expect(user.user_status.emoji).to eq(status_emoji)
expect(user.user_status.ends_at).to eq_time(ends_at)
new_status = "surfing"
new_status_emoji = "surfing_man"
put "/user-status.json", params: { description: new_status, emoji: new_status_emoji }
new_ends_at = DateTime.parse("2100-01-01 18:59")
put "/user-status.json", params: {
description: new_status,
emoji: new_status_emoji,
ends_at: new_ends_at
}
expect(response.status).to eq(200)
user.reload
expect(user.user_status.description).to eq(new_status)
expect(user.user_status.emoji).to eq(new_status_emoji)
expect(user.user_status.ends_at).to eq_time(new_ends_at)
end
it "publishes to message bus" do
status = "off to dentist"
emoji = "tooth"
ends_at = "2100-01-01T18:00:00Z"
messages = MessageBus.track_publish do
put "/user-status.json", params: { description: status, emoji: emoji }
put "/user-status.json", params: {
description: status,
emoji: emoji,
ends_at: ends_at
}
end
expect(messages.size).to eq(1)
expect(messages[0].channel).to eq("/user-status/#{user.id}")
expect(messages[0].data[:description]).to eq(status)
expect(messages[0].user_ids).to eq([user.id])
expect(messages[0].data[:description]).to eq(status)
expect(messages[0].data[:emoji]).to eq(emoji)
expect(messages[0].data[:ends_at]).to eq(ends_at)
end
end
end
@ -101,6 +135,7 @@ describe UserStatusController do
it "clears user status" do
delete "/user-status.json"
expect(response.status).to eq(200)
user.reload
expect(user.user_status).to be_nil

View File

@ -182,7 +182,7 @@ RSpec.describe CurrentUserSerializer do
fab!(:user) { Fabricate(:user, user_status: user_status) }
let(:serializer) { described_class.new(user, scope: Guardian.new(user), root: false) }
it "serializes when enabled" do
it "adds user status when enabled" do
SiteSetting.enable_user_status = true
json = serializer.as_json
@ -193,11 +193,31 @@ RSpec.describe CurrentUserSerializer do
end
end
it "doesn't serialize when disabled" do
it "doesn't add user status when disabled" do
SiteSetting.enable_user_status = false
json = serializer.as_json
expect(json.keys).not_to include :status
end
it "doesn't add expired user status" do
SiteSetting.enable_user_status = true
user.user_status.ends_at = 1.minutes.ago
serializer = described_class.new(user, scope: Guardian.new(user), root: false)
json = serializer.as_json
expect(json.keys).not_to include :status
end
it "doesn't return status if user doesn't have it set" do
SiteSetting.enable_user_status = true
user.clear_status!
user.reload
json = serializer.as_json
expect(json.keys).not_to include :status
end
end
describe '#sidebar_tag_names' do

View File

@ -76,7 +76,7 @@ describe UserCardSerializer do
fab!(:user) { Fabricate(:user, user_status: user_status) }
let(:serializer) { described_class.new(user, scope: Guardian.new(user), root: false) }
it "serializes when enabled" do
it "adds user status when enabled" do
SiteSetting.enable_user_status = true
json = serializer.as_json
@ -87,10 +87,30 @@ describe UserCardSerializer do
end
end
it "doesn't serialize when disabled" do
it "doesn't add user status when disabled" do
SiteSetting.enable_user_status = false
json = serializer.as_json
expect(json.keys).not_to include :status
end
it "doesn't add expired user status" do
SiteSetting.enable_user_status = true
user.user_status.ends_at = 1.minutes.ago
serializer = described_class.new(user, scope: Guardian.new(user), root: false)
json = serializer.as_json
expect(json.keys).not_to include :status
end
it "doesn't return status if user doesn't have it set" do
SiteSetting.enable_user_status = true
user.clear_status!
user.reload
json = serializer.as_json
expect(json.keys).not_to include :status
end
end
end