DEV: better highlighting of mentions (#28403)

This commit improves the hilight-ing of mentions in posts and chat messages.

- `@here` and `@all` will generate a `<a class="mention --wide">`
- bots will generate a `<a class="mention --bot">`
- current user will generate a `<a class="mention --current">`

To achieve this change the following value transformer has been added: "mentions-class". It will be run in posts and chat messages after the mention is rendered.

A bug were bots were not considered in mentioned users has also been fixed as part of this PR.
This commit is contained in:
Joffrey JAFFEUX 2024-08-20 14:37:28 +02:00 committed by GitHub
parent 35b748e7f4
commit ccb1861ada
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 161 additions and 77 deletions

View File

@ -0,0 +1,21 @@
import { withPluginApi } from "discourse/lib/plugin-api";
export default {
initialize() {
withPluginApi("1.36.0", (api) => {
api.registerValueTransformer("mentions-class", ({ value, context }) => {
const { user } = context;
if (user.id < 0) {
value.push("--bot");
} else if (user.id === api.getCurrentUser()?.id) {
value.push("--current");
} else if (user.username === "here" || user.username === "all") {
value.push("--wide");
}
return value;
});
});
},
};

View File

@ -7,6 +7,7 @@ export const VALUE_TRANSFORMERS = Object.freeze([
// use only lowercase names // use only lowercase names
"category-description-text", "category-description-text",
"category-display-name", "category-display-name",
"mentions-class",
"header-notifications-avatar-size", "header-notifications-avatar-size",
"home-logo-href", "home-logo-href",
"home-logo-image-url", "home-logo-image-url",

View File

@ -4,6 +4,7 @@ import { isValidLink } from "discourse/lib/click-track";
import { number } from "discourse/lib/formatter"; import { number } from "discourse/lib/formatter";
import highlightHTML, { unhighlightHTML } from "discourse/lib/highlight-html"; import highlightHTML, { unhighlightHTML } from "discourse/lib/highlight-html";
import highlightSearch from "discourse/lib/highlight-search"; import highlightSearch from "discourse/lib/highlight-search";
import { applyValueTransformer } from "discourse/lib/transformer";
import { import {
destroyUserStatusOnMentions, destroyUserStatusOnMentions,
updateUserStatusOnMention, updateUserStatusOnMention,
@ -66,16 +67,15 @@ export default class PostCooked {
// todo should be a better way of detecting if it is composer preview // todo should be a better way of detecting if it is composer preview
this._isInComposerPreview = !this.decoratorHelper; this._isInComposerPreview = !this.decoratorHelper;
const cookedDiv = this._computeCooked(); this.cookedDiv = this._computeCooked();
this.cookedDiv = cookedDiv;
this._insertQuoteControls(cookedDiv); this._insertQuoteControls(this.cookedDiv);
this._showLinkCounts(cookedDiv); this._showLinkCounts(this.cookedDiv);
this._applySearchHighlight(cookedDiv); this._applySearchHighlight(this.cookedDiv);
this._initUserStatusOnMentions(); this._decorateMentions();
this._decorateAndAdopt(cookedDiv); this._decorateAndAdopt(this.cookedDiv);
return cookedDiv; return this.cookedDiv;
} }
destroy() { destroy() {
@ -365,9 +365,7 @@ export default class PostCooked {
if ( if (
(this.attrs.firstPost || this.attrs.embeddedPost) && (this.attrs.firstPost || this.attrs.embeddedPost) &&
this.ignoredUsers && this.ignoredUsers?.includes?.(this.attrs.username)
this.ignoredUsers.length > 0 &&
this.ignoredUsers.includes(this.attrs.username)
) { ) {
cookedDiv.classList.add("post-ignored"); cookedDiv.classList.add("post-ignored");
cookedDiv.innerHTML = I18n.t("post.ignored"); cookedDiv.innerHTML = I18n.t("post.ignored");
@ -378,44 +376,65 @@ export default class PostCooked {
return cookedDiv; return cookedDiv;
} }
_initUserStatusOnMentions() { _decorateMentions() {
if (!this._isInComposerPreview) { if (!this._isInComposerPreview) {
this._trackMentionedUsersStatus();
this._rerenderUserStatusOnMentions();
}
}
_rerenderUserStatusOnMentions() {
destroyUserStatusOnMentions(); destroyUserStatusOnMentions();
this._post()?.mentioned_users?.forEach((user) =>
this._rerenderUserStatusOnMention(this.cookedDiv, user)
);
} }
_rerenderUserStatusOnMention(postElement, user) { this._extractMentions().forEach(({ mentions, user }) => {
const href = getURL(`/u/${user.username.toLowerCase()}`); if (!this._isInComposerPreview) {
const mentions = postElement.querySelectorAll(`a.mention[href="${href}"]`); this._trackMentionedUserStatus(user);
this._rerenderUserStatusOnMentions(mentions, user);
}
const classes = applyValueTransformer("mentions-class", [], {
user,
});
mentions.forEach((mention) => {
mention.classList.add(...classes);
});
});
}
_rerenderUserStatusOnMentions(mentions, user) {
mentions.forEach((mention) => { mentions.forEach((mention) => {
updateUserStatusOnMention( updateUserStatusOnMention(
getOwnerWithFallback(this._post()), getOwnerWithFallback(this),
mention, mention,
user.status user.status
); );
}); });
} }
_trackMentionedUsersStatus() { _rerenderUsersStatusOnMentions() {
this._post()?.mentioned_users?.forEach((user) => { this._extractMentions().forEach(({ mentions, user }) => {
user.statusManager?.trackStatus?.(); this._rerenderUserStatusOnMentions(mentions, user);
user.on?.("status-changed", this, "_rerenderUserStatusOnMentions");
}); });
} }
_extractMentions() {
return (
this._post()?.mentioned_users?.map((user) => {
const href = getURL(`/u/${user.username.toLowerCase()}`);
const mentions = this.cookedDiv.querySelectorAll(
`a.mention[href="${href}"]`
);
return { user, mentions };
}) || []
);
}
_trackMentionedUserStatus(user) {
user.statusManager?.trackStatus?.();
user.on?.("status-changed", this, "_rerenderUsersStatusOnMentions");
}
_stopTrackingMentionedUsersStatus() { _stopTrackingMentionedUsersStatus() {
this._post()?.mentioned_users?.forEach((user) => { this._post()?.mentioned_users?.forEach((user) => {
user.statusManager?.stopTrackingStatus?.(); user.statusManager?.stopTrackingStatus?.();
user.off?.("status-changed", this, "_rerenderUserStatusOnMentions"); user.off?.("status-changed", this, "_rerenderUsersStatusOnMentions");
}); });
} }

View File

@ -0,0 +1,13 @@
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
acceptance("mentions-class transformer", function (needs) {
needs.user();
test("applying a value transformation", async function (assert) {
await visit("/t/internationalization-localization/280");
assert.dom(".mention[href='/u/eviltrout']").hasClass("--current");
});
});

View File

@ -667,6 +667,7 @@ export default {
avatar_template: "/images/avatar.png", avatar_template: "/images/avatar.png",
uploaded_avatar_id: 5253, uploaded_avatar_id: 5253,
created_at: "2013-02-07T14:02:07.869Z", created_at: "2013-02-07T14:02:07.869Z",
mentioned_users: [{id: 19, username: "eviltrout"}],
cooked: cooked:
'<p><aside class="quote" data-post="3" data-topic="280"><div class="title">\n<div class="quote-controls"></div>\n<img width="20" height="20" src="/user_avatar/meta.discourse.org/codinghorror/40/5297.png" class="avatar">codinghorror said:</div>\n<blockquote><p>So you could replace that lookup table with the "de" one to get German.</p></blockquote></aside></p>\n\n<p>The problem I see here is that this file is likely two grow and change massively over the next couple months, and tracking these changes in order to keep a localized file up to date is going to be a <em>bitch</em>. </p>\n\n<p>I wonder where there is a tool that can compare two yml structures and point out which nodes are missing? That would help keep track of new strings.</p>\n\n<p>Re keeping track of <em>changed</em> strings, <a class="mention" href="/u/eviltrout">@eviltrout</a> I found this very interesting: <a href="http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders" rel="nofollow">http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders</a> if plain English placeholders were used, any change in strings would lead to a <em>new</em> node in the yml file, making keeping the translation up to date easier. Maybe worth thinking about in the future.</p>', '<p><aside class="quote" data-post="3" data-topic="280"><div class="title">\n<div class="quote-controls"></div>\n<img width="20" height="20" src="/user_avatar/meta.discourse.org/codinghorror/40/5297.png" class="avatar">codinghorror said:</div>\n<blockquote><p>So you could replace that lookup table with the "de" one to get German.</p></blockquote></aside></p>\n\n<p>The problem I see here is that this file is likely two grow and change massively over the next couple months, and tracking these changes in order to keep a localized file up to date is going to be a <em>bitch</em>. </p>\n\n<p>I wonder where there is a tool that can compare two yml structures and point out which nodes are missing? That would help keep track of new strings.</p>\n\n<p>Re keeping track of <em>changed</em> strings, <a class="mention" href="/u/eviltrout">@eviltrout</a> I found this very interesting: <a href="http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders" rel="nofollow">http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders</a> if plain English placeholders were used, any change in strings would lead to a <em>new</em> node in the yml file, making keeping the translation up to date easier. Maybe worth thinking about in the future.</p>',
post_number: 5, post_number: 5,

View File

@ -262,6 +262,18 @@ $hpad: 0.65em;
border-radius: 0.6em; border-radius: 0.6em;
text-decoration: none; text-decoration: none;
text-wrap: nowrap; text-wrap: nowrap;
&.--bot {
background: var(--highlight-low-or-medium);
}
&.--wide {
background: var(--highlight);
}
&.--current {
background: var(--tertiary-low);
}
} }
@mixin nav-active() { @mixin nav-active() {

View File

@ -15,9 +15,11 @@ import { eq, not } from "truth-helpers";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class"; import concatClass from "discourse/helpers/concat-class";
import optionalService from "discourse/lib/optional-service"; import optionalService from "discourse/lib/optional-service";
import { applyValueTransformer } from "discourse/lib/transformer";
import { updateUserStatusOnMention } from "discourse/lib/update-user-status-on-mention"; import { updateUserStatusOnMention } from "discourse/lib/update-user-status-on-mention";
import isZoomed from "discourse/lib/zoom-check"; import isZoomed from "discourse/lib/zoom-check";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import getURL from "discourse-common/lib/get-url";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
@ -95,11 +97,6 @@ export default class ChatMessage extends Component {
}; };
}); });
constructor() {
super(...arguments);
this.initMentionedUsers();
}
get pane() { get pane() {
return this.threadContext ? this.chatThreadPane : this.chatChannelPane; return this.threadContext ? this.chatThreadPane : this.chatChannelPane;
} }
@ -214,6 +211,8 @@ export default class ChatMessage extends Component {
@action @action
didInsertMessage(element) { didInsertMessage(element) {
this.messageContainer = element; this.messageContainer = element;
this.initMentionedUsers();
this.decorateMentions(element);
this.debounceDecorateCookedMessage(); this.debounceDecorateCookedMessage();
this.refreshStatusOnMentions(); this.refreshStatusOnMentions();
} }
@ -239,6 +238,46 @@ export default class ChatMessage extends Component {
); );
} }
initMentionedUsers() {
this.args.message.mentionedUsers.forEach((user) => {
if (!user.statusManager.isTrackingStatus()) {
user.statusManager.trackStatus();
user.on("status-changed", this, "refreshStatusOnMentions");
}
});
}
decorateMentions(cooked) {
if (this.args.message.channel.allowChannelWideMentions) {
const wideMentions = [...cooked.querySelectorAll("span.mention")];
MENTION_KEYWORDS.forEach((keyword) => {
const mentions = wideMentions.filter((node) => {
return node.textContent.trim() === `@${keyword}`;
});
const classes = applyValueTransformer("mentions-class", [], {
user: { username: keyword },
});
mentions.forEach((mention) => {
mention.classList.add(...classes);
});
});
}
this.args.message.mentionedUsers.forEach((user) => {
const href = getURL(`/u/${user.username.toLowerCase()}`);
const mentions = cooked.querySelectorAll(`a.mention[href="${href}"]`);
const classes = applyValueTransformer("mentions-class", [], {
user,
});
mentions.forEach((mention) => {
mention.classList.add(...classes);
});
});
}
@action @action
decorateCookedMessage(message) { decorateCookedMessage(message) {
schedule("afterRender", () => { schedule("afterRender", () => {
@ -248,18 +287,6 @@ export default class ChatMessage extends Component {
}); });
} }
@action
initMentionedUsers() {
this.args.message.mentionedUsers.forEach((user) => {
if (user.statusManager.isTrackingStatus()) {
return;
}
user.statusManager.trackStatus();
user.on("status-changed", this, "refreshStatusOnMentions");
});
}
get show() { get show() {
return ( return (
!this.args.message?.deletedAt || !this.args.message?.deletedAt ||

View File

@ -3,7 +3,6 @@ import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
import { replaceIcon } from "discourse-common/lib/icon-library"; import { replaceIcon } from "discourse-common/lib/icon-library";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import { MENTION_KEYWORDS } from "discourse/plugins/chat/discourse/components/chat-message";
import { clearChatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; import { clearChatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
import ChannelHashtagType from "discourse/plugins/chat/discourse/lib/hashtag-types/channel"; import ChannelHashtagType from "discourse/plugins/chat/discourse/lib/hashtag-types/channel";
import ChatHeaderIcon from "../components/chat/header/icon"; import ChatHeaderIcon from "../components/chat/header/icon";
@ -164,24 +163,6 @@ export default {
document.body.classList.remove("chat-drawer-active"); document.body.classList.remove("chat-drawer-active");
} }
}); });
api.decorateChatMessage(function (chatMessage, chatChannel) {
if (!this.currentUser) {
return;
}
const highlightable = [`@${this.currentUser.username}`];
if (chatChannel.allowChannelWideMentions) {
highlightable.push(...MENTION_KEYWORDS.map((k) => `@${k}`));
}
chatMessage.querySelectorAll(".mention").forEach((node) => {
const mention = node.textContent.trim();
if (highlightable.includes(mention)) {
node.classList.add("highlighted", "valid-mention");
}
});
});
}); });
}, },

View File

@ -70,7 +70,8 @@
width: 100%; width: 100%;
} }
a.mention { a.mention,
span.mention.--wide {
@include mention; @include mention;
&.highlighted { &.highlighted {
@ -79,7 +80,7 @@
} }
// unlinked, invalid mention // unlinked, invalid mention
span.mention { span.mention:not(--wide) {
color: var(--primary-high); color: var(--primary-high);
} }

View File

@ -86,7 +86,7 @@ module Chat
end end
def chat_users def chat_users
User.distinct.joins(:user_option).real.where(user_options: { chat_enabled: true }) User.distinct.joins(:user_option).where(user_options: { chat_enabled: true })
end end
def mentionable_groups def mentionable_groups

View File

@ -145,6 +145,15 @@ RSpec.describe Chat::ParsedMentions do
expect(result).to contain_exactly(channel_member_1.username, channel_member_2.username) expect(result).to contain_exactly(channel_member_1.username, channel_member_2.username)
end end
it "returns bots who were mentioned directly" do
message = create_message("mentioning @system")
mentions = described_class.new(message)
result = mentions.direct_mentions.pluck(:username)
expect(result).to contain_exactly(Discourse.system_user.username)
end
it "returns a user when self-mentioning" do it "returns a user when self-mentioning" do
message = create_message("Hey @#{channel_member_1.username}", user: channel_member_1) message = create_message("Hey @#{channel_member_1.username}", user: channel_member_1)

View File

@ -176,7 +176,8 @@ RSpec.describe "Chat channel", type: :system do
Fabricate( Fabricate(
:chat_message, :chat_message,
chat_channel: channel_1, chat_channel: channel_1,
message: "hello @here @all @#{current_user.username} @#{other_user.username} @unexisting", message:
"hello @here @all @#{current_user.username} @#{other_user.username} @unexisting @system",
user: other_user, user: other_user,
) )
end end
@ -191,14 +192,12 @@ RSpec.describe "Chat channel", type: :system do
it "highlights the mentions" do it "highlights the mentions" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
expect(page).to have_selector(".mention.highlighted.valid-mention", text: "@here") expect(page).to have_selector(".mention.--wide", text: "@here")
expect(page).to have_selector(".mention.highlighted.valid-mention", text: "@all") expect(page).to have_selector(".mention.--wide", text: "@all")
expect(page).to have_selector( expect(page).to have_selector(".mention.--current", text: "@#{current_user.username}")
".mention.highlighted.valid-mention",
text: "@#{current_user.username}",
)
expect(page).to have_selector(".mention", text: "@#{other_user.username}") expect(page).to have_selector(".mention", text: "@#{other_user.username}")
expect(page).to have_selector(".mention", text: "@unexisting") expect(page).to have_selector(".mention", text: "@unexisting")
expect(page).to have_selector(".mention.--bot", text: "@system")
end end
it "renders user status on mentions" do it "renders user status on mentions" do