PERF: fix performance of chat email notifications

When chat is enabled, there's a scheduled job that runs every 5 minutes to check whether we need to send a "chat summary" email to users with unread chat messages or mentions.

On Discourse with a large number of users, the query used wasn't optimal and sometimes taking minutes. Which isn't good when the query is called every 5 minutes 😬

This PR reworks the query in `Chat::Mailer.send_unread_mentions_summary`.

Instead of starting from the `users` table, it starts from the `user_chat_channel_memberships` table which is the main piece tying everything together.

The new query is mostly similar to the previous one, with some bug fixes (like ensuring the user has `allow_private_messages` enabled for direct messages) and is also slightly simpler since it doesn't keep track of the `memberships_with_unread_messages` anymore. That part has been moved to the `user_notifications.chat_summary` email method.

The `UserEmailExtension` has been deleted since that was using to N+1 update the `user_chat_channel_memberships.last_unread_mention_when_emailed_it`(quite a mouthful 😛) but that's now done directly in the `user_notifications.chat_summary` email method.

The "plat de résistance" of that PR - the `user_notifications.chat_summary` method has been re-worked for improved performances 🚀

Instead of doing everything in one query, it does 4 tiny ones.

- One to retrieve the list of unread mentions (@something) in "category" channels
- One to retrieve the list of unread messages in "direct message" channels (aka. 1-1 and group discussions)
- One to load all the chat messages for each "category" channels from the last unread mention
- One to load all the chat messages for each "direct message" channels from the last unread message

All the specs for both `Chat::Mailer` and `UserNotification.chat_summary` have been rewriten for easier comprehension and faster execution (mostly by not using chat services which makes the specs go 10x slower...)

Internal ref - t/129848
This commit is contained in:
Régis Hanol 2024-06-08 00:20:37 +02:00
parent 49aac85057
commit 71391cd40d
12 changed files with 913 additions and 1234 deletions

View File

@ -21,7 +21,7 @@ module Chat
end
def generate_auto_slug
false if !self.slug.present?
self.slug.blank?
end
end
end

View File

@ -4,7 +4,7 @@ module Chat
class MentionNotification < ActiveRecord::Base
self.table_name = "chat_mention_notifications"
belongs_to :chat_mention
belongs_to :chat_mention, class_name: "Chat::Mention"
belongs_to :notification, dependent: :destroy
end
end

View File

@ -2,7 +2,6 @@
<table class="chat-summary-header text-header with-dir" style="background-color:#<%= @header_bgcolor -%>;width:100%;min-width:100%;">
<tr>
<td align="center" style="text-align: center;padding: 20px 0; font-family:Arial,sans-serif;">
<a href="<%= Discourse.base_url %>" style="color:#<%= @header_color -%>;font-size:22px;text-decoration:none;">
<%- if logo_url.blank? %>
<%= SiteSetting.title %>
@ -10,73 +9,68 @@
<img src="<%= logo_url %>" height="40" style="clear:both;display:block;height:40px;margin:auto;max-width:100%;outline:0;text-decoration:none;" alt="<%= SiteSetting.title %>">
<%- end %>
</a>
</td>
</tr>
<tr>
<td align="center" style="font-weight:bold;font-size:22px;color:#<%= @header_color -%>">
<%= I18n.t("user_notifications.chat_summary.description", count: @messages.size) %>
<%= I18n.t("user_notifications.chat_summary.description", count: @count) %>
</td>
</tr>
</table>
<%- @grouped_messages.each do |chat_channel, messages| %>
<%- other_messages_count = messages.size - 2 %>
<table class="chat-summary-content" style="padding:1em;margin-top:20px;width:100%;min-width:100%;background-color:#f7f7f7;">
<tbody>
<tr>
<td colspan="100%">
<h5 style="margin:0.5em 0 0.5em 0;font-size:0.9em;">
<%- if SiteSetting.private_email %>
<%= I18n.t("system_messages.private_channel_title", id: chat_channel.id) %>
<%- else %>
<%= chat_channel.title(@user) %>
<%- end %>
</h5>
</td>
</tr>
<%- unless SiteSetting.private_email %>
<%- messages.take(2).each do |chat_message| %>
<%- sender = chat_message.user %>
<%- sender_name = @display_usernames ? sender.username : sender.name %>
<tr class="message-row">
<td style="white-space:nowrap;vertical-align:top;padding:<%= rtl? ? '1em 2em 0 0' : '1em 0 0 2em' %>">
<img src="<%= sender.small_avatar_url -%>" style="height:20px;width:20px;margin:<%= rtl? ? '0 0 5px 0' : '0 5px 0 0' %>;border-radius:50%;clear:both;display:inline-block;outline:0;text-decoration:none;vertical-align:top;" alt="<%= sender_name -%>">
<span style="display:inline-block;color:#0a0a0a;vertical-align:top;font-weight:bold;">
<%= sender_name -%>
</span>
<span style="display:inline-block;color:#0a0a0a;font-size:0.8em;">
<%= I18n.l(@user_tz.to_local(chat_message.created_at), format: :long) -%>
</span>
</td>
</tr>
<tr>
<td style="width:99%;margin:0;padding:<%= rtl? ? '0 2em 0 0' : '0 0 0 2em' %>;vertical-align:top;">
<%= email_excerpt(chat_message.cooked_for_excerpt) %>
</td>
</tr>
<%- end %>
<%- end %>
<tr>
<td colspan="100%" style="padding:<%= rtl? ? '2em 2em 0 0' : '2em 0 0 2em' %>">
<a class="more-messages-link" href="<%= messages.first.full_url %>">
<%- if SiteSetting.private_email %>
<%= I18n.t("user_notifications.chat_summary.view_messages", count: messages.size)%>
<%- else %>
<%- if other_messages_count <= 0 %>
<%= I18n.t("user_notifications.chat_summary.view_messages", count: messages.size)%>
<%- [@grouped_channels, @grouped_dms].each do |grouped_messages| %>
<%- grouped_messages.each do |channel, messages| %>
<%- other_messages_count = messages.size - 2 %>
<table class="chat-summary-content" style="padding:1em;margin-top:20px;width:100%;min-width:100%;background-color:#f7f7f7;">
<tbody>
<tr>
<td colspan="100%">
<h5 style="margin:0.5em 0 0.5em 0;font-size:0.9em;">
<%- if SiteSetting.private_email %>
<%= I18n.t("system_messages.private_channel_title", id: channel.id) %>
<%- else %>
<%= I18n.t("user_notifications.chat_summary.view_more", count: other_messages_count)%>
<%= channel.title(@user) %>
<%- end %>
<%- end %>
</a>
</td>
</tr>
</tbody>
</table>
</h5>
</td>
</tr>
<%- unless SiteSetting.private_email %>
<%- messages.take(2).each do |chat_message| %>
<%- sender = chat_message.user %>
<tr class="message-row">
<td style="white-space:nowrap;vertical-align:top;padding:<%= rtl? ? '1em 2em 0 0' : '1em 0 0 2em' %>">
<img src="<%= sender.small_avatar_url -%>" style="height:20px;width:20px;margin:<%= rtl? ? '0 0 5px 0' : '0 5px 0 0' %>;border-radius:50%;clear:both;display:inline-block;outline:0;text-decoration:none;vertical-align:top;" alt="<%= sender.display_name -%>">
<span style="display:inline-block;color:#0a0a0a;vertical-align:top;font-weight:bold;">
<%= sender.display_name -%>
</span>
<span style="display:inline-block;color:#0a0a0a;font-size:0.8em;">
<%= I18n.l(@user_tz.to_local(chat_message.created_at), format: :long) -%>
</span>
</td>
</tr>
<tr>
<td style="width:99%;margin:0;padding:<%= rtl? ? '0 2em 0 0' : '0 0 0 2em' %>;vertical-align:top;">
<%= email_excerpt(chat_message.cooked_for_excerpt) %>
</td>
</tr>
<%- end %>
<%- end %>
<tr>
<td colspan="100%" style="padding:<%= rtl? ? '2em 2em 0 0' : '2em 0 0 2em' %>">
<a class="more-messages-link" href="<%= messages.first.full_url %>">
<%- if SiteSetting.private_email || other_messages_count <= 0 %>
<%= I18n.t("user_notifications.chat_summary.view_messages", count: messages.size) %>
<%- else %>
<%= I18n.t("user_notifications.chat_summary.view_more", count: other_messages_count) %>
<%- end %>
</a>
</td>
</tr>
</tbody>
</table>
<%- end %>
<%- end %>
</div>

View File

@ -1,6 +1,6 @@
<%- site_link = raw(@markdown_linker.create(@site_name, '/')) %>
<%= t('user_notifications.chat_summary.description', count: @messages.size,) %>
<%= raw(@markdown_linker.create(t("user_notifications.chat_summary.view_messages", count: @messages.size), "/chat")) %>
<%= t('user_notifications.chat_summary.description', count: @count,) %>
<%= raw(@markdown_linker.create(t("user_notifications.chat_summary.view_messages", count: @count), "/chat")) %>
<%- if @unsubscribe_link %>
<%= raw(t :'user_notifications.chat_summary.unsubscribe',
site_link: site_link,
@ -11,5 +11,4 @@
site_link: site_link,
email_preferences_link: @markdown_linker.create(t('user_notifications.chat_summary.your_chat_settings'), @preferences_path)) %>
<%- end %>
<%= raw(@markdown_linker.references) %>
<%= raw(@markdown_linker.references) %>

View File

@ -240,18 +240,20 @@ en:
other: "You have new chat messages"
from: "%{site_name}"
subject:
private_message: "[%{email_prefix}] New message"
direct_message_from_1: "[%{email_prefix}] New message from %{username}"
direct_message_from_2: "[%{email_prefix}] New message from %{username1} and %{username2}"
direct_message_from_more:
one: "[%{email_prefix}] New message from %{username} and %{count} other"
other: "[%{email_prefix}] New message from %{username} and %{count} others"
chat_channel_1: "[%{email_prefix}] New message in %{channel}"
chat_channel_2: "[%{email_prefix}] New message in %{channel1} and %{channel2}"
chat_channel_more:
one: "[%{email_prefix}] New message in %{channel} and %{count} other"
other: "[%{email_prefix}] New message in %{channel} and %{count} others"
chat_channel_and_direct_message: "[%{email_prefix}] New message in %{channel} and from %{username}"
private_email:
one: "[%{site_name}] New message"
other: "[%{site_name}] New messages"
chat_dm_1:
one: "[%{site_name}] New message from %{name}"
other: "[%{site_name}] New messages from %{name}"
chat_dm_2: "[%{site_name}] New messages from %{name_1} and %{name_2}"
chat_dm_3_or_more: "[%{site_name}] New messages from %{name} and %{count} others"
chat_channel_1:
one: "[%{site_name}] New message in %{channel}"
other: "[%{site_name}] New messages in %{channel}"
chat_channel_2: "[%{site_name}] New messages in %{channel_1} and %{channel_2}"
chat_channel_3_or_more: "[%{site_name}] New messages in %{channel} and %{count} others"
chat_channel_and_dm: "[%{site_name}] New messages in %{channel} and from %{name}"
unsubscribe: "This chat summary is sent from %{site_link} when you are away. Change your %{email_preferences_link}, or %{unsubscribe_link} to unsubscribe."
unsubscribe_no_link: "This chat summary is sent from %{site_link} when you are away. Change your %{email_preferences_link}."
view_messages:

View File

@ -5,73 +5,63 @@ module Chat
def self.send_unread_mentions_summary
return unless SiteSetting.chat_enabled
users_with_unprocessed_unread_mentions.find_each do |user|
# Apply modifier to `true` -- this allows other plugins to block the chat summary email send
if !DiscoursePluginRegistry.apply_modifier(:chat_mailer_send_summary_to_user, true, user)
next
User
.real
.activated
.not_staged
.not_suspended
.where(id: users_with_unreads)
.find_each do |user|
if DiscoursePluginRegistry.apply_modifier(:chat_mailer_send_summary_to_user, true, user)
Jobs.enqueue(
:user_email,
type: :chat_summary,
user_id: user.id,
force_respect_seen_recently: true,
)
end
end
# user#memberships_with_unread_messages is a nested array that looks like [[membership_id, unread_message_id]]
# Find the max unread id per membership.
membership_and_max_unread_mention_ids =
user
.memberships_with_unread_messages
.group_by { |memberships| memberships[0] }
.transform_values do |membership_and_msg_ids|
membership_and_msg_ids.max_by { |membership, msg| msg }
end
.values
Jobs.enqueue(
:user_email,
type: "chat_summary",
user_id: user.id,
force_respect_seen_recently: true,
memberships_to_update_data: membership_and_max_unread_mention_ids,
)
end
end
private
def self.users_with_unprocessed_unread_mentions
when_away_frequency = UserOption.chat_email_frequencies[:when_away]
allowed_group_ids = Chat.allowed_group_ids
def self.users_with_unreads
groups_join_sql =
if Chat.allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone])
""
else
"JOIN group_users ON group_users.user_id = users.id AND group_users.group_id IN (#{Chat.allowed_group_ids.join(",")})"
end
users =
User
.joins(:user_option)
.where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency })
.where("users.last_seen_at < ?", 15.minutes.ago)
if !allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone])
users = users.joins(:groups).where(groups: { id: allowed_group_ids })
end
users
.select(
"users.id",
"ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages",
)
.joins("INNER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id")
.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 = n.user_id AND uccm.following IS true AND cc.chatable_type = 'Category') OR
(cc.chatable_type = 'DirectMessage')
DB.query_single <<~SQL
SELECT uccm.user_id
FROM user_chat_channel_memberships uccm
JOIN users ON users.id = uccm.user_id
JOIN user_options ON user_options.user_id = users.id
#{groups_join_sql}
JOIN chat_channels ON chat_channels.id = uccm.chat_channel_id
JOIN chat_messages ON chat_messages.chat_channel_id = chat_channels.id
JOIN users sender ON sender.id = chat_messages.user_id
LEFT JOIN chat_mentions ON chat_mentions.chat_message_id = chat_messages.id
LEFT JOIN chat_mention_notifications cmn ON cmn.chat_mention_id = chat_mentions.id
LEFT JOIN notifications ON notifications.id = cmn.notification_id AND notifications.user_id = uccm.user_id
WHERE NOT uccm.muted
AND (uccm.last_read_message_id IS NULL OR uccm.last_read_message_id < chat_messages.id)
AND (uccm.last_unread_mention_when_emailed_id IS NULL OR uccm.last_unread_mention_when_emailed_id < chat_messages.id)
AND users.last_seen_at < now() - interval '15 minutes'
AND user_options.chat_enabled
AND user_options.chat_email_frequency = #{UserOption.chat_email_frequencies[:when_away]}
AND user_options.email_level <> #{UserOption.email_level_types[:never]}
AND chat_channels.deleted_at IS NULL
AND chat_messages.deleted_at IS NULL
AND chat_messages.created_at > now() - interval '1 week'
AND chat_messages.user_id <> users.id
AND (
(chat_channels.chatable_type = 'DirectMessage' AND user_options.allow_private_messages) OR
(chat_channels.chatable_type = 'Category' AND uccm.following AND NOT notifications.read)
)
GROUP BY uccm.user_id
SQL
.group("users.id, uccm.user_id")
end
end
end

View File

@ -1,18 +0,0 @@
# frozen_string_literal: true
module Chat
module UserEmailExtension
def execute(args)
super(args)
if args[:type] == "chat_summary" && args[:memberships_to_update_data].present?
args[:memberships_to_update_data].to_a.each do |membership_id, max_unread_mention_id|
Chat::UserChatChannelMembership.find_by(
user: args[:user_id],
id: membership_id.to_i,
)&.update(last_unread_mention_when_emailed_id: max_unread_mention_id.to_i)
end
end
end
end
end

View File

@ -6,137 +6,163 @@ module Chat
guardian = Guardian.new(user)
return unless guardian.can_chat?
@messages =
Chat::Message
.joins(:user, :chat_channel)
.where.not(user: user)
.where("chat_messages.created_at > ?", 1.week.ago)
.joins("LEFT OUTER JOIN chat_mentions cm ON cm.chat_message_id = chat_messages.id")
.joins("LEFT OUTER JOIN chat_mention_notifications cmn ON cmn.chat_mention_id = cm.id")
.joins("LEFT OUTER JOIN notifications n ON cmn.notification_id = n.id")
.joins(
"INNER JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = chat_channels.id",
)
.where(<<~SQL, user_id: user.id)
uccm.user_id = :user_id AND
(uccm.last_read_message_id IS NULL OR chat_messages.id > uccm.last_read_message_id) AND
(uccm.last_unread_mention_when_emailed_id IS NULL OR chat_messages.id > uccm.last_unread_mention_when_emailed_id) AND
(
(n.user_id = :user_id AND cmn.notification_id IS NOT NULL AND uccm.following IS true AND chat_channels.chatable_type = 'Category') OR
(chat_channels.chatable_type = 'DirectMessage')
)
SQL
.to_a
# TODO: handle muted & silenced users ?
return if @messages.empty?
@grouped_messages = @messages.group_by { |message| message.chat_channel }
@grouped_messages =
@grouped_messages.select { |channel, _| guardian.can_join_chat_channel?(channel) }
return if @grouped_messages.empty?
# ensures these haven't since the job was enqueued
return if user.last_seen_at > 15.minutes.ago
return if user.user_option.send_chat_email_never?
return if user.user_option.email_level == UserOption.email_level_types[:never]
unread_mentions = DB.query_array <<~SQL
WITH unread_mentions AS (
SELECT uccm.id membership_id, uccm.chat_channel_id, MIN(chat_messages.id) first_chat_message_id, MAX(chat_messages.id) last_chat_message_id
FROM user_chat_channel_memberships uccm
JOIN chat_channels ON chat_channels.id = uccm.chat_channel_id
JOIN chat_messages ON chat_messages.chat_channel_id = chat_channels.id
JOIN chat_mentions ON chat_mentions.chat_message_id = chat_messages.id
JOIN chat_mention_notifications cmn ON cmn.chat_mention_id = chat_mentions.id
JOIN notifications ON notifications.id = cmn.notification_id
JOIN users ON users.id = chat_messages.user_id
WHERE uccm.user_id = #{user.id}
AND NOT uccm.muted
AND uccm.following
AND chat_channels.deleted_at IS NULL
AND chat_channels.chatable_type = 'Category'
AND chat_messages.deleted_at IS NULL
AND chat_messages.user_id != uccm.user_id
AND chat_messages.created_at > now() - interval '1 week'
AND (uccm.last_read_message_id IS NULL OR uccm.last_read_message_id < chat_messages.id)
AND (uccm.last_unread_mention_when_emailed_id IS NULL OR uccm.last_unread_mention_when_emailed_id < chat_messages.id)
AND NOT notifications.read
GROUP BY uccm.id
)
UPDATE user_chat_channel_memberships uccm
SET last_unread_mention_when_emailed_id = um.last_chat_message_id
FROM unread_mentions um
WHERE uccm.id = um.membership_id
AND uccm.user_id = #{user.id}
RETURNING um.membership_id, um.chat_channel_id, um.first_chat_message_id
SQL
unread_messages = DB.query_array <<~SQL
WITH unread_messages AS (
SELECT uccm.id membership_id, uccm.chat_channel_id, MIN(chat_messages.id) first_chat_message_id, MAX(chat_messages.id) last_chat_message_id
FROM user_chat_channel_memberships uccm
JOIN chat_channels ON chat_channels.id = uccm.chat_channel_id
JOIN chat_messages ON chat_messages.chat_channel_id = chat_channels.id
JOIN users ON users.id = chat_messages.user_id
WHERE uccm.user_id = #{user.id}
AND NOT uccm.muted
AND chat_channels.deleted_at IS NULL
AND chat_channels.chatable_type = 'DirectMessage'
AND chat_messages.deleted_at IS NULL
AND chat_messages.user_id != uccm.user_id
AND chat_messages.created_at > now() - interval '1 week'
AND (uccm.last_read_message_id IS NULL OR uccm.last_read_message_id < chat_messages.id)
AND (uccm.last_unread_mention_when_emailed_id IS NULL OR uccm.last_unread_mention_when_emailed_id < chat_messages.id)
GROUP BY uccm.id
)
UPDATE user_chat_channel_memberships uccm
SET last_unread_mention_when_emailed_id = um.last_chat_message_id
FROM unread_messages um
WHERE uccm.id = um.membership_id
AND uccm.user_id = #{user.id}
RETURNING um.membership_id, um.chat_channel_id, um.first_chat_message_id
SQL
@grouped_channels = chat_messages_for(unread_mentions, guardian)
@grouped_dms =
user.user_option.allow_private_messages ? chat_messages_for(unread_messages, guardian) : {}
@count = @grouped_channels.values.sum(&:size) + @grouped_dms.values.sum(&:size)
return if @count.zero?
@grouped_messages.each do |chat_channel, messages|
@grouped_messages[chat_channel] = messages.sort_by(&:created_at)
end
@user = user
@user_tz = UserOption.user_tzinfo(user.id)
@display_usernames = SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names
build_summary_for(user)
@preferences_path = "#{Discourse.base_url}/my/preferences/chat"
opts = {
from_alias: I18n.t("user_notifications.chat_summary.from", site_name: Email.site_title),
subject: summary_subject(user, @grouped_messages),
}
build_summary_for(user)
build_email(user.email, opts)
end
def summary_subject(user, grouped_messages)
if SiteSetting.private_email
return(
I18n.t(
"user_notifications.chat_summary.subject.private_message",
email_prefix: @email_prefix,
)
)
end
all_channels = grouped_messages.keys
grouped_channels = all_channels.partition { |c| !c.direct_message_channel? }
channels = grouped_channels.first
dm_messages = grouped_channels.last.flat_map { |c| grouped_messages[c] }
dm_users = dm_messages.sort_by(&:created_at).uniq { |m| m.user_id }.map(&:user)
# Prioritize messages from regular channels over direct messages
if channels.any?
channel_notification_text(
channels.sort_by { |channel| [channel.last_message.created_at, channel.created_at] },
dm_users,
)
else
direct_message_notification_text(dm_users)
end
build_email(
user.email,
from_alias: chat_summary_from_alias,
subject: chat_summary_subject(@grouped_channels, @grouped_dms, @count),
)
end
private
def channel_notification_text(channels, dm_users)
total_count = channels.size + dm_users.size
if total_count > 2
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_more",
email_prefix: @email_prefix,
channel: channels.first.title,
count: total_count - 1,
def chat_messages_for(data, guardian)
# Note: we probably want to limit the number of messages we fetch
# since we only display the first 2 per channel in the email
# I've left this as if for now because we also display the total count
# and a count of unread messages per channel
Chat::Message
.includes(:user, :chat_channel)
.where(chat_channel_id: data.map { _1[1] })
.where(
"chat_messages.id >= (
SELECT min_unread_id
FROM (VALUES #{data.map { "(#{_1[1]}, #{_1[2]})" }.join(",")}) AS t(channel_id, min_unread_id)
WHERE t.channel_id = chat_messages.chat_channel_id
)",
)
elsif channels.size == 1 && dm_users.size == 0
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_1",
email_prefix: @email_prefix,
channel: channels.first.title,
)
elsif channels.size == 1 && dm_users.size == 1
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_and_direct_message",
email_prefix: @email_prefix,
channel: channels.first.title,
username: dm_users.first.username,
)
elsif channels.size == 2
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_2",
email_prefix: @email_prefix,
channel1: channels.first.title,
channel2: channels.second.title,
)
end
.order(created_at: :asc)
.group_by(&:chat_channel)
.select { |channel, _| guardian.can_join_chat_channel?(channel) }
end
def direct_message_notification_text(dm_users)
case dm_users.size
when 1
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_1",
email_prefix: @email_prefix,
username: dm_users.first.username,
)
when 2
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_2",
email_prefix: @email_prefix,
username1: dm_users.first.username,
username2: dm_users.second.username,
)
def chat_summary_from_alias
I18n.t("user_notifications.chat_summary.from", site_name: @site_name)
end
def subject(type, **args)
I18n.t("user_notifications.chat_summary.subject.#{type}", { site_name: @site_name, **args })
end
def chat_summary_subject(grouped_channels, grouped_dms, count)
return subject(:private_email, count:) if SiteSetting.private_email
# consider "direct messages" with more than 2 users as group messages (aka. channels)
dms, groups = grouped_dms.keys.partition { _1.user_chat_channel_memberships.count == 2 }
channels = grouped_channels.keys + groups
if channels.any?
if dms.any?
subject(
:chat_channel_and_dm,
channel: channels.first.title(@user),
name: dms.first.title(@user),
)
elsif channels.size == 1
subject(
:chat_channel_1,
channel: channels.first.title(@user),
count: (grouped_channels[channels.first] || grouped_dms[channels.first]).size,
)
elsif channels.size == 2
subject(
:chat_channel_2,
channel_1: channels.first.title(@user),
channel_2: channels.second.title(@user),
)
else
subject(
:chat_channel_3_or_more,
channel: channels.first.title(@user),
count: channels.size - 1,
)
end
elsif dms.size == 1
subject(:chat_dm_1, name: dms.first.title(@user), count: grouped_dms[dms.first].size)
elsif dms.size == 2
subject(:chat_dm_2, name_1: dms.first.title(@user), name_2: dms.second.title(@user))
elsif dms.size >= 3
subject(:chat_dm_3_or_more, name: dms.first.title(@user), count: dms.size - 1)
else
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_more",
email_prefix: @email_prefix,
username: dm_users.first.username,
count: dm_users.size - 1,
)
subject(:private_email, count:)
end
end
end

View File

@ -72,7 +72,6 @@ after_initialize do
Bookmark.prepend Chat::BookmarkExtension
User.prepend Chat::UserExtension
Group.prepend Chat::GroupExtension
Jobs::UserEmail.prepend Chat::UserEmailExtension
Plugin::Instance.prepend Chat::PluginInstanceExtension
Jobs::ExportCsvFile.prepend Chat::MessagesExporter
WebHook.prepend Chat::OutgoingWebHookExtension

View File

@ -1,334 +1,282 @@
# frozen_string_literal: true
describe Chat::Mailer do
fab!(:chatters_group) { Fabricate(:group) }
fab!(:sender) { Fabricate(:user, group_ids: [chatters_group.id], refresh_auto_groups: true) }
fab!(:user_1) do
Fabricate(
:user,
group_ids: [chatters_group.id],
last_seen_at: 15.minutes.ago,
refresh_auto_groups: true,
)
end
fab!(:chat_channel) { Fabricate(:category_channel) }
fab!(:chat_message) { Fabricate(:chat_message, user: sender, chat_channel: chat_channel) }
fab!(:user_1_chat_channel_membership) do
Fabricate(
:user_chat_channel_membership,
user: user_1,
chat_channel: chat_channel,
last_read_message_id: nil,
)
end
fab!(:private_chat_channel) do
result =
Chat::CreateDirectMessageChannel.call(
guardian: sender.guardian,
target_usernames: [sender.username, user_1.username],
)
service_failed!(result) if result.failure?
result.channel
fab!(:user) { Fabricate(:user, last_seen_at: 1.hour.ago) }
fab!(:other) { Fabricate(:user) }
fab!(:group) do
Fabricate(:group, mentionable_level: Group::ALIAS_LEVELS[:everyone], users: [user, other])
end
fab!(:followed_channel) { Fabricate(:category_channel) }
fab!(:non_followed_channel) { Fabricate(:category_channel) }
fab!(:muted_channel) { Fabricate(:category_channel) }
fab!(:unseen_channel) { Fabricate(:category_channel) }
fab!(:direct_message) { Fabricate(:direct_message_channel, users: [user, other]) }
fab!(:job) { :user_email }
fab!(:args) { { type: :chat_summary, user_id: user.id, force_respect_seen_recently: true } }
before do
SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = chatters_group.id
Fabricate(:user_chat_channel_membership, user: sender, chat_channel: chat_channel)
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
end
def assert_summary_skipped
expect(
job_enqueued?(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }),
).to eq(false)
end
def assert_only_queued_once
expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_1.id })
def expect_enqueued
expect_enqueued_with(job:, args:) { described_class.send_unread_mentions_summary }
expect(Jobs::UserEmail.jobs.size).to eq(1)
end
describe "for chat mentions" do
fab!(:notification) do
Fabricate(:notification, notification_type: Notification.types[:chat_mention], user: user_1)
end
fab!(:mention) do
Fabricate(:user_chat_mention, chat_message: chat_message, notifications: [notification])
end
def expect_not_enqueued
expect_not_enqueued_with(job:, args:) { described_class.send_unread_mentions_summary }
end
it "skips users without chat access" do
chatters_group.remove(user_1)
# This helper is much faster than `Fabricate(:chat_message_with_service, ...)`
def create_message(chat_channel, message, mention_klass = nil)
chat_message = Fabricate(:chat_message, user: other, chat_channel:, message:)
described_class.send_unread_mentions_summary
if mention_klass
notification_type = Notification.types[:chat_mention]
assert_summary_skipped
end
it "skips users with summaries disabled" do
user_1.user_option.update(chat_email_frequency: UserOption.chat_email_frequencies[:never])
described_class.send_unread_mentions_summary
assert_summary_skipped
end
it "skips a job if the user haven't read the channel since the last summary" do
user_1_chat_channel_membership.update!(last_unread_mention_when_emailed_id: chat_message.id)
described_class.send_unread_mentions_summary
assert_summary_skipped
end
it "skips without chat enabled" do
user_1.user_option.update(
chat_enabled: false,
chat_email_frequency: UserOption.chat_email_frequencies[:when_away],
)
described_class.send_unread_mentions_summary
assert_summary_skipped
end
it "queues a job for users that was mentioned and never read the channel before" do
described_class.send_unread_mentions_summary
assert_only_queued_once
end
it "skips the job when the user was mentioned but already read the message" do
user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id)
described_class.send_unread_mentions_summary
assert_summary_skipped
end
it "skips the job when the user is not following a public channel anymore" do
user_1_chat_channel_membership.update!(
last_read_message_id: chat_message.id - 1,
following: false,
)
described_class.send_unread_mentions_summary
assert_summary_skipped
end
it "doesnt skip the job when the user is not following a direct channel" do
private_chat_channel
.user_chat_channel_memberships
.where(user_id: user_1.id)
.update!(last_read_message_id: chat_message.id - 1, following: false)
described_class.send_unread_mentions_summary
assert_only_queued_once
end
it "skips users with unread messages from a different channel" do
user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id)
second_channel = Fabricate(:category_channel)
Fabricate(
:user_chat_channel_membership,
user: user_1,
chat_channel: second_channel,
last_read_message_id: chat_message.id - 1,
:chat_mention_notification,
notification: Fabricate(:notification, user:, notification_type:),
chat_mention: mention_klass.find_by(chat_message:),
)
described_class.send_unread_mentions_summary
assert_summary_skipped
end
it "only queues the job once for users who are member of multiple groups with chat access" do
chatters_group_2 = Fabricate(:group, users: [user_1])
SiteSetting.chat_allowed_groups = [chatters_group, chatters_group_2].map(&:id).join("|")
chat_message
end
described_class.send_unread_mentions_summary
describe "in a followed channel" do
before { followed_channel.add(user) }
assert_only_queued_once
end
describe "user is @direct mentioned" do
let!(:chat_message) do
create_message(followed_channel, "hello @#{user.username}", Chat::UserMention)
end
it "skips users when the mention was deleted" do
chat_message.trash!
it "queues a chat summary email" do
expect_enqueued
end
described_class.send_unread_mentions_summary
it "does not queue a chat summary when chat is globally disabled" do
SiteSetting.chat_enabled = false
expect_not_enqueued
end
assert_summary_skipped
end
it "does not queue a chat summary email when user has chat disabled" do
user.user_option.update!(chat_enabled: false)
expect_not_enqueued
end
it "queues the job if the user has unread mentions and already read all the messages in the previous summary" do
user_1_chat_channel_membership.update!(
last_read_message_id: chat_message.id,
last_unread_mention_when_emailed_id: chat_message.id,
)
unread_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender)
notification_2 =
Fabricate(:notification, notification_type: Notification.types[:chat_mention], user: user_1)
Fabricate(
:user_chat_mention,
user: user_1,
chat_message: unread_message,
notifications: [notification_2],
)
it "does not queue a chat summary email when user has chat email frequency = never" do
user.user_option.update!(chat_email_frequency: UserOption.chat_email_frequencies[:never])
expect_not_enqueued
end
described_class.send_unread_mentions_summary
it "does not queue a chat summary email when user has email level = never" do
user.user_option.update!(email_level: UserOption.email_level_types[:never])
expect_not_enqueued
end
expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_1.id })
expect(Jobs::UserEmail.jobs.size).to eq(1)
end
it "does not queue a chat summary email when chat message has been deleted" do
chat_message.trash!
expect_not_enqueued
end
it "skips users who were seen recently" do
user_1.update!(last_seen_at: 2.minutes.ago)
it "does not queue a chat summary email when chat message is older than 1 week" do
chat_message.update!(created_at: 2.weeks.ago)
expect_not_enqueued
end
described_class.send_unread_mentions_summary
it "does not queue a chat summary email when chat channel has been deleted" do
followed_channel.trash!
expect_not_enqueued
end
assert_summary_skipped
end
it "does not queue a chat summary email when user is not part of chat allowed groups" do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:admins]
expect_not_enqueued
end
it "doesn't mix mentions from other users" do
mention.destroy!
user_2 = Fabricate(:user, groups: [chatters_group], last_seen_at: 20.minutes.ago)
Fabricate(
:user_chat_channel_membership,
user: user_2,
chat_channel: chat_channel,
last_read_message_id: nil,
)
new_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender)
notification_2 =
Fabricate(:notification, notification_type: Notification.types[:chat_mention], user: user_2)
Fabricate(
:user_chat_mention,
user: user_2,
chat_message: new_message,
notifications: [notification_2],
)
it "does not queue a chat summary email when user has read the mention notification" do
Notification.find_by(
user: user,
notification_type: Notification.types[:chat_mention],
).update!(read: true)
described_class.send_unread_mentions_summary
expect_not_enqueued
end
expect(
job_enqueued?(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }),
).to eq(false)
expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_2.id })
expect(Jobs::UserEmail.jobs.size).to eq(1)
end
it "does not queue a chat summary email when user has been seen in the past 15 minutes" do
user.update!(last_seen_at: 5.minutes.ago)
expect_not_enqueued
end
it "skips users when the message is older than 1 week" do
chat_message.update!(created_at: 1.5.weeks.ago)
it "does not queue a chat summary email when user has read the message" do
followed_channel.membership_for(user).update!(last_read_message_id: chat_message.id)
expect_not_enqueued
end
described_class.send_unread_mentions_summary
assert_summary_skipped
end
it "queues a job when the chat_allowed_groups is set to everyone" do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
described_class.send_unread_mentions_summary
assert_only_queued_once
end
context "with chat_mailer_send_summary_to_user modifier" do
let(:modifier_block) { Proc.new { |_| false } }
it "skips when modifier evaluates to false" do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
plugin_instance = Plugin::Instance.new
plugin_instance.register_modifier(:chat_mailer_send_summary_to_user, &modifier_block)
described_class.send_unread_mentions_summary
assert_summary_skipped
ensure
DiscoursePluginRegistry.unregister_modifier(
plugin_instance,
:chat_mailer_send_summary_to_user,
&modifier_block
it "does not queue a chat summary email when user has received an email for this message" do
followed_channel.membership_for(user).update!(
last_unread_mention_when_emailed_id: chat_message.id,
)
expect_not_enqueued
end
it "does not queue a chat summary email when user is not active" do
user.update!(active: false)
expect_not_enqueued
end
it "does not queue a chat summary email when user is staged" do
user.update!(staged: true)
expect_not_enqueued
end
it "does not queue a chat summary email when user is suspended" do
user.update!(suspended_till: 1.day.from_now)
expect_not_enqueued
end
it "does not queue a chat summary email when sender has been deleted" do
other.destroy!
expect_not_enqueued
end
it "queues a chat summary email even when user has private messages disabled" do
user.user_option.update!(allow_private_messages: false)
expect_enqueued
end
describe "when another plugin blocks the email" do
let!(:plugin) { Plugin::Instance.new }
let!(:modifier) { :chat_mailer_send_summary_to_user }
let!(:block) { Proc.new { false } }
before { DiscoursePluginRegistry.register_modifier(plugin, modifier, &block) }
after { DiscoursePluginRegistry.unregister_modifier(plugin, modifier, &block) }
it "does not queue a chat summary email" do
expect_not_enqueued
end
end
end
describe "update the user membership after we send the email" do
before { Jobs.run_immediately! }
describe "user is @group mentioned" do
before { create_message(followed_channel, "hello @#{group.name}", Chat::GroupMention) }
it "doesn't send the same summary the summary again if the user haven't read any channel messages since the last one" do
user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id - 1)
described_class.send_unread_mentions_summary
expect(user_1_chat_channel_membership.reload.last_unread_mention_when_emailed_id).to eq(
chat_message.id,
)
another_channel_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender)
Fabricate(:user_chat_mention, user: user_1, chat_message: another_channel_message)
expect { described_class.send_unread_mentions_summary }.not_to change(
Jobs::UserEmail.jobs,
:size,
)
it "queues a chat summary email" do
expect_enqueued
end
end
it "only updates the last_message_read_when_emailed_id on the channel with unread mentions" do
another_channel = Fabricate(:category_channel)
another_channel_message =
Fabricate(:chat_message, chat_channel: another_channel, user: sender)
Fabricate(:user_chat_mention, user: user_1, chat_message: another_channel_message)
another_channel_membership =
Fabricate(
:user_chat_channel_membership,
user: user_1,
chat_channel: another_channel,
last_read_message_id: another_channel_message.id,
)
user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id - 1)
describe "user is @all mentioned" do
before { create_message(followed_channel, "hello @all", Chat::AllMention) }
described_class.send_unread_mentions_summary
expect(user_1_chat_channel_membership.reload.last_unread_mention_when_emailed_id).to eq(
chat_message.id,
)
expect(another_channel_membership.reload.last_unread_mention_when_emailed_id).to be_nil
it "queues a chat summary email" do
expect_enqueued
end
end
end
describe "for direct messages" do
before { Fabricate(:chat_message, user: sender, chat_channel: private_chat_channel) }
describe "in a non-followed channel" do
before { non_followed_channel.add(user).update!(following: false) }
it "queue a job when the user has unread private mentions" do
described_class.send_unread_mentions_summary
describe "user is @direct mentioned" do
before { create_message(non_followed_channel, "hello @#{user.username}", Chat::UserMention) }
assert_only_queued_once
it "does not queue a chat summary email" do
expect_not_enqueued
end
end
it "only queues the job once when the user has mentions and private messages" do
Fabricate(:user_chat_mention, user: user_1, chat_message: chat_message)
describe "user is @group mentioned" do
before { create_message(non_followed_channel, "hello @#{group.name}", Chat::GroupMention) }
described_class.send_unread_mentions_summary
assert_only_queued_once
it "does not queue a chat summary email" do
expect_not_enqueued
end
end
it "doesn't mix or update mentions from other users when joining tables" do
user_2 = Fabricate(:user, groups: [chatters_group], last_seen_at: 20.minutes.ago)
user_2_membership =
Fabricate(
:user_chat_channel_membership,
user: user_2,
chat_channel: chat_channel,
last_read_message_id: chat_message.id,
)
Fabricate(:user_chat_mention, user: user_2, chat_message: chat_message)
describe "user is @all mentioned" do
before { create_message(non_followed_channel, "hello @all", Chat::AllMention) }
described_class.send_unread_mentions_summary
it "does not queue a chat summary email" do
expect_not_enqueued
end
end
end
assert_only_queued_once
expect(user_2_membership.reload.last_unread_mention_when_emailed_id).to be_nil
describe "in a muted channel" do
before { muted_channel.add(user).update!(muted: true) }
describe "user is @direct mentioned" do
before { create_message(muted_channel, "hello @#{user.username}", Chat::UserMention) }
it "does not queue a chat summary email" do
expect_not_enqueued
end
end
describe "user is @group mentioned" do
before { create_message(muted_channel, "hello @#{group.name}", Chat::GroupMention) }
it "does not queue a chat summary email" do
expect_not_enqueued
end
end
describe "user is @all mentioned" do
before { create_message(muted_channel, "hello @all", Chat::AllMention) }
it "does not queue a chat summary email" do
expect_not_enqueued
end
end
end
describe "in an unseen channel" do
describe "user is @direct mentioned" do
before { create_message(unseen_channel, "hello @#{user.username}") }
it "does not queue a chat summary email" do
expect_not_enqueued
end
end
describe "user is @group mentioned" do
before { create_message(unseen_channel, "hello @#{group.name}") }
it "doest not queue a chat summary email" do
expect_not_enqueued
end
end
end
describe "in a direct message" do
before { create_message(direct_message, "Howdy 👋") }
it "queues a chat summary email" do
expect_enqueued
end
it "queues a chat summary email when user isn't following the direct message anymore" do
direct_message.membership_for(user).update!(following: false)
expect_enqueued
end
it "does not queue a chat summary email when user has muted the direct message" do
direct_message.membership_for(user).update!(muted: true)
expect_not_enqueued
end
it "does not queue a chat summary email when user has private messages disabled" do
user.user_option.update!(allow_private_messages: false)
expect_not_enqueued
end
end
end

View File

@ -115,6 +115,11 @@ Fabricator(:chat_message_with_service, class_name: "Chat::CreateMessage") do
end
end
Fabricator(:chat_mention_notification, class_name: "Chat::MentionNotification") do
chat_mention { Fabricate(:user_chat_mention) }
notification { Fabricate(:notification) }
end
Fabricator(:user_chat_mention, class_name: "Chat::UserMention") do
transient read: false
transient high_priority: true

File diff suppressed because it is too large Load Diff