# 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 = +<<~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 SQL end def self.publish_unread(post) topic = post.topic return unless topic.private_message? scope = TopicUser.tracking(post.topic_id).includes(user: %i[user_stat user_option]) allowed_group_ids = topic.allowed_groups.pluck(:id) group_ids = if post.post_type == Post.types[:whisper] [Group::AUTO_GROUPS[:staff]] else allowed_group_ids end 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 peformance 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| 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: allowed_group_ids, created_by_user_id: post.user_id, }, } MessageBus.publish(self.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| MessageBus.publish(self.user_channel(user_id), message, user_ids: [user_id]) end topic .allowed_groups .pluck(:id) .each do |group_id| MessageBus.publish(self.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(self.group_channel(group_id), message, group_ids: [group_id]) end def self.publish_read(topic_id, last_read_post_number, user, notification_level = nil) self.publish_read_message( message_type: READ_MESSAGE_TYPE, channel_name: self.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