FEATURE: Show live user status on inline mentions on posts (#18683)

Note that we don't have a database table and a model for post mentions yet, and I decided to implement it without adding one to avoid heavy data migrations. Still, we may want to add such a model later, that would be convenient, we have such a model for mentions in chat.

Note that status appears on all mentions on all posts in a topic except of the case when you just posted a new post, and it appeared on the bottom of the topic. On such posts, status won't be shown immediately for now (you'll need to reload the page to see the status). I'll take care of it in one of the following PRs.
This commit is contained in:
Andrei Prigorshnev 2022-12-06 19:10:36 +04:00 committed by GitHub
parent d247e5d37c
commit a76d864c51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 400 additions and 20 deletions

View File

@ -608,7 +608,7 @@ export default RestModel.extend({
},
prependPost(post) {
this._initUserModel(post);
this._initUserModels(post);
const stored = this.storePost(post);
if (stored) {
const posts = this.posts;
@ -619,7 +619,7 @@ export default RestModel.extend({
},
appendPost(post) {
this._initUserModel(post);
this._initUserModels(post);
const stored = this.storePost(post);
if (stored) {
const posts = this.posts;
@ -1259,7 +1259,7 @@ export default RestModel.extend({
}
},
_initUserModel(post) {
_initUserModels(post) {
post.user = User.create({
id: post.user_id,
username: post.username,
@ -1268,6 +1268,10 @@ export default RestModel.extend({
if (post.user_status) {
post.user.status = post.user_status;
}
if (post.mentioned_users) {
post.mentioned_users = post.mentioned_users.map((u) => User.create(u));
}
},
_checkIfShouldShowRevisions() {

View File

@ -4,10 +4,13 @@ import { ajax } from "discourse/lib/ajax";
import highlightSearch from "discourse/lib/highlight-search";
import { iconHTML } from "discourse-common/lib/icon-library";
import { isValidLink } from "discourse/lib/click-track";
import { number } from "discourse/lib/formatter";
import { number, until } from "discourse/lib/formatter";
import { spinnerHTML } from "discourse/helpers/loading-spinner";
import { escape } from "pretty-text/sanitizer";
import domFromString from "discourse-common/lib/dom-from-string";
import getURL from "discourse-common/lib/get-url";
import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
let _beforeAdoptDecorators = [];
let _afterAdoptDecorators = [];
@ -57,16 +60,25 @@ export default class PostCooked {
init() {
this.originalQuoteContents = null;
// todo should be a better way of detecting if it is composer preview
this._isInComposerPreview = !this.decoratorHelper;
const cookedDiv = this._computeCooked();
this.cookedDiv = cookedDiv;
this._insertQuoteControls(cookedDiv);
this._showLinkCounts(cookedDiv);
this._applySearchHighlight(cookedDiv);
this._initUserStatusToMentions();
this._decorateAndAdopt(cookedDiv);
return cookedDiv;
}
destroy() {
this._stopTrackingMentionedUsersStatus();
}
_decorateAndAdopt(cooked) {
_beforeAdoptDecorators.forEach((d) => d(cooked, this.decoratorHelper));
@ -200,7 +212,7 @@ export default class PostCooked {
try {
const result = await ajax(`/posts/by_number/${topicId}/${postId}`);
const post = this.decoratorHelper.getModel();
const post = this._post();
const quotedPosts = post.quoted || {};
quotedPosts[result.id] = result;
post.set("quoted", quotedPosts);
@ -361,6 +373,79 @@ export default class PostCooked {
return cookedDiv;
}
_initUserStatusToMentions() {
if (!this._isInComposerPreview) {
this._trackMentionedUsersStatus();
this._rerenderUserStatusOnMentions();
}
}
_rerenderUserStatusOnMentions() {
this._post()?.mentioned_users?.forEach((user) =>
this._rerenderUserStatusOnMention(this.cookedDiv, user)
);
}
_rerenderUserStatusOnMention(postElement, user) {
const href = getURL(`/u/${user.username.toLowerCase()}`);
const mentions = postElement.querySelectorAll(`a.mention[href="${href}"]`);
mentions.forEach((mention) => {
this._updateUserStatus(mention, user.status);
});
}
_updateUserStatus(mention, status) {
this._removeUserStatus(mention);
if (status) {
this._insertUserStatus(mention, status);
}
}
_insertUserStatus(mention, status) {
const emoji = escapeExpression(`:${status.emoji}:`);
const statusHtml = emojiUnescape(emoji, {
class: "user-status",
title: this._userStatusTitle(status),
});
mention.insertAdjacentHTML("beforeend", statusHtml);
}
_removeUserStatus(mention) {
mention.querySelector("img.user-status")?.remove();
}
_userStatusTitle(status) {
if (!status.ends_at) {
return status.description;
}
const until_ = until(
status.ends_at,
this.currentUser.timezone,
this.currentUser.locale
);
return escapeExpression(`${status.description} ${until_}`);
}
_trackMentionedUsersStatus() {
this._post()?.mentioned_users?.forEach((user) => {
user.trackStatus();
user.on("status-changed", this, "_rerenderUserStatusOnMentions");
});
}
_stopTrackingMentionedUsersStatus() {
this._post()?.mentioned_users?.forEach((user) => {
user.stopTrackingStatus();
user.off("status-changed", this, "_rerenderUserStatusOnMentions");
});
}
_post() {
return this.decoratorHelper?.getModel?.();
}
}
PostCooked.prototype.type = "Widget";

View File

@ -0,0 +1,160 @@
import {
acceptance,
exists,
publishToMessageBus,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
import { cloneJSON } from "discourse-common/lib/object";
import topicFixtures from "../fixtures/topic";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
acceptance("Post inline mentions test", function (needs) {
needs.user();
const topicId = 130;
const mentionedUserId = 1;
const status = {
description: "Surfing",
emoji: "surfing_man",
ends_at: null,
};
function topicWithoutUserStatus() {
const topic = cloneJSON(topicFixtures[`/t/${topicId}.json`]);
const firstPost = topic.post_stream.posts[0];
firstPost.cooked =
'<p>I am mentioning <a class="mention" href="/u/user1">@user1</a> again.</p>';
firstPost.mentioned_users = [
{
id: mentionedUserId,
username: "user1",
avatar_template: "/letter_avatar_proxy/v4/letter/a/bbce88/{size}.png",
},
];
return topic;
}
function topicWithUserStatus() {
const topic = topicWithoutUserStatus();
topic.post_stream.posts[0].mentioned_users[0].status = status;
return topic;
}
test("shows user status on inline mentions", async function (assert) {
pretender.get(`/t/${topicId}.json`, () => {
return response(topicWithUserStatus());
});
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.ok(
exists(".topic-post .cooked .mention .user-status"),
"user status is shown"
);
const statusElement = query(".topic-post .cooked .mention .user-status");
assert.equal(
statusElement.title,
status.description,
"status description is correct"
);
assert.ok(
statusElement.src.includes(status.emoji),
"status emoji is correct"
);
});
test("inserts user status on message bus message", async function (assert) {
pretender.get(`/t/${topicId}.json`, () => {
return response(topicWithoutUserStatus());
});
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.notOk(
exists(".topic-post .cooked .mention .user-status"),
"user status isn't shown"
);
await publishToMessageBus("/user-status", {
[mentionedUserId]: {
description: status.description,
emoji: status.emoji,
},
});
assert.ok(
exists(".topic-post .cooked .mention .user-status"),
"user status is shown"
);
const statusElement = query(".topic-post .cooked .mention .user-status");
assert.equal(
statusElement.title,
status.description,
"status description is correct"
);
assert.ok(
statusElement.src.includes(status.emoji),
"status emoji is correct"
);
});
test("updates user status on message bus message", async function (assert) {
pretender.get(`/t/${topicId}.json`, () => {
return response(topicWithUserStatus());
});
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.ok(
exists(".topic-post .cooked .mention .user-status"),
"initial user status is shown"
);
const newStatus = {
description: "off to dentist",
emoji: "tooth",
};
await publishToMessageBus("/user-status", {
[mentionedUserId]: {
description: newStatus.description,
emoji: newStatus.emoji,
},
});
assert.ok(
exists(".topic-post .cooked .mention .user-status"),
"updated user status is shown"
);
const statusElement = query(".topic-post .cooked .mention .user-status");
assert.equal(
statusElement.title,
newStatus.description,
"updated status description is correct"
);
assert.ok(
statusElement.src.includes(newStatus.emoji),
"updated status emoji is correct"
);
});
test("removes user status on message bus message", async function (assert) {
pretender.get(`/t/${topicId}.json`, () => {
return response(topicWithUserStatus());
});
await visit(`/t/lorem-ipsum-dolor-sit-amet/${topicId}`);
assert.ok(
exists(".topic-post .cooked .mention .user-status"),
"initial user status is shown"
);
await publishToMessageBus("/user-status", {
[mentionedUserId]: null,
});
assert.notOk(
exists(".topic-post .cooked .mention .user-status"),
"updated user has disappeared"
);
});
});

View File

@ -1128,6 +1128,10 @@ class Post < ActiveRecord::Base
end
end
def mentions
PrettyText.extract_mentions(Nokogiri::HTML5.fragment(cooked))
end
private
def parse_quote_into_arguments(quote)

View File

@ -78,18 +78,7 @@ class PostAnalyzer
def raw_mentions
return [] if @raw.blank?
return @raw_mentions if @raw_mentions.present?
raw_mentions = cooked_stripped.css('.mention, .mention-group').map do |e|
if name = e.inner_text
name = name[1..-1]
name = User.normalize_username(name)
name
end
end
raw_mentions.compact!
raw_mentions.uniq!
@raw_mentions = raw_mentions
@raw_mentions = PrettyText.extract_mentions(cooked_stripped)
end
# from rack ... compat with ruby 2.2

View File

@ -88,7 +88,8 @@ class PostSerializer < BasicPostSerializer
:reviewable_score_count,
:reviewable_score_pending_count,
:user_suspended,
:user_status
:user_status,
:mentioned_users
def initialize(object, opts)
super(object, opts)
@ -560,6 +561,17 @@ class PostSerializer < BasicPostSerializer
UserStatusSerializer.new(object.user&.user_status, root: false)
end
def mentioned_users
if @topic_view && (mentions = @topic_view.mentions[object.id])
return mentions
.map { |username| @topic_view.mentioned_users[username] }
.compact
.map { |user| BasicUserWithStatusSerializer.new(user, root: false) }
end
[]
end
private
def can_review_topic?
@ -590,5 +602,4 @@ private
def post_actions
@post_actions ||= (@topic_view&.all_post_actions || {})[object.id]
end
end

View File

@ -32,6 +32,7 @@ class WebHookPostSerializer < PostSerializer
flair_bg_color
flair_color
notice
mentioned_users
}.each do |attr|
define_method("include_#{attr}?") do
false

View File

@ -445,6 +445,20 @@ module PrettyText
links
end
def self.extract_mentions(cooked)
mentions = cooked.css('.mention, .mention-group').map do |e|
if (name = e.inner_text)
name = name[1..-1]
name = User.normalize_username(name)
name
end
end
mentions.compact!
mentions.uniq!
mentions
end
def self.excerpt(html, max_length, options = {})
# TODO: properly fix this HACK in ExcerptParser without introducing XSS
doc = Nokogiri::HTML5.fragment(html)

View File

@ -34,7 +34,9 @@ class TopicView
:queued_posts_enabled,
:personal_message,
:can_review_topic,
:page
:page,
:mentioned_users,
:mentions
)
alias queued_posts_enabled? queued_posts_enabled
@ -145,6 +147,9 @@ class TopicView
end
end
parse_mentions
load_mentioned_users
TopicView.preload(self)
@draft_key = @topic.draft_key
@ -670,6 +675,23 @@ class TopicView
@topic.published_page
end
def parse_mentions
@mentions = @posts
.to_h { |p| [p.id, p.mentions] }
.reject { |_, v| v.empty? }
end
def load_mentioned_users
usernames = @mentions.values.flatten.uniq
mentioned_users = User.where(username: usernames)
if SiteSetting.enable_user_status
mentioned_users = mentioned_users.includes(:user_status)
end
@mentioned_users = mentioned_users.to_h { |u| [u.username, u] }
end
protected
def read_posts_set

View File

@ -200,6 +200,11 @@
},
"reviewable_score_pending_count": {
"type": "integer"
},
"mentioned_users": {
"type": "array",
"items": {
}
}
},
"required": [

View File

@ -2684,6 +2684,91 @@ RSpec.describe TopicsController do
expect(response.status).to eq(200)
end
context "with mentions" do
fab!(:post) { Fabricate(:post, user: post_author1) }
fab!(:topic) { post.topic }
fab!(:post2) { Fabricate(
:post,
user: post_author2,
topic: topic,
raw: "I am mentioning @#{post_author1.username}."
) }
it "returns mentions" do
get "/t/#{topic.slug}/#{topic.id}.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["post_stream"]["posts"][1]["mentioned_users"].length).to be(1)
mentioned_user = json["post_stream"]["posts"][1]["mentioned_users"][0]
expect(mentioned_user["id"]).to be(post_author1.id)
expect(mentioned_user["name"]).to eq(post_author1.name)
expect(mentioned_user["username"]).to eq(post_author1.username)
end
it "doesn't return status on mentions by default" do
post_author1.set_status!("off to dentist", "tooth")
get "/t/#{topic.slug}/#{topic.id}.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["post_stream"]["posts"][1]["mentioned_users"].length).to be(1)
status = json["post_stream"]["posts"][1]["mentioned_users"][0]["status"]
expect(status).to be_nil
end
it "returns mentions with status if user status is enabled" do
SiteSetting.enable_user_status = true
post_author1.set_status!("off to dentist", "tooth")
get "/t/#{topic.slug}/#{topic.id}.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["post_stream"]["posts"][1]["mentioned_users"].length).to be(1)
status = json["post_stream"]["posts"][1]["mentioned_users"][0]["status"]
expect(status).to be_present
expect(status["emoji"]).to eq(post_author1.user_status.emoji)
expect(status["description"]).to eq(post_author1.user_status.description)
end
it "returns an empty list of mentioned users if there is no mentions in a post" do
Fabricate(
:post,
user: post_author2,
topic: topic,
raw: "Post without mentions.")
get "/t/#{topic.slug}/#{topic.id}.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["post_stream"]["posts"][2]["mentioned_users"].length).to be(0)
end
it "returns an empty list of mentioned users if an unexisting user was mentioned" do
Fabricate(
:post,
user: post_author2,
topic: topic,
raw: "Mentioning an @unexisting_user.")
get "/t/#{topic.slug}/#{topic.id}.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["post_stream"]["posts"][2]["mentioned_users"].length).to be(0)
end
end
describe "has_escaped_fragment?" do
context "when the SiteSetting is disabled" do
it "uses the application layout even with an escaped fragment param" do