FEATURE: thread pagination (#22624)

Prior to this commit we were loading a large number of thread messages without any pagination. This commit attempts to fix this and also improves the following points:

- code sharing between channels and threads:
Attempts to reuse/share the code use in channels for threads. To make it possible part of this code has been extracted in dedicated helpers or has been improved to reduce the duplication needed.

Examples of extracted helpers:
- `stackingContextFix`: the ios hack for rendering bug when momentum scrolling is interrupted
- `scrollListToMessage`, `scrollListToTop`, `scrollListToBottom`:  a series of helper to correctly scroll to a specific position in the list of messages

- better general performance of listing messages:
One of the main changes which has been made is to remove the computation of visible message during scroll, it will only happen when needed (update last read for example). This constant recomputation of `message.visible` on intersection observer event while scrolling was consuming a lot of CPU time.
This commit is contained in:
Joffrey JAFFEUX
2023-07-27 09:57:03 +02:00
committed by GitHub
parent 7fb4bd3f43
commit 2d567cee26
105 changed files with 2533 additions and 2576 deletions

View File

@@ -1,6 +1,15 @@
# frozen_string_literal: true
class Chat::Api::ChannelMessagesController < Chat::ApiController
def index
with_service(::Chat::ListChannelMessages) do
on_success { render_serialized(result, ::Chat::MessagesSerializer, root: false) }
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
on_failed_policy(:target_message_exists) { raise Discourse::NotFound }
on_model_not_found(:channel) { raise Discourse::NotFound }
end
end
def destroy
with_service(Chat::TrashMessage) { on_model_not_found(:message) { raise Discourse::NotFound } }
end

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
class Chat::Api::ChannelThreadMessagesController < Chat::ApiController
def index
with_service(::Chat::ListChannelThreadMessages) do
on_success do
render_serialized(
result,
::Chat::MessagesSerializer,
root: false,
include_thread_preview: false,
include_thread_original_message: false,
)
end
on_failed_policy(:ensure_thread_enabled) { raise Discourse::NotFound }
on_failed_policy(:target_message_exists) { raise Discourse::NotFound }
on_failed_policy(:can_view_thread) { raise Discourse::InvalidAccess }
on_model_not_found(:thread) { raise Discourse::NotFound }
end
end
end

View File

@@ -33,7 +33,8 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController
::Chat::ThreadSerializer,
root: "thread",
membership: result.membership,
include_preview: true,
include_thread_preview: true,
include_thread_original_message: true,
participants: result.participants,
)
end

View File

@@ -79,39 +79,13 @@ class Chat::Api::ChannelsController < Chat::ApiController
end
def show
if should_build_channel_view?
with_service(
Chat::ChannelViewBuilder,
**params.permit(
:channel_id,
:target_message_id,
:thread_id,
:target_date,
:page_size,
:direction,
:fetch_from_last_read,
).slice(
:channel_id,
:target_message_id,
:thread_id,
:target_date,
:page_size,
:direction,
:fetch_from_last_read,
),
) do
on_success { render_serialized(result.view, Chat::ViewSerializer, root: false) }
on_failed_policy(:target_message_exists) { raise Discourse::NotFound }
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
end
else
render_serialized(
channel_from_params,
Chat::ChannelSerializer,
membership: channel_from_params.membership_for(current_user),
root: "channel",
)
end
render_serialized(
channel_from_params,
Chat::ChannelSerializer,
membership: channel_from_params.membership_for(current_user),
root: "channel",
include_extra_info: true,
)
end
def update
@@ -172,9 +146,4 @@ class Chat::Api::ChannelsController < Chat::ApiController
permitted_params += CATEGORY_CHANNEL_EDITABLE_PARAMS if channel.category_channel?
params.require(:channel).permit(*permitted_params)
end
def should_build_channel_view?
params[:target_message_id].present? || params[:target_date].present? ||
params[:include_messages].present? || params[:fetch_from_last_read].present?
end
end

View File

@@ -139,7 +139,8 @@ module Chat
message =
(
if chat_message_creator.chat_message.in_thread?
if @user_chat_channel_membership.last_read_message_id &&
chat_message_creator.chat_message.in_thread?
Chat::Message.find(@user_chat_channel_membership.last_read_message_id)
else
chat_message_creator.chat_message
@@ -147,7 +148,8 @@ module Chat
)
Chat::Publisher.publish_user_tracking_state!(current_user, @chat_channel, message)
render json: success_json.merge(message_id: message.id)
render json: success_json.merge(message_id: chat_message_creator.chat_message.id)
end
def edit_message

View File

@@ -15,7 +15,7 @@ module Chat
belongs_to :user
belongs_to :in_reply_to, class_name: "Chat::Message"
belongs_to :last_editor, class_name: "User"
belongs_to :thread, class_name: "Chat::Thread"
belongs_to :thread, class_name: "Chat::Thread", optional: true
has_many :replies,
class_name: "Chat::Message",

View File

@@ -11,7 +11,10 @@ module Chat
belongs_to :channel, foreign_key: "channel_id", class_name: "Chat::Channel"
belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User"
belongs_to :original_message, foreign_key: "original_message_id", class_name: "Chat::Message"
belongs_to :original_message,
-> { with_deleted },
foreign_key: "original_message_id",
class_name: "Chat::Message"
has_many :chat_messages,
-> {

View File

@@ -1,114 +0,0 @@
# frozen_string_literal: true
module Chat
class View
attr_reader :user,
:chat_channel,
:chat_messages,
:can_load_more_past,
:can_load_more_future,
:unread_thread_overview,
:threads,
:tracking,
:thread_memberships,
:thread_participants
def initialize(
chat_channel:,
chat_messages:,
user:,
can_load_more_past: nil,
can_load_more_future: nil,
unread_thread_overview: nil,
threads: nil,
tracking: nil,
thread_memberships: nil,
thread_participants: nil
)
@chat_channel = chat_channel
@chat_messages = chat_messages
@user = user
@can_load_more_past = can_load_more_past
@can_load_more_future = can_load_more_future
@unread_thread_overview = unread_thread_overview
@threads = threads
@tracking = tracking
@thread_memberships = thread_memberships
@thread_participants = thread_participants
end
def reviewable_ids
return @reviewable_ids if defined?(@reviewable_ids)
@reviewable_ids = @user.staff? ? get_reviewable_ids : nil
end
def user_flag_statuses
return @user_flag_statuses if defined?(@user_flag_statuses)
@user_flag_statuses = get_user_flag_statuses
end
private
def get_reviewable_ids
sql = <<~SQL
SELECT
target_id,
MAX(r.id) reviewable_id
FROM
reviewables r
JOIN
reviewable_scores s ON reviewable_id = r.id
WHERE
r.target_id IN (:message_ids) AND
r.target_type = :target_type AND
s.status = :pending
GROUP BY
target_id
SQL
ids = {}
DB
.query(
sql,
pending: ReviewableScore.statuses[:pending],
message_ids: @chat_messages.map(&:id),
target_type: Chat::Message.polymorphic_name,
)
.each { |row| ids[row.target_id] = row.reviewable_id }
ids
end
def get_user_flag_statuses
sql = <<~SQL
SELECT
target_id,
s.status
FROM
reviewables r
JOIN
reviewable_scores s ON reviewable_id = r.id
WHERE
s.user_id = :user_id AND
r.target_id IN (:message_ids) AND
r.target_type = :target_type
SQL
statuses = {}
DB
.query(
sql,
message_ids: @chat_messages.map(&:id),
user_id: @user.id,
target_type: Chat::Message.polymorphic_name,
)
.each { |row| statuses[row.target_id] = row.status }
statuses
end
end
end

View File

@@ -16,12 +16,12 @@ module Chat
# It is assumed that the user's permission to view the channel has already been
# established by the caller.
class MessagesQuery
PAST_MESSAGE_LIMIT = 20
FUTURE_MESSAGE_LIMIT = 20
PAST_MESSAGE_LIMIT = 25
FUTURE_MESSAGE_LIMIT = 25
PAST = "past"
FUTURE = "future"
VALID_DIRECTIONS = [PAST, FUTURE]
MAX_PAGE_SIZE = 100
MAX_PAGE_SIZE = 50
# @param channel [Chat::Channel] The channel to query messages within.
# @param guardian [Guardian] The guardian to use for permission checks.
@@ -82,7 +82,7 @@ module Chat
.includes(:bookmarks)
.includes(:uploads)
.includes(chat_channel: :chatable)
.includes(:thread)
.includes(thread: %i[original_message last_message])
.where(chat_channel_id: channel.id)
if SiteSetting.enable_user_status
@@ -177,6 +177,7 @@ module Chat
can_load_more_future = future_messages.size == FUTURE_MESSAGE_LIMIT
{
target_message_id: future_messages.first&.id,
past_messages: past_messages,
future_messages: future_messages,
target_date: target_date,

View File

@@ -121,6 +121,15 @@ module Chat
data[:can_join_chat_channel] = scope.can_join_chat_channel?(object)
end
data[:can_flag] = scope.can_flag_in_chat_channel?(
object,
post_allowed_category_ids: @opts[:post_allowed_category_ids],
)
data[:user_silenced] = !scope.can_create_chat_message?
data[:can_moderate] = scope.can_moderate_chat?(object.chatable)
data[:can_delete_self] = scope.can_delete_own_chats?(object.chatable)
data[:can_delete_others] = scope.can_delete_other_chats?(object.chatable)
data
end

View File

@@ -24,6 +24,7 @@ module Chat
user_flag_status
reviewable_id
edited
thread
]
),
)
@@ -176,12 +177,24 @@ module Chat
end
end
def include_threading_data?
channel.threading_enabled
def include_thread?
include_thread_id? && object.thread_om? && object.thread.present?
end
def include_thread_id?
include_threading_data?
channel.threading_enabled
end
def thread
Chat::ThreadSerializer.new(
object.thread,
scope: scope,
membership: @options[:thread_memberships]&.find { |m| m.thread_id == object.thread.id },
participants: @options[:thread_participants]&.dig(object.thread.id),
include_thread_preview: true,
include_thread_original_message: @options[:include_thread_original_message],
root: false,
)
end
end
end

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
module Chat
class MessagesSerializer < ::ApplicationSerializer
attributes :messages, :tracking, :meta
def initialize(object, opts)
super(object, opts)
@opts = opts
end
def messages
object.messages.map do |message|
::Chat::MessageSerializer.new(
message,
scope: scope,
root: false,
include_thread_preview: true,
include_thread_original_message: true,
thread_participants: object.thread_participants,
thread_memberships: object.thread_memberships,
**@opts,
)
end
end
def tracking
object.tracking || {}
end
def meta
{
target_message_id: object.target_message_id,
can_load_more_future: object.can_load_more_future,
can_load_more_past: object.can_load_more_past,
}
end
end
end

View File

@@ -31,6 +31,7 @@ module Chat
# have been fetched with [Chat::ChannelFetcher], which only returns channels that
# the user has access to based on category permissions.
can_join_chat_channel: true,
post_allowed_category_ids: @options[:post_allowed_category_ids],
)
end
end

View File

@@ -6,11 +6,12 @@ module Chat
def threads
object.threads.map do |thread|
Chat::ThreadSerializer.new(
::Chat::ThreadSerializer.new(
thread,
scope: scope,
membership: object.memberships.find { |m| m.thread_id == thread.id },
include_preview: true,
include_thread_preview: true,
include_thread_original_message: true,
root: nil,
)
end

View File

@@ -1,43 +1,30 @@
# frozen_string_literal: true
module Chat
class ThreadOriginalMessageSerializer < Chat::MessageSerializer
has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects
class ThreadOriginalMessageSerializer < ::ApplicationSerializer
attributes :id,
:message,
:cooked,
:created_at,
:excerpt,
:chat_channel_id,
:deleted_at,
:mentioned_users
def excerpt
object.censored_excerpt(max_length: Chat::Thread::EXCERPT_LENGTH)
object.censored_excerpt
end
def include_available_flags?
false
def mentioned_users
object
.chat_mentions
.map(&:user)
.compact
.sort_by(&:id)
.map { |user| BasicUserWithStatusSerializer.new(user, root: false) }
.as_json
end
def include_reactions?
false
end
def include_edited?
false
end
def include_in_reply_to?
false
end
def include_user_flag_status?
false
end
def include_uploads?
false
end
def include_bookmark?
false
end
def include_chat_webhook_event?
false
end
has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects
end
end

View File

@@ -22,6 +22,10 @@ module Chat
@current_user_membership = opts[:membership]
end
def include_original_message?
@opts[:include_thread_original_message].presence || true
end
def meta
{ message_bus_last_ids: { thread_message_bus_last_id: thread_message_bus_last_id } }
end
@@ -31,7 +35,7 @@ module Chat
end
def include_preview?
@opts[:include_preview]
@opts[:include_thread_preview]
end
def preview

View File

@@ -1,78 +0,0 @@
# frozen_string_literal: true
module Chat
class ViewSerializer < ApplicationSerializer
attributes :meta, :chat_messages, :threads, :tracking, :unread_thread_overview, :channel
def threads
return [] if !object.threads
object.threads.map do |thread|
Chat::ThreadSerializer.new(
thread,
scope: scope,
membership: object.thread_memberships.find { |m| m.thread_id == thread.id },
participants: object.thread_participants[thread.id],
include_preview: true,
root: nil,
)
end
end
def tracking
object.tracking || {}
end
def unread_thread_overview
object.unread_thread_overview || {}
end
def include_threads?
include_thread_data?
end
def include_unread_thread_overview?
include_thread_data?
end
def include_thread_data?
channel.threading_enabled
end
def channel
object.chat_channel
end
def chat_messages
ActiveModel::ArraySerializer.new(
object.chat_messages,
each_serializer: Chat::MessageSerializer,
reviewable_ids: object.reviewable_ids,
user_flag_statuses: object.user_flag_statuses,
chat_channel: object.chat_channel,
scope: scope,
)
end
def meta
meta_hash = {
channel_id: object.chat_channel.id,
can_flag: scope.can_flag_in_chat_channel?(object.chat_channel),
channel_status: object.chat_channel.status,
user_silenced: !scope.can_create_chat_message?,
can_moderate: scope.can_moderate_chat?(object.chat_channel.chatable),
can_delete_self: scope.can_delete_own_chats?(object.chat_channel.chatable),
can_delete_others: scope.can_delete_other_chats?(object.chat_channel.chatable),
channel_message_bus_last_id:
MessageBus.last_id(Chat::Publisher.root_message_bus_channel(object.chat_channel.id)),
}
meta_hash[
:can_load_more_past
] = object.can_load_more_past unless object.can_load_more_past.nil?
meta_hash[
:can_load_more_future
] = object.can_load_more_future unless object.can_load_more_future.nil?
meta_hash
end
end
end

View File

@@ -1,263 +0,0 @@
# 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_overview
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, :last_message).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 = 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 hashes consisting
# of thread IDs that have unread messages as well as the datetime of the
# last reply in the thread.
#
# 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_overview(guardian:, channel:, threads_enabled:, **)
if !threads_enabled
context.unread_thread_overview = {}
else
context.unread_thread_overview =
::Chat::TrackingStateReportQuery.call(
guardian: guardian,
channel_ids: [channel.id],
include_threads: true,
include_read: false,
include_last_reply_details: true,
).find_channel_thread_overviews(channel.id)
end
end
def fetch_threads_for_messages(guardian:, messages:, channel:, threads_enabled:, **)
if !threads_enabled
context.threads = []
else
context.threads =
::Chat::Thread
.strict_loading
.includes(last_message: %i[user uploads], 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_overview:,
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_overview: unread_thread_overview,
threads: threads,
tracking: tracking,
thread_memberships: thread_memberships,
thread_participants: thread_participants,
)
end
end
end

View File

@@ -0,0 +1,164 @@
# frozen_string_literal: true
module Chat
# List messages of a channel before and after a specific target (id, date),
# or fetching paginated messages from last read.
#
# @example
# Chat::ListChannelMessages.call(channel_id: 2, guardian: guardian, **optional_params)
#
class ListChannelMessages
include Service::Base
# @!method call(guardian:)
# @param [Integer] channel_id
# @param [Guardian] guardian
# @return [Service::Base::Context]
contract
model :channel
policy :can_view_channel
step :fetch_optional_membership
step :enabled_threads?
step :determine_target_message_id
policy :target_message_exists
step :fetch_messages
step :fetch_thread_ids
step :fetch_tracking
step :fetch_thread_participants
step :fetch_thread_memberships
step :update_membership_last_viewed_at
class Contract
attribute :channel_id, :integer
validates :channel_id, presence: true
attribute :page_size, :integer
validates :page_size,
numericality: {
less_than_or_equal_to: ::Chat::MessagesQuery::MAX_PAGE_SIZE,
only_integer: true,
},
allow_nil: true
# If this is not present, then we just fetch messages with page_size
# and direction.
attribute :target_message_id, :integer # (optional)
attribute :direction, :string # (optional)
attribute :fetch_from_last_read, :boolean # (optional)
attribute :target_date, :string # (optional)
validates :direction,
inclusion: {
in: Chat::MessagesQuery::VALID_DIRECTIONS,
},
allow_nil: true
end
private
def fetch_channel(contract:, **)
::Chat::Channel.strict_loading.includes(:chatable).find_by(id: contract.channel_id)
end
def fetch_optional_membership(channel:, guardian:, **)
context.membership = channel.membership_for(guardian.user)
end
def enabled_threads?(channel:, **)
context.enabled_threads = channel.threading_enabled
end
def can_view_channel(guardian:, channel:, **)
guardian.can_preview_chat_channel?(channel)
end
def determine_target_message_id(contract:, **)
if contract.fetch_from_last_read
context.target_message_id = context.membership&.last_read_message_id
else
context.target_message_id = contract.target_message_id
end
end
def target_message_exists(channel:, guardian:, **)
return true if context.target_message_id.blank?
target_message =
Chat::Message.with_deleted.find_by(id: context.target_message_id, chat_channel: channel)
return false if target_message.blank?
return true if !target_message.trashed?
target_message.user_id == guardian.user.id || guardian.is_staff?
end
def fetch_messages(channel:, contract:, guardian:, enabled_threads:, **)
messages_data =
::Chat::MessagesQuery.call(
channel: channel,
guardian: guardian,
target_message_id: context.target_message_id,
include_thread_messages: !enabled_threads,
page_size: contract.page_size || Chat::MessagesQuery::MAX_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]
context.target_message_id = messages_data[:target_message_id]
messages_data[:target_message] = (
if enabled_threads && messages_data[:target_message]&.thread_reply?
[]
else
[messages_data[:target_message]]
end
)
context.messages = [
messages_data[:messages],
messages_data[:past_messages]&.reverse,
messages_data[:target_message],
messages_data[:future_messages],
].flatten.compact
end
def fetch_tracking(guardian:, enabled_threads:, **)
context.tracking = {}
return if !enabled_threads || !context.thread_ids.present?
context.tracking =
::Chat::TrackingStateReportQuery.call(
guardian: guardian,
thread_ids: context.thread_ids,
include_threads: true,
)
end
def fetch_thread_ids(messages:, **)
context.thread_ids = messages.map(&:thread_id).compact.uniq
end
def fetch_thread_participants(messages:, **)
return if context.thread_ids.empty?
context.thread_participants =
::Chat::ThreadParticipantQuery.call(thread_ids: context.thread_ids)
end
def fetch_thread_memberships(guardian:, **)
return if context.thread_ids.empty?
context.thread_memberships =
::Chat::UserChatThreadMembership.where(
thread_id: context.thread_ids,
user_id: guardian.user.id,
)
end
def update_membership_last_viewed_at(guardian:, **)
context.membership&.update!(last_viewed_at: Time.zone.now)
end
end
end

View File

@@ -0,0 +1,116 @@
# frozen_string_literal: true
module Chat
# List messages of a thread before and after a specific target (id, date),
# or fetching paginated messages from last read.
#
# @example
# Chat::ListThreadMessages.call(thread_id: 2, guardian: guardian, **optional_params)
#
class ListChannelThreadMessages
include Service::Base
# @!method call(guardian:)
# @param [Integer] channel_id
# @param [Guardian] guardian
# @option optional_params [Integer] thread_id
# @option optional_params [Integer] channel_id
# @return [Service::Base::Context]
contract
model :thread
policy :ensure_thread_enabled
policy :can_view_thread
step :fetch_optional_membership
step :determine_target_message_id
policy :target_message_exists
step :fetch_messages
class Contract
attribute :thread_id, :integer
validates :thread_id, presence: true
# If this is not present, then we just fetch messages with page_size
# and direction.
attribute :target_message_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 :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
end
private
def fetch_optional_membership(thread:, guardian:, **)
context.membership = thread.membership_for(guardian.user)
end
def fetch_thread(contract:, **)
::Chat::Thread.strict_loading.includes(channel: :chatable).find_by(id: contract.thread_id)
end
def ensure_thread_enabled(thread:, **)
thread.channel.threading_enabled
end
def can_view_thread(guardian:, thread:, **)
guardian.can_preview_chat_channel?(thread.channel)
end
def determine_target_message_id(contract:, membership:, guardian:, **)
if contract.fetch_from_last_read
context.target_message_id = membership&.last_read_message_id
else
context.target_message_id = contract.target_message_id
end
end
def target_message_exists(contract:, guardian:, **)
return true if context.target_message_id.blank?
target_message =
::Chat::Message.with_deleted.find_by(
id: context.target_message_id,
thread_id: contract.thread_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 fetch_messages(thread:, guardian:, contract:, **)
messages_data =
::Chat::MessagesQuery.call(
channel: thread.channel,
guardian: guardian,
target_message_id: context.target_message_id,
thread_id: thread.id,
page_size: contract.page_size || Chat::MessagesQuery::MAX_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]
context.messages = [
messages_data[:messages],
messages_data[:past_messages]&.reverse,
messages_data[:target_message],
messages_data[:future_messages],
].flatten.compact
end
end
end