FEATURE: user status emoji (#17025)

This commit is contained in:
Andrei Prigorshnev 2022-06-22 18:15:33 +04:00 committed by GitHub
parent 0b8e6adabe
commit 033f72c65f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 351 additions and 91 deletions

View File

@ -14,6 +14,7 @@ import { isEmpty } from "@ember/utils";
import { prioritizeNameInUx } from "discourse/lib/settings"; import { prioritizeNameInUx } from "discourse/lib/settings";
import { dasherize } from "@ember/string"; import { dasherize } from "@ember/string";
import { emojiUnescape } from "discourse/lib/text"; import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, { export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
elementId: "user-card", elementId: "user-card",
@ -55,10 +56,9 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
return this.siteSettings.enable_user_status && this.user.status; return this.siteSettings.enable_user_status && this.user.status;
}, },
@discourseComputed("user.status") @discourseComputed("user.status.emoji")
userStatusEmoji() { userStatusEmoji(emoji) {
const emoji = this.user.status.emoji ?? "mega"; return emojiUnescape(escapeExpression(`:${emoji}:`));
return emojiUnescape(`:${emoji}:`);
}, },
isSuspendedOrHasBio: or("user.suspend_reason", "user.bio_excerpt"), isSuspendedOrHasBio: or("user.suspend_reason", "user.bio_excerpt"),

View File

@ -0,0 +1,57 @@
import Component from "@ember/component";
import { action, computed } from "@ember/object";
import { scheduleOnce } from "@ember/runloop";
import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
export default class UserStatusPicker extends Component {
tagName = "";
isFocused = false;
emojiPickerIsActive = false;
emoji = null;
description = null;
@computed("emoji")
get emojiHtml() {
const emoji = escapeExpression(`:${this.emoji}:`);
return emojiUnescape(emoji);
}
@action
blur() {
this.set("isFocused", false);
}
@action
emojiSelected(emoji) {
this.set("emoji", emoji);
this.set("emojiPickerIsActive", false);
scheduleOnce("afterRender", () => {
document.querySelector(".btn-emoji")?.focus();
});
}
@action
focus() {
this.set("isFocused", true);
}
@action
onEmojiPickerOutsideClick() {
this.set("emojiPickerIsActive", false);
}
@action
setDefaultEmoji() {
if (!this.emoji) {
this.set("emoji", "speech_balloon");
}
}
@action
toggleEmojiPicker(event) {
event.stopPropagation();
this.set("emojiPickerIsActive", !this.emojiPickerIsActive);
}
}

View File

@ -1,49 +1,49 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { notEmpty } from "@ember/object/computed";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import bootbox from "bootbox"; import bootbox from "bootbox";
import discourseComputed from "discourse-common/utils/decorators";
export default Controller.extend(ModalFunctionality, { export default Controller.extend(ModalFunctionality, {
userStatusService: service("user-status"), userStatusService: service("user-status"),
emoji: null,
description: null, description: null,
statusIsSet: notEmpty("description"),
showDeleteButton: false, showDeleteButton: false,
onShow() { onShow() {
if (this.currentUser.status) { const status = this.currentUser.status;
this.setProperties({ this.setProperties({
description: this.currentUser.status.description, emoji: status?.emoji,
showDeleteButton: true, description: status?.description,
}); showDeleteButton: !!status,
} });
},
@discourseComputed("emoji", "description")
statusIsSet(emoji, description) {
return !!emoji && !!description;
}, },
@action @action
delete() { delete() {
this.userStatusService this.userStatusService
.clear() .clear()
.then(() => { .then(() => this.send("closeModal"))
this._resetModal();
this.send("closeModal");
})
.catch((e) => this._handleError(e)); .catch((e) => this._handleError(e));
}, },
@action @action
saveAndClose() { saveAndClose() {
if (this.description) { const status = { description: this.description, emoji: this.emoji };
const status = { description: this.description }; this.userStatusService
this.userStatusService .set(status)
.set(status) .then(() => {
.then(() => { this.send("closeModal");
this.send("closeModal"); })
}) .catch((e) => this._handleError(e));
.catch((e) => this._handleError(e));
}
}, },
_handleError(e) { _handleError(e) {
@ -53,9 +53,4 @@ export default Controller.extend(ModalFunctionality, {
popupAjaxError(e); popupAjaxError(e);
} }
}, },
_resetModal() {
this.set("description", null);
this.set("showDeleteButton", false);
},
}); });

View File

@ -8,7 +8,7 @@ export default class UserStatusService extends Service {
await ajax({ await ajax({
url: "/user-status.json", url: "/user-status.json",
type: "PUT", type: "PUT",
data: { description: status.description }, data: status,
}); });
this.currentUser.set("status", status); this.currentUser.set("status", status);

View File

@ -0,0 +1,30 @@
<div class="user-status-picker-wrap">
<div class="emoji-picker-anchor user-status-picker {{if this.isFocused "focused"}}">
<button
type="button"
class="btn-emoji btn-flat"
onclick={{action "toggleEmojiPicker"}}
{{on "focus" this.focus}}
{{on "blur" this.blur}}
>
{{#if @emoji}}
{{html-safe this.emojiHtml}}
{{else}}
{{d-icon "discourse-emojis"}}
{{/if}}
</button>
<Input
class="user-status-description"
@value={{@description}}
placeholder={{i18n "user_status.what_are_you_doing"}}
{{on "input" this.setDefaultEmoji}}
{{on "focus" this.focus}}
{{on "blur" this.blur}} />
</div>
</div>
{{emoji-picker
isActive=this.emojiPickerIsActive
emojiSelected=(action "emojiSelected")
onEmojiPickerClose=(action "onEmojiPickerOutsideClick")
placement="bottom"
}}

View File

@ -1,10 +1,7 @@
{{#d-modal-body}} {{#d-modal-body}}
{{#conditional-loading-spinner condition=loading}} {{#conditional-loading-spinner condition=loading}}
<div class="control-group user-status-description-wrap"> <div class="control-group">
{{input {{user-status-picker emoji=emoji description=description}}
class="user-status-description"
placeholder=(i18n "user_status.what_are_you_doing")
value=description}}
</div> </div>
<div class="modal-footer control-group"> <div class="modal-footer control-group">
{{d-button {{d-button

View File

@ -30,10 +30,9 @@ createWidgetFrom(QuickAccessItem, "user-status-item", {
const action = "hideMenuAndSetStatus"; const action = "hideMenuAndSetStatus";
const userStatus = this.currentUser.status; const userStatus = this.currentUser.status;
if (userStatus) { if (userStatus) {
const emoji = userStatus.emoji ?? "mega";
return this.attach("flat-button", { return this.attach("flat-button", {
action, action,
emoji, emoji: userStatus.emoji,
translatedLabel: userStatus.description, translatedLabel: userStatus.description,
}); });
} else { } else {

View File

@ -4,7 +4,6 @@ export default createWidget("user-status-bubble", {
tagName: "div.user-status-background", tagName: "div.user-status-background",
html(attrs) { html(attrs) {
const emoji = attrs.emoji ?? "mega"; return this.attach("emoji", { name: attrs.emoji });
return this.attach("emoji", { name: emoji });
}, },
}); });

View File

@ -8,9 +8,21 @@ import {
import { click, fillIn, visit } from "@ember/test-helpers"; import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
async function openUserStatusModal() {
await click(".header-dropdown-toggle.current-user");
await click(".menu-links-row .user-preferences-link");
await click(".user-status button");
}
async function pickEmoji(emoji) {
await click(".btn-emoji");
await fillIn(".emoji-picker-content .filter", emoji);
await click(".results .emoji");
}
acceptance("User Status", function (needs) { acceptance("User Status", function (needs) {
const userStatusFallbackEmoji = "mega";
const userStatus = "off to dentist"; const userStatus = "off to dentist";
const userStatusEmoji = "tooth";
const userId = 1; const userId = 1;
needs.user({ id: userId }); needs.user({ id: userId });
@ -19,6 +31,7 @@ acceptance("User Status", function (needs) {
server.put("/user-status.json", () => { server.put("/user-status.json", () => {
publishToMessageBus(`/user-status/${userId}`, { publishToMessageBus(`/user-status/${userId}`, {
description: userStatus, description: userStatus,
emoji: userStatusEmoji,
}); });
return helper.response({ success: true }); return helper.response({ success: true });
}); });
@ -38,7 +51,7 @@ acceptance("User Status", function (needs) {
assert.notOk(exists("div.quick-access-panel li.user-status")); assert.notOk(exists("div.quick-access-panel li.user-status"));
}); });
test("shows the user status button on the menu when disabled in settings", async function (assert) { test("shows the user status button on the menu when enabled in settings", async function (assert) {
this.siteSettings.enable_user_status = true; this.siteSettings.enable_user_status = true;
await visit("/"); await visit("/");
@ -57,7 +70,9 @@ acceptance("User Status", function (needs) {
test("shows user status on loaded page", async function (assert) { test("shows user status on loaded page", async function (assert) {
this.siteSettings.enable_user_status = true; this.siteSettings.enable_user_status = true;
updateCurrentUser({ status: { description: userStatus } }); updateCurrentUser({
status: { description: userStatus, emoji: userStatusEmoji },
});
await visit("/"); await visit("/");
await click(".header-dropdown-toggle.current-user"); await click(".header-dropdown-toggle.current-user");
@ -72,30 +87,75 @@ acceptance("User Status", function (needs) {
assert.equal( assert.equal(
query("div.quick-access-panel li.user-status img.emoji").alt, query("div.quick-access-panel li.user-status img.emoji").alt,
`:${userStatusFallbackEmoji}:`, `:${userStatusEmoji}:`,
"shows user status emoji on the menu" "shows user status emoji on the menu"
); );
assert.equal( assert.equal(
query(".header-dropdown-toggle .user-status-background img.emoji").alt, query(".header-dropdown-toggle .user-status-background img.emoji").alt,
`:${userStatusFallbackEmoji}:`, `:${userStatusEmoji}:`,
"shows user status emoji on the user avatar in the header" "shows user status emoji on the user avatar in the header"
); );
}); });
test("shows user status on the user status modal", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({
status: { description: userStatus, emoji: userStatusEmoji },
});
await visit("/");
await openUserStatusModal();
assert.equal(
query(`.btn-emoji img.emoji`).title,
userStatusEmoji,
"status emoji is shown"
);
assert.equal(
query(".user-status-description").value,
userStatus,
"status description is shown"
);
});
test("emoji picking", async function (assert) {
this.siteSettings.enable_user_status = true;
await visit("/");
await openUserStatusModal();
assert.ok(exists(`.d-icon-discourse-emojis`), "empty status icon is shown");
await click(".btn-emoji");
assert.ok(exists(".emoji-picker.opened"), "emoji picker is opened");
await fillIn(".emoji-picker-content .filter", userStatusEmoji);
await click(".results .emoji");
assert.ok(
exists(`.btn-emoji img.emoji[title=${userStatusEmoji}]`),
"chosen status emoji is shown"
);
});
test("setting user status", async function (assert) { test("setting user status", async function (assert) {
this.siteSettings.enable_user_status = true; this.siteSettings.enable_user_status = true;
await visit("/"); await visit("/");
await click(".header-dropdown-toggle.current-user"); await openUserStatusModal();
await click(".menu-links-row .user-preferences-link");
await click(".user-status button");
await fillIn(".user-status-description", userStatus); await fillIn(".user-status-description", userStatus);
await click(".btn-primary"); await pickEmoji(userStatusEmoji);
assert.ok(
exists(`.btn-emoji img.emoji[title=${userStatusEmoji}]`),
"chosen status emoji is shown"
);
await click(".btn-primary"); // save
assert.equal( assert.equal(
query(".header-dropdown-toggle .user-status-background img.emoji").alt, query(".header-dropdown-toggle .user-status-background img.emoji").alt,
`:${userStatusFallbackEmoji}:`, `:${userStatusEmoji}:`,
"shows user status emoji on the user avatar in the header" "shows user status emoji on the user avatar in the header"
); );
@ -110,7 +170,7 @@ acceptance("User Status", function (needs) {
assert.equal( assert.equal(
query("div.quick-access-panel li.user-status img.emoji").alt, query("div.quick-access-panel li.user-status img.emoji").alt,
`:${userStatusFallbackEmoji}:`, `:${userStatusEmoji}:`,
"shows user status emoji on the menu" "shows user status emoji on the menu"
); );
}); });
@ -121,11 +181,11 @@ acceptance("User Status", function (needs) {
const updatedStatus = "off to dentist the second time"; const updatedStatus = "off to dentist the second time";
await visit("/"); await visit("/");
await click(".header-dropdown-toggle.current-user"); await openUserStatusModal();
await click(".menu-links-row .user-preferences-link");
await click(".user-status button");
await fillIn(".user-status-description", updatedStatus); await fillIn(".user-status-description", updatedStatus);
await click(".btn-primary"); await pickEmoji(userStatusEmoji);
await click(".btn-primary"); // save
await click(".header-dropdown-toggle.current-user"); await click(".header-dropdown-toggle.current-user");
await click(".menu-links-row .user-preferences-link"); await click(".menu-links-row .user-preferences-link");
@ -135,6 +195,11 @@ acceptance("User Status", function (needs) {
updatedStatus, updatedStatus,
"shows user status description on the menu" "shows user status description on the menu"
); );
assert.equal(
query("div.quick-access-panel li.user-status img.emoji").alt,
`:${userStatusEmoji}:`,
"shows user status emoji on the menu"
);
}); });
test("clearing user status", async function (assert) { test("clearing user status", async function (assert) {
@ -142,22 +207,68 @@ acceptance("User Status", function (needs) {
updateCurrentUser({ status: { description: userStatus } }); updateCurrentUser({ status: { description: userStatus } });
await visit("/"); await visit("/");
await click(".header-dropdown-toggle.current-user"); await openUserStatusModal();
await click(".menu-links-row .user-preferences-link");
await click(".user-status button");
await click(".btn.delete-status"); await click(".btn.delete-status");
assert.notOk(exists(".header-dropdown-toggle .user-status-background")); assert.notOk(exists(".header-dropdown-toggle .user-status-background"));
}); });
test("it's impossible to set status without description", async function (assert) {
this.siteSettings.enable_user_status = true;
await visit("/");
await openUserStatusModal();
await pickEmoji(userStatusEmoji);
assert.ok(exists(`.btn-primary[disabled]`), "the save button is disabled");
});
test("sets default status emoji automatically after user started inputting status description", async function (assert) {
this.siteSettings.enable_user_status = true;
const defaultStatusEmoji = "speech_balloon";
await visit("/");
await openUserStatusModal();
await fillIn(".user-status-description", "some status");
assert.ok(
exists(`.btn-emoji img.emoji[title=${defaultStatusEmoji}]`),
"default status emoji is shown"
);
});
test("shows actual status on the modal after canceling the modal", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({
status: { description: userStatus, emoji: userStatusEmoji },
});
await visit("/");
await openUserStatusModal();
await fillIn(".user-status-description", "another status");
await pickEmoji("cold_face"); // another emoji
await click(".d-modal-cancel");
await openUserStatusModal();
assert.equal(
query(`.btn-emoji img.emoji`).title,
userStatusEmoji,
"the actual status emoji is shown"
);
assert.equal(
query(".user-status-description").value,
userStatus,
"the actual status description is shown"
);
});
test("shows the trash button when editing status that was set before", async function (assert) { test("shows the trash button when editing status that was set before", async function (assert) {
this.siteSettings.enable_user_status = true; this.siteSettings.enable_user_status = true;
updateCurrentUser({ status: { description: userStatus } }); updateCurrentUser({ status: { description: userStatus } });
await visit("/"); await visit("/");
await click(".header-dropdown-toggle.current-user"); await openUserStatusModal();
await click(".menu-links-row .user-preferences-link");
await click(".user-status button");
assert.ok(exists(".btn.delete-status")); assert.ok(exists(".btn.delete-status"));
}); });
@ -167,10 +278,28 @@ acceptance("User Status", function (needs) {
updateCurrentUser({ status: null }); updateCurrentUser({ status: null });
await visit("/"); await visit("/");
await click(".header-dropdown-toggle.current-user"); await openUserStatusModal();
await click(".menu-links-row .user-preferences-link");
await click(".user-status button");
assert.notOk(exists(".btn.delete-status")); assert.notOk(exists(".btn.delete-status"));
}); });
test("shows empty modal after deleting the status", async function (assert) {
this.siteSettings.enable_user_status = true;
updateCurrentUser({
status: { description: userStatus, emoji: userStatusEmoji },
});
await visit("/");
await openUserStatusModal();
await click(".btn.delete-status");
await openUserStatusModal();
assert.ok(exists(`.d-icon-discourse-emojis`), "empty status icon is shown");
assert.equal(
query(".user-status-description").value,
"",
"no status description is shown"
);
});
}); });

View File

@ -28,6 +28,7 @@
@import "time-shortcut-picker"; @import "time-shortcut-picker";
@import "user-card"; @import "user-card";
@import "user-info"; @import "user-info";
@import "user-status-picker";
@import "user-stream-item"; @import "user-stream-item";
@import "user-stream"; @import "user-stream";
@import "widget-dropdown"; @import "widget-dropdown";

View File

@ -0,0 +1,34 @@
.user-status-picker-wrap {
display: inline-flex;
width: 100%;
align-items: end;
.user-status-picker {
display: flex;
width: 100%;
border: 1px solid var(--primary-medium);
.btn-emoji {
margin: 3px;
width: 2.3em;
text-align: center;
.svg-icon {
color: var(--primary-high);
}
}
.user-status-description {
width: 100%;
margin-bottom: 0;
border: none;
outline: none;
padding-left: 0;
}
}
.user-status-picker.focused {
border: 1px solid var(--tertiary);
outline: 1px solid var(--tertiary);
}
}

View File

@ -22,17 +22,5 @@
@media (max-width: 600px) { @media (max-width: 600px) {
width: 100%; width: 100%;
} }
.ember-text-field.user-status-description {
min-width: 220px;
width: 100%;
margin-bottom: 0.5em;
}
.user-status-description-wrap {
display: inline-flex;
width: 100%;
align-items: end;
}
} }
} }

View File

@ -5,9 +5,10 @@ class UserStatusController < ApplicationController
def set def set
ensure_feature_enabled ensure_feature_enabled
raise Discourse::InvalidParameters.new(:description) if params[:description].blank? description = params.require(:description)
emoji = params.require(:emoji)
current_user.set_status!(params[:description]) current_user.set_status!(description, emoji)
render json: success_json render json: success_json
end end

View File

@ -1522,14 +1522,18 @@ class User < ActiveRecord::Base
publish_user_status(nil) publish_user_status(nil)
end end
def set_status!(description) def set_status!(description, emoji)
now = Time.zone.now now = Time.zone.now
if user_status if user_status
user_status.update!(description: description, set_at: now) user_status.update!(
description: description,
emoji: emoji,
set_at: now)
else else
self.user_status = UserStatus.create!( self.user_status = UserStatus.create!(
user_id: id, user_id: id,
description: description, description: description,
emoji: emoji,
set_at: now set_at: now
) )
end end

View File

@ -18,7 +18,7 @@ end
# Table name: user_statuses # Table name: user_statuses
# #
# user_id :integer not null, primary key # user_id :integer not null, primary key
# emoji :string # emoji :string not null
# description :string not null # description :string not null
# set_at :datetime not null # set_at :datetime not null
# ends_at :datetime # ends_at :datetime

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class DisallowNullEmojiOnUserStatus < ActiveRecord::Migration[7.0]
def up
execute "UPDATE user_statuses SET emoji = 'speech_balloon'"
change_column :user_statuses, :emoji, :string, null: false
end
def down
change_column :user_statuses, :emoji, :string, null: true
end
end

View File

@ -25,32 +25,46 @@ describe UserStatusController do
SiteSetting.enable_user_status = true SiteSetting.enable_user_status = true
end end
it "sets user status" do it 'the description parameter is mandatory' do
status = "off to dentist" put "/user-status.json", params: { emoji: "tooth" }
put "/user-status.json", params: { description: status } expect(response.status).to eq(400)
expect(user.user_status.description).to eq(status)
end end
it 'the description parameter is mandatory' do it 'the emoji parameter is mandatory' do
put "/user-status.json", params: {} put "/user-status.json", params: { description: "off to dentist" }
expect(response.status).to eq(400) expect(response.status).to eq(400)
end end
it "sets user status" do
status = "off to dentist"
status_emoji = "tooth"
put "/user-status.json", params: { description: status, emoji: status_emoji }
expect(user.user_status.description).to eq(status)
expect(user.user_status.emoji).to eq(status_emoji)
end
it "following calls update status" do it "following calls update status" do
status = "off to dentist" status = "off to dentist"
put "/user-status.json", params: { description: status } status_emoji = "tooth"
put "/user-status.json", params: { description: status, emoji: status_emoji }
user.reload user.reload
expect(user.user_status.description).to eq(status) expect(user.user_status.description).to eq(status)
expect(user.user_status.emoji).to eq(status_emoji)
new_status = "working" new_status = "surfing"
put "/user-status.json", params: { description: new_status } new_status_emoji = "surfing_man"
put "/user-status.json", params: { description: new_status, emoji: new_status_emoji }
user.reload user.reload
expect(user.user_status.description).to eq(new_status) expect(user.user_status.description).to eq(new_status)
expect(user.user_status.emoji).to eq(new_status_emoji)
end end
it "publishes to message bus" do it "publishes to message bus" do
status = "off to dentist" status = "off to dentist"
messages = MessageBus.track_publish { put "/user-status.json", params: { description: status } } emoji = "tooth"
messages = MessageBus.track_publish do
put "/user-status.json", params: { description: status, emoji: emoji }
end
expect(messages.size).to eq(1) expect(messages.size).to eq(1)
expect(messages[0].channel).to eq("/user-status/#{user.id}") expect(messages[0].channel).to eq("/user-status/#{user.id}")