Skip to content
This repository was archived by the owner on Feb 6, 2023. It is now read-only.

FEATURE: Chat notification emails #838

Merged
merged 6 commits into from
May 24, 2022
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/jobs/scheduled/email_chat_notifications.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Jobs
class EmailChatNotifications < ::Jobs::Scheduled
every 5.minutes

def execute(args = {})
return unless SiteSetting.chat_enabled

DiscourseChat::ChatMailer.send_unread_mentions_summary
end
end
end
6 changes: 5 additions & 1 deletion app/models/chat_message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ def excerpt
return uploads.first.original_filename if cooked.blank? && uploads.present?

# this may return blank for some complex things like quotes, that is acceptable
PrettyText.excerpt(cooked, 50)
PrettyText.excerpt(cooked, 50, {})
end

def cooked_for_excerpt
(cooked.blank? && uploads.present?) ? "<p>#{uploads.first.original_filename}</p>" : cooked
end

def push_notification_excerpt
Expand Down
2 changes: 1 addition & 1 deletion app/models/direct_message_channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def chat_channel_title_for_user(chat_channel, acting_user)
# all users deleted
return chat_channel.id if !users.first

usernames_formatted = users.map { |u| "@#{u.username}" }
usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" }
if usernames_formatted.size > 5
return I18n.t(
"chat.channel.dm_title.multi_user_truncated",
Expand Down
21 changes: 11 additions & 10 deletions app/models/user_chat_channel_membership.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,17 @@ def changes_for_direct_message_channels
#
# Table name: user_chat_channel_memberships
#
# id :bigint not null, primary key
# user_id :integer not null
# chat_channel_id :integer not null
# last_read_message_id :integer
# following :boolean default(FALSE), not null
# muted :boolean default(FALSE), not null
# desktop_notification_level :integer default("mention"), not null
# mobile_notification_level :integer default("mention"), not null
# created_at :datetime not null
# updated_at :datetime not null
# id :bigint not null, primary key
# user_id :integer not null
# chat_channel_id :integer not null
# last_read_message_id :integer
# following :boolean default(FALSE), not null
# muted :boolean default(FALSE), not null
# desktop_notification_level :integer default("mention"), not null
# mobile_notification_level :integer default("mention"), not null
# created_at :datetime not null
# updated_at :datetime not null
# last_read_message_when_emailed_id :integer
#
# Indexes
#
Expand Down
76 changes: 76 additions & 0 deletions app/views/user_notifications/chat_summary.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<div class="summary-email">
<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 %>
<%- else %>
<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:#0a0a0a">
<%= I18n.t("user_notifications.chat_summary.description", count: @messages.size) %>
</td>
</tr>
</table>

<%- @grouped_mentions.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;"><%= chat_channel.title(@user) %></h5>
</td>
</tr>
<%- 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>
<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 %>
<tr>
<td colspan="100%" style="padding:<%= rtl? ? '2em 2em 0 0' : '2em 0 0 2em' %>">
<a class="more-messages-link" href="<%= messages.first.url %>">
<%- if 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 %>
</div>

<table class='summary-footer with-dir' style="margin-top:2em;width:100%">
<tr>
<td align="center">
<%= raw(t 'user_notifications.chat_summary.unsubscribe',
site_link: html_site_link,
email_preferences_link: link_to(t('user_notifications.chat_summary.your_chat_settings'), Discourse.base_url + '/my/preferences/chat'))
%>
</td>
</tr>
</table>
8 changes: 8 additions & 0 deletions app/views/user_notifications/chat_summary.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<%- 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")) %>
<%= raw(t :'user_notifications.chat_summary.unsubscribe',
site_link: site_link,
email_preferences_link: @markdown_linker.create(t('user_notifications.chat_summary.your_chat_settings'), Discourse.base_url + '/my/preferences/chat'))
%>
<%= raw(@markdown_linker.references) %>
15 changes: 13 additions & 2 deletions assets/javascripts/discourse/controllers/preferences-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,26 @@ import { action } from "@ember/object";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { CHAT_SOUNDS } from "discourse/plugins/discourse-chat/discourse/initializers/chat-notification-sounds";

const chatAttrs = [
const CHAT_ATTRS = [
"chat_enabled",
"chat_isolated",
"only_chat_push_notifications",
"ignore_channel_wide_mention",
"chat_sound",
"chat_email_frequency",
];

const EMAIL_FREQUENCY_OPTIONS = [
{ name: I18n.t(`chat.email_frequency.never`), value: "never" },
{ name: I18n.t(`chat.email_frequency.when_away`), value: "when_away" },
];

export default Controller.extend({
init() {
this._super(...arguments);
this.set("emailFrequencyOptions", EMAIL_FREQUENCY_OPTIONS);
},

@discourseComputed
chatSounds() {
return Object.keys(CHAT_SOUNDS).map((value) => {
Expand All @@ -35,7 +46,7 @@ export default Controller.extend({
save() {
this.set("saved", false);
return this.model
.save(chatAttrs)
.save(CHAT_ATTRS)
.then(() => {
this.set("saved", true);
if (!isTesting()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const CHAT_ISOLATED_FIELD = "chat_isolated";
const ONLY_CHAT_PUSH_NOTI_FIELD = "only_chat_push_notifications";
const IGNORE_CHANNEL_WIDE_MENTION = "ignore_channel_wide_mention";
const CHAT_SOUND = "chat_sound";
const CHAT_EMAIL_FREQUENCY = "chat_email_frequency";

export default {
name: "chat-user-options",
Expand All @@ -18,6 +19,7 @@ export default {
api.addSaveableUserOptionField(ONLY_CHAT_PUSH_NOTI_FIELD);
api.addSaveableUserOptionField(IGNORE_CHANNEL_WIDE_MENTION);
api.addSaveableUserOptionField(CHAT_SOUND);
api.addSaveableUserOptionField(CHAT_EMAIL_FREQUENCY);
}
});
},
Expand Down
14 changes: 14 additions & 0 deletions assets/javascripts/discourse/templates/preferences/chat.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,18 @@
}}
</div>

<div class="control-group chat-setting controls-dropdown">
<label for="user_chat_email_frequency">{{i18n "chat.email_frequency.title"}}</label>
{{combo-box
valueProperty="value"
content=emailFrequencyOptions
value=model.user_option.chat_email_frequency
id="user_chat_email_frequency"
onChange=(action (mut model.user_option.chat_email_frequency))
}}
{{#if (eq model.user_option.chat_email_frequency "when_away")}}
<div class="control-instructions">{{i18n "chat.email_frequency.description"}}</div>
{{/if}}
</div>

{{save-controls id="user_chat_preference_save" model=model action=(action "save") saved=saved}}
5 changes: 5 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ en:
public_available: "There are public channels available for you to follow."
start_direct_message: "You can also start a personal chat with one or more users."
title: "Get started by joining a channel"
email_frequency:
description: "We'll only email you if we haven't seen you in the last 15 minutes."
never: "Never"
title: "Email Notifications"
when_away: "Only when away"
enable: "Enable chat"
flag: "Flag"
flagged: "This message has been flagged for review"
Expand Down
19 changes: 19 additions & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,22 @@ en:
reviewable_score_types:
needs_review:
title: "Needs Review"

user_notifications:
chat_summary:
deleted_user: "Deleted user"
description:
one: "You have a new chat message"
other: "You have new chat messages"
from: "%{site_name}"
subject:
one: "[%{email_prefix}] New chat message"
other: "[%{email_prefix}] New chat messages"
unsubscribe: "This chat summary is sent from %{site_link} when you are away. Change your %{email_preferences_link}."
view_messages:
one: "View message"
other: "View %{count} messages"
view_more:
one: "View %{count} more message"
other: "View %{count} more messages"
your_chat_settings: "chat email frequency preference"
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class TrackLastUnreadMentionWhenEmailed < ActiveRecord::Migration[7.0]
def change
add_column :user_chat_channel_memberships, :last_unread_mention_when_emailed_id, :integer
end
end
14 changes: 14 additions & 0 deletions db/post_migrate/20220516142658_remove_email_statuses_table.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

class RemoveEmailStatusesTable < ActiveRecord::Migration[7.0]
def up
remove_index :chat_message_email_statuses, :status
remove_index :chat_message_email_statuses, [:user_id, :chat_message_id]

Migration::TableDropper.execute_drop("chat_message_email_statuses")
end

def down
raise ActiveRecord::IrreversibleMigration
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class RemoveUserOptionLastEmailedAt < ActiveRecord::Migration[7.0]
def change
remove_column :user_options, :last_emailed_for_chat, :datetime
end
end
53 changes: 53 additions & 0 deletions lib/chat_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

class DiscourseChat::ChatMailer
def send_unread_mentions_summary
return unless SiteSetting.chat_enabled

users_with_unprocessed_unread_mentions.find_each do |user|
# 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 users_with_unprocessed_unread_mentions
when_away_frequency = UserOption.chat_email_frequencies[:when_away]
allowed_group_ids = DiscourseChat.allowed_group_ids

User
.select('users.id', 'ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages')
.joins(:user_option)
.where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency })
.where('users.last_seen_at < ?', 15.minutes.ago)
.joins(:groups)
.where(groups: { id: allowed_group_ids })
.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')
.where(uccm: { following: true })
.where('c_msg.deleted_at IS NULL AND c_msg.user_id <> users.id')
.where(
<<~SQL
(c_mentions.user_id = uccm.user_id OR cc.chatable_type = 'DirectMessageChannel') AND
(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)
SQL
)
.group('users.id, uccm.user_id')
end
end
7 changes: 6 additions & 1 deletion lib/chat_message_creator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ def create
ChatDraft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all
ChatPublisher.publish_new!(@chat_channel, @chat_message, @staged_id)
Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id })
DiscourseChat::ChatNotifier.notify_new(chat_message: @chat_message, timestamp: @chat_message.created_at)
DiscourseChat::ChatNotifier.notify_new(
chat_message: @chat_message,
timestamp: @chat_message.created_at
)
rescue => error
@error = error
end
Expand All @@ -53,13 +56,15 @@ def failed?

def validate_user_permissions!
return if @guardian.can_create_chat_message!

raise StandardError.new(
I18n.t("chat.errors.user_cannot_send_message")
)
end

def validate_channel_status!
return if @guardian.can_create_channel_message?(@chat_channel)

raise StandardError.new(
I18n.t("chat.errors.channel_new_message_disallowed", status: @chat_channel.status_name)
)
Expand Down
5 changes: 4 additions & 1 deletion lib/chat_message_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ def update
revision = save_revision!
ChatPublisher.publish_edit!(@chat_channel, @chat_message)
Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id })
DiscourseChat::ChatNotifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at)
DiscourseChat::ChatNotifier.notify_edit(
chat_message: @chat_message,
timestamp: revision.created_at
)
rescue => error
@error = error
end
Expand Down
Loading