mirror of
https://github.com/discourse/discourse.git
synced 2026-08-08 04:04:59 -05:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user