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
@@ -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
@@ -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
@@ -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