After #19666, we send notifications asynchronously. However, this has dramatically delayed the different post-send warnings we send. This change moves all the post-send warnings logic from `Chat::ChatNotifier` into a separate service, allowing us to run this part synchronously, displaying the warnings almost instantly without doing all the heavy lifting for sending notifications. It also simplifies the notifier, making another refactor we planned simpler. Finally, this removes the need to run jobs on the system specs that test the post-warning logic, hopefully making them faster.
293 lines
9.5 KiB
Ruby
293 lines
9.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
##
|
|
# When we are attempting to notify users based on a message we have to take
|
|
# into account the following:
|
|
#
|
|
# * Individual user mentions like @alfred
|
|
# * Group mentions that include N users such as @support
|
|
# * Global @here and @all mentions
|
|
# * Users watching the channel via UserChatChannelMembership
|
|
#
|
|
# For various reasons a mention may not notify a user:
|
|
#
|
|
# * The target user of the mention is ignoring or muting the user who created the message
|
|
# * In the case of global @here and @all mentions users with the preference
|
|
# `ignore_channel_wide_mention` set to true will not be notified
|
|
#
|
|
# The ignore/mute filtering is also applied via the ChatNotifyWatching job,
|
|
# which prevents desktop / push notifications being sent.
|
|
class Chat::ChatNotifier
|
|
class << self
|
|
def user_has_seen_message?(membership, chat_message_id)
|
|
(membership.last_read_message_id || 0) >= chat_message_id
|
|
end
|
|
|
|
def push_notification_tag(type, chat_channel_id)
|
|
"#{Discourse.current_hostname}-chat-#{type}-#{chat_channel_id}"
|
|
end
|
|
|
|
def notify_edit(chat_message:, timestamp:)
|
|
Jobs.enqueue(
|
|
:send_message_notifications,
|
|
chat_message_id: chat_message.id,
|
|
timestamp: timestamp.iso8601(6),
|
|
reason: :edit,
|
|
)
|
|
end
|
|
|
|
def notify_new(chat_message:, timestamp:)
|
|
Jobs.enqueue(
|
|
:send_message_notifications,
|
|
chat_message_id: chat_message.id,
|
|
timestamp: timestamp.iso8601(6),
|
|
reason: :new,
|
|
)
|
|
end
|
|
end
|
|
|
|
def initialize(chat_message, timestamp)
|
|
@chat_message = chat_message
|
|
@timestamp = timestamp
|
|
@chat_channel = @chat_message.chat_channel
|
|
@user = @chat_message.user
|
|
end
|
|
|
|
### Public API
|
|
|
|
def notify_new
|
|
to_notify = list_users_to_notify
|
|
mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids]
|
|
|
|
mentioned_user_ids.each do |member_id|
|
|
ChatPublisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id)
|
|
end
|
|
|
|
notify_mentioned_users(to_notify)
|
|
notify_watching_users(except: mentioned_user_ids << @user.id)
|
|
|
|
to_notify
|
|
end
|
|
|
|
def notify_edit
|
|
existing_notifications =
|
|
ChatMention.includes(:user, :notification).where(chat_message: @chat_message)
|
|
already_notified_user_ids = existing_notifications.map(&:user_id)
|
|
|
|
to_notify = list_users_to_notify
|
|
mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids]
|
|
|
|
needs_deletion = already_notified_user_ids - mentioned_user_ids
|
|
needs_deletion.each do |user_id|
|
|
chat_mention = existing_notifications.detect { |n| n.user_id == user_id }
|
|
chat_mention.notification.destroy!
|
|
chat_mention.destroy!
|
|
end
|
|
|
|
needs_notification_ids = mentioned_user_ids - already_notified_user_ids
|
|
return if needs_notification_ids.blank?
|
|
|
|
notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids)
|
|
|
|
to_notify
|
|
end
|
|
|
|
private
|
|
|
|
def list_users_to_notify
|
|
direct_mentions_count = direct_mentions_from_cooked.length
|
|
group_mentions_count = group_name_mentions.length
|
|
|
|
skip_notifications =
|
|
(direct_mentions_count + group_mentions_count) > SiteSetting.max_mentions_per_chat_message
|
|
|
|
{}.tap do |to_notify|
|
|
# The order of these methods is the precedence
|
|
# between different mention types.
|
|
|
|
already_covered_ids = []
|
|
|
|
expand_direct_mentions(to_notify, already_covered_ids, skip_notifications)
|
|
expand_group_mentions(to_notify, already_covered_ids, skip_notifications)
|
|
expand_here_mention(to_notify, already_covered_ids, skip_notifications)
|
|
expand_global_mention(to_notify, already_covered_ids, skip_notifications)
|
|
|
|
filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids)
|
|
|
|
to_notify[:all_mentioned_user_ids] = already_covered_ids
|
|
end
|
|
end
|
|
|
|
def chat_users
|
|
User
|
|
.includes(:user_chat_channel_memberships, :group_users)
|
|
.distinct
|
|
.joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id")
|
|
.joins(:user_option)
|
|
.real
|
|
.not_suspended
|
|
.where(user_options: { chat_enabled: true })
|
|
.where.not(username_lower: @user.username.downcase)
|
|
end
|
|
|
|
def rest_of_the_channel
|
|
chat_users.where(
|
|
user_chat_channel_memberships: {
|
|
following: true,
|
|
chat_channel_id: @chat_channel.id,
|
|
},
|
|
)
|
|
end
|
|
|
|
def members_accepting_channel_wide_notifications
|
|
rest_of_the_channel.where(user_options: { ignore_channel_wide_mention: [false, nil] })
|
|
end
|
|
|
|
def direct_mentions_from_cooked
|
|
@direct_mentions_from_cooked ||=
|
|
Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention").map(&:text)
|
|
end
|
|
|
|
def normalized_mentions(mentions)
|
|
mentions.reduce([]) do |memo, mention|
|
|
%w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase)
|
|
end
|
|
end
|
|
|
|
def expand_global_mention(to_notify, already_covered_ids, skip)
|
|
typed_global_mention = direct_mentions_from_cooked.include?("@all")
|
|
|
|
if typed_global_mention && @chat_channel.allow_channel_wide_mentions && !skip
|
|
to_notify[:global_mentions] = members_accepting_channel_wide_notifications
|
|
.where.not(username_lower: normalized_mentions(direct_mentions_from_cooked))
|
|
.where.not(id: already_covered_ids)
|
|
.pluck(:id)
|
|
|
|
already_covered_ids.concat(to_notify[:global_mentions])
|
|
else
|
|
to_notify[:global_mentions] = []
|
|
end
|
|
end
|
|
|
|
def expand_here_mention(to_notify, already_covered_ids, skip)
|
|
typed_here_mention = direct_mentions_from_cooked.include?("@here")
|
|
|
|
if typed_here_mention && @chat_channel.allow_channel_wide_mentions && !skip
|
|
to_notify[:here_mentions] = members_accepting_channel_wide_notifications
|
|
.where("last_seen_at > ?", 5.minutes.ago)
|
|
.where.not(username_lower: normalized_mentions(direct_mentions_from_cooked))
|
|
.where.not(id: already_covered_ids)
|
|
.pluck(:id)
|
|
|
|
already_covered_ids.concat(to_notify[:here_mentions])
|
|
else
|
|
to_notify[:here_mentions] = []
|
|
end
|
|
end
|
|
|
|
def select_channel_participants(users)
|
|
users.select do |user|
|
|
guardian = Guardian.new(user)
|
|
guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) &&
|
|
user.user_chat_channel_memberships.any? do |m|
|
|
predicate = m.chat_channel_id == @chat_channel.id
|
|
predicate = predicate && m.following == true if @chat_channel.public_channel?
|
|
predicate
|
|
end
|
|
end
|
|
end
|
|
|
|
def expand_direct_mentions(to_notify, already_covered_ids, skip)
|
|
if skip
|
|
direct_mentions = []
|
|
else
|
|
direct_mentions =
|
|
chat_users
|
|
.where(username_lower: normalized_mentions(direct_mentions_from_cooked))
|
|
.where.not(id: already_covered_ids)
|
|
end
|
|
|
|
to_notify[:direct_mentions] = select_channel_participants(direct_mentions).map(&:id)
|
|
already_covered_ids.concat(to_notify[:direct_mentions])
|
|
end
|
|
|
|
def group_name_mentions
|
|
@group_mentions_from_cooked ||=
|
|
normalized_mentions(
|
|
Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention-group").map(&:text),
|
|
)
|
|
end
|
|
|
|
def visible_groups
|
|
@visible_groups ||= Group.where("LOWER(name) IN (?)", group_name_mentions).visible_groups(@user)
|
|
end
|
|
|
|
def expand_group_mentions(to_notify, already_covered_ids, skip)
|
|
return [] if skip || visible_groups.empty?
|
|
|
|
mentionable_groups =
|
|
Group.mentionable(@user, include_public: false).where(id: visible_groups.map(&:id))
|
|
|
|
mentionable =
|
|
mentionable_groups.reject do |group|
|
|
group.user_count > SiteSetting.max_users_notified_per_group_mention
|
|
end
|
|
|
|
mentionable.each { |g| to_notify[g.name.downcase] = [] }
|
|
|
|
reached_by_group =
|
|
chat_users
|
|
.includes(:groups)
|
|
.joins(:groups)
|
|
.where(groups: mentionable)
|
|
.where.not(id: already_covered_ids)
|
|
|
|
select_channel_participants(reached_by_group).each do |user|
|
|
# When a user is a member of multiple mentioned groups,
|
|
# the most far to the left should take precedence.
|
|
ordered_group_names = group_name_mentions & mentionable.map { |mg| mg.name.downcase }
|
|
user_group_names = user.groups.map { |ug| ug.name.downcase }
|
|
group_name = ordered_group_names.detect { |gn| user_group_names.include?(gn) }
|
|
|
|
to_notify[group_name] << user.id
|
|
already_covered_ids << user.id
|
|
end
|
|
end
|
|
|
|
# Filters out users from global, here, group, and direct mentions that are
|
|
# ignoring or muting the creator of the message, so they will not receive
|
|
# a notification via the ChatNotifyMentioned job and are not prompted for
|
|
# invitation by the creator.
|
|
def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids)
|
|
return if already_covered_ids.blank?
|
|
|
|
screener = UserCommScreener.new(acting_user: @user, target_user_ids: already_covered_ids)
|
|
to_notify.each do |key, user_ids|
|
|
to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) }
|
|
end
|
|
|
|
already_covered_ids.reject! do |already_covered|
|
|
screener.ignoring_or_muting_actor?(already_covered)
|
|
end
|
|
end
|
|
|
|
def notify_mentioned_users(to_notify, already_notified_user_ids: [])
|
|
Jobs.enqueue(
|
|
:chat_notify_mentioned,
|
|
{
|
|
chat_message_id: @chat_message.id,
|
|
to_notify_ids_map: to_notify.as_json,
|
|
already_notified_user_ids: already_notified_user_ids,
|
|
timestamp: @timestamp,
|
|
},
|
|
)
|
|
end
|
|
|
|
def notify_watching_users(except: [])
|
|
Jobs.enqueue(
|
|
:chat_notify_watching,
|
|
{ chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp },
|
|
)
|
|
end
|
|
end
|