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

View File

@ -4,10 +4,13 @@ import { ajax } from "discourse/lib/ajax";
import highlightSearch from "discourse/lib/highlight-search"; import highlightSearch from "discourse/lib/highlight-search";
import { iconHTML } from "discourse-common/lib/icon-library"; import { iconHTML } from "discourse-common/lib/icon-library";
import { isValidLink } from "discourse/lib/click-track"; 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 { spinnerHTML } from "discourse/helpers/loading-spinner";
import { escape } from "pretty-text/sanitizer"; import { escape } from "pretty-text/sanitizer";
import domFromString from "discourse-common/lib/dom-from-string"; 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 _beforeAdoptDecorators = [];
let _afterAdoptDecorators = []; let _afterAdoptDecorators = [];
@ -57,16 +60,25 @@ export default class PostCooked {
init() { init() {
this.originalQuoteContents = null; this.originalQuoteContents = null;
// todo should be a better way of detecting if it is composer preview
this._isInComposerPreview = !this.decoratorHelper;
const cookedDiv = this._computeCooked(); const cookedDiv = this._computeCooked();
this.cookedDiv = cookedDiv;
this._insertQuoteControls(cookedDiv); this._insertQuoteControls(cookedDiv);
this._showLinkCounts(cookedDiv); this._showLinkCounts(cookedDiv);
this._applySearchHighlight(cookedDiv); this._applySearchHighlight(cookedDiv);
this._initUserStatusToMentions();
this._decorateAndAdopt(cookedDiv); this._decorateAndAdopt(cookedDiv);
return cookedDiv; return cookedDiv;
} }
destroy() {
this._stopTrackingMentionedUsersStatus();
}
_decorateAndAdopt(cooked) { _decorateAndAdopt(cooked) {
_beforeAdoptDecorators.forEach((d) => d(cooked, this.decoratorHelper)); _beforeAdoptDecorators.forEach((d) => d(cooked, this.decoratorHelper));
@ -200,7 +212,7 @@ export default class PostCooked {
try { try {
const result = await ajax(`/posts/by_number/${topicId}/${postId}`); const result = await ajax(`/posts/by_number/${topicId}/${postId}`);
const post = this.decoratorHelper.getModel(); const post = this._post();
const quotedPosts = post.quoted || {}; const quotedPosts = post.quoted || {};
quotedPosts[result.id] = result; quotedPosts[result.id] = result;
post.set("quoted", quotedPosts); post.set("quoted", quotedPosts);
@ -361,6 +373,79 @@ export default class PostCooked {
return cookedDiv; 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"; 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
end end
def mentions
PrettyText.extract_mentions(Nokogiri::HTML5.fragment(cooked))
end
private private
def parse_quote_into_arguments(quote) def parse_quote_into_arguments(quote)

View File

@ -78,18 +78,7 @@ class PostAnalyzer
def raw_mentions def raw_mentions
return [] if @raw.blank? return [] if @raw.blank?
return @raw_mentions if @raw_mentions.present? return @raw_mentions if @raw_mentions.present?
@raw_mentions = PrettyText.extract_mentions(cooked_stripped)
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
end end
# from rack ... compat with ruby 2.2 # from rack ... compat with ruby 2.2

View File

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

View File

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

View File

@ -445,6 +445,20 @@ module PrettyText
links links
end 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 = {}) def self.excerpt(html, max_length, options = {})
# TODO: properly fix this HACK in ExcerptParser without introducing XSS # TODO: properly fix this HACK in ExcerptParser without introducing XSS
doc = Nokogiri::HTML5.fragment(html) doc = Nokogiri::HTML5.fragment(html)

View File

@ -34,7 +34,9 @@ class TopicView
:queued_posts_enabled, :queued_posts_enabled,
:personal_message, :personal_message,
:can_review_topic, :can_review_topic,
:page :page,
:mentioned_users,
:mentions
) )
alias queued_posts_enabled? queued_posts_enabled alias queued_posts_enabled? queued_posts_enabled
@ -145,6 +147,9 @@ class TopicView
end end
end end
parse_mentions
load_mentioned_users
TopicView.preload(self) TopicView.preload(self)
@draft_key = @topic.draft_key @draft_key = @topic.draft_key
@ -670,6 +675,23 @@ class TopicView
@topic.published_page @topic.published_page
end 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 protected
def read_posts_set def read_posts_set

View File

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

View File

@ -2684,6 +2684,91 @@ RSpec.describe TopicsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
end 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 describe "has_escaped_fragment?" do
context "when the SiteSetting is disabled" do context "when the SiteSetting is disabled" do
it "uses the application layout even with an escaped fragment param" do it "uses the application layout even with an escaped fragment param" do