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}} + +
+ {{emoji @status.emoji skipTitle=true}} + + {{@status.description}} + + {{#if this.until}} +
+ {{this.until}} +
+ {{/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); + } +}