discourse/plugins/chat/app/services/chat/channel_view_builder.rb

263 lines
8.7 KiB
Ruby

# frozen_string_literal: true
module Chat
# Builds up a Chat::View object for a channel, and handles several
# different querying scenraios:
#
# * Fetching messages before and after a specific target_message_id,
# or fetching paginated messages.
# * Fetching threads for the found messages.
# * Fetching thread tracking state.
# * Fetching an overview of unread threads for the channel.
#
# @example
# Chat::ChannelViewBuilder.call(channel_id: 2, guardian: guardian, **optional_params)
#
class ChannelViewBuilder
include Service::Base
# @!method call(channel_id:, guardian:)
# @param [Integer] channel_id
# @param [Guardian] guardian
# @option optional_params [Integer] thread_id
# @option optional_params [Integer] target_message_id
# @option optional_params [Boolean] fetch_from_last_read
# @option optional_params [Integer] page_size
# @option optional_params [String] direction
# @return [Service::Base::Context]
contract
model :channel
policy :can_view_channel
step :determine_target_message_id
policy :target_message_exists
step :determine_threads_enabled
step :determine_include_thread_messages
step :fetch_messages
step :fetch_unread_thread_ids
step :fetch_threads_for_messages
step :fetch_tracking
step :fetch_thread_memberships
step :fetch_thread_participants
step :update_channel_last_viewed_at
step :build_view
class Contract
attribute :channel_id, :integer
# If this is not present, then we just fetch messages with page_size
# and direction.
attribute :target_message_id, :integer # (optional)
attribute :thread_id, :integer # (optional)
attribute :direction, :string # (optional)
attribute :page_size, :integer # (optional)
attribute :fetch_from_last_read, :boolean # (optional)
attribute :target_date, :string # (optional)
validates :channel_id, presence: true
validates :direction,
inclusion: {
in: Chat::MessagesQuery::VALID_DIRECTIONS,
},
allow_nil: true
validates :page_size,
numericality: {
less_than_or_equal_to: Chat::MessagesQuery::MAX_PAGE_SIZE,
only_integer: true,
},
allow_nil: true
validate :page_size_present, if: -> { target_message_id.blank? && !fetch_from_last_read }
def page_size_present
errors.add(:page_size, :blank) if page_size.blank?
end
end
private
def fetch_channel(contract:, **)
Chat::Channel.includes(:chatable).find_by(id: contract.channel_id)
end
def can_view_channel(guardian:, channel:, **)
guardian.can_preview_chat_channel?(channel)
end
def determine_target_message_id(contract:, channel:, guardian:, **)
if contract.fetch_from_last_read
contract.target_message_id = channel.membership_for(guardian.user)&.last_read_message_id
# We need to force a page size here because we don't want to
# load all messages in the channel (since starting from 0
# makes them all unread). When the target_message_id is provided
# page size is not required since we load N messages either side of
# the target.
if contract.target_message_id.blank?
contract.page_size = contract.page_size || Chat::MessagesQuery::MAX_PAGE_SIZE
end
end
end
def target_message_exists(contract:, guardian:, **)
return true if contract.target_message_id.blank?
target_message =
Chat::Message.with_deleted.find_by(
id: contract.target_message_id,
chat_channel_id: contract.channel_id,
)
return false if target_message.blank?
return true if !target_message.trashed?
target_message.user_id == guardian.user.id || guardian.is_staff?
end
def determine_threads_enabled(channel:, **)
context.threads_enabled =
SiteSetting.enable_experimental_chat_threaded_discussions && channel.threading_enabled
end
def determine_include_thread_messages(contract:, threads_enabled:, **)
context.include_thread_messages = contract.thread_id.present? || !threads_enabled
end
def fetch_messages(channel:, guardian:, contract:, include_thread_messages:, **)
messages_data =
::Chat::MessagesQuery.call(
channel: channel,
guardian: guardian,
target_message_id: contract.target_message_id,
thread_id: contract.thread_id,
include_thread_messages: include_thread_messages,
page_size: contract.page_size,
direction: contract.direction,
target_date: contract.target_date,
)
context.can_load_more_past = messages_data[:can_load_more_past]
context.can_load_more_future = messages_data[:can_load_more_future]
if !messages_data[:target_message] && !messages_data[:target_date]
context.messages = messages_data[:messages]
else
messages_data[:target_message] = (
if !include_thread_messages && messages_data[:target_message]&.thread_reply?
[]
else
[messages_data[:target_message]]
end
)
context.messages = [
messages_data[:past_messages].reverse,
messages_data[:target_message],
messages_data[:future_messages],
].reduce([], :concat).compact
end
end
# The thread tracking overview is a simple array of thread IDs
# that have unread messages, only threads with unread messages
# will be included in this array. This is a low-cost way to know
# how many threads the user has unread across the entire channel.
def fetch_unread_thread_ids(guardian:, channel:, threads_enabled:, **)
if !threads_enabled
context.unread_thread_ids = []
else
context.unread_thread_ids =
::Chat::TrackingStateReportQuery
.call(
guardian: guardian,
channel_ids: [channel.id],
include_threads: true,
include_read: false,
)
.find_channel_threads(channel.id)
.keys
end
end
def fetch_threads_for_messages(guardian:, messages:, channel:, threads_enabled:, **)
if !threads_enabled
context.threads = []
else
context.threads =
::Chat::Thread.includes(original_message_user: :user_status).where(
id: messages.map(&:thread_id).compact.uniq,
)
# Saves us having to load the same message we already have.
context.threads.each do |thread|
thread.original_message =
messages.find { |message| message.id == thread.original_message_id }
end
end
end
# Only thread tracking is necessary to fetch here -- we preload
# channel tracking state for all the current user's tracked channels
# in the CurrentUserSerializer.
def fetch_tracking(guardian:, messages:, channel:, threads_enabled:, **)
thread_ids = messages.map(&:thread_id).compact.uniq
if !threads_enabled || thread_ids.empty?
context.tracking = {}
else
context.tracking =
::Chat::TrackingStateReportQuery.call(
guardian: guardian,
thread_ids: thread_ids,
include_threads: true,
)
end
end
def fetch_thread_memberships(threads:, guardian:, **)
if threads.empty?
context.thread_memberships = []
else
context.thread_memberships =
::Chat::UserChatThreadMembership.where(
thread_id: threads.map(&:id),
user_id: guardian.user.id,
)
end
end
def fetch_thread_participants(threads:, **)
context.thread_participants =
::Chat::ThreadParticipantQuery.call(thread_ids: threads.map(&:id))
end
def update_channel_last_viewed_at(channel:, guardian:, **)
channel.membership_for(guardian.user)&.update!(last_viewed_at: Time.zone.now)
end
def build_view(
guardian:,
channel:,
messages:,
threads:,
tracking:,
unread_thread_ids:,
can_load_more_past:,
can_load_more_future:,
thread_memberships:,
thread_participants:,
**
)
context.view =
Chat::View.new(
chat_channel: channel,
chat_messages: messages,
user: guardian.user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
unread_thread_ids: unread_thread_ids,
threads: threads,
tracking: tracking,
thread_memberships: thread_memberships,
thread_participants: thread_participants,
)
end
end
end