diff --git a/app/assets/javascripts/discourse/app/components/user-status-message.js b/app/assets/javascripts/discourse/app/components/user-status-message.js
new file mode 100644
index 00000000000..b36791c85d0
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-status-message.js
@@ -0,0 +1,26 @@
+import Component from "@ember/component";
+import { computed } from "@ember/object";
+import I18n from "I18n";
+
+export default class extends Component {
+ tagName = "";
+
+ @computed("status.ends_at")
+ get until() {
+ if (!this.status.ends_at) {
+ return null;
+ }
+
+ const timezone = this.currentUser.timezone;
+ const endsAt = moment.tz(this.status.ends_at, timezone);
+ const now = moment.tz(timezone);
+ const until = I18n.t("user_status.until");
+
+ if (now.isSame(endsAt, "day")) {
+ const localeData = moment.localeData(this.currentUser.locale);
+ return `${until} ${endsAt.format(localeData.longDateFormat("LT"))}`;
+ } else {
+ return `${until} ${endsAt.format("MMM D")}`;
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/routes/user.js b/app/assets/javascripts/discourse/app/routes/user.js
index e88075cfd60..c33c8d1b6cc 100644
--- a/app/assets/javascripts/discourse/app/routes/user.js
+++ b/app/assets/javascripts/discourse/app/routes/user.js
@@ -47,6 +47,7 @@ export default DiscourseRoute.extend({
return user
.findDetails()
.then(() => user.findStaffInfo())
+ .then(() => user.trackStatus())
.catch(() => this.replaceWith("/404"));
},
@@ -87,6 +88,7 @@ export default DiscourseRoute.extend({
const user = this.modelFor("user");
this.messageBus.unsubscribe(`/u/${user.username_lower}`);
this.messageBus.unsubscribe(`/u/${user.username_lower}/counters`);
+ user.stopTrackingStatus();
// Remove the search context
this.searchService.set("searchContext", null);
diff --git a/app/assets/javascripts/discourse/app/templates/components/user-status-message.hbs b/app/assets/javascripts/discourse/app/templates/components/user-status-message.hbs
new file mode 100644
index 00000000000..a6bb89864f3
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/components/user-status-message.hbs
@@ -0,0 +1,21 @@
+
+ {{emoji @status.emoji skipTitle=true}}
+ {{#if @showDescription}}
+
+ {{@status.description}}
+
+ {{/if}}
+
+
+
+
diff --git a/app/assets/javascripts/discourse/app/templates/user.hbs b/app/assets/javascripts/discourse/app/templates/user.hbs
index c728f78cb46..c990875aaf6 100644
--- a/app/assets/javascripts/discourse/app/templates/user.hbs
+++ b/app/assets/javascripts/discourse/app/templates/user.hbs
@@ -79,7 +79,13 @@
-
{{if this.nameFirst this.model.name (format-username this.model.username)}} {{user-status this.model currentUser=this.currentUser}}
+
+ {{if this.nameFirst this.model.name (format-username this.model.username)}}
+ {{user-status this.model currentUser=this.currentUser}}
+ {{#if this.model.status}}
+
+ {{/if}}
+
{{#if this.nameFirst}}{{this.model.username}}{{else}}{{this.model.name}}{{/if}}
{{#if this.model.staged}}
{{i18n "user.staged"}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-profile-summary-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-profile-summary-test.js
new file mode 100644
index 00000000000..7734a0b5048
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/user-profile-summary-test.js
@@ -0,0 +1,115 @@
+import {
+ acceptance,
+ exists,
+ query,
+} from "discourse/tests/helpers/qunit-helpers";
+import { visit } from "@ember/test-helpers";
+import { test } from "qunit";
+import I18n from "I18n";
+import userFixtures from "discourse/tests/fixtures/user-fixtures";
+import { cloneJSON } from "discourse-common/lib/object";
+
+acceptance("User Profile - Summary", function (needs) {
+ needs.user();
+ needs.pretender((server, helper) => {
+ server.get("/u/eviltrout.json", () => {
+ const response = cloneJSON(userFixtures["/u/eviltrout.json"]);
+ return helper.response(response);
+ });
+ });
+
+ test("Viewing Summary", async function (assert) {
+ await visit("/u/eviltrout/summary");
+
+ assert.ok(exists(".replies-section li a"), "replies");
+ assert.ok(exists(".topics-section li a"), "topics");
+ assert.ok(exists(".links-section li a"), "links");
+ assert.ok(exists(".replied-section .user-info"), "liked by");
+ assert.ok(exists(".liked-by-section .user-info"), "liked by");
+ assert.ok(exists(".liked-section .user-info"), "liked");
+ assert.ok(exists(".badges-section .badge-card"), "badges");
+ assert.ok(
+ exists(".top-categories-section .category-link"),
+ "top categories"
+ );
+ });
+});
+
+acceptance("User Profile - Summary - User Status", function (needs) {
+ needs.user();
+ needs.pretender((server, helper) => {
+ server.get("/u/eviltrout.json", () => {
+ const response = cloneJSON(userFixtures["/u/eviltrout.json"]);
+ response.user.status = {
+ description: "off to dentist",
+ emoji: "tooth",
+ };
+ return helper.response(response);
+ });
+ });
+
+ test("Shows User Status", async function (assert) {
+ await visit("/u/eviltrout/summary");
+ assert.ok(exists(".user-status-message .emoji[alt='tooth']"));
+ });
+});
+
+acceptance("User Profile - Summary - Stats", function (needs) {
+ needs.pretender((server, helper) => {
+ server.get("/u/eviltrout/summary.json", () => {
+ return helper.response(200, {
+ user_summary: {
+ likes_given: 1,
+ likes_received: 2,
+ topics_entered: 3,
+ posts_read_count: 4,
+ days_visited: 5,
+ topic_count: 6,
+ post_count: 7,
+ time_read: 100000,
+ recent_time_read: 1000,
+ bookmark_count: 0,
+ can_see_summary_stats: true,
+ topic_ids: [1234],
+ replies: [{ topic_id: 1234 }],
+ links: [{ topic_id: 1234, url: "https://eviltrout.com" }],
+ most_replied_to_users: [{ id: 333 }],
+ most_liked_by_users: [{ id: 333 }],
+ most_liked_users: [{ id: 333 }],
+ badges: [{ badge_id: 444 }],
+ top_categories: [
+ {
+ id: 1,
+ name: "bug",
+ color: "e9dd00",
+ text_color: "000000",
+ slug: "bug",
+ read_restricted: false,
+ parent_category_id: null,
+ topic_count: 1,
+ post_count: 1,
+ },
+ ],
+ },
+ badges: [{ id: 444, count: 1 }],
+ topics: [{ id: 1234, title: "cool title", slug: "cool-title" }],
+ });
+ });
+ });
+
+ test("Summary Read Times", async function (assert) {
+ await visit("/u/eviltrout/summary");
+
+ assert.equal(query(".stats-time-read span").textContent.trim(), "1d");
+ assert.equal(
+ query(".stats-time-read span").title,
+ I18n.t("user.summary.time_read_title", { duration: "1 day" })
+ );
+
+ assert.equal(query(".stats-recent-read span").textContent.trim(), "17m");
+ assert.equal(
+ query(".stats-recent-read span").title,
+ I18n.t("user.summary.recent_time_read_title", { duration: "17 mins" })
+ );
+ });
+});
diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-test.js
index e8ae776aa2a..54711f0aff1 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/user-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/user-test.js
@@ -13,7 +13,6 @@ import {
import { click, currentRouteName, visit } from "@ember/test-helpers";
import { cloneJSON } from "discourse-common/lib/object";
import { test } from "qunit";
-import I18n from "I18n";
acceptance("User Routes", function (needs) {
needs.user();
@@ -93,22 +92,6 @@ acceptance("User Routes", function (needs) {
assert.ok(exists(".container.viewing-self"), "has the viewing-self class");
});
- test("Viewing Summary", async function (assert) {
- await visit("/u/eviltrout/summary");
-
- assert.ok(exists(".replies-section li a"), "replies");
- assert.ok(exists(".topics-section li a"), "topics");
- assert.ok(exists(".links-section li a"), "links");
- assert.ok(exists(".replied-section .user-info"), "liked by");
- assert.ok(exists(".liked-by-section .user-info"), "liked by");
- assert.ok(exists(".liked-section .user-info"), "liked");
- assert.ok(exists(".badges-section .badge-card"), "badges");
- assert.ok(
- exists(".top-categories-section .category-link"),
- "top categories"
- );
- });
-
test("Viewing Drafts", async function (assert) {
await visit("/u/eviltrout/activity/drafts");
assert.ok(exists(".user-stream"), "has drafts stream");
@@ -125,66 +108,6 @@ acceptance("User Routes", function (needs) {
});
});
-acceptance("User Summary - Stats", function (needs) {
- needs.pretender((server, helper) => {
- server.get("/u/eviltrout/summary.json", () => {
- return helper.response(200, {
- user_summary: {
- likes_given: 1,
- likes_received: 2,
- topics_entered: 3,
- posts_read_count: 4,
- days_visited: 5,
- topic_count: 6,
- post_count: 7,
- time_read: 100000,
- recent_time_read: 1000,
- bookmark_count: 0,
- can_see_summary_stats: true,
- topic_ids: [1234],
- replies: [{ topic_id: 1234 }],
- links: [{ topic_id: 1234, url: "https://eviltrout.com" }],
- most_replied_to_users: [{ id: 333 }],
- most_liked_by_users: [{ id: 333 }],
- most_liked_users: [{ id: 333 }],
- badges: [{ badge_id: 444 }],
- top_categories: [
- {
- id: 1,
- name: "bug",
- color: "e9dd00",
- text_color: "000000",
- slug: "bug",
- read_restricted: false,
- parent_category_id: null,
- topic_count: 1,
- post_count: 1,
- },
- ],
- },
- badges: [{ id: 444, count: 1 }],
- topics: [{ id: 1234, title: "cool title", slug: "cool-title" }],
- });
- });
- });
-
- test("Summary Read Times", async function (assert) {
- await visit("/u/eviltrout/summary");
-
- assert.equal(query(".stats-time-read span").textContent.trim(), "1d");
- assert.equal(
- query(".stats-time-read span").title,
- I18n.t("user.summary.time_read_title", { duration: "1 day" })
- );
-
- assert.equal(query(".stats-recent-read span").textContent.trim(), "17m");
- assert.equal(
- query(".stats-recent-read span").title,
- I18n.t("user.summary.recent_time_read_title", { duration: "17 mins" })
- );
- });
-});
-
acceptance(
"User Routes - Periods in current user's username",
function (needs) {
diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-status-message-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-status-message-test.js
new file mode 100644
index 00000000000..bb58ca535fc
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/user-status-message-test.js
@@ -0,0 +1,114 @@
+import { module, test } from "qunit";
+import { render, triggerEvent } from "@ember/test-helpers";
+import { setupRenderingTest } from "discourse/tests/helpers/component-test";
+import { hbs } from "ember-cli-htmlbars";
+import { exists, fakeTime, query } from "discourse/tests/helpers/qunit-helpers";
+
+async function mouseenter() {
+ await triggerEvent(query(".user-status-message"), "mouseenter");
+}
+
+module("Integration | Component | user-status-message", function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.currentUser.timezone = "UTC";
+ });
+
+ hooks.afterEach(function () {
+ if (this.clock) {
+ this.clock.restore();
+ }
+ });
+
+ test("it renders user status emoji", async function (assert) {
+ this.set("status", { emoji: "tooth", description: "off to dentist" });
+ await render(hbs``);
+ assert.ok(exists("img.emoji[alt='tooth']"), "the status emoji is shown");
+ });
+
+ test("it doesn't render status description by default", async function (assert) {
+ this.set("status", { emoji: "tooth", description: "off to dentist" });
+ await render(hbs``);
+ assert.notOk(exists(".user-status-message-description"));
+ });
+
+ test("it renders status description if enabled", async function (assert) {
+ this.set("status", { emoji: "tooth", description: "off to dentist" });
+ await render(hbs`
+
+ `);
+ assert.equal(
+ query(".user-status-message-description").innerText.trim(),
+ "off to dentist"
+ );
+ });
+
+ test("it shows the until TIME on the tooltip if status will expire today", async function (assert) {
+ this.clock = fakeTime(
+ "2100-02-01T08:00:00.000Z",
+ this.currentUser.timezone,
+ true
+ );
+ this.set("status", {
+ emoji: "tooth",
+ description: "off to dentist",
+ ends_at: "2100-02-01T12:30:00.000Z",
+ });
+
+ await render(hbs``);
+
+ await mouseenter();
+ assert.equal(
+ document
+ .querySelector("[data-tippy-root] .user-status-tooltip-until")
+ .textContent.trim(),
+ "Until: 12:30 PM"
+ );
+ });
+
+ test("it shows the until DATE on the tooltip if status will expire tomorrow", async function (assert) {
+ this.clock = fakeTime(
+ "2100-02-01T08:00:00.000Z",
+ this.currentUser.timezone,
+ true
+ );
+ this.set("status", {
+ emoji: "tooth",
+ description: "off to dentist",
+ ends_at: "2100-02-02T12:30:00.000Z",
+ });
+
+ await render(hbs``);
+
+ await mouseenter();
+ assert.equal(
+ document
+ .querySelector("[data-tippy-root] .user-status-tooltip-until")
+ .textContent.trim(),
+ "Until: Feb 2"
+ );
+ });
+
+ test("it doesn't show until datetime on the tooltip if status doesn't have expiration date", async function (assert) {
+ this.clock = fakeTime(
+ "2100-02-01T08:00:00.000Z",
+ this.currentUser.timezone,
+ true
+ );
+ this.set("status", {
+ emoji: "tooth",
+ description: "off to dentist",
+ ends_at: null,
+ });
+
+ await render(hbs``);
+
+ await mouseenter();
+ assert.notOk(
+ document.querySelector("[data-tippy-root] .user-status-tooltip-until")
+ );
+ });
+});
diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss
index 66f90063d82..a054cd44783 100644
--- a/app/assets/stylesheets/common/components/_index.scss
+++ b/app/assets/stylesheets/common/components/_index.scss
@@ -29,6 +29,7 @@
@import "time-shortcut-picker";
@import "user-card";
@import "user-info";
+@import "user-status-message";
@import "user-status-picker";
@import "user-stream-item";
@import "user-stream";
diff --git a/app/assets/stylesheets/common/components/user-status-message.scss b/app/assets/stylesheets/common/components/user-status-message.scss
new file mode 100644
index 00000000000..8e45d8e6d21
--- /dev/null
+++ b/app/assets/stylesheets/common/components/user-status-message.scss
@@ -0,0 +1,17 @@
+.user-status-message-tooltip {
+ .emoji {
+ width: 1em;
+ height: 1em;
+ }
+
+ .user-status-tooltip-description {
+ font-weight: bold;
+ margin-left: 0.1em;
+ vertical-align: middle;
+ }
+
+ .user-status-tooltip-until {
+ margin-top: 0.2em;
+ color: var(--primary-medium);
+ }
+}