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