mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06: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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
-> {
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
39
plugins/chat/app/serializers/chat/messages_serializer.rb
Normal file
39
plugins/chat/app/serializers/chat/messages_serializer.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
164
plugins/chat/app/services/chat/list_channel_messages.rb
Normal file
164
plugins/chat/app/services/chat/list_channel_messages.rb
Normal 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
|
||||
116
plugins/chat/app/services/chat/list_channel_thread_messages.rb
Normal file
116
plugins/chat/app/services/chat/list_channel_thread_messages.rb
Normal 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
|
||||
Reference in New Issue
Block a user