Skip to content

Commit

Permalink
FEATURE: High priority bookmark reminder notifications (discourse#9290)
Browse files Browse the repository at this point in the history
Introduce the concept of "high priority notifications" which include PM and bookmark reminder notifications. Now bookmark reminder notifications act in the same way as PM notifications (float to top of recent list, show in the green bubble) and most instances of unread_private_messages in the UI have been replaced with unread_high_priority_notifications.

The user email digest is changed to just have a section about unread high priority notifications, the unread PM section has been removed.

A high_priority boolean column has been added to the Notification table and relevant indices added to account for it.

unread_private_messages has been kept on the User model purely for backwards compat, but now just returns unread_high_priority_notifications count so this may cause some inconsistencies in the UI.
  • Loading branch information
martin-brennan authored Mar 31, 2020
1 parent b2a0d34 commit b79ea98
Show file tree
Hide file tree
Showing 19 changed files with 263 additions and 54 deletions.
2 changes: 1 addition & 1 deletion app/assets/javascripts/discourse/components/site-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, {

@observes(
"currentUser.unread_notifications",
"currentUser.unread_private_messages",
"currentUser.unread_high_priority_notifications",
"currentUser.reviewable_count"
)
notificationsChanged() {
Expand Down
2 changes: 1 addition & 1 deletion app/assets/javascripts/discourse/initializers/badging.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default {
if (!user) return; // must be logged in

this.notifications =
user.unread_notifications + user.unread_private_messages;
user.unread_notifications + user.unread_high_priority_notifications;

container
.lookup("service:app-events")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,27 @@ export default {
data => {
const store = container.lookup("service:store");
const oldUnread = user.get("unread_notifications");
const oldPM = user.get("unread_private_messages");
const oldHighPriority = user.get(
"unread_high_priority_notifications"
);

user.setProperties({
unread_notifications: data.unread_notifications,
unread_private_messages: data.unread_private_messages,
unread_high_priority_notifications:
data.unread_high_priority_notifications,
read_first_notification: data.read_first_notification
});

if (
oldUnread !== data.unread_notifications ||
oldPM !== data.unread_private_messages
oldHighPriority !== data.unread_high_priority_notifications
) {
appEvents.trigger("notifications:changed");

if (
site.mobileView &&
(data.unread_notifications - oldUnread > 0 ||
data.unread_private_messages - oldPM > 0)
data.unread_high_priority_notifications - oldHighPriority > 0)
) {
appEvents.trigger("header:update-topic", null, 5000);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default {
if (!user) return; // must be logged in

const notifications =
user.unread_notifications + user.unread_private_messages;
user.unread_notifications + user.unread_high_priority_notifications;

Discourse.updateNotificationCount(notifications);
}
Expand Down
14 changes: 8 additions & 6 deletions app/assets/javascripts/discourse/widgets/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ createWidget("header-notifications", {
);
}

const unreadPMs = user.get("unread_private_messages");
if (!!unreadPMs) {
const unreadHighPriority = user.get("unread_high_priority_notifications");
if (!!unreadHighPriority) {
// highlight the avatar if the first ever PM is not read
if (
!user.get("read_first_notification") &&
!user.get("enforcedSecondFactor")
Expand All @@ -90,14 +91,15 @@ createWidget("header-notifications", {
}
}

// add the counter for the unread high priority
contents.push(
this.attach("link", {
action: attrs.action,
className: "badge-notification unread-private-messages",
rawLabel: unreadPMs,
className: "badge-notification unread-high-priority-notifications",
rawLabel: unreadHighPriority,
omitSpan: true,
title: "notifications.tooltip.message",
titleOptions: { count: unreadPMs }
title: "notifications.tooltip.high_priority",
titleOptions: { count: unreadHighPriority }
})
);
}
Expand Down
2 changes: 1 addition & 1 deletion app/assets/stylesheets/common/base/discourse.scss
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ table {
align-items: center;
}

.unread-private-messages {
.unread-high-priority-notifications {
color: $secondary;
background: $success;

Expand Down
2 changes: 1 addition & 1 deletion app/assets/stylesheets/common/base/header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@
right: -3px;
background-color: dark-light-choose($tertiary-medium, $tertiary);
}
.unread-private-messages,
.unread-high-priority-notifications,
.ring {
left: auto;
right: 25px;
Expand Down
4 changes: 2 additions & 2 deletions app/mailers/user_notifications.rb
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,8 @@ def digest(user, opts = {})
value = user.unread_notifications
@counts << { label_key: 'user_notifications.digest.unread_notifications', value: value, href: "#{Discourse.base_url}/my/notifications" } if value > 0

value = user.unread_private_messages
@counts << { label_key: 'user_notifications.digest.unread_messages', value: value, href: "#{Discourse.base_url}/my/messages" } if value > 0
value = user.unread_high_priority_notifications
@counts << { label_key: 'user_notifications.digest.unread_high_priority', value: value, href: "#{Discourse.base_url}/my/notifications" } if value > 0

if @counts.size < 3
value = user.unread_notifications_of_type(Notification.types[:liked])
Expand Down
38 changes: 27 additions & 11 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ class Notification < ActiveRecord::Base
send_email unless NotificationConsolidator.new(self).consolidate!
end

before_create do
self.high_priority = Notification.high_priority_types.include?(self.notification_type)
end

def self.purge_old!
return if SiteSetting.max_notifications_per_user == 0

Expand All @@ -64,10 +68,10 @@ def self.purge_old!
end

def self.ensure_consistency!
DB.exec(<<~SQL, Notification.types[:private_message])
DB.exec(<<~SQL)
DELETE
FROM notifications n
WHERE notification_type = ?
WHERE high_priority
AND NOT EXISTS (
SELECT 1
FROM posts p
Expand Down Expand Up @@ -108,6 +112,17 @@ def self.types
)
end

def self.high_priority_types
@high_priority_types ||= [
types[:private_message],
types[:bookmark_reminder]
]
end

def self.normal_priority_types
@normal_priority_types ||= types.reject { |_k, v| high_priority_types.include?(v) }.values
end

def self.mark_posts_read(user, topic_id, post_numbers)
Notification
.where(
Expand Down Expand Up @@ -210,14 +225,14 @@ def self.recent_report(user, count = nil)

if notifications.present?

ids = DB.query_single(<<~SQL, count.to_i)
ids = DB.query_single(<<~SQL, limit: count.to_i)
SELECT n.id FROM notifications n
WHERE
n.notification_type = 6 AND
n.high_priority = TRUE AND
n.user_id = #{user.id.to_i} AND
NOT read
ORDER BY n.id ASC
LIMIT ?
LIMIT :limit
SQL

if ids.length > 0
Expand All @@ -230,9 +245,9 @@ def self.recent_report(user, count = nil)
end

notifications.uniq(&:id).sort do |x, y|
if x.unread_pm? && !y.unread_pm?
if x.unread_high_priority? && !y.unread_high_priority?
-1
elsif y.unread_pm? && !x.unread_pm?
elsif y.unread_high_priority? && !x.unread_high_priority?
1
else
y.created_at <=> x.created_at
Expand All @@ -244,8 +259,8 @@ def self.recent_report(user, count = nil)

end

def unread_pm?
Notification.types[:private_message] == self.notification_type && !read
def unread_high_priority?
self.high_priority? && !read
end

def post_id
Expand Down Expand Up @@ -280,14 +295,15 @@ def send_email
# topic_id :integer
# post_number :integer
# post_action_id :integer
# high_priority :boolean default(FALSE), not null
#
# Indexes
#
# idx_notifications_speedup_unread_count (user_id,notification_type) WHERE (NOT read)
# index_notifications_on_post_action_id (post_action_id)
# index_notifications_on_read_or_n_type (user_id,id DESC,read,topic_id) UNIQUE WHERE (read OR (notification_type <> 6))
# index_notifications_on_topic_id_and_post_number (topic_id,post_number)
# index_notifications_on_user_id_and_created_at (user_id,created_at)
# index_notifications_on_user_id_and_id (user_id,id) UNIQUE WHERE ((notification_type = 6) AND (NOT read))
# index_notifications_on_user_id_and_topic_id_and_post_number (user_id,topic_id,post_number)
# index_notifications_read_or_not_high_priority (user_id,id DESC,read,topic_id) WHERE (read OR (high_priority = false))
# index_notifications_unique_unread_high_priority (user_id,id) UNIQUE WHERE ((NOT read) AND (high_priority = true))
#
45 changes: 34 additions & 11 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,8 @@ def reload
@unread_notifications = nil
@unread_total_notifications = nil
@unread_pms = nil
@unread_bookmarks = nil
@unread_high_prios = nil
@user_fields_cache = nil
@ignored_user_ids = nil
@muted_user_ids = nil
Expand All @@ -491,17 +493,41 @@ def unread_notifications_of_type(notification_type)
FROM notifications n
LEFT JOIN topics t ON t.id = n.topic_id
WHERE t.deleted_at IS NULL
AND n.notification_type = :type
AND n.notification_type = :notification_type
AND n.user_id = :user_id
AND NOT read
SQL

# to avoid coalesce we do to_i
DB.query_single(sql, user_id: id, type: notification_type)[0].to_i
DB.query_single(sql, user_id: id, notification_type: notification_type)[0].to_i
end

def unread_notifications_of_priority(high_priority:)
# perf critical, much more efficient than AR
sql = <<~SQL
SELECT COUNT(*)
FROM notifications n
LEFT JOIN topics t ON t.id = n.topic_id
WHERE t.deleted_at IS NULL
AND n.high_priority = :high_priority
AND n.user_id = :user_id
AND NOT read
SQL

# to avoid coalesce we do to_i
DB.query_single(sql, user_id: id, high_priority: high_priority)[0].to_i
end

###
# DEPRECATED: This is only maintained for backwards compat until v2.5. There
# may be inconsistencies with counts in the UI because of this, because unread
# high priority includes PMs AND bookmark reminders.
def unread_private_messages
@unread_pms ||= unread_notifications_of_type(Notification.types[:private_message])
@unread_pms ||= unread_high_priority_notifications
end

def unread_high_priority_notifications
@unread_high_prios ||= unread_notifications_of_priority(high_priority: true)
end

# PERF: This safeguard is in place to avoid situations where
Expand All @@ -526,7 +552,7 @@ def unread_notifications
notifications n
LEFT JOIN topics t ON t.id = n.topic_id
WHERE t.deleted_at IS NULL AND
n.notification_type <> :pm AND
n.high_priority = FALSE AND
n.user_id = :user_id AND
n.id > :seen_notification_id AND
NOT read
Expand All @@ -537,7 +563,6 @@ def unread_notifications
DB.query_single(sql,
user_id: id,
seen_notification_id: seen_notification_id,
pm: Notification.types[:private_message],
limit: User.max_unread_notifications
)[0].to_i
end
Expand Down Expand Up @@ -579,7 +604,7 @@ def publish_notifications_state
LEFT JOIN topics t ON n.topic_id = t.id
WHERE
t.deleted_at IS NULL AND
n.notification_type = :type AND
n.high_priority AND
n.user_id = :user_id AND
NOT read
ORDER BY n.id DESC
Expand All @@ -591,23 +616,21 @@ def publish_notifications_state
LEFT JOIN topics t ON n.topic_id = t.id
WHERE
t.deleted_at IS NULL AND
(n.notification_type <> :type OR read) AND
(n.high_priority = FALSE OR read) AND
n.user_id = :user_id
ORDER BY n.id DESC
LIMIT 20
) AS y
SQL

recent = DB.query(sql,
user_id: id,
type: Notification.types[:private_message]
).map! do |r|
recent = DB.query(sql, user_id: id).map! do |r|
[r.id, r.read]
end

payload = {
unread_notifications: unread_notifications,
unread_private_messages: unread_private_messages,
unread_high_priority_notifications: unread_high_priority_notifications,
read_first_notification: read_first_notification?,
last_notification: json,
recent: recent,
Expand Down
1 change: 1 addition & 0 deletions app/serializers/current_user_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class CurrentUserSerializer < BasicUserSerializer
attributes :name,
:unread_notifications,
:unread_private_messages,
:unread_high_priority_notifications,
:read_first_notification?,
:admin?,
:notification_channel_position,
Expand Down
3 changes: 3 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1812,6 +1812,9 @@ en:
message:
one: "%{count} unread message"
other: "{{count}} unread messages"
high_priority:
one: "%{count} unread high priority notification"
other: "%{count} unread high priority notificationis"
title: "notifications of @name mentions, replies to your posts and topics, messages, etc"
none: "Unable to load notifications at this time."
empty: "No notifications found."
Expand Down
2 changes: 1 addition & 1 deletion config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3521,8 +3521,8 @@ en:
why: "A brief summary of %{site_link} since your last visit on %{last_seen_at}"
since_last_visit: "Since your last visit"
new_topics: "New Topics"
unread_messages: "Unread Messages"
unread_notifications: "Unread Notifications"
unread_high_priority: "Unread High Priority Notifications"
liked_received: "Likes Received"
new_users: "New Users"
popular_topics: "Popular Topics"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

class AddHighPriorityColumnToNotifications < ActiveRecord::Migration[6.0]
disable_ddl_transaction!
def up
if !column_exists?(:notifications, :high_priority)
add_column :notifications, :high_priority, :boolean, default: nil
end

# type 6 = private message, 24 = bookmark reminder
# priority 0 = low, 1 = normal, 2 = high
if column_exists?(:notifications, :high_priority)
execute <<~SQL
UPDATE notifications SET high_priority = TRUE WHERE notification_type IN (6, 24);
SQL

execute <<~SQL
UPDATE notifications SET high_priority = FALSE WHERE notification_type NOT IN (6, 24);
SQL

execute <<~SQL
ALTER TABLE notifications ALTER COLUMN high_priority SET DEFAULT FALSE;
SQL

execute <<~SQL
ALTER TABLE notifications ALTER COLUMN high_priority SET NOT NULL;
SQL
end

execute <<~SQL
CREATE INDEX CONCURRENTLY IF NOT EXISTS index_notifications_read_or_not_high_priority ON notifications(user_id, id DESC, read, topic_id) WHERE (read OR (high_priority = FALSE));
SQL

execute <<~SQL
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS index_notifications_unique_unread_high_priority ON notifications(user_id, id) WHERE NOT read AND high_priority = TRUE;
SQL
end

def down
DB.exec("ALTER TABLE notifications DROP COLUMN IF EXISTS high_priority")
end
end
Loading

0 comments on commit b79ea98

Please sign in to comment.