diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/account.js b/app/assets/javascripts/discourse/app/controllers/preferences/account.js index 94ba197cfdb..5d1c18c1bd9 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/account.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/account.js @@ -11,6 +11,7 @@ import getURL from "discourse-common/lib/get-url"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { inject as service } from "@ember/service"; import { next } from "@ember/runloop"; +import showModal from "discourse/lib/show-modal"; export default Controller.extend(CanCheckEmails, { dialog: service(), @@ -22,16 +23,19 @@ export default Controller.extend(CanCheckEmails, { "title", "primary_group_id", "flair_group_id", + "status", ]; this.set("revoking", {}); }, canEditName: setting("enable_names"), + canSelectUserStatus: setting("enable_user_status"), canSaveUser: true, newNameInput: null, newTitleInput: null, newPrimaryGroupInput: null, + newStatus: null, revoking: null, @@ -146,6 +150,19 @@ export default Controller.extend(CanCheckEmails, { }); }, + @action + showUserStatusModal(status) { + showModal("user-status", { + title: "user_status.set_custom_status", + modalClass: "user-status", + model: { + status, + saveAction: async (s) => this.set("newStatus", s), + deleteAction: async () => this.set("newStatus", null), + }, + }); + }, + actions: { save() { this.set("saved", false); @@ -155,6 +172,7 @@ export default Controller.extend(CanCheckEmails, { title: this.newTitleInput, primary_group_id: this.newPrimaryGroupInput, flair_group_id: this.newFlairGroupId, + status: this.newStatus, }); return this.model diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index d829fb8e18f..70e1abd31f2 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -69,6 +69,7 @@ let userFields = [ "user_notification_schedule", "sidebar_category_ids", "sidebar_tag_names", + "status", ]; export function addSaveableUserField(fieldName) { diff --git a/app/assets/javascripts/discourse/app/routes/preferences-account.js b/app/assets/javascripts/discourse/app/routes/preferences-account.js index fc12eb6c076..e7516e2f208 100644 --- a/app/assets/javascripts/discourse/app/routes/preferences-account.js +++ b/app/assets/javascripts/discourse/app/routes/preferences-account.js @@ -31,6 +31,7 @@ export default RestrictedUserRoute.extend({ newTitleInput: user.get("title"), newPrimaryGroupInput: user.get("primary_group_id"), newFlairGroupId: user.get("flair_group_id"), + newStatus: user.status, }); }, diff --git a/app/assets/javascripts/discourse/app/templates/preferences/account.hbs b/app/assets/javascripts/discourse/app/templates/preferences/account.hbs index 40ae04547ad..6d7069c5f9d 100644 --- a/app/assets/javascripts/discourse/app/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/app/templates/preferences/account.hbs @@ -173,6 +173,24 @@ {{/if}} +{{#if this.canSelectUserStatus}} +
+ +
+ {{#if this.newStatus}} + + {{else}} + {{i18n "user.status.not_set"}} + {{/if}} + +
+
+{{/if}} + {{#if this.canSelectPrimaryGroup}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-account-user-status-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-account-user-status-test.js new file mode 100644 index 00000000000..b89854744e8 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-account-user-status-test.js @@ -0,0 +1,129 @@ +import { + acceptance, + exists, + query, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +async function openUserStatusModal() { + await click(".pref-user-status .btn-default"); +} + +async function pickEmoji(emoji) { + await click(".btn-emoji"); + await fillIn(".emoji-picker-content .filter", emoji); + await click(".results .emoji"); +} + +async function setStatus(status) { + await openUserStatusModal(); + await pickEmoji(status.emoji); + await fillIn(".user-status-description", status.description); + await click(".modal-footer .btn-primary"); // save and close modal +} + +acceptance("User Profile - Account - User Status", function (needs) { + const username = "eviltrout"; + const status = { + emoji: "tooth", + description: "off to dentist", + }; + + needs.user({ username, status }); + + test("doesn't render status block if status is disabled in site settings", async function (assert) { + this.siteSettings.enable_user_status = false; + await visit(`/u/${username}/preferences/account`); + assert.notOk(exists(".pref-user-status")); + }); + + test("renders status block if status is enabled in site settings", async function (assert) { + this.siteSettings.enable_user_status = true; + + await visit(`/u/${username}/preferences/account`); + + assert.ok( + exists(".pref-user-status .user-status-message"), + "status is shown" + ); + assert.ok( + exists(`.pref-user-status .emoji[alt='${status.emoji}']`), + "status emoji is correct" + ); + assert.equal( + query( + `.pref-user-status .user-status-message-description` + ).innerText.trim(), + status.description, + "status description is correct" + ); + }); + + test("the status modal sets status", async function (assert) { + this.siteSettings.enable_user_status = true; + updateCurrentUser({ status: null }); + + await visit(`/u/${username}/preferences/account`); + assert.notOk( + exists(".pref-user-status .user-status-message"), + "status isn't shown" + ); + + await setStatus(status); + + assert.ok( + exists(".pref-user-status .user-status-message"), + "status is shown" + ); + assert.ok( + exists(`.pref-user-status .emoji[alt='${status.emoji}']`), + "status emoji is correct" + ); + assert.equal( + query( + `.pref-user-status .user-status-message-description` + ).innerText.trim(), + status.description, + "status description is correct" + ); + }); + + test("the status modal updates status", async function (assert) { + this.siteSettings.enable_user_status = true; + + await visit(`/u/${username}/preferences/account`); + const newStatus = { emoji: "surfing_man", description: "surfing" }; + await setStatus(newStatus); + + assert.ok( + exists(".pref-user-status .user-status-message"), + "status is shown" + ); + assert.ok( + exists(`.pref-user-status .emoji[alt='${newStatus.emoji}']`), + "status emoji is correct" + ); + assert.equal( + query( + `.pref-user-status .user-status-message-description` + ).innerText.trim(), + newStatus.description, + "status description is correct" + ); + }); + + test("the status modal clears status", async function (assert) { + this.siteSettings.enable_user_status = true; + + await visit(`/u/${username}/preferences/account`); + await openUserStatusModal(); + await click(".btn.delete-status"); + + assert.notOk( + exists(".pref-user-status .user-status-message"), + "status isn't shown" + ); + }); +}); diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1e24e3d5d5d..63f06911f38 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1965,6 +1965,10 @@ class UsersController < ApplicationController end end + if SiteSetting.enable_user_status + permitted << { status: [:emoji, :description, :ends_at] } + end + result = params .permit(permitted, theme_ids: [], seen_popups: []) .reverse_merge( diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index 4de4057b4be..31feebbf91d 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -218,9 +218,17 @@ class UserUpdater update_sidebar_tag_section_links(attributes[:sidebar_tag_names]) end + if SiteSetting.enable_user_status? + update_user_status(attributes[:status]) + end + name_changed = user.name_changed? - if (saved = (!save_options || user.user_option.save) && (user_notification_schedule.nil? || user_notification_schedule.save) && user_profile.save && user.save) && - (name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0) + saved = (!save_options || user.user_option.save) && + (user_notification_schedule.nil? || user_notification_schedule.save) && + user_profile.save && + user.save + + if saved && (name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0) StaffActionLogger.new(@actor).log_name_change( user.id, @@ -341,6 +349,14 @@ class UserUpdater end end + def update_user_status(status) + if status.blank? + @user.clear_status! + else + @user.set_status!(status[:description], status[:emoji], status[:ends_at]) + end + end + attr_reader :user, :guardian def format_url(website) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 059e4d1f3b9..350d32feb01 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1793,6 +1793,9 @@ en: title: "Flair" none: "(none)" instructions: "icon displayed next to your profile picture" + status: + title: "Custom Status" + not_set: "Not set" primary_group: title: "Primary Group" none: "(none)" diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 56276bdecc9..5c41f7d92b3 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -2472,6 +2472,224 @@ RSpec.describe UsersController do expect(response.status).to eq(400) end end + + context "with user status" do + context "as a regular user" do + before do + SiteSetting.enable_user_status = true + sign_in(user) + end + + it "sets user status" do + status = { + emoji: "tooth", + description: "off to dentist", + } + + put "/u/#{user.username}.json", params: { + status: status + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).not_to be_nil + expect(user.user_status.emoji).to eq(status[:emoji]) + expect(user.user_status.description).to eq(status[:description]) + end + + it "updates user status" do + user.set_status!("off to dentist", "tooth") + user.reload + + new_status = { + emoji: "surfing_man", + description: "surfing", + } + put "/u/#{user.username}.json", params: { + status: new_status + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).not_to be_nil + expect(user.user_status.emoji).to eq(new_status[:emoji]) + expect(user.user_status.description).to eq(new_status[:description]) + end + + it "clears user status" do + user.set_status!("off to dentist", "tooth") + user.reload + + put "/u/#{user.username}.json", params: { + status: nil + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).to be_nil + end + + it "can't set status of another user" do + put "/u/#{user1.username}.json", params: { + status: { + emoji: "tooth", + description: "off to dentist", + } + } + expect(response.status).to eq(403) + + user1.reload + expect(user1.user_status).to be_nil + end + + it "can't update status of another user" do + old_status = { + emoji: "tooth", + description: "off to dentist", + } + user1.set_status!(old_status[:description], old_status[:emoji]) + user1.reload + + new_status = { + emoji: "surfing_man", + description: "surfing", + } + put "/u/#{user1.username}.json", params: { + status: new_status + } + expect(response.status).to eq(403) + + user1.reload + expect(user1.user_status).not_to be_nil + expect(user1.user_status.emoji).to eq(old_status[:emoji]) + expect(user1.user_status.description).to eq(old_status[:description]) + end + + it "can't clear status of another user" do + user1.set_status!("off to dentist", "tooth") + user1.reload + + put "/u/#{user1.username}.json", params: { + status: nil + } + expect(response.status).to eq(403) + + user1.reload + expect(user1.user_status).not_to be_nil + end + + context 'when user status is disabled' do + before do + SiteSetting.enable_user_status = false + end + + it "doesn't set user status" do + put "/u/#{user.username}.json", params: { + status: { + emoji: "tooth", + description: "off to dentist", + } + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).to be_nil + end + + it "doesn't update user status" do + old_status = { + emoji: "tooth", + description: "off to dentist", + } + user.set_status!(old_status[:description], old_status[:emoji]) + user.reload + + new_status = { + emoji: "surfing_man", + description: "surfing", + } + put "/u/#{user.username}.json", params: { + status: new_status + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).not_to be_nil + expect(user.user_status.emoji).to eq(old_status[:emoji]) + expect(user.user_status.description).to eq(old_status[:description]) + end + + it "doesn't clear user status" do + user.set_status!("off to dentist", "tooth") + user.reload + + put "/u/#{user.username}.json", params: { + status: nil + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).not_to be_nil + end + end + end + + context 'as a staff user' do + before do + SiteSetting.enable_user_status = true + sign_in(moderator) + end + + it "sets another user's status" do + status = { + emoji: "tooth", + description: "off to dentist", + } + + put "/u/#{user.username}.json", params: { + status: status + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).not_to be_nil + expect(user.user_status.emoji).to eq(status[:emoji]) + expect(user.user_status.description).to eq(status[:description]) + end + + it "updates another user's status" do + user.set_status!("off to dentist", "tooth") + user.reload + + new_status = { + emoji: "surfing_man", + description: "surfing", + } + put "/u/#{user.username}.json", params: { + status: new_status + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).not_to be_nil + expect(user.user_status.emoji).to eq(new_status[:emoji]) + expect(user.user_status.description).to eq(new_status[:description]) + end + + it "clears another user's status" do + user.set_status!("off to dentist", "tooth") + user.reload + + put "/u/#{user.username}.json", params: { + status: nil + } + expect(response.status).to eq(200) + + user.reload + expect(user.user_status).to be_nil + end + end + end end describe '#badge_title' do