FEATURE: Add user status to inline mentions in chat (#20564)

This PR adds status to mentions in chat and makes those mentions receive live updates.

There are known unfinished part in this implementation: when posting a message, status on mentions on that message appears immediately, but only if a user used autocomplete when typing the message. If user copy and paste a message with mentions into chat composer, those mentions won't have user status on them.

PRs with fixes for both problems are following soon.

Preparations for this PR that were made previously include:
- DEV: correct a relationship – a chat message may have several mentions 0dcfd7ddec
- DEV: extract the logic for extracting and expanding mentions from ChatNotifier 75b81b6854
- DEV: Always create chat mention records fa543cda06
- DEV: better split create_notification! and send_notifications logic e292c45924
- DEV: more tests for mentions when updating chat messages e7292e1682
- DEV: extract updating status on mentions into a lib function e49d338c21
- DEV: Create and update chat message mentions earlier 35a414bb38
- DEV: Create a chat_mention record when self mentioning 2703f2311a
- DEV: When deleting a chat message, do not delete mention records f4fde4e49b
This commit is contained in:
Andrei Prigorshnev 2023-05-24 16:55:20 +04:00 committed by GitHub
parent 436b68a581
commit d4a5b79592
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 845 additions and 9 deletions

View File

@ -1436,6 +1436,10 @@ User.reopen(Evented, {
this._subscribersCount--;
},
isTrackingStatus() {
return this._subscribersCount > 0;
},
_statusChanged(sender, key) {
this.trigger("status-changed");

View File

@ -39,6 +39,10 @@ export function success() {
return response({ success: true });
}
export function OK(resp = {}, headers = {}) {
return [200, headers, resp];
}
const loggedIn = () => !!User.current();
const helpers = { response, success, parsePostData };

View File

@ -416,6 +416,7 @@ module Chat
.includes(:uploads)
.includes(chat_channel: :chatable)
.includes(:thread)
.includes(:chat_mentions)
query = query.includes(user: :user_status) if SiteSetting.enable_user_status

View File

@ -76,6 +76,7 @@ module Chat
.includes(:uploads)
.includes(chat_channel: :chatable)
.includes(:thread)
.includes(:chat_mentions)
.where(chat_channel_id: channel.id)
query = query.includes(user: :user_status) if SiteSetting.enable_user_status

View File

@ -18,13 +18,21 @@ module Chat
:thread_id,
:thread_reply_count,
:thread_title,
:chat_channel_id
:chat_channel_id,
:mentioned_users
has_one :user, serializer: Chat::MessageUserSerializer, embed: :objects
has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects
has_one :in_reply_to, serializer: Chat::InReplyToSerializer, embed: :objects
has_many :uploads, serializer: ::UploadSerializer, embed: :objects
def mentioned_users
User
.where(id: object.chat_mentions.pluck(:user_id))
.map { |user| BasicUserWithStatusSerializer.new(user, root: false) }
.as_json
end
def channel
@channel ||= @options.dig(:chat_channel) || object.chat_channel
end

View File

@ -19,6 +19,7 @@ import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
import { isEmpty, isPresent } from "@ember/utils";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { Promise } from "rsvp";
import User from "discourse/models/user";
export default class ChatComposer extends Component {
@service capabilities;
@ -419,7 +420,11 @@ export default class ChatComposer extends Component {
width: "100%",
treatAsTextarea: true,
autoSelectFirstSuggestion: true,
transformComplete: (v) => v.username || v.name,
transformComplete: (userData) => {
const user = User.create(userData);
this.currentMessage.mentionedUsers.set(user.id, user);
return user.username || user.name;
},
dataSource: (term) => {
return userSearch({ term, includeGroups: true }).then((result) => {
if (result?.users?.length > 0) {

View File

@ -60,6 +60,9 @@
</div>
{{else}}
<div
{{did-insert this.refreshStatusOnMentions}}
{{did-update this.refreshStatusOnMentions @message.version}}
{{did-update this.initMentionedUsers @message.version}}
class={{concat-class
"chat-message"
(if @message.staged "chat-message-staged" "chat-message-persisted")

View File

@ -12,6 +12,7 @@ import { getOwner } from "discourse-common/lib/get-owner";
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
import discourseDebounce from "discourse-common/lib/debounce";
import { bind } from "discourse-common/utils/decorators";
import { updateUserStatusOnMention } from "discourse/lib/update-user-status-on-mention";
let _chatMessageDecorators = [];
@ -43,6 +44,11 @@ export default class ChatMessage extends Component {
@optionalService adminTools;
constructor() {
super(...arguments);
this.initMentionedUsers();
}
get pane() {
return this.args.context === MESSAGE_CONTEXT_THREAD
? this.chatChannelThreadPane
@ -104,7 +110,7 @@ export default class ChatMessage extends Component {
};
this.args.message.expanded = true;
this.refreshStatusOnMentions();
recursiveExpand(this.args.message);
}
@ -120,6 +126,27 @@ export default class ChatMessage extends Component {
@action
teardownChatMessage() {
cancel(this._invitationSentTimer);
this.#teardownMentionedUsers();
}
@action
refreshStatusOnMentions() {
schedule("afterRender", () => {
if (!this.messageContainer) {
return;
}
this.args.message.mentionedUsers.forEach((user) => {
const href = `/u/${user.username.toLowerCase()}`;
const mentions = this.messageContainer.querySelectorAll(
`a.mention[href="${href}"]`
);
mentions.forEach((mention) => {
updateUserStatusOnMention(mention, user.status, this.currentUser);
});
});
});
}
@action
@ -135,6 +162,18 @@ export default class ChatMessage extends Component {
});
}
@action
initMentionedUsers() {
this.args.message.mentionedUsers.forEach((user) => {
if (user.isTrackingStatus()) {
return;
}
user.trackStatus();
user.on("status-changed", this, "refreshStatusOnMentions");
});
}
get messageContainer() {
const id = this.args.message?.id;
if (id) {
@ -406,4 +445,11 @@ export default class ChatMessage extends Component {
dismissMentionWarning() {
this.args.message.mentionWarning = null;
}
#teardownMentionedUsers() {
this.args.message.mentionedUsers.forEach((user) => {
user.stopTrackingStatus();
user.off("status-changed", this, "refreshStatusOnMentions");
});
}
}

View File

@ -88,6 +88,7 @@ export default class ChatMessage {
this.uploads = new TrackedArray(args.uploads || []);
this.user = this.#initUserModel(args.user);
this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null;
this.mentionedUsers = this.#initMentionedUsers(args.mentioned_users);
}
duplicate() {
@ -311,6 +312,17 @@ export default class ChatMessage {
return reactions.map((reaction) => ChatMessageReaction.create(reaction));
}
#initMentionedUsers(mentionedUsers) {
const map = new Map();
if (mentionedUsers) {
mentionedUsers.forEach((userData) => {
const user = User.create(userData);
map.set(user.id, user);
});
}
return map;
}
#initUserModel(user) {
if (!user || user instanceof User) {
return user;

View File

@ -66,8 +66,10 @@ export default class ChatRoute extends DiscourseRoute {
}
deactivate(transition) {
const url = this.router.urlFor(transition.from.name);
this.chatStateManager.storeChatURL(url);
if (transition) {
const url = this.router.urlFor(transition.from.name);
this.chatStateManager.storeChatURL(url);
}
this.chat.activeChannel = null;
this.chat.updatePresence();

View File

@ -136,6 +136,19 @@ describe Chat::MessageCreator do
expect(events.map { _1[:event_name] }).to include(:chat_message_created)
end
it "publishes created message to message bus" do
content = "a test chat message"
messages =
MessageBus.track_publish("/chat/#{public_chat_channel.id}") do
described_class.create(chat_channel: public_chat_channel, user: user1, content: content)
end
expect(messages.count).to be(1)
message = messages[0].data
expect(message["chat_message"]["message"]).to eq(content)
expect(message["chat_message"]["user"]["id"]).to eq(user1.id)
end
context "with mentions" do
it "creates mentions and mention notifications for public chat" do
message =
@ -405,6 +418,52 @@ describe Chat::MessageCreator do
mention = user2.chat_mentions.where(chat_message: message).first
expect(mention.notification).to be_nil
end
it "adds mentioned user and their status to the message bus message" do
SiteSetting.enable_user_status = true
status = { description: "dentist", emoji: "tooth" }
user2.set_status!(status[:description], status[:emoji])
messages =
MessageBus.track_publish("/chat/#{public_chat_channel.id}") do
described_class.create(
chat_channel: public_chat_channel,
user: user1,
content: "Hey @#{user2.username}",
)
end
expect(messages.count).to be(1)
message = messages[0].data
expect(message["chat_message"]["mentioned_users"].count).to be(1)
mentioned_user = message["chat_message"]["mentioned_users"][0]
expect(mentioned_user["id"]).to eq(user2.id)
expect(mentioned_user["username"]).to eq(user2.username)
expect(mentioned_user["status"]).to be_present
expect(mentioned_user["status"].slice(:description, :emoji)).to eq(status)
end
it "doesn't add mentioned user's status to the message bus message when status is disabled" do
SiteSetting.enable_user_status = false
user2.set_status!("dentist", "tooth")
messages =
MessageBus.track_publish("/chat/#{public_chat_channel.id}") do
described_class.create(
chat_channel: public_chat_channel,
user: user1,
content: "Hey @#{user2.username}",
)
end
expect(messages.count).to be(1)
message = messages[0].data
expect(message["chat_message"]["mentioned_users"].count).to be(1)
mentioned_user = message["chat_message"]["mentioned_users"][0]
expect(mentioned_user["status"]).to be_blank
end
end
it "creates a chat_mention record without notification when self mentioning" do

View File

@ -124,6 +124,23 @@ describe Chat::MessageUpdater do
expect(events.map { _1[:event_name] }).to include(:chat_message_edited)
end
it "publishes updated message to message bus" do
chat_message = create_chat_message(user1, "This will be changed", public_chat_channel)
new_content = "New content"
messages =
MessageBus.track_publish("/chat/#{public_chat_channel.id}") do
described_class.update(
guardian: guardian,
chat_message: chat_message,
new_content: new_content,
)
end
expect(messages.count).to be(1)
message = messages[0].data
expect(message["chat_message"]["message"]).to eq(new_content)
end
context "with mentions" do
it "sends notifications if a message was updated with new mentions" do
message = create_chat_message(user1, "Mentioning @#{user2.username}", public_chat_channel)
@ -228,6 +245,56 @@ describe Chat::MessageUpdater do
expect(mention.notification).to be_nil
end
it "adds mentioned user and their status to the message bus message" do
SiteSetting.enable_user_status = true
status = { description: "dentist", emoji: "tooth" }
user2.set_status!(status[:description], status[:emoji])
chat_message = create_chat_message(user1, "This will be updated", public_chat_channel)
new_content = "Hey @#{user2.username}"
messages =
MessageBus.track_publish("/chat/#{public_chat_channel.id}") do
described_class.update(
guardian: guardian,
chat_message: chat_message,
new_content: new_content,
)
end
expect(messages.count).to be(1)
message = messages[0].data
expect(message["chat_message"]["mentioned_users"].count).to be(1)
mentioned_user = message["chat_message"]["mentioned_users"][0]
expect(mentioned_user["id"]).to eq(user2.id)
expect(mentioned_user["username"]).to eq(user2.username)
expect(mentioned_user["status"]).to be_present
expect(mentioned_user["status"].slice(:description, :emoji)).to eq(status)
end
it "doesn't add mentioned user's status to the message bus message when status is disabled" do
SiteSetting.enable_user_status = false
user2.set_status!("dentist", "tooth")
chat_message = create_chat_message(user1, "This will be updated", public_chat_channel)
new_content = "Hey @#{user2.username}"
messages =
MessageBus.track_publish("/chat/#{public_chat_channel.id}") do
described_class.update(
guardian: guardian,
chat_message: chat_message,
new_content: new_content,
)
end
expect(messages.count).to be(1)
message = messages[0].data
expect(message["chat_message"]["mentioned_users"].count).to be(1)
mentioned_user = message["chat_message"]["mentioned_users"][0]
expect(mentioned_user["status"]).to be_blank
end
context "when updating a mentioned user" do
it "updates the mention record" do
chat_message = create_chat_message(user1, "ping @#{user2.username}", public_chat_channel)

View File

@ -145,6 +145,60 @@ RSpec.describe Chat::ChatController do
expect(response.parsed_body["meta"]["channel_message_bus_last_id"]).not_to eq(nil)
end
context "with mentions" do
it "returns mentioned users" do
last_message = chat_channel.chat_messages.last
user1 = Fabricate(:user)
user2 = Fabricate(:user)
Fabricate(:chat_mention, user: user1, chat_message: last_message)
Fabricate(:chat_mention, user: user2, chat_message: last_message)
get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size }
mentioned_users = response.parsed_body["chat_messages"].last["mentioned_users"]
expect(mentioned_users[0]["id"]).to eq(user1.id)
expect(mentioned_users[0]["username"]).to eq(user1.username)
expect(mentioned_users[1]["id"]).to eq(user2.id)
expect(mentioned_users[1]["username"]).to eq(user2.username)
end
it "returns an empty list if no one was mentioned" do
get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size }
last_message = response.parsed_body["chat_messages"].last
expect(last_message).to have_key("mentioned_users")
expect(last_message["mentioned_users"]).to be_empty
end
context "with user status" do
fab!(:status) { Fabricate(:user_status) }
fab!(:user1) { Fabricate(:user, user_status: status) }
fab!(:chat_mention) do
Fabricate(:chat_mention, user: user1, chat_message: chat_channel.chat_messages.last)
end
it "returns status if enabled in settings" do
SiteSetting.enable_user_status = true
get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size }
mentioned_user = response.parsed_body["chat_messages"].last["mentioned_users"][0]
expect(mentioned_user).to have_key("status")
expect(mentioned_user["status"]["emoji"]).to eq(status.emoji)
expect(mentioned_user["status"]["description"]).to eq(status.description)
end
it "doesn't return status if disabled in settings" do
SiteSetting.enable_user_status = false
get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size }
mentioned_user = response.parsed_body["chat_messages"].last["mentioned_users"][0]
expect(mentioned_user).not_to have_key("status")
end
end
end
describe "scrolling to the past" do
it "returns the correct messages in created_at, id order" do
get "/chat/#{chat_channel.id}/messages.json",

View File

@ -133,16 +133,18 @@ RSpec.describe "Chat channel", type: :system, js: true do
context "when a message contains mentions" do
fab!(:other_user) { Fabricate(:user) }
before do
channel_1.add(other_user)
channel_1.add(current_user)
fab!(:message) do
Fabricate(
:chat_message,
chat_channel: channel_1,
message: "hello @here @all @#{current_user.username} @#{other_user.username} @unexisting",
user: other_user,
)
end
before do
channel_1.add(other_user)
channel_1.add(current_user)
sign_in(current_user)
end
@ -158,6 +160,23 @@ RSpec.describe "Chat channel", type: :system, js: true do
expect(page).to have_selector(".mention", text: "@#{other_user.username}")
expect(page).to have_selector(".mention", text: "@unexisting")
end
it "renders user status on mentions" do
SiteSetting.enable_user_status = true
current_user.set_status!("off to dentist", "tooth")
other_user.set_status!("surfing", "surfing_man")
Fabricate(:chat_mention, user: current_user, chat_message: message)
Fabricate(:chat_mention, user: other_user, chat_message: message)
chat.visit_channel(channel_1)
expect(page).to have_selector(
".mention .user-status[title='#{current_user.user_status.description}']",
)
expect(page).to have_selector(
".mention .user-status[title='#{other_user.user_status.description}']",
)
end
end
context "when reply is right under" do

View File

@ -0,0 +1,351 @@
import {
acceptance,
emulateAutocomplete,
loggedInUser,
publishToMessageBus,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import {
click,
triggerEvent,
triggerKeyEvent,
visit,
waitFor,
} from "@ember/test-helpers";
import pretender, { OK } from "discourse/tests/helpers/create-pretender";
acceptance("Chat | User status on mentions", function (needs) {
const channelId = 1;
const messageId = 1;
const actingUser = {
id: 1,
username: "acting_user",
};
const mentionedUser1 = {
id: 1000,
username: "user1",
status: {
description: "surfing",
emoji: "surfing_man",
},
};
const mentionedUser2 = {
id: 2000,
username: "user2",
status: {
description: "vacation",
emoji: "desert_island",
},
};
const mentionedUser3 = {
id: 3000,
username: "user3",
status: {
description: "off to dentist",
emoji: "tooth",
},
};
const message = {
id: messageId,
message: `Hey @${mentionedUser1.username}`,
cooked: `<p>Hey <a class="mention" href="/u/${mentionedUser1.username}">@${mentionedUser1.username}</a></p>`,
mentioned_users: [mentionedUser1],
user: actingUser,
};
const newStatus = {
description: "working remotely",
emoji: "house",
};
const channel = {
id: channelId,
chatable_id: 1,
chatable_type: "Category",
meta: { message_bus_last_ids: {} },
current_user_membership: { following: true },
chatable: { id: 1 },
};
needs.settings({ chat_enabled: true });
needs.user({
...actingUser,
has_chat_enabled: true,
chat_channels: {
public_channels: [channel],
direct_message_channels: [],
meta: { message_bus_last_ids: {} },
tracking: {},
},
});
needs.hooks.beforeEach(function () {
pretender.post(`/chat/1`, () => OK());
pretender.put(`/chat/1/edit/${messageId}`, () => OK());
pretender.post(`/chat/drafts`, () => OK());
pretender.put(`/chat/api/channels/1/read/1`, () => OK());
pretender.delete(`/chat/api/channels/1/messages/${messageId}`, () => OK());
pretender.put(`/chat/api/channels/1/messages/${messageId}/restore`, () =>
OK()
);
pretender.get(`/chat/api/channels/1`, () =>
OK({
channel,
chat_messages: [message],
meta: { can_delete_self: true },
})
);
setupAutocompleteResponses([mentionedUser2, mentionedUser3]);
});
test("just posted messages | it shows status on mentions ", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await typeWithAutocompleteAndSend(`mentioning @${mentionedUser2.username}`);
assertStatusIsRendered(
assert,
statusSelector(mentionedUser2.username),
mentionedUser2.status
);
});
test("just posted messages | it updates status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await typeWithAutocompleteAndSend(`mentioning @${mentionedUser2.username}`);
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser2.id]: newStatus,
});
const selector = statusSelector(mentionedUser2.username);
await waitFor(selector);
assertStatusIsRendered(assert, selector, newStatus);
});
test("just posted messages | it deletes status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await typeWithAutocompleteAndSend(`mentioning @${mentionedUser2.username}`);
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser2.id]: null,
});
const selector = statusSelector(mentionedUser2.username);
await waitFor(selector, { count: 0 });
assert.dom(selector).doesNotExist("status is deleted");
});
test("edited messages | it shows status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await editMessage(
".chat-message-content",
`mentioning @${mentionedUser3.username}`
);
assertStatusIsRendered(
assert,
statusSelector(mentionedUser3.username),
mentionedUser3.status
);
});
test("edited messages | it updates status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await editMessage(
".chat-message-content",
`mentioning @${mentionedUser3.username}`
);
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser3.id]: newStatus,
});
const selector = statusSelector(mentionedUser3.username);
await waitFor(selector);
assertStatusIsRendered(assert, selector, newStatus);
});
test("edited messages | it deletes status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await editMessage(
".chat-message-content",
`mentioning @${mentionedUser3.username}`
);
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser3.id]: null,
});
const selector = statusSelector(mentionedUser3.username);
await waitFor(selector, { count: 0 });
assert.dom(selector).doesNotExist("status is deleted");
});
test("deleted messages | it shows status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await click(".chat-message-expand");
assertStatusIsRendered(
assert,
statusSelector(mentionedUser1.username),
mentionedUser1.status
);
});
test("deleted messages | it updates status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await click(".chat-message-expand");
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser1.id]: newStatus,
});
const selector = statusSelector(mentionedUser1.username);
await waitFor(selector);
assertStatusIsRendered(assert, selector, newStatus);
});
test("deleted messages | it deletes status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await click(".chat-message-expand");
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser1.id]: null,
});
const selector = statusSelector(mentionedUser1.username);
await waitFor(selector, { count: 0 });
assert.dom(selector).doesNotExist("status is deleted");
});
test("restored messages | it shows status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await restoreMessage(".chat-message-deleted");
assertStatusIsRendered(
assert,
statusSelector(mentionedUser1.username),
mentionedUser1.status
);
});
test("restored messages | it updates status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await restoreMessage(".chat-message-deleted");
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser1.id]: newStatus,
});
const selector = statusSelector(mentionedUser1.username);
await waitFor(selector);
assertStatusIsRendered(assert, selector, newStatus);
});
test("restored messages | it deletes status on mentions", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await restoreMessage(".chat-message-deleted");
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser1.id]: null,
});
const selector = statusSelector(mentionedUser1.username);
await waitFor(selector, { count: 0 });
assert.dom(selector).doesNotExist("status is deleted");
});
function assertStatusIsRendered(assert, selector, status) {
assert
.dom(selector)
.exists("status is rendered")
.hasAttribute(
"title",
status.description,
"status description is updated"
)
.hasAttribute(
"src",
new RegExp(`${status.emoji}.png`),
"status emoji is updated"
);
}
async function deleteMessage(messageSelector) {
await triggerEvent(query(messageSelector), "mouseenter");
await click(".more-buttons .select-kit-header-wrapper");
await click(".select-kit-collection .select-kit-row[data-value='delete']");
await publishToMessageBus(`/chat/${channelId}`, {
type: "delete",
deleted_id: messageId,
deleted_at: "2022-01-01T08:00:00.000Z",
});
}
async function editMessage(messageSelector, text) {
await triggerEvent(query(messageSelector), "mouseenter");
await click(".more-buttons .select-kit-header-wrapper");
await click(".select-kit-collection .select-kit-row[data-value='edit']");
await typeWithAutocompleteAndSend(text);
}
async function restoreMessage(messageSelector) {
await triggerEvent(query(messageSelector), "mouseenter");
await click(".more-buttons .select-kit-header-wrapper");
await click(".select-kit-collection .select-kit-row[data-value='restore']");
await publishToMessageBus(`/chat/${channelId}`, {
type: "restore",
chat_message: message,
});
}
async function typeWithAutocompleteAndSend(text) {
await emulateAutocomplete(".chat-composer__input", text);
await click(".autocomplete.ac-user .selected");
await triggerKeyEvent(".chat-composer__input", "keydown", "Enter");
}
function setupAutocompleteResponses(results) {
pretender.get("/u/search/users", () => {
return [
200,
{},
{
users: results,
},
];
});
pretender.get("/chat/api/mentions/groups.json", () => {
return [
200,
{},
{
unreachable: [],
over_members_limit: [],
invalid: ["and"],
},
];
});
}
function statusSelector(username) {
return `.mention[href='/u/${username}'] .user-status`;
}
});

View File

@ -0,0 +1,200 @@
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import hbs from "htmlbars-inline-precompile";
import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
import { render, waitFor } from "@ember/test-helpers";
import { module, test } from "qunit";
import pretender, { OK } from "discourse/tests/helpers/create-pretender";
import { publishToMessageBus } from "discourse/tests/helpers/qunit-helpers";
module(
"Discourse Chat | Component | chat-channel | status on mentions",
function (hooks) {
setupRenderingTest(hooks);
const channelId = 1;
const channel = {
id: channelId,
chatable_id: 1,
chatable_type: "Category",
meta: { message_bus_last_ids: {} },
current_user_membership: { following: true },
chatable: { id: 1 },
};
const actingUser = {
id: 1,
username: "acting_user",
};
const mentionedUser = {
id: 1000,
username: "user1",
status: {
description: "surfing",
emoji: "surfing_man",
},
};
const mentionedUser2 = {
id: 2000,
username: "user2",
status: {
description: "vacation",
emoji: "desert_island",
},
};
const message = {
id: 1891,
message: `Hey @${mentionedUser.username}`,
cooked: `<p>Hey <a class="mention" href="/u/${mentionedUser.username}">@${mentionedUser.username}</a></p>`,
mentioned_users: [mentionedUser],
user: {
id: 1,
username: "jesse",
},
};
hooks.beforeEach(function () {
pretender.get(`/chat/api/channels/1`, () =>
OK({
channel,
chat_messages: [message],
meta: { can_delete_self: true },
})
);
this.channel = fabricators.channel({
id: channelId,
currentUserMembership: { following: true },
meta: { can_join_chat_channel: false },
});
this.appEvents = this.container.lookup("service:appEvents");
});
test("it shows status on mentions", async function (assert) {
await render(hbs`<ChatChannel @channel={{this.channel}} />`);
assertStatusIsRendered(
assert,
statusSelector(mentionedUser.username),
mentionedUser.status
);
});
test("it updates status on mentions", async function (assert) {
await render(hbs`<ChatChannel @channel={{this.channel}} />`);
const newStatus = {
description: "off to dentist",
emoji: "tooth",
};
this.appEvents.trigger("user-status:changed", {
[mentionedUser.id]: newStatus,
});
const selector = statusSelector(mentionedUser.username);
await waitFor(selector);
assertStatusIsRendered(
assert,
statusSelector(mentionedUser.username),
newStatus
);
});
test("it deletes status on mentions", async function (assert) {
await render(hbs`<ChatChannel @channel={{this.channel}} />`);
this.appEvents.trigger("user-status:changed", {
[mentionedUser.id]: null,
});
const selector = statusSelector(mentionedUser.username);
await waitFor(selector, { count: 0 });
assert.dom(selector).doesNotExist("status is deleted");
});
test("it shows status on mentions on messages that came from Message Bus", async function (assert) {
await render(hbs`<ChatChannel @channel={{this.channel}} />`);
await receiveChatMessageViaMessageBus();
assertStatusIsRendered(
assert,
statusSelector(mentionedUser2.username),
mentionedUser2.status
);
});
test("it updates status on mentions on messages that came from Message Bus", async function (assert) {
await render(hbs`<ChatChannel @channel={{this.channel}} />`);
await receiveChatMessageViaMessageBus();
const newStatus = {
description: "off to meeting",
emoji: "calendar",
};
this.appEvents.trigger("user-status:changed", {
[mentionedUser2.id]: newStatus,
});
const selector = statusSelector(mentionedUser2.username);
await waitFor(selector);
assertStatusIsRendered(
assert,
statusSelector(mentionedUser2.username),
newStatus
);
});
test("it deletes status on mentions on messages that came from Message Bus", async function (assert) {
await render(hbs`<ChatChannel @channel={{this.channel}} />`);
await receiveChatMessageViaMessageBus();
this.appEvents.trigger("user-status:changed", {
[mentionedUser2.id]: null,
});
const selector = statusSelector(mentionedUser2.username);
await waitFor(selector, { count: 0 });
assert.dom(selector).doesNotExist("status is deleted");
});
function assertStatusIsRendered(assert, selector, status) {
assert
.dom(selector)
.exists("status is rendered")
.hasAttribute(
"title",
status.description,
"status description is updated"
)
.hasAttribute(
"src",
new RegExp(`${status.emoji}.png`),
"status emoji is updated"
);
}
async function receiveChatMessageViaMessageBus() {
await publishToMessageBus(`/chat/${channelId}`, {
chat_message: {
id: 2138,
message: `Hey @${mentionedUser2.username}`,
cooked: `<p>Hey <a class="mention" href="/u/${mentionedUser2.username}">@${mentionedUser2.username}</a></p>`,
created_at: "2023-05-18T16:07:59.588Z",
excerpt: `Hey @${mentionedUser2.username}`,
available_flags: [],
thread_title: null,
chat_channel_id: 7,
mentioned_users: [mentionedUser2],
user: actingUser,
chat_webhook_event: null,
uploads: [],
},
type: "sent",
});
}
function statusSelector(username) {
return `.mention[href='/u/${username}'] .user-status`;
}
}
);