diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index f830f235adb..de813acf0c6 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -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(); diff --git a/app/assets/javascripts/discourse/app/components/user-card-contents.js b/app/assets/javascripts/discourse/app/components/user-card-contents.js index 51d653ddcdb..930f77a8c14 100644 --- a/app/assets/javascripts/discourse/app/components/user-card-contents.js +++ b/app/assets/javascripts/discourse/app/components/user-card-contents.js @@ -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, diff --git a/app/assets/javascripts/discourse/app/controllers/user-status.js b/app/assets/javascripts/discourse/app/controllers/user-status.js index 20f025d379d..4040f6f9183 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-status.js +++ b/app/assets/javascripts/discourse/app/controllers/user-status.js @@ -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()]; + }, }); 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 bf4235ba858..3244064e1a7 100644 --- a/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js +++ b/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js @@ -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"); diff --git a/app/assets/javascripts/discourse/app/lib/time-shortcut.js b/app/assets/javascripts/discourse/app/lib/time-shortcut.js index 1ecd26dfdf2..ad3e9223b2a 100644 --- a/app/assets/javascripts/discourse/app/lib/time-shortcut.js +++ b/app/assets/javascripts/discourse/app/lib/time-shortcut.js @@ -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, diff --git a/app/assets/javascripts/discourse/app/lib/time-utils.js b/app/assets/javascripts/discourse/app/lib/time-utils.js index 049bb8ecfd5..135e386d277 100644 --- a/app/assets/javascripts/discourse/app/lib/time-utils.js +++ b/app/assets/javascripts/discourse/app/lib/time-utils.js @@ -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")); } diff --git a/app/assets/javascripts/discourse/app/models/rest.js b/app/assets/javascripts/discourse/app/models/rest.js index 26219a9c48c..7bdc397703a 100644 --- a/app/assets/javascripts/discourse/app/models/rest.js +++ b/app/assets/javascripts/discourse/app/models/rest.js @@ -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)); diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index b24c8d96365..bfb0f21be0f 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -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 diff --git a/app/assets/javascripts/discourse/app/templates/components/user-card-contents.hbs b/app/assets/javascripts/discourse/app/templates/components/user-card-contents.hbs index 079ebdb24ee..0d9d1615b72 100644 --- a/app/assets/javascripts/discourse/app/templates/components/user-card-contents.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/user-card-contents.hbs @@ -63,7 +63,11 @@