FEATURE: Use rich user status tooltip everywhere (#21125)

- Inline mentions on posts
- Inline mentions on chat messages
- The user autocomplete for the composer
- The user autocomplete for chat
- The chat section of the sidebar
This commit is contained in:
Jan Cernik 2023-07-03 11:09:41 -03:00 committed by GitHub
parent 5034eda386
commit 585a2e4e77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 398 additions and 134 deletions

View File

@ -42,6 +42,11 @@ import { isTesting } from "discourse-common/config/environment";
import { loadOneboxes } from "discourse/lib/load-oneboxes"; import { loadOneboxes } from "discourse/lib/load-oneboxes";
import putCursorAtEnd from "discourse/lib/put-cursor-at-end"; import putCursorAtEnd from "discourse/lib/put-cursor-at-end";
import userSearch from "discourse/lib/user-search"; import userSearch from "discourse/lib/user-search";
import {
destroyTippyInstances,
initUserStatusHtml,
renderUserStatusHtml,
} from "discourse/lib/user-status-on-autocomplete";
// original string `![image|foo=bar|690x220, 50%|bar=baz](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")` // original string `![image|foo=bar|690x220, 50%|bar=baz](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")`
// group 1 `image|foo=bar` // group 1 `image|foo=bar`
@ -220,18 +225,27 @@ export default Component.extend(
if (this.siteSettings.enable_mentions) { if (this.siteSettings.enable_mentions) {
$input.autocomplete({ $input.autocomplete({
template: findRawTemplate("user-selector-autocomplete"), template: findRawTemplate("user-selector-autocomplete"),
dataSource: (term) => dataSource: (term) => {
userSearch({ destroyTippyInstances();
return userSearch({
term, term,
topicId: this.topic?.id, topicId: this.topic?.id,
categoryId: this.topic?.category_id || this.composer?.categoryId, categoryId: this.topic?.category_id || this.composer?.categoryId,
includeGroups: true, includeGroups: true,
}), }).then((result) => {
initUserStatusHtml(result.users);
return result;
});
},
onRender: (options) => {
renderUserStatusHtml(options);
},
key: "@", key: "@",
transformComplete: (v) => v.username || v.name, transformComplete: (v) => v.username || v.name,
afterComplete: this._afterMentionComplete, afterComplete: this._afterMentionComplete,
triggerRule: (textarea) => triggerRule: (textarea) =>
!inCodeBlock(textarea.value, caretPosition(textarea)), !inCodeBlock(textarea.value, caretPosition(textarea)),
onClose: destroyTippyInstances,
}); });
} }

View File

@ -19,6 +19,7 @@ export default class DiscourseTooltip extends Component {
} }
stopPropagation(instance, event) { stopPropagation(instance, event) {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }

View File

@ -52,6 +52,7 @@
}} }}
> >
{{@content}} {{@content}}
{{@contentComponent}}
</span> </span>
{{#if @badgeText}} {{#if @badgeText}}

View File

@ -47,6 +47,10 @@
@didInsert={{link.didInsert}} @didInsert={{link.didInsert}}
@willDestroy={{link.willDestroy}} @willDestroy={{link.willDestroy}}
@content={{link.text}} @content={{link.text}}
@contentComponent={{component
link.contentComponent
status=link.contentComponentArgs
}}
/> />
{{/each}} {{/each}}
</Sidebar::Section> </Sidebar::Section>

View File

@ -1,23 +1,25 @@
<span class={{concat-class "user-status-message" @class}}> {{#if @status}}
{{emoji @status.emoji skipTitle=true}} <span class={{concat-class "user-status-message" @class}}>
{{#if @showDescription}} {{emoji @status.emoji skipTitle=true}}
<span class="user-status-message-description"> {{#if @showDescription}}
{{@status.description}} <span class="user-status-message-description">
</span> {{@status.description}}
{{/if}} </span>
{{#if this.showTooltip}} {{/if}}
<DTooltip> {{#if this.showTooltip}}
<div class="user-status-message-tooltip"> <DTooltip>
{{emoji @status.emoji skipTitle=true}} <div class="user-status-message-tooltip">
<span class="user-status-tooltip-description"> {{emoji @status.emoji skipTitle=true}}
{{@status.description}} <span class="user-status-tooltip-description">
</span> {{@status.description}}
{{#if this.until}} </span>
<div class="user-status-tooltip-until"> {{#if this.until}}
{{this.until}} <div class="user-status-tooltip-until">
</div> {{this.until}}
{{/if}} </div>
</div> {{/if}}
</DTooltip> </div>
{{/if}} </DTooltip>
</span> {{/if}}
</span>
{{/if}}

View File

@ -148,6 +148,7 @@ export default function (options) {
function closeAutocomplete() { function closeAutocomplete() {
_autoCompletePopper?.destroy(); _autoCompletePopper?.destroy();
options.onClose && options.onClose();
if (div) { if (div) {
div.hide().remove(); div.hide().remove();
@ -404,6 +405,10 @@ export default function (options) {
scrollElement = div.find(options.scrollElementSelector); scrollElement = div.find(options.scrollElementSelector);
} }
if (options.onRender) {
options.onRender(autocompleteOptions);
}
if (isInput || options.treatAsTextarea) { if (isInput || options.treatAsTextarea) {
_autoCompletePopper && _autoCompletePopper.destroy(); _autoCompletePopper && _autoCompletePopper.destroy();
_autoCompletePopper = createPopper(me[0], div[0], { _autoCompletePopper = createPopper(me[0], div[0], {

View File

@ -0,0 +1,22 @@
import tippy from "tippy.js";
function stopPropagation(instance, event) {
event.preventDefault();
event.stopPropagation();
}
function hasTouchCapabilities() {
return navigator.maxTouchPoints > 0;
}
export default function createDTooltip(target, content) {
return tippy(target, {
interactive: false,
content,
trigger: hasTouchCapabilities() ? "click" : "mouseenter",
theme: "d-tooltip",
arrow: false,
placement: "bottom-start",
onTrigger: stopPropagation,
onUntrigger: stopPropagation,
});
}

View File

@ -1,36 +1,14 @@
import { escapeExpression } from "discourse/lib/utilities"; import createUserStatusMessage from "discourse/lib/user-status-message";
import { emojiUnescape } from "discourse/lib/text";
import { until } from "discourse/lib/formatter";
export function updateUserStatusOnMention(mention, status, currentUser) { export function updateUserStatusOnMention(mention, status, tippyInstances) {
removeStatus(mention); removeStatus(mention);
if (status) { if (status) {
const html = statusHtml(status, currentUser); const statusHtml = createUserStatusMessage(status, { showTooltip: true });
mention.insertAdjacentHTML("beforeend", html); tippyInstances.push(statusHtml._tippy);
mention.appendChild(statusHtml);
} }
} }
function removeStatus(mention) { function removeStatus(mention) {
mention.querySelector("img.user-status")?.remove(); mention.querySelector("span.user-status-message")?.remove();
}
function statusHtml(status, currentUser) {
const emoji = escapeExpression(`:${status.emoji}:`);
return emojiUnescape(emoji, {
class: "user-status",
title: statusTitle(status, currentUser),
});
}
function statusTitle(status, currentUser) {
if (!status.ends_at) {
return status.description;
}
const timezone = currentUser
? currentUser.user_option?.timezone
: moment.tz.guess();
const until_ = until(status.ends_at, timezone, currentUser?.locale);
return escapeExpression(`${status.description} ${until_}`);
} }

View File

@ -0,0 +1,62 @@
import createDTooltip from "discourse/lib/d-tooltip";
import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
import { until } from "discourse/lib/formatter";
import User from "discourse/models/user";
function getUntil(endsAt) {
const currentUser = User.current();
const timezone = currentUser
? currentUser.user_option?.timezone
: moment.tz.guess();
return until(endsAt, timezone, currentUser?.locale);
}
function getEmoji(emojiName) {
const emoji = escapeExpression(`:${emojiName}:`);
return emojiUnescape(emoji, {
skipTitle: true,
});
}
function attachTooltip(target, status) {
const content = document.createElement("div");
content.classList.add("user-status-message-tooltip");
content.innerHTML = getEmoji(status.emoji);
const tooltipDescription = document.createElement("span");
tooltipDescription.classList.add("user-status-tooltip-description");
tooltipDescription.innerText = status.description;
content.appendChild(tooltipDescription);
if (status.ends_at) {
const untilElement = document.createElement("div");
untilElement.classList.add("user-status-tooltip-until");
untilElement.innerText = getUntil(status.ends_at);
content.appendChild(untilElement);
}
createDTooltip(target, content);
}
export default function createUserStatusMessage(status, opts) {
const userStatusMessage = document.createElement("span");
userStatusMessage.classList.add("user-status-message");
if (opts?.class) {
userStatusMessage.classList.add(opts.class);
}
userStatusMessage.innerHTML = getEmoji(status.emoji);
if (opts?.showDescription) {
const messageDescription = document.createElement("span");
messageDescription.classList.add("user-status-message-description");
messageDescription.innerText = status.description;
userStatusMessage.appendChild(messageDescription);
}
if (opts?.showTooltip) {
attachTooltip(userStatusMessage, status);
}
return userStatusMessage;
}

View File

@ -0,0 +1,36 @@
import createUserStatusMessage from "discourse/lib/user-status-message";
let tippyInstances = [];
export function initUserStatusHtml(users) {
(users || []).forEach((user, index) => {
if (user.status) {
user.index = index;
user.statusHtml = createUserStatusMessage(user.status, {
showTooltip: true,
showDescription: true,
});
tippyInstances.push(user.statusHtml._tippy);
}
});
}
export function renderUserStatusHtml(options) {
const users = document.querySelectorAll(".autocomplete.ac-user li");
users.forEach((user) => {
const index = user.dataset.index;
const statusHtml = options.find(function (el) {
return el.index === parseInt(index, 10);
})?.statusHtml;
if (statusHtml) {
user.querySelector(".user-status").replaceWith(statusHtml);
}
});
}
export function destroyTippyInstances() {
tippyInstances.forEach((instance) => {
instance.destroy();
});
tippyInstances = [];
}

View File

@ -2,7 +2,7 @@
<ul> <ul>
{{#each options as |item|}} {{#each options as |item|}}
{{#if item.isUser}} {{#if item.isUser}}
<li> <li data-index={{item.index}}>
<a href title="{{item.name}}" class="{{item.cssClasses}}"> <a href title="{{item.name}}" class="{{item.cssClasses}}">
{{avatar item imageSize="tiny"}} {{avatar item imageSize="tiny"}}
<span class='username'>{{format-username item.username}}</span> <span class='username'>{{format-username item.username}}</span>
@ -10,13 +10,7 @@
<span class='name'>{{item.name}}</span> <span class='name'>{{item.name}}</span>
{{/if}} {{/if}}
{{#if item.status}} {{#if item.status}}
{{emoji item.status.emoji}} <span class='user-status'></span>
<span class='status-description' title='{{item.status.description}}'>
{{item.status.description}}
</span>
{{#if item.status.ends_at}}
{{format-age item.status.ends_at}}
{{/if}}
{{/if}} {{/if}}
</a> </a>
</li> </li>

View File

@ -36,6 +36,7 @@ function createDetachedElement(nodeName) {
export default class PostCooked { export default class PostCooked {
originalQuoteContents = null; originalQuoteContents = null;
tippyInstances = [];
constructor(attrs, decoratorHelper, currentUser) { constructor(attrs, decoratorHelper, currentUser) {
this.attrs = attrs; this.attrs = attrs;
@ -76,6 +77,7 @@ export default class PostCooked {
destroy() { destroy() {
this._stopTrackingMentionedUsersStatus(); this._stopTrackingMentionedUsersStatus();
this._destroyTippyInstances();
} }
_decorateAndAdopt(cooked) { _decorateAndAdopt(cooked) {
@ -380,7 +382,14 @@ export default class PostCooked {
} }
} }
_destroyTippyInstances() {
this.tippyInstances.forEach((instance) => {
instance.destroy();
});
}
_rerenderUserStatusOnMentions() { _rerenderUserStatusOnMentions() {
this._destroyTippyInstances();
this._post()?.mentioned_users?.forEach((user) => this._post()?.mentioned_users?.forEach((user) =>
this._rerenderUserStatusOnMention(this.cookedDiv, user) this._rerenderUserStatusOnMention(this.cookedDiv, user)
); );
@ -391,7 +400,7 @@ export default class PostCooked {
const mentions = postElement.querySelectorAll(`a.mention[href="${href}"]`); const mentions = postElement.querySelectorAll(`a.mention[href="${href}"]`);
mentions.forEach((mention) => { mentions.forEach((mention) => {
updateUserStatusOnMention(mention, user.status, this.currentUser); updateUserStatusOnMention(mention, user.status, this.tippyInstances);
}); });
} }

View File

@ -137,19 +137,16 @@ acceptance("Composer - editor mentions", function (needs) {
await emulateAutocomplete(".d-editor-input", "@u"); await emulateAutocomplete(".d-editor-input", "@u");
assert.ok( assert.ok(
exists(`.autocomplete .emoji[title='${status.emoji}']`), exists(`.autocomplete .emoji[alt='${status.emoji}']`),
"status emoji is shown" "status emoji is shown"
); );
assert.equal( assert.equal(
query(".autocomplete .status-description").textContent.trim(), query(
".autocomplete .user-status-message-description"
).textContent.trim(),
status.description, status.description,
"status description is shown" "status description is shown"
); );
assert.equal(
query(".autocomplete .relative-date").textContent.trim(),
"1h",
"status expiration time is shown"
);
}); });
test("metadata matches are moved to the end", async function (assert) { test("metadata matches are moved to the end", async function (assert) {

View File

@ -4,7 +4,7 @@ import {
publishToMessageBus, publishToMessageBus,
query, query,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers"; import { triggerEvent, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { cloneJSON } from "discourse-common/lib/object"; import { cloneJSON } from "discourse-common/lib/object";
import topicFixtures from "../fixtures/topic"; import topicFixtures from "../fixtures/topic";
@ -32,6 +32,14 @@ function topicWithUserStatus(topicId, mentionedUserId, status) {
return topic; return topic;
} }
async function mouseenter() {
await triggerEvent(query(".user-status-message"), "mouseenter");
}
async function mouseleave() {
await triggerEvent(query(".user-status-message"), "mouseleave");
}
acceptance("Post inline mentions", function (needs) { acceptance("Post inline mentions", function (needs) {
needs.user(); needs.user();
@ -51,19 +59,28 @@ acceptance("Post inline mentions", function (needs) {
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`); await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.ok( assert.ok(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"user status is shown" "user status is shown"
); );
const statusElement = query(".topic-post .cooked .mention .user-status");
assert.equal( const statusElement = query(
statusElement.title, ".topic-post .cooked .mention .user-status-message img"
status.description,
"status description is correct"
); );
assert.ok( assert.ok(
statusElement.src.includes(status.emoji), statusElement.src.includes(status.emoji),
"status emoji is correct" "status emoji is correct"
); );
await mouseenter();
const statusTooltipDescription = document.querySelector(
".user-status-message-tooltip .user-status-tooltip-description"
);
assert.equal(
statusTooltipDescription.innerText,
status.description,
"status description is correct"
);
}); });
test("inserts user status on message bus message", async function (assert) { test("inserts user status on message bus message", async function (assert) {
@ -73,7 +90,7 @@ acceptance("Post inline mentions", function (needs) {
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`); await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.notOk( assert.notOk(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"user status isn't shown" "user status isn't shown"
); );
@ -85,19 +102,30 @@ acceptance("Post inline mentions", function (needs) {
}); });
assert.ok( assert.ok(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"user status is shown" "user status is shown"
); );
const statusElement = query(".topic-post .cooked .mention .user-status");
assert.equal( const statusElement = query(
statusElement.title, ".topic-post .cooked .mention .user-status-message img"
status.description,
"status description is correct"
); );
assert.ok( assert.ok(
statusElement.src.includes(status.emoji), statusElement.src.includes(status.emoji),
"status emoji is correct" "status emoji is correct"
); );
await mouseenter();
const statusTooltipDescription = document.querySelector(
".user-status-message-tooltip .user-status-tooltip-description"
);
assert.equal(
statusTooltipDescription.innerText,
status.description,
"status description is correct"
);
// Needed to remove the tooltip in between tests
await mouseleave();
}); });
test("updates user status on message bus message", async function (assert) { test("updates user status on message bus message", async function (assert) {
@ -107,7 +135,7 @@ acceptance("Post inline mentions", function (needs) {
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`); await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.ok( assert.ok(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"initial user status is shown" "initial user status is shown"
); );
@ -122,20 +150,32 @@ acceptance("Post inline mentions", function (needs) {
}, },
}); });
await mouseenter();
assert.ok( assert.ok(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"updated user status is shown" "updated user status is shown"
); );
const statusElement = query(".topic-post .cooked .mention .user-status");
assert.equal( const statusElement = query(
statusElement.title, ".topic-post .cooked .mention .user-status-message img"
newStatus.description,
"updated status description is correct"
); );
assert.ok( assert.ok(
statusElement.src.includes(newStatus.emoji), statusElement.src.includes(newStatus.emoji),
"updated status emoji is correct" "updated status emoji is correct"
); );
const statusTooltipDescription = document.querySelector(
".user-status-message-tooltip .user-status-tooltip-description"
);
assert.equal(
statusTooltipDescription.innerText,
newStatus.description,
"updated status description is correct"
);
// Needed to remove the tooltip in between tests
await mouseleave();
}); });
test("removes user status on message bus message", async function (assert) { test("removes user status on message bus message", async function (assert) {
@ -145,7 +185,7 @@ acceptance("Post inline mentions", function (needs) {
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`); await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.ok( assert.ok(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"initial user status is shown" "initial user status is shown"
); );
@ -154,7 +194,7 @@ acceptance("Post inline mentions", function (needs) {
}); });
assert.notOk( assert.notOk(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"updated user has disappeared" "updated user has disappeared"
); );
}); });
@ -177,7 +217,7 @@ acceptance("Post inline mentions as an anonymous user", function () {
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`); await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.ok( assert.ok(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"user status is shown" "user status is shown"
); );
}); });
@ -197,7 +237,7 @@ acceptance("Post inline mentions as an anonymous user", function () {
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`); await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.ok( assert.ok(
exists(".topic-post .cooked .mention .user-status"), exists(".topic-post .cooked .mention .user-status-message"),
"user status is shown" "user status is shown"
); );
}); });

View File

@ -475,10 +475,17 @@ html.composer-open {
color: var(--primary-high); color: var(--primary-high);
} }
.status-description { .user-status-message {
@include ellipsis; display: flex;
font-size: var(--font-down-2); align-items: center;
color: var(--primary-high); gap: 0.25em;
.user-status-message-description {
@include ellipsis;
font-size: var(--font-down-2);
color: var(--primary-high);
margin: 0;
}
} }
.relative-date { .relative-date {

View File

@ -20,6 +20,11 @@ import { isPresent } from "@ember/utils";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import User from "discourse/models/user"; import User from "discourse/models/user";
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
import {
destroyTippyInstances,
initUserStatusHtml,
renderUserStatusHtml,
} from "discourse/lib/user-status-on-autocomplete";
export default class ChatComposer extends Component { export default class ChatComposer extends Component {
@service capabilities; @service capabilities;
@ -409,6 +414,7 @@ export default class ChatComposer extends Component {
return obj.username || obj.name; return obj.username || obj.name;
}, },
dataSource: (term) => { dataSource: (term) => {
destroyTippyInstances();
return userSearch({ term, includeGroups: true }).then((result) => { return userSearch({ term, includeGroups: true }).then((result) => {
if (result?.users?.length > 0) { if (result?.users?.length > 0) {
const presentUserNames = const presentUserNames =
@ -418,16 +424,21 @@ export default class ChatComposer extends Component {
user.cssClasses = "is-online"; user.cssClasses = "is-online";
} }
}); });
initUserStatusHtml(result.users);
} }
return result; return result;
}); });
}, },
onRender: (options) => {
renderUserStatusHtml(options);
},
afterComplete: (text, event) => { afterComplete: (text, event) => {
event.preventDefault(); event.preventDefault();
this.composer.textarea.value = text; this.composer.textarea.value = text;
this.composer.focus(); this.composer.focus();
this.captureMentions(); this.captureMentions();
}, },
onClose: destroyTippyInstances,
}); });
} }

View File

@ -14,6 +14,7 @@ import { updateUserStatusOnMention } from "discourse/lib/update-user-status-on-m
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
let _chatMessageDecorators = []; let _chatMessageDecorators = [];
let _tippyInstances = [];
export function addChatMessageDecorator(decorator) { export function addChatMessageDecorator(decorator) {
_chatMessageDecorators.push(decorator); _chatMessageDecorators.push(decorator);
@ -136,6 +137,13 @@ export default class ChatMessage extends Component {
this.#teardownMentionedUsers(); this.#teardownMentionedUsers();
} }
#destroyTippyInstances() {
_tippyInstances.forEach((instance) => {
instance.destroy();
});
_tippyInstances = [];
}
@action @action
refreshStatusOnMentions() { refreshStatusOnMentions() {
schedule("afterRender", () => { schedule("afterRender", () => {
@ -146,7 +154,7 @@ export default class ChatMessage extends Component {
); );
mentions.forEach((mention) => { mentions.forEach((mention) => {
updateUserStatusOnMention(mention, user.status, this.currentUser); updateUserStatusOnMention(mention, user.status, _tippyInstances);
}); });
}); });
}); });
@ -402,5 +410,6 @@ export default class ChatMessage extends Component {
user.stopTrackingStatus(); user.stopTrackingStatus();
user.off("status-changed", this, "refreshStatusOnMentions"); user.off("status-changed", this, "refreshStatusOnMentions");
}); });
this.#destroyTippyInstances();
} }
} }

View File

@ -227,15 +227,19 @@ export default {
return this.channel.chatable.users.length === 1; return this.channel.chatable.users.length === 1;
} }
get contentComponentArgs() {
return this.channel.chatable.users[0].get("status");
}
get contentComponent() {
return "user-status-message";
}
get text() { get text() {
const username = this.channel.escapedTitle.replaceAll("@", ""); const username = this.channel.escapedTitle.replaceAll("@", "");
if (this.oneOnOneMessage) { if (this.oneOnOneMessage) {
const status = this.channel.chatable.users[0].get("status");
const statusHtml = status ? this._userStatusHtml(status) : "";
return htmlSafe( return htmlSafe(
`${escapeExpression( `${escapeExpression(username)}${decorateUsername(
username
)}${statusHtml} ${decorateUsername(
escapeExpression(username) escapeExpression(username)
)}` )}`
); );
@ -307,14 +311,6 @@ export default {
return I18n.t("chat.direct_messages.leave"); return I18n.t("chat.direct_messages.leave");
} }
_userStatusHtml(status) {
const emoji = escapeExpression(`:${status.emoji}:`);
const title = this._userStatusTitle(status);
return `<span class="user-status">${emojiUnescape(emoji, {
title,
})}</span>`;
}
_userStatusTitle(status) { _userStatusTitle(status) {
let title = `${escapeExpression(status.description)}`; let title = `${escapeExpression(status.description)}`;

View File

@ -181,10 +181,10 @@ RSpec.describe "Chat channel", type: :system do
chat.visit_channel(channel_1) chat.visit_channel(channel_1)
expect(page).to have_selector( expect(page).to have_selector(
".mention .user-status[title='#{current_user.user_status.description}']", ".mention .user-status-message img[alt='#{current_user.user_status.emoji}']",
) )
expect(page).to have_selector( expect(page).to have_selector(
".mention .user-status[title='#{other_user.user_status.description}']", ".mention .user-status-message img[alt='#{other_user.user_status.emoji}']",
) )
end end
end end

View File

@ -146,7 +146,7 @@ RSpec.describe "Sidebar navigation menu", type: :system do
visit("/") visit("/")
expect(sidebar_page.dms_section.find("a.sidebar-section-link:nth-child(1)")).to have_css( expect(sidebar_page.dms_section.find("a.sidebar-section-link:nth-child(1)")).to have_css(
".user-status", ".user-status-message",
) )
end end
end end

View File

@ -20,8 +20,8 @@ RSpec.describe "User status | sidebar", type: :system do
visit("/") visit("/")
expect(find(".user-status .emoji")["title"]).to eq("online") expect(find(".user-status-message .emoji")["alt"]).to eq("heart")
expect(find(".user-status .emoji")["src"]).to include("heart") expect(find(".user-status-message .emoji")["src"]).to include("heart")
end end
context "when changing status" do context "when changing status" do
@ -31,8 +31,8 @@ RSpec.describe "User status | sidebar", type: :system do
visit("/") visit("/")
current_user.set_status!("offline", "tooth") current_user.set_status!("offline", "tooth")
expect(page).to have_css('.user-status .emoji[title="offline"]') expect(page).to have_css('.user-status-message .emoji[alt="tooth"]')
expect(find(".user-status .emoji")["src"]).to include("tooth") expect(find(".user-status-message .emoji")["src"]).to include("tooth")
end end
end end
@ -43,7 +43,7 @@ RSpec.describe "User status | sidebar", type: :system do
visit("/") visit("/")
current_user.clear_status! current_user.clear_status!
expect(page).to have_no_css(".user-status") expect(page).to have_no_css(".user-status-message")
end end
end end
end end

View File

@ -102,6 +102,11 @@ acceptance("Chat | User status on mentions", function (needs) {
statusSelector(mentionedUser2.username), statusSelector(mentionedUser2.username),
mentionedUser2.status mentionedUser2.status
); );
await assertStatusTooltipIsRendered(
assert,
statusSelector(mentionedUser2.username),
mentionedUser2.status
);
}); });
skip("just posted messages | it updates status on mentions", async function (assert) { skip("just posted messages | it updates status on mentions", async function (assert) {
@ -115,6 +120,7 @@ acceptance("Chat | User status on mentions", function (needs) {
const selector = statusSelector(mentionedUser2.username); const selector = statusSelector(mentionedUser2.username);
await waitFor(selector); await waitFor(selector);
assertStatusIsRendered(assert, selector, newStatus); assertStatusIsRendered(assert, selector, newStatus);
await assertStatusTooltipIsRendered(assert, selector, newStatus);
}); });
skip("just posted messages | it deletes status on mentions", async function (assert) { skip("just posted messages | it deletes status on mentions", async function (assert) {
@ -144,6 +150,11 @@ acceptance("Chat | User status on mentions", function (needs) {
statusSelector(mentionedUser3.username), statusSelector(mentionedUser3.username),
mentionedUser3.status mentionedUser3.status
); );
await assertStatusTooltipIsRendered(
assert,
statusSelector(mentionedUser3.username),
mentionedUser3.status
);
}); });
skip("edited messages | it updates status on mentions", async function (assert) { skip("edited messages | it updates status on mentions", async function (assert) {
@ -160,6 +171,7 @@ acceptance("Chat | User status on mentions", function (needs) {
const selector = statusSelector(mentionedUser3.username); const selector = statusSelector(mentionedUser3.username);
await waitFor(selector); await waitFor(selector);
assertStatusIsRendered(assert, selector, newStatus); assertStatusIsRendered(assert, selector, newStatus);
await assertStatusTooltipIsRendered(assert, selector, newStatus);
}); });
skip("edited messages | it deletes status on mentions", async function (assert) { skip("edited messages | it deletes status on mentions", async function (assert) {
@ -190,6 +202,11 @@ acceptance("Chat | User status on mentions", function (needs) {
statusSelector(mentionedUser1.username), statusSelector(mentionedUser1.username),
mentionedUser1.status mentionedUser1.status
); );
await assertStatusTooltipIsRendered(
assert,
statusSelector(mentionedUser1.username),
mentionedUser1.status
);
}); });
test("deleted messages | it updates status on mentions", async function (assert) { test("deleted messages | it updates status on mentions", async function (assert) {
@ -205,6 +222,7 @@ acceptance("Chat | User status on mentions", function (needs) {
const selector = statusSelector(mentionedUser1.username); const selector = statusSelector(mentionedUser1.username);
await waitFor(selector); await waitFor(selector);
assertStatusIsRendered(assert, selector, newStatus); assertStatusIsRendered(assert, selector, newStatus);
await assertStatusTooltipIsRendered(assert, selector, newStatus);
}); });
test("deleted messages | it deletes status on mentions", async function (assert) { test("deleted messages | it deletes status on mentions", async function (assert) {
@ -233,6 +251,11 @@ acceptance("Chat | User status on mentions", function (needs) {
statusSelector(mentionedUser1.username), statusSelector(mentionedUser1.username),
mentionedUser1.status mentionedUser1.status
); );
await assertStatusTooltipIsRendered(
assert,
statusSelector(mentionedUser1.username),
mentionedUser1.status
);
}); });
test("restored messages | it updates status on mentions", async function (assert) { test("restored messages | it updates status on mentions", async function (assert) {
@ -248,6 +271,7 @@ acceptance("Chat | User status on mentions", function (needs) {
const selector = statusSelector(mentionedUser1.username); const selector = statusSelector(mentionedUser1.username);
await waitFor(selector); await waitFor(selector);
assertStatusIsRendered(assert, selector, newStatus); assertStatusIsRendered(assert, selector, newStatus);
await assertStatusTooltipIsRendered(assert, selector, newStatus);
}); });
test("restored messages | it deletes status on mentions", async function (assert) { test("restored messages | it deletes status on mentions", async function (assert) {
@ -269,11 +293,6 @@ acceptance("Chat | User status on mentions", function (needs) {
assert assert
.dom(selector) .dom(selector)
.exists("status is rendered") .exists("status is rendered")
.hasAttribute(
"title",
status.description,
"status description is updated"
)
.hasAttribute( .hasAttribute(
"src", "src",
new RegExp(`${status.emoji}.png`), new RegExp(`${status.emoji}.png`),
@ -281,6 +300,27 @@ acceptance("Chat | User status on mentions", function (needs) {
); );
} }
async function assertStatusTooltipIsRendered(assert, selector, status) {
await triggerEvent(selector, "mouseenter");
assert.equal(
document
.querySelector(".user-status-tooltip-description")
.textContent.trim(),
status.description,
"status description is correct"
);
assert.ok(
document.querySelector(
`.user-status-message-tooltip img[alt='${status.emoji}']`
),
"status emoji is correct"
);
await triggerEvent(selector, "mouseleave");
}
async function deleteMessage(messageSelector) { async function deleteMessage(messageSelector) {
await triggerEvent(query(messageSelector), "mouseenter"); await triggerEvent(query(messageSelector), "mouseenter");
await click(".more-buttons .select-kit-header-wrapper"); await click(".more-buttons .select-kit-header-wrapper");
@ -340,6 +380,6 @@ acceptance("Chat | User status on mentions", function (needs) {
} }
function statusSelector(username) { function statusSelector(username) {
return `.mention[href='/u/${username}'] .user-status`; return `.mention[href='/u/${username}'] .user-status-message img`;
} }
}); });

View File

@ -1,7 +1,7 @@
import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import hbs from "htmlbars-inline-precompile"; import hbs from "htmlbars-inline-precompile";
import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
import { render, waitFor } from "@ember/test-helpers"; import { render, triggerEvent, waitFor } from "@ember/test-helpers";
import { module, test } from "qunit"; import { module, test } from "qunit";
import pretender, { OK } from "discourse/tests/helpers/create-pretender"; import pretender, { OK } from "discourse/tests/helpers/create-pretender";
import { publishToMessageBus } from "discourse/tests/helpers/qunit-helpers"; import { publishToMessageBus } from "discourse/tests/helpers/qunit-helpers";
@ -76,6 +76,11 @@ module(
statusSelector(mentionedUser.username), statusSelector(mentionedUser.username),
mentionedUser.status mentionedUser.status
); );
await assertStatusTooltipIsRendered(
assert,
statusSelector(mentionedUser.username),
mentionedUser.status
);
}); });
test("it updates status on mentions", async function (assert) { test("it updates status on mentions", async function (assert) {
@ -97,6 +102,11 @@ module(
statusSelector(mentionedUser.username), statusSelector(mentionedUser.username),
newStatus newStatus
); );
await assertStatusTooltipIsRendered(
assert,
statusSelector(mentionedUser.username),
newStatus
);
}); });
test("it deletes status on mentions", async function (assert) { test("it deletes status on mentions", async function (assert) {
@ -121,6 +131,11 @@ module(
statusSelector(mentionedUser2.username), statusSelector(mentionedUser2.username),
mentionedUser2.status mentionedUser2.status
); );
await assertStatusTooltipIsRendered(
assert,
statusSelector(mentionedUser2.username),
mentionedUser2.status
);
}); });
test("it updates status on mentions on messages that came from Message Bus", async function (assert) { test("it updates status on mentions on messages that came from Message Bus", async function (assert) {
@ -142,6 +157,11 @@ module(
statusSelector(mentionedUser2.username), statusSelector(mentionedUser2.username),
newStatus newStatus
); );
await assertStatusTooltipIsRendered(
assert,
statusSelector(mentionedUser2.username),
newStatus
);
}); });
test("it deletes status on mentions on messages that came from Message Bus", async function (assert) { test("it deletes status on mentions on messages that came from Message Bus", async function (assert) {
@ -161,11 +181,6 @@ module(
assert assert
.dom(selector) .dom(selector)
.exists("status is rendered") .exists("status is rendered")
.hasAttribute(
"title",
status.description,
"status description is updated"
)
.hasAttribute( .hasAttribute(
"src", "src",
new RegExp(`${status.emoji}.png`), new RegExp(`${status.emoji}.png`),
@ -173,6 +188,27 @@ module(
); );
} }
async function assertStatusTooltipIsRendered(assert, selector, status) {
await triggerEvent(selector, "mouseenter");
assert.equal(
document
.querySelector(".user-status-tooltip-description")
.textContent.trim(),
status.description,
"status description is correct"
);
assert.ok(
document.querySelector(
`.user-status-message-tooltip img[alt='${status.emoji}']`
),
"status emoji is correct"
);
await triggerEvent(selector, "mouseleave");
}
async function receiveChatMessageViaMessageBus() { async function receiveChatMessageViaMessageBus() {
await publishToMessageBus(`/chat/${channelId}`, { await publishToMessageBus(`/chat/${channelId}`, {
chat_message: { chat_message: {
@ -193,7 +229,7 @@ module(
} }
function statusSelector(username) { function statusSelector(username) {
return `.mention[href='/u/${username}'] .user-status`; return `.mention[href='/u/${username}'] .user-status-message img`;
} }
} }
); );