Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DEV: Redesign chat mentions #24752

Merged
merged 16 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
32 changes: 31 additions & 1 deletion plugins/chat/app/jobs/regular/chat/notify_mentioned.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,43 @@ def process_mentions(user_ids, mention_type)
memberships = get_memberships(user_ids)

memberships.each do |membership|
mention = ::Chat::Mention.find_by(user: membership.user, chat_message: @chat_message)
mention = find_mention(@chat_message, mention_type, membership.user.id)
if mention.present?
create_notification!(membership, mention, mention_type)
send_notifications(membership, mention_type)
end
end
end

def find_mention(chat_message, mention_type, user_id)
mention_klass = resolve_mention_klass(mention_type)

target_id = nil
if mention_klass == ::Chat::UserMention
target_id = user_id
elsif mention_klass == ::Chat::GroupMention
begin
target_id = Group.where("LOWER(name) = ?", "#{mention_type}").first.id
Copy link
Member

@ZogStriP ZogStriP Jan 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: if you only care about some fields about 1 record, you can use #pick and list the fields you want.

This avoids loading the whole record in memory (due to the .first) when we only want the id.

Suggested change
target_id = Group.where("LOWER(name) = ?", "#{mention_type}").first.id
target_id = Group.where("LOWER(name) = ?", "#{mention_type}").pick(:id)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, merged the PR before I saw this. I've fixed it in a follow-up – #25301.

rescue => e
Discourse.warn_exception(e, message: "Mentioned group doesn't exist")
end
end

mention_klass.find_by(chat_message: chat_message, target_id: target_id)
end

def resolve_mention_klass(mention_type)
case mention_type
when :global_mentions
::Chat::AllMention
when :here_mentions
::Chat::HereMention
when :direct_mentions
::Chat::UserMention
else
::Chat::GroupMention
end
end
end
end
end
6 changes: 6 additions & 0 deletions plugins/chat/app/models/chat/all_mention.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

module Chat
class AllMention < Mention
end
end
7 changes: 7 additions & 0 deletions plugins/chat/app/models/chat/group_mention.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module Chat
class GroupMention < Mention
belongs_to :group, foreign_key: :target_id
end
end
6 changes: 6 additions & 0 deletions plugins/chat/app/models/chat/here_mention.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

module Chat
class HereMention < Mention
end
end
10 changes: 6 additions & 4 deletions plugins/chat/app/models/chat/mention.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
module Chat
class Mention < ActiveRecord::Base
self.table_name = "chat_mentions"
self.ignored_columns = ["notification_id"]
self.ignored_columns = %w[notification_id user_id]

belongs_to :user
belongs_to :chat_message, class_name: "Chat::Message"
has_many :mention_notifications,
class_name: "Chat::MentionNotification",
Expand All @@ -20,12 +19,15 @@ class Mention < ActiveRecord::Base
#
# id :bigint not null, primary key
# chat_message_id :integer not null
# user_id :integer not null
# user_id :integer
# notification_id :integer not null
# target_id :integer
# type :integer not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# chat_mentions_index (chat_message_id,user_id,notification_id) UNIQUE
# index_chat_mentions_on_chat_message_id (chat_message_id)
# index_chat_mentions_on_target_id (target_id)
#
84 changes: 60 additions & 24 deletions plugins/chat/app/models/chat/message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ class Message < ActiveRecord::Base
dependent: :destroy,
class_name: "Chat::Mention",
foreign_key: :chat_message_id
has_many :user_mentions,
dependent: :destroy,
class_name: "Chat::UserMention",
foreign_key: :chat_message_id
has_many :group_mentions,
dependent: :destroy,
class_name: "Chat::GroupMention",
foreign_key: :chat_message_id
has_one :all_mention,
dependent: :destroy,
class_name: "Chat::AllMention",
foreign_key: :chat_message_id
has_one :here_mention,
dependent: :destroy,
class_name: "Chat::HereMention",
foreign_key: :chat_message_id

scope :in_public_channel,
-> do
Expand Down Expand Up @@ -248,14 +264,10 @@ def url
end

def upsert_mentions
mentioned_user_ids = parsed_mentions.all_mentioned_users_ids
old_mentions = chat_mentions.pluck(:user_id)

mentioned_user_ids_to_drop = old_mentions - mentioned_user_ids
delete_mentions(mentioned_user_ids_to_drop)

mentioned_user_ids_to_add = mentioned_user_ids - old_mentions
insert_mentions(mentioned_user_ids_to_add)
upsert_user_mentions
upsert_group_mentions
create_or_delete_all_mention
create_or_delete_here_mention
end

def in_thread?
Expand All @@ -280,24 +292,16 @@ def invalidate_parsed_mentions

private

def delete_mentions(user_ids)
chat_mentions.where(user_id: user_ids).destroy_all
def delete_mentions(mention_type, target_ids)
chat_mentions.where(type: mention_type, target_id: target_ids).destroy_all
end

def insert_mentions(user_ids)
return if user_ids.empty?

now = Time.zone.now
mentions = []
User
.where(id: user_ids)
.find_each do |user|
mentions << {
chat_message_id: self.id,
user_id: user.id,
created_at: now,
updated_at: now,
}
def insert_mentions(type, target_ids)
return if target_ids.empty?

mentions =
target_ids.map do |target_id|
{ chat_message_id: self.id, target_id: target_id, type: type }
end

Chat::Mention.insert_all(mentions)
Expand All @@ -314,6 +318,38 @@ def message_too_long?
def ensure_last_editor_id
self.last_editor_id ||= self.user_id
end

def create_or_delete_all_mention
if !parsed_mentions.has_global_mention && all_mention.present?
all_mention.destroy!
association(:all_mention).reload
elsif parsed_mentions.has_global_mention && all_mention.blank?
build_all_mention.save!
end
end

def create_or_delete_here_mention
if !parsed_mentions.has_here_mention && here_mention.present?
here_mention.destroy!
association(:here_mention).reload
elsif parsed_mentions.has_here_mention && here_mention.blank?
build_here_mention.save!
end
end

def upsert_group_mentions
old_mentions = group_mentions.pluck(:target_id)
new_mentions = parsed_mentions.groups_to_mention.pluck(:id)
delete_mentions("Chat::GroupMention", old_mentions - new_mentions)
insert_mentions("Chat::GroupMention", new_mentions - old_mentions)
end

def upsert_user_mentions
old_mentions = user_mentions.pluck(:target_id)
new_mentions = parsed_mentions.direct_mentions.pluck(:id)
delete_mentions("Chat::UserMention", old_mentions - new_mentions)
insert_mentions("Chat::UserMention", new_mentions - old_mentions)
end
Comment on lines +322 to +352
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if I follow this correctly we will in final call: insert_all (and destory also maybe) multiple times if we have a message with group/user/here/all, can't we find a way to have all the inserts in one operation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if I follow this correctly we will in final call: insert_all (and destory also maybe) multiple times if we have a message with group/user/here/all,

Yes, this is correct. One thing though is that "multiple" is actually from one to two inserts (and maybe deletes) in normal cases. For example, if you mention several users and several groups like this that'll be two inserts:

@user1, @user2, @user3, @group1, @group2, @group2

The worst case is 4 inserts, but this should happen rarely:

@a_user, @ a_group, @here, @all

can't we find a way to have all the inserts in one operation?

We can do that, but is it really worth optimizing? Those inserts and deletes are lightweight, for example this is an insert that adds two user mentions:

Screenshot 2024-01-04 at 19 27 17

There is no much to win here. At the same time, I'm not quite happy with how this code looks now, and I believe we can make it more concise at some point and If we refactor it to make all inserts / deletes in one query, it'll become even worse, and it'll be harder to improve it in the future.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, if you are comfortable with this, let's keep it that way. We can always reconsider if necessary. 👍

end
end

Expand Down
7 changes: 7 additions & 0 deletions plugins/chat/app/models/chat/user_mention.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module Chat
class UserMention < Mention
belongs_to :user, foreign_key: :target_id
end
end
4 changes: 2 additions & 2 deletions plugins/chat/app/queries/chat/messages_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ def self.base_query(channel:)

if SiteSetting.enable_user_status
query = query.includes(user: :user_status)
query = query.includes(chat_mentions: { user: :user_status })
query = query.includes(user_mentions: { user: :user_status })
else
query = query.includes(chat_mentions: :user)
query = query.includes(user_mentions: :user)
end

query
Expand Down
2 changes: 1 addition & 1 deletion plugins/chat/app/serializers/chat/message_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class MessageSerializer < ::ApplicationSerializer

def mentioned_users
object
.chat_mentions
.user_mentions
.limit(SiteSetting.max_mentions_per_chat_message)
.map(&:user)
.compact
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def excerpt

def mentioned_users
object
.chat_mentions
.user_mentions
.limit(SiteSetting.max_mentions_per_chat_message)
.map(&:user)
.compact
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true
AndrewPrigorshnev marked this conversation as resolved.
Show resolved Hide resolved

class AddTypeAndTargetIdToChatMentions < ActiveRecord::Migration[7.0]
def up
add_column :chat_mentions, :type, :string, null: true
add_column :chat_mentions, :target_id, :integer, null: true
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the target_id column will always be nullable, because there are types of mentions that don't have targets, more specifically:

  1. For UserMention, target_id is user_id
  2. For GroupMention, target_id is group_id
  3. For AllMention, target_id is null
  4. For HereMention, target_id is null

change_column_null :chat_mentions, :user_id, true
end

def down
change_column_null :chat_mentions, :user_id, false
remove_column :chat_mentions, :target_id
remove_column :chat_mentions, :type
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

class SetTypeAndTargetIdOnChatMentions < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
BATCH_SIZE = 5000

def up
begin
updated_count = DB.exec(<<~SQL, batch_size: BATCH_SIZE)
WITH cte AS (SELECT id, user_id
FROM chat_mentions
WHERE type IS NULL AND target_id IS NULL
LIMIT :batch_size)
UPDATE chat_mentions
SET type = 'Chat::UserMention', target_id = cte.user_id
FROM cte
WHERE chat_mentions.id = cte.id;
SQL
end while updated_count > 0
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this migration just sets the type for all old mentions to Chat::UserMention and doesn't try to collapse old group, here and all mentions into one mention. This is not fully correct, but it's OK logically (in the past when we were creating a mention per user every mention was kind of what we will be calling a UserMention starting from this PR). Also, the code handles such old mentions just fine.

The fully correct way of migrating the data would be to figure out which mentions were group, here and all mentions, and then to collapse such mentions into one mention per group (or per "here" or "all" appearance in the message). But such migration would be pretty difficult to implement, and I'm not sure, it's worth it. In such migration, we'd need to look at cooked messages and figure out what the types of mentions were used. Then we'd need to be aware that say groups may have changed list of members at the moment the migrations is run, that messages may have been edited and to take care of other stuff like that. Also, note that we have a code that handles messages that contain several mentions of different types. That code looks at mentions one after another and makes sure not to create several mentions for one user (for example, one user may be reached both with a direct and a group mention posted in the same message). So we'd need to handle that case too. I expect, even if we implement it, the migration likely won't be very reliable.

At the same time, It seems we can get away with this simple migration instead.

We still may decide to fully migrate old mentions correctly, but even if we decide so, it's better to do that in a follow-up.


def down
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

class AddAndRemoveIndexesOnChatMentions < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def up
remove_index :chat_mentions,
name: :chat_mentions_index,
algorithm: :concurrently,
if_exists: true
add_index :chat_mentions, %i[chat_message_id], algorithm: :concurrently
add_index :chat_mentions, %i[target_id], algorithm: :concurrently
end

def down
remove_index :chat_mentions, %i[target_id], algorithm: :concurrently, if_exists: true
remove_index :chat_mentions, %i[chat_message_id], algorithm: :concurrently, if_exists: true
add_index :chat_mentions,
%i[chat_message_id user_id notification_id],
unique: true,
name: "chat_mentions_index",
algorithm: :concurrently
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

class SetTypeAndTargetIdOnChatMentionsPostMigrate < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
BATCH_SIZE = 5000

def up
# we're setting it again in post-migration
# in case some mentions have been created after we run
# this query the first time in the regular migration
begin
updated_count = DB.exec(<<~SQL, batch_size: BATCH_SIZE)
WITH cte AS (SELECT id, user_id
FROM chat_mentions
WHERE type IS NULL AND target_id IS NULL
LIMIT :batch_size)
UPDATE chat_mentions
SET type = 'Chat::UserMention', target_id = cte.user_id
FROM cte
WHERE chat_mentions.id = cte.id;
SQL
end while updated_count > 0
end

def down
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class MakeTypeOnChatMentionsNonNullable < ActiveRecord::Migration[7.0]
def up
change_column_null :chat_mentions, :type, false
end

def down
change_column_null :chat_mentions, :type, true
end
end
11 changes: 11 additions & 0 deletions plugins/chat/lib/chat/group_extension.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Chat
module GroupExtension
extend ActiveSupport::Concern

prepended do
has_many :chat_mentions, class_name: "Chat::GroupMention", foreign_key: "target_id"
end
end
end
6 changes: 5 additions & 1 deletion plugins/chat/lib/chat/mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,17 @@ def self.users_with_unprocessed_unread_mentions
.joins("INNER JOIN chat_channels cc ON cc.id = uccm.chat_channel_id")
.joins("INNER JOIN chat_messages c_msg ON c_msg.chat_channel_id = uccm.chat_channel_id")
.joins("LEFT OUTER JOIN chat_mentions c_mentions ON c_mentions.chat_message_id = c_msg.id")
.joins(
"LEFT OUTER JOIN chat_mention_notifications cmn ON cmn.chat_mention_id = c_mentions.id",
)
.joins("LEFT OUTER JOIN notifications n ON cmn.notification_id = n.id")
.where("c_msg.deleted_at IS NULL AND c_msg.user_id <> users.id")
.where("c_msg.created_at > ?", 1.week.ago)
.where(<<~SQL)
(uccm.last_read_message_id IS NULL OR c_msg.id > uccm.last_read_message_id) AND
(uccm.last_unread_mention_when_emailed_id IS NULL OR c_msg.id > uccm.last_unread_mention_when_emailed_id) AND
(
(uccm.user_id = c_mentions.user_id AND uccm.following IS true AND cc.chatable_type = 'Category') OR
(uccm.user_id = n.user_id AND uccm.following IS true AND cc.chatable_type = 'Category') OR
(cc.chatable_type = 'DirectMessage')
)
SQL
Expand Down