mirror of
https://github.com/discourse/discourse.git
synced 2026-08-07 13:19:19 +08:00
Small action posts (closing, opening, pinning, archiving, category changes, auto-close, etc. — post_type 3) no longer count toward a topic's counters or unread tracking. They still occupy a post_number so the post stream stays gapless, but they contribute to nothing: not highest_post_number, highest_staff_post_number, posts_count, last_posted_at, last_post_user_id, word_count or bumped_at, and they never mark a topic unread or publish unread updates — for regular users and staff alike. Previously this exclusion only applied to private messages. Because small actions advanced highest_post_number, a topic whose only new post was a small action (say an auto-close at the end of a thread) was treated as unread: the nav "Unread (N)" badge counted it while /unread did not list it — the "Unread (N) but /unread is empty" phantom reported in https://meta.discourse.org/t/-/403986. More broadly, routine administrivia such as bulk closes and auto-close timers should not notify watchers or inflate reply counts (https://meta.discourse.org/t/-/404058). How it works: - Topic.next_post_number assigns small actions a post_number but advances no counter (whispers bump only the staff counter; small actions bump neither). - Topic.reset_highest / reset_all_highest! exclude them via two shared SQL fragments — public_post_types_sql (no whispers, no small actions) and staff_post_types_sql (no small actions) — collapsing the old regular-vs-PM branches into one. - PostCreator skips last_posted_at / last_post_user_id / word_count / bumped_at and the user post count for small actions; PostDestroyer's last-post lookup skips them too. - TopicTrackingState / PrivateMessageTopicTrackingState#publish_unread and the PostUpdateTopicTrackingState job return early for small actions. - Category time-based post counts, UserStat#calc_topic_reply_count! and the import.rake topic backfill exclude them as well. - A post-deploy migration recomputes the affected counters for existing topics and clamps any topic_users.last_read_post_number left pointing past the new highest (whisperers to highest_staff_post_number, everyone else to highest_post_number) so nobody is stuck with phantom read state. Keeping the unread count and the /unread list filtering on the same column is a separate, complementary fix that ships on its own. This supersedes the earlier proof-of-concept PRs #40290 and #39093.
211 lines
6.9 KiB
Ruby
Vendored
211 lines
6.9 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
# This class is used to mirror unread and new status for private messages between
|
|
# server and client.
|
|
#
|
|
# On the server side, this class has two main responsibilities. The first is to
|
|
# query the database for the initial state of a user's unread and new private
|
|
# messages. The second is to publish message_bus messages to notify the client
|
|
# of various topic events.
|
|
#
|
|
# On the client side, we have a `PrivateMessageTopicTrackingState` class as well
|
|
# which will load the initial state into memory and subscribes to the relevant
|
|
# message_bus messages. When a message is received, it modifies the in-memory
|
|
# state based on the message type. The filtering for new and unread topics is
|
|
# done on the client side based on the in-memory state in order to derive the
|
|
# count of new and unread topics efficiently.
|
|
class PrivateMessageTopicTrackingState
|
|
include TopicTrackingStatePublishable
|
|
|
|
CHANNEL_PREFIX = "/private-message-topic-tracking-state"
|
|
NEW_MESSAGE_TYPE = "new_topic"
|
|
UNREAD_MESSAGE_TYPE = "unread"
|
|
READ_MESSAGE_TYPE = "read"
|
|
GROUP_ARCHIVE_MESSAGE_TYPE = "group_archive"
|
|
|
|
def self.report(user)
|
|
sql = new_and_unread_sql(user)
|
|
|
|
DB.query(
|
|
sql + "\n\n LIMIT :max_topics",
|
|
{
|
|
max_topics: TopicTrackingState::MAX_TOPICS,
|
|
min_new_topic_date: Time.at(SiteSetting.min_new_topics_time).to_datetime,
|
|
},
|
|
)
|
|
end
|
|
|
|
def self.new_and_unread_sql(user)
|
|
sql = report_raw_sql(user, skip_unread: true)
|
|
sql << "\nUNION ALL\n\n"
|
|
sql << report_raw_sql(user, skip_new: true)
|
|
end
|
|
|
|
def self.report_raw_sql(user, skip_unread: false, skip_new: false)
|
|
unread =
|
|
if skip_unread
|
|
"1=0"
|
|
else
|
|
first_unread_pm_at = DB.query_single(<<~SQL, user_id: user.id).first
|
|
SELECT
|
|
LEAST(
|
|
MIN(user_stats.first_unread_pm_at),
|
|
MIN(group_users.first_unread_pm_at)
|
|
)
|
|
FROM group_users
|
|
JOIN groups ON groups.id = group_users.group_id
|
|
JOIN user_stats ON user_stats.user_id = :user_id
|
|
WHERE group_users.user_id = :user_id;
|
|
SQL
|
|
|
|
<<~SQL
|
|
#{TopicTrackingState.unread_filter_sql(whisperer: user.whisperer?)}
|
|
#{first_unread_pm_at ? "AND topics.updated_at > '#{first_unread_pm_at}'" : ""}
|
|
SQL
|
|
end
|
|
|
|
new =
|
|
if skip_new
|
|
"1=0"
|
|
else
|
|
TopicTrackingState.new_filter_sql
|
|
end
|
|
|
|
+<<~SQL
|
|
SELECT
|
|
DISTINCT topics.id AS topic_id,
|
|
u.id AS user_id,
|
|
last_read_post_number,
|
|
tu.notification_level,
|
|
#{TopicTrackingState.highest_post_number_column_select(user.whisperer?)},
|
|
ARRAY(SELECT group_id FROM topic_allowed_groups WHERE topic_allowed_groups.topic_id = topics.id) AS group_ids
|
|
FROM topics
|
|
JOIN users u on u.id = #{user.id.to_i}
|
|
JOIN user_stats AS us ON us.user_id = u.id
|
|
JOIN user_options AS uo ON uo.user_id = u.id
|
|
LEFT JOIN group_users gu ON gu.user_id = u.id
|
|
LEFT JOIN topic_allowed_groups tag ON tag.topic_id = topics.id AND tag.group_id = gu.group_id
|
|
LEFT JOIN topic_users tu ON tu.topic_id = topics.id AND tu.user_id = u.id
|
|
LEFT JOIN topic_allowed_users tau ON tau.topic_id = topics.id AND tau.user_id = u.id
|
|
#{skip_new ? "" : "LEFT JOIN dismissed_topic_users ON dismissed_topic_users.topic_id = topics.id AND dismissed_topic_users.user_id = #{user.id.to_i}"}
|
|
WHERE (tau.topic_id IS NOT NULL OR tag.topic_id IS NOT NULL) AND
|
|
topics.archetype = 'private_message' AND
|
|
((#{unread}) OR (#{new})) AND
|
|
topics.deleted_at IS NULL
|
|
#{user.staff? ? "" : "AND topics.visible"}
|
|
SQL
|
|
end
|
|
|
|
def self.publish_unread(post)
|
|
topic = post.topic
|
|
return unless topic.private_message?
|
|
return if post.small_action?
|
|
|
|
scope = TopicUser.tracking(post.topic_id).includes(user: %i[user_stat user_option])
|
|
|
|
group_ids = post.whisper? ? [Group::AUTO_GROUPS[:staff]] : topic.allowed_groups.pluck(:id)
|
|
|
|
if group_ids.present?
|
|
scope =
|
|
scope.joins("INNER JOIN group_users gu ON gu.user_id = topic_users.user_id").where(
|
|
"gu.group_id IN (?)",
|
|
group_ids,
|
|
)
|
|
end
|
|
|
|
# Note: At some point we may want to make the same performance optimisation
|
|
# here as we did with the other topic tracking state, where we only send
|
|
# one 'unread' update to all users, not a more accurate unread update to
|
|
# each individual user with their own read state.
|
|
#
|
|
# cf. f6c852bf8e7f4dea519425ba87a114f22f52a8f4
|
|
scope
|
|
.select(%i[user_id last_read_post_number notification_level])
|
|
.each do |tu|
|
|
next if tu.user_id == post.user_id # skip post creator
|
|
|
|
if tu.last_read_post_number.nil? &&
|
|
topic.created_at < tu.user.user_option.treat_as_new_topic_start_date
|
|
next
|
|
end
|
|
|
|
message = {
|
|
topic_id: post.topic_id,
|
|
message_type: UNREAD_MESSAGE_TYPE,
|
|
payload: {
|
|
last_read_post_number: tu.last_read_post_number,
|
|
highest_post_number: post.post_number,
|
|
notification_level: tu.notification_level,
|
|
group_ids: group_ids,
|
|
created_by_user_id: post.user_id,
|
|
},
|
|
}
|
|
|
|
MessageBus.publish(user_channel(tu.user_id), message.as_json, user_ids: [tu.user_id])
|
|
end
|
|
end
|
|
|
|
def self.publish_new(topic)
|
|
return unless topic.private_message?
|
|
|
|
message = {
|
|
message_type: NEW_MESSAGE_TYPE,
|
|
topic_id: topic.id,
|
|
payload: {
|
|
last_read_post_number: nil,
|
|
highest_post_number: 1,
|
|
group_ids: topic.allowed_groups.pluck(:id),
|
|
created_by_user_id: topic.user_id,
|
|
},
|
|
}.as_json
|
|
|
|
topic
|
|
.allowed_users
|
|
.pluck(:id)
|
|
.each do |user_id|
|
|
next if user_id == topic.user_id # skip topic creator
|
|
MessageBus.publish(user_channel(user_id), message, user_ids: [user_id])
|
|
end
|
|
|
|
topic
|
|
.allowed_groups
|
|
.pluck(:id)
|
|
.each do |group_id|
|
|
MessageBus.publish(group_channel(group_id), message, group_ids: [group_id])
|
|
end
|
|
end
|
|
|
|
def self.publish_group_archived(topic:, group_id:, acting_user_id: nil)
|
|
return unless topic.private_message?
|
|
|
|
message = {
|
|
message_type: GROUP_ARCHIVE_MESSAGE_TYPE,
|
|
topic_id: topic.id,
|
|
payload: {
|
|
group_ids: [group_id],
|
|
acting_user_id: acting_user_id,
|
|
},
|
|
}.as_json
|
|
|
|
MessageBus.publish(group_channel(group_id), message, group_ids: [group_id])
|
|
end
|
|
|
|
def self.publish_read(topic_id, last_read_post_number, user, notification_level = nil)
|
|
publish_read_message(
|
|
message_type: READ_MESSAGE_TYPE,
|
|
channel_name: user_channel(user.id),
|
|
topic_id: topic_id,
|
|
user: user,
|
|
last_read_post_number: last_read_post_number,
|
|
notification_level: notification_level,
|
|
)
|
|
end
|
|
|
|
def self.user_channel(user_id)
|
|
"#{CHANNEL_PREFIX}/user/#{user_id}"
|
|
end
|
|
|
|
def self.group_channel(group_id)
|
|
"#{CHANNEL_PREFIX}/group/#{group_id}"
|
|
end
|
|
end
|