diff --git a/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb b/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb
index 5496aae6e9c..a68aa2d3967 100644
--- a/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb
+++ b/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb
@@ -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
diff --git a/plugins/chat/app/controllers/chat/api/channel_thread_messages_controller.rb b/plugins/chat/app/controllers/chat/api/channel_thread_messages_controller.rb
new file mode 100644
index 00000000000..324069d9f8c
--- /dev/null
+++ b/plugins/chat/app/controllers/chat/api/channel_thread_messages_controller.rb
@@ -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
diff --git a/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb
index 2c7d1d163e5..fd16e5f0cd0 100644
--- a/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb
+++ b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb
@@ -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
diff --git a/plugins/chat/app/controllers/chat/api/channels_controller.rb b/plugins/chat/app/controllers/chat/api/channels_controller.rb
index 9cdd3a42482..b69b33ff33c 100644
--- a/plugins/chat/app/controllers/chat/api/channels_controller.rb
+++ b/plugins/chat/app/controllers/chat/api/channels_controller.rb
@@ -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
diff --git a/plugins/chat/app/controllers/chat/chat_controller.rb b/plugins/chat/app/controllers/chat/chat_controller.rb
index 77a7090cd31..943d2786be7 100644
--- a/plugins/chat/app/controllers/chat/chat_controller.rb
+++ b/plugins/chat/app/controllers/chat/chat_controller.rb
@@ -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
diff --git a/plugins/chat/app/models/chat/message.rb b/plugins/chat/app/models/chat/message.rb
index bda702260ae..7e7aabc6958 100644
--- a/plugins/chat/app/models/chat/message.rb
+++ b/plugins/chat/app/models/chat/message.rb
@@ -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",
diff --git a/plugins/chat/app/models/chat/thread.rb b/plugins/chat/app/models/chat/thread.rb
index c1a7c8bc2c6..2dc62e7df10 100644
--- a/plugins/chat/app/models/chat/thread.rb
+++ b/plugins/chat/app/models/chat/thread.rb
@@ -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,
-> {
diff --git a/plugins/chat/app/models/chat/view.rb b/plugins/chat/app/models/chat/view.rb
deleted file mode 100644
index 927df9ae3fd..00000000000
--- a/plugins/chat/app/models/chat/view.rb
+++ /dev/null
@@ -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
diff --git a/plugins/chat/app/queries/chat/messages_query.rb b/plugins/chat/app/queries/chat/messages_query.rb
index ea8be51aa7a..8a888ec6a22 100644
--- a/plugins/chat/app/queries/chat/messages_query.rb
+++ b/plugins/chat/app/queries/chat/messages_query.rb
@@ -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,
diff --git a/plugins/chat/app/serializers/chat/channel_serializer.rb b/plugins/chat/app/serializers/chat/channel_serializer.rb
index fe154125dff..9ed2dcde225 100644
--- a/plugins/chat/app/serializers/chat/channel_serializer.rb
+++ b/plugins/chat/app/serializers/chat/channel_serializer.rb
@@ -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
diff --git a/plugins/chat/app/serializers/chat/message_serializer.rb b/plugins/chat/app/serializers/chat/message_serializer.rb
index ffe59d2a467..891fd5aea87 100644
--- a/plugins/chat/app/serializers/chat/message_serializer.rb
+++ b/plugins/chat/app/serializers/chat/message_serializer.rb
@@ -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
diff --git a/plugins/chat/app/serializers/chat/messages_serializer.rb b/plugins/chat/app/serializers/chat/messages_serializer.rb
new file mode 100644
index 00000000000..0646328bdfe
--- /dev/null
+++ b/plugins/chat/app/serializers/chat/messages_serializer.rb
@@ -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
diff --git a/plugins/chat/app/serializers/chat/structured_channel_serializer.rb b/plugins/chat/app/serializers/chat/structured_channel_serializer.rb
index 5911191230c..03993c942a0 100644
--- a/plugins/chat/app/serializers/chat/structured_channel_serializer.rb
+++ b/plugins/chat/app/serializers/chat/structured_channel_serializer.rb
@@ -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
diff --git a/plugins/chat/app/serializers/chat/thread_list_serializer.rb b/plugins/chat/app/serializers/chat/thread_list_serializer.rb
index c67b0016963..fd5be6d0a8f 100644
--- a/plugins/chat/app/serializers/chat/thread_list_serializer.rb
+++ b/plugins/chat/app/serializers/chat/thread_list_serializer.rb
@@ -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
diff --git a/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb
index 0577290f8b5..67f5f067c06 100644
--- a/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb
+++ b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb
@@ -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
diff --git a/plugins/chat/app/serializers/chat/thread_serializer.rb b/plugins/chat/app/serializers/chat/thread_serializer.rb
index 54a98d6b955..8ba6372a2da 100644
--- a/plugins/chat/app/serializers/chat/thread_serializer.rb
+++ b/plugins/chat/app/serializers/chat/thread_serializer.rb
@@ -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
diff --git a/plugins/chat/app/serializers/chat/view_serializer.rb b/plugins/chat/app/serializers/chat/view_serializer.rb
deleted file mode 100644
index 027387b3962..00000000000
--- a/plugins/chat/app/serializers/chat/view_serializer.rb
+++ /dev/null
@@ -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
diff --git a/plugins/chat/app/services/chat/channel_view_builder.rb b/plugins/chat/app/services/chat/channel_view_builder.rb
deleted file mode 100644
index aa3c104524a..00000000000
--- a/plugins/chat/app/services/chat/channel_view_builder.rb
+++ /dev/null
@@ -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
diff --git a/plugins/chat/app/services/chat/list_channel_messages.rb b/plugins/chat/app/services/chat/list_channel_messages.rb
new file mode 100644
index 00000000000..6c1f5155a49
--- /dev/null
+++ b/plugins/chat/app/services/chat/list_channel_messages.rb
@@ -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
diff --git a/plugins/chat/app/services/chat/list_channel_thread_messages.rb b/plugins/chat/app/services/chat/list_channel_thread_messages.rb
new file mode 100644
index 00000000000..b592787e09f
--- /dev/null
+++ b/plugins/chat/app/services/chat/list_channel_thread_messages.rb
@@ -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
diff --git a/plugins/chat/assets/javascripts/discourse/chat-route-map.js b/plugins/chat/assets/javascripts/discourse/chat-route-map.js
index a53c4ed78c9..c762056236e 100644
--- a/plugins/chat/assets/javascripts/discourse/chat-route-map.js
+++ b/plugins/chat/assets/javascripts/discourse/chat-route-map.js
@@ -8,7 +8,9 @@ export default function () {
this.route("channel", { path: "/c/:channelTitle/:channelId" }, function () {
this.route("near-message", { path: "/:messageId" });
this.route("threads", { path: "/t" });
- this.route("thread", { path: "/t/:threadId" });
+ this.route("thread", { path: "/t/:threadId" }, function () {
+ this.route("near-message", { path: "/:messageId" });
+ });
});
this.route(
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js
index ac4a09ffc73..bf45efe8be7 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js
@@ -37,10 +37,6 @@ export default class ChatBrowseView extends Component {
}
}
- get chatProgressBarContainer() {
- return document.querySelector("#chat-progress-bar-container");
- }
-
@action
showChatNewMessageModal() {
this.modal.show(ChatModalNewMessage);
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js
index 6063524a3ff..0912bd77b07 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js
@@ -36,10 +36,6 @@ export default class ChatChannelMembersView extends Component {
this.appEvents.off("chat:refresh-channel-members", this, "onFilterMembers");
}
- get chatProgressBarContainer() {
- return document.querySelector("#chat-progress-bar-container");
- }
-
@action
onFilterMembers(username) {
this.set("filter", username);
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js
index bd4363d9b9d..83f2b957961 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js
@@ -17,7 +17,10 @@ export default class ChatChannelStatus extends Component {
}
get shouldRender() {
- return this.args.channel.status !== CHANNEL_STATUSES.open;
+ return (
+ this.channelStatusIcon &&
+ this.args.channel.status !== CHANNEL_STATUSES.open
+ );
}
get channelStatusMessage() {
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs
index a7150008dc2..26a9dba1225 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs
@@ -1,9 +1,9 @@
- {{#if this.loadedOnce}}
- {{#each @channel.messages key="id" as |message|}}
-
- {{/each}}
+ {{#each this.messagesManager.messages key="id" as |message|}}
+
{{else}}
-
- {{/if}}
+ {{#unless this.messagesLoader.fetchedOnce}}
+
+ {{/unless}}
+ {{/each}}
{{! at bottom even if shown at top due to column-reverse }}
- {{#if (and this.loadedOnce (not @channel.messagesManager.canLoadMorePast))}}
+ {{#if this.messagesLoader.loadedPast}}
{{i18n "chat.all_loaded"}}
{{/if}}
-
{{#if this.pane.selectingMessages}}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js
index 79380665187..f261cf52002 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js
@@ -1,59 +1,64 @@
-import { capitalize } from "@ember/string";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import Component from "@glimmer/component";
-import { bind, debounce } from "discourse-common/utils/decorators";
+import { bind } from "discourse-common/utils/decorators";
import { action } from "@ember/object";
// TODO (martin) Remove this when the handleSentMessage logic inside chatChannelPaneSubscriptionsManager
// is moved over from this file completely.
import { handleStagedMessage } from "discourse/plugins/chat/discourse/services/chat-pane-base-subscriptions-manager";
import { popupAjaxError } from "discourse/lib/ajax-error";
-import { cancel, later, next, schedule } from "@ember/runloop";
-import discourseLater from "discourse-common/lib/later";
+import { cancel, next, schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
-import { Promise } from "rsvp";
import { resetIdle } from "discourse/lib/desktop-notifications";
import {
onPresenceChange,
removeOnPresenceChange,
} from "discourse/lib/user-presence";
-import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check";
-import { tracked } from "@glimmer/tracking";
+import { bodyScrollFix } from "discourse/plugins/chat/discourse/lib/chat-ios-hacks";
+import {
+ scrollListToBottom,
+ scrollListToMessage,
+} from "discourse/plugins/chat/discourse/lib/scroll-helpers";
+import {
+ checkMessageBottomVisibility,
+ checkMessageTopVisibility,
+} from "discourse/plugins/chat/discourse/lib/check-message-visibility";
+import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader";
+import { cached, tracked } from "@glimmer/tracking";
import discourseDebounce from "discourse-common/lib/debounce";
import DiscourseURL from "discourse/lib/url";
+import { getOwner } from "discourse-common/lib/get-owner";
+import {
+ FUTURE,
+ PAST,
+ READ_INTERVAL_MS,
+} from "discourse/plugins/chat/discourse/lib/chat-constants";
+import { stackingContextFix } from "../lib/chat-ios-hacks";
-const PAGE_SIZE = 50;
-const PAST = "past";
-const FUTURE = "future";
-const READ_INTERVAL_MS = 1000;
-
-export default class ChatLivePane extends Component {
+export default class ChatChannel extends Component {
+ @service appEvents;
@service capabilities;
@service chat;
+ @service chatApi;
@service chatChannelsManager;
- @service router;
- @service chatEmojiPickerManager;
+ @service chatChannelPaneSubscriptionsManager;
@service chatComposerPresenceManager;
+ @service chatDraftsManager;
+ @service chatEmojiPickerManager;
@service chatStateManager;
@service("chat-channel-composer") composer;
@service("chat-channel-pane") pane;
- @service chatChannelPaneSubscriptionsManager;
- @service chatApi;
@service currentUser;
- @service appEvents;
@service messageBus;
+ @service router;
@service site;
- @service chatDraftsManager;
- @tracked loading = false;
- @tracked loadingMorePast = false;
- @tracked loadingMoreFuture = false;
@tracked sending = false;
@tracked showChatQuoteSuccess = false;
@tracked includeHeader = true;
- @tracked hasNewMessages = false;
@tracked needsArrow = false;
- @tracked loadedOnce = false;
+ @tracked atBottom = false;
@tracked uploadDropZone;
+ @tracked isScrolling = false;
scrollable = null;
_loadedChannelId = null;
@@ -61,6 +66,19 @@ export default class ChatLivePane extends Component {
_unreachableGroupMentions = [];
_overMembersLimitGroupMentions = [];
+ @cached
+ get messagesLoader() {
+ return new ChatMessagesLoader(getOwner(this), this.args.channel);
+ }
+
+ get messagesManager() {
+ return this.args.channel.messagesManager;
+ }
+
+ get currentUserMembership() {
+ return this.args.channel.currentUserMembership;
+ }
+
@action
setUploadDropZone(element) {
this.uploadDropZone = element;
@@ -73,46 +91,32 @@ export default class ChatLivePane extends Component {
@action
setupListeners() {
- document.addEventListener("scroll", this._forceBodyScroll, {
- passive: true,
- });
-
- onPresenceChange({
- callback: this.onPresenceChangeCallback,
- });
+ onPresenceChange({ callback: this.onPresenceChangeCallback });
}
@action
teardownListeners() {
this.#cancelHandlers();
- document.removeEventListener("scroll", this._forceBodyScroll);
removeOnPresenceChange(this.onPresenceChangeCallback);
this.unsubscribeToUpdates(this._loadedChannelId);
- this.requestedTargetMessageId = null;
}
@action
didResizePane() {
this.debounceFillPaneAttempt();
this.computeDatesSeparators();
- this.forceRendering();
- }
-
- @action
- resetIdle() {
- resetIdle();
}
@action
didUpdateChannel() {
this.#cancelHandlers();
- this.loadedOnce = false;
-
if (!this.args.channel) {
return;
}
+ this.messagesManager.clear();
+
if (
this.args.channel.isDirectMessageChannel &&
!this.args.channel.isFollowing
@@ -120,10 +124,6 @@ export default class ChatLivePane extends Component {
this.chatChannelsManager.follow(this.args.channel);
}
- // Technically we could keep messages to avoid re-fetching them, but
- // it's not worth the complexity for now
- this.args.channel.clearMessages();
-
if (this._loadedChannelId !== this.args.channel.id) {
this.unsubscribeToUpdates(this._loadedChannelId);
this.pane.selectingMessages = false;
@@ -140,255 +140,110 @@ export default class ChatLivePane extends Component {
}
this.composer.focus();
-
this.loadMessages();
+
+ // We update this value server-side when we load the Channel
+ // here, so this reflects reality for sidebar unread logic.
+ this.args.channel.updateLastViewedAt();
}
@action
loadMessages() {
if (!this.args.channel?.id) {
- this.loadedOnce = true;
return;
}
- if (this.args.targetMessageId) {
- this.requestedTargetMessageId = parseInt(this.args.targetMessageId, 10);
- }
+ this.subscribeToUpdates(this.args.channel);
- if (this.requestedTargetMessageId) {
- this.highlightOrFetchMessage(this.requestedTargetMessageId);
+ if (this.args.targetMessageId) {
+ this.debounceHighlightOrFetchMessage(this.args.targetMessageId);
} else {
- this.debounceFetchMessages({ fetchFromLastMessage: false });
+ this.fetchMessages({ fetch_from_last_read: true });
}
}
@bind
onPresenceChangeCallback(present) {
if (present) {
- this.updateLastReadMessage();
+ this.debouncedUpdateLastReadMessage();
}
}
- debounceFetchMessages(options) {
- this._debounceFetchMessagesHandler = discourseDebounce(
- this,
- this.fetchMessages,
- options,
- 100
- );
- }
-
- fetchMessages(options = {}) {
- if (this._selfDeleted) {
+ async fetchMessages(findArgs = {}) {
+ if (this.messagesLoader.loading) {
return;
}
- this.loadingMorePast = true;
+ this.messagesManager.clear();
- const findArgs = { pageSize: PAGE_SIZE, includeMessages: true };
- const fetchingFromLastRead = !options.fetchFromLastMessage;
- let scrollToMessageId = null;
- if (this.requestedTargetMessageId) {
- findArgs.targetMessageId = this.requestedTargetMessageId;
- scrollToMessageId = this.requestedTargetMessageId;
- } else if (this.requestedTargetDate) {
- findArgs.targetDate = this.requestedTargetDate;
- } else if (fetchingFromLastRead) {
- findArgs.fetchFromLastRead = true;
- scrollToMessageId =
- this.args.channel.currentUserMembership.lastReadMessageId;
+ const result = await this.messagesLoader.load(findArgs);
+ this.messagesManager.messages = this.processMessages(
+ this.args.channel,
+ result
+ );
+
+ if (findArgs.target_message_id) {
+ this.scrollToMessageId(findArgs.target_message_id, { highlight: true });
+ } else if (findArgs.fetch_from_last_read) {
+ const lastReadMessageId = this.currentUserMembership?.lastReadMessageId;
+ this.scrollToMessageId(lastReadMessageId);
+ } else if (findArgs.target_date) {
+ this.scrollToMessageId(result.meta.target_message_id, {
+ highlight: true,
+ position: "center",
+ });
+ } else {
+ this._ignoreNextScroll = true;
+ this.scrollToBottom();
}
- return this.chatApi
- .channel(this.args.channel.id, findArgs)
- .then((result) => {
- if (this._selfDeleted || this.args.channel.id !== result.channel.id) {
- return;
- }
-
- const [messages, meta] = this.afterFetchCallback(
- this.args.channel,
- result
- );
-
- this.args.channel.addMessages(messages);
- this.args.channel.details = meta;
-
- // We update this value server-side when we load the Channel
- // here, so this reflects reality for sidebar unread logic.
- this.args.channel.updateLastViewedAt();
-
- if (result.threads) {
- result.threads.forEach((thread) => {
- const storedThread = this.args.channel.threadsManager.add(
- this.args.channel,
- thread,
- { replace: true }
- );
-
- this.#preloadThreadTrackingState(
- storedThread,
- result.tracking.thread_tracking
- );
-
- const originalMessage = messages.findBy(
- "id",
- storedThread.originalMessage.id
- );
- if (originalMessage) {
- originalMessage.thread = storedThread;
- }
- });
- }
-
- if (result.unread_thread_overview) {
- this.args.channel.threadsManager.unreadThreadOverview =
- result.unread_thread_overview;
- }
-
- if (this.requestedTargetMessageId) {
- this.scrollToMessage(scrollToMessageId, {
- highlight: true,
- });
- return;
- } else if (this.requestedTargetDate) {
- const message = this.args.channel?.findFirstMessageOfDay(
- this.requestedTargetDate
- );
-
- this.scrollToMessage(message.id, {
- highlight: true,
- });
- return;
- }
-
- if (
- fetchingFromLastRead &&
- messages.length &&
- scrollToMessageId !== messages[messages.length - 1].id
- ) {
- this.scrollToMessage(scrollToMessageId);
- return;
- }
- this.scrollToBottom();
- })
- .catch(this._handleErrors)
- .finally(() => {
- if (this._selfDeleted) {
- return;
- }
-
- this.loadedOnce = true;
- this.requestedTargetMessageId = null;
- this.requestedTargetDate = null;
- this.loadingMorePast = false;
- this.debounceFillPaneAttempt();
- this.updateLastReadMessage();
- this.subscribeToUpdates(this.args.channel);
- });
+ this.debounceFillPaneAttempt();
+ this.debouncedUpdateLastReadMessage();
}
- @bind
- fetchMoreMessages({ direction }) {
- const loadingPast = direction === PAST;
- const loadingMoreKey = `loadingMore${capitalize(direction)}`;
-
- const canLoadMore = loadingPast
- ? this.args.channel?.canLoadMorePast
- : this.args.channel?.canLoadMoreFuture;
-
- if (
- !canLoadMore ||
- this.loading ||
- this[loadingMoreKey] ||
- !this.args.channel.messages?.length > 0
- ) {
- return Promise.resolve();
+ async fetchMoreMessages({ direction }, opts = {}) {
+ if (this.messagesLoader.loading) {
+ return;
}
- this[loadingMoreKey] = true;
+ const result = await this.messagesLoader.loadMore({ direction });
+ if (!result) {
+ return;
+ }
- const messageIndex = loadingPast
- ? 0
- : this.args.channel.messages.length - 1;
- const messageId = this.args.channel.messages[messageIndex].id;
- const findArgs = {
- channelId: this.args.channel.id,
- pageSize: PAGE_SIZE,
- direction,
- messageId,
- };
+ const messages = this.processMessages(this.args.channel, result);
+ if (!messages.length) {
+ return;
+ }
- return this.chatApi
- .channel(this.args.channel.id, findArgs)
- .then((result) => {
- if (
- this._selfDeleted ||
- this.args.channel.id !== result.meta.channel_id ||
- !this.scrollable
- ) {
- return;
- }
+ const targetMessageId = this.messagesManager.messages.lastObject.id;
+ stackingContextFix(this.scrollable, () => {
+ this.messagesManager.addMessages(messages);
+ });
- const [messages, meta] = this.afterFetchCallback(
- this.args.channel,
- result
- );
-
- if (result.threads) {
- result.threads.forEach((thread) => {
- const storedThread = this.args.channel.threadsManager.add(
- this.args.channel,
- thread,
- { replace: true }
- );
-
- this.#preloadThreadTrackingState(
- storedThread,
- result.tracking.thread_tracking
- );
-
- const originalMessage = messages.findBy(
- "id",
- storedThread.originalMessage.id
- );
- if (originalMessage) {
- originalMessage.thread = storedThread;
- }
- });
- }
-
- if (result.unread_thread_overview) {
- this.args.channel.threadsManager.unreadThreadOverview =
- result.unread_thread_overview;
- }
-
- this.args.channel.details = meta;
-
- if (!messages?.length) {
- return;
- }
-
- this.args.channel.addMessages(messages);
-
- // Edge case for IOS to avoid blank screens
- // and/or scrolling to bottom losing track of scroll position
- if (!loadingPast && (this.capabilities.isIOS || !this.isScrolling)) {
- this.scrollToMessage(messages[0].id, { position: "end" });
- }
- })
- .catch(this._handleErrors)
- .finally(() => {
- this[loadingMoreKey] = false;
- this.debounceFillPaneAttempt();
+ if (direction === FUTURE && !opts.noScroll) {
+ this.scrollToMessageId(targetMessageId, {
+ position: "end",
+ forceAuto: true,
});
+ }
+
+ this.debounceFillPaneAttempt();
+ }
+
+ @action
+ scrollToBottom() {
+ this._ignoreNextScroll = true;
+ scrollListToBottom(this.scrollable);
+ }
+
+ scrollToMessageId(messageId, options = {}) {
+ this._ignoreNextScroll = true;
+ const message = this.messagesManager.findMessage(messageId);
+ scrollListToMessage(this.scrollable, message, options);
}
debounceFillPaneAttempt() {
- if (!this.loadedOnce) {
- return;
- }
-
this._debouncedFillPaneAttemptHandler = discourseDebounce(
this,
this.fillPaneAttempt,
@@ -398,43 +253,53 @@ export default class ChatLivePane extends Component {
@bind
fetchMessagesByDate(date) {
- const message = this.args.channel?.findFirstMessageOfDay(date);
- if (message.firstOfResults && this.args.channel?.canLoadMorePast) {
- this.requestedTargetDate = date;
- this.debounceFetchMessages();
+ if (this.messagesLoader.loading) {
+ return;
+ }
+
+ const message = this.messagesManager.findFirstMessageOfDay(new Date(date));
+ if (message.firstOfResults && this.messagesLoader.canLoadMorePast) {
+ this.fetchMessages({ target_date: date, direction: FUTURE });
} else {
- this.highlightOrFetchMessage(message.id);
+ this.highlightOrFetchMessage(message.id, { position: "center" });
}
}
- fillPaneAttempt() {
- if (this._selfDeleted) {
+ async fillPaneAttempt() {
+ if (!this.messagesLoader.fetchedOnce) {
return;
}
// safeguard
- if (this.args.channel.messages?.length > 200) {
+ if (this.messagesManager.messages.length > 200) {
return;
}
- if (!this.args.channel?.canLoadMorePast) {
+ if (!this.messagesLoader.canLoadMorePast) {
return;
}
- const firstMessage = this.args.channel?.messages?.firstObject;
- if (!firstMessage?.visible) {
- return;
- }
-
- this.fetchMoreMessages({ direction: PAST });
+ schedule("afterRender", () => {
+ const firstMessageId = this.messagesManager.messages.firstObject?.id;
+ const messageContainer = this.scrollable.querySelector(
+ `.chat-message-container[data-id="${firstMessageId}"]`
+ );
+ if (
+ messageContainer &&
+ checkMessageTopVisibility(this.scrollable, messageContainer)
+ ) {
+ this.fetchMoreMessages({ direction: PAST });
+ }
+ });
}
@bind
- afterFetchCallback(channel, result) {
+ processMessages(channel, result) {
const messages = [];
let foundFirstNew = false;
+ const hasNewest = this.messagesManager.messages.some((m) => m.newest);
- result.chat_messages.forEach((messageData, index) => {
+ result.messages.forEach((messageData, index) => {
messageData.firstOfResults = index === 0;
if (this.currentUser.ignored_users) {
@@ -455,129 +320,100 @@ export default class ChatLivePane extends Component {
// newest has to be in after fetch callback as we don't want to make it
// dynamic or it will make the pane jump around, it will disappear on reload
if (
+ !hasNewest &&
!foundFirstNew &&
- messageData.id >
- this.args.channel.currentUserMembership.lastReadMessageId &&
- !channel.messages.some((m) => m.newest)
+ messageData.id > this.currentUserMembership?.lastReadMessageId
) {
foundFirstNew = true;
messageData.newest = true;
}
const message = ChatMessage.create(channel, messageData);
+ message.manager = channel.messagesManager;
+
+ if (message.thread) {
+ this.#preloadThreadTrackingState(
+ message.thread,
+ result.tracking.thread_tracking
+ );
+ }
+
messages.push(message);
});
- return [messages, result.meta];
+ return messages;
}
- @debounce(100)
- highlightOrFetchMessage(messageId) {
- const message = this.args.channel?.findMessage(messageId);
+ debounceHighlightOrFetchMessage(messageId, options = {}) {
+ this._debouncedHighlightOrFetchMessageHandler = discourseDebounce(
+ this,
+ this.highlightOrFetchMessage,
+ messageId,
+ options,
+ 100
+ );
+ }
+
+ highlightOrFetchMessage(messageId, options = {}) {
+ const message = this.messagesManager.findMessage(messageId);
if (message) {
- this.scrollToMessage(message.id, {
- highlight: true,
- position: "start",
- autoExpand: true,
- });
- this.requestedTargetMessageId = null;
+ this.scrollToMessageId(
+ message.id,
+ Object.assign(
+ {
+ highlight: true,
+ position: "start",
+ autoExpand: true,
+ behavior: this.capabilities.isIOS ? "smooth" : null,
+ },
+ options
+ )
+ );
} else {
- this.debounceFetchMessages();
+ this.fetchMessages({ target_message_id: messageId });
}
}
- scrollToMessage(
- messageId,
- opts = { highlight: false, position: "start", autoExpand: false }
- ) {
- if (this._selfDeleted) {
+ debouncedUpdateLastReadMessage() {
+ this._debouncedUpdateLastReadMessageHandler = discourseDebounce(
+ this,
+ this.updateLastReadMessage,
+ READ_INTERVAL_MS
+ );
+ }
+
+ updateLastReadMessage() {
+ if (!this.args.channel.isFollowing) {
return;
}
- const message = this.args.channel?.findMessage(messageId);
- if (message?.deletedAt && opts.autoExpand) {
- message.expanded = true;
- }
-
schedule("afterRender", () => {
- if (this._selfDeleted) {
+ let lastFullyVisibleMessageNode = null;
+
+ this.scrollable
+ .querySelectorAll(".chat-message-container")
+ .forEach((item) => {
+ if (checkMessageBottomVisibility(this.scrollable, item)) {
+ lastFullyVisibleMessageNode = item;
+ }
+ });
+
+ if (!lastFullyVisibleMessageNode) {
return;
}
- const messageEl = this.scrollable.querySelector(
- `.chat-message-container[data-id='${messageId}']`
+ let lastUnreadVisibleMessage = this.messagesManager.findMessage(
+ lastFullyVisibleMessageNode.dataset.id
);
- if (!messageEl) {
- return;
- }
-
- if (opts.highlight) {
- message.highlighted = true;
-
- discourseLater(() => {
- if (this._selfDeleted) {
- return;
- }
-
- message.highlighted = false;
- }, 2000);
- }
-
- this.forceRendering(() => {
- messageEl.scrollIntoView({
- block: opts.position ?? "center",
- });
- });
- });
- }
-
- @debounce(READ_INTERVAL_MS)
- updateLastReadMessage() {
- schedule("afterRender", () => {
- if (this._selfDeleted) {
+ if (!lastUnreadVisibleMessage) {
return;
}
const lastReadId =
this.args.channel.currentUserMembership?.lastReadMessageId;
- let lastUnreadVisibleMessage = this.args.channel.visibleMessages.findLast(
- (message) => !message.staged && (!lastReadId || message.id > lastReadId)
- );
-
- // all intersecting messages are read
- if (!lastUnreadVisibleMessage) {
- return;
- }
-
- const element = this.scrollable.querySelector(
- `[data-id='${lastUnreadVisibleMessage.id}']`
- );
-
- // if the last visible message is not fully visible, we don't want to mark it as read
- // attempt to mark previous one as read
- if (
- element &&
- !this.#isBottomOfMessageVisible(element, this.scrollable)
- ) {
- lastUnreadVisibleMessage = lastUnreadVisibleMessage.previousMessage;
-
- if (
- !lastUnreadVisibleMessage ||
- lastReadId > lastUnreadVisibleMessage.id
- ) {
- return;
- }
- }
-
- if (!this.args.channel.isFollowing || !lastUnreadVisibleMessage.id) {
- return;
- }
-
- if (
- this.args.channel.currentUserMembership.lastReadMessageId >=
- lastUnreadVisibleMessage.id
- ) {
+ // we don't return early if === as we want to ensure different tabs will do the check
+ if (lastReadId > lastUnreadVisibleMessage.id) {
return;
}
@@ -590,69 +426,55 @@ export default class ChatLivePane extends Component {
@action
scrollToLatestMessage() {
- next(() => {
- schedule("afterRender", () => {
- if (this._selfDeleted) {
- return;
- }
+ if (this.messagesLoader.canLoadMoreFuture) {
+ this.fetchMessages({ fetch_from_last_read: true });
+ } else if (this.messagesManager.messages.length > 0) {
+ this._ignoreNextScroll = true;
+ this.scrollToBottom(this.scrollable);
+ }
+ }
- if (this.args.channel?.canLoadMoreFuture) {
- this._fetchAndScrollToLatest();
- } else if (this.args.channel.messages?.length > 0) {
- this.scrollToMessage(
- this.args.channel.messages[this.args.channel.messages.length - 1].id
- );
- }
- });
+ @action
+ onScroll(state) {
+ bodyScrollFix();
+
+ next(() => {
+ if (this.#flushIgnoreNextScroll()) {
+ return;
+ }
+
+ this.needsArrow =
+ (this.messagesLoader.fetchedOnce &&
+ this.messagesLoader.canLoadMoreFuture) ||
+ (state.distanceToBottom.pixels > 250 && !state.atBottom);
+ this.isScrolling = true;
+ this.debouncedUpdateLastReadMessage();
+
+ if (
+ state.atTop ||
+ (!this.capabilities.isIOS &&
+ state.up &&
+ state.distanceToTop.percentage < 40)
+ ) {
+ this.fetchMoreMessages({ direction: PAST });
+ } else if (state.atBottom) {
+ this.fetchMoreMessages({ direction: FUTURE });
+ }
});
}
@action
- computeArrow() {
- if (!this.scrollable) {
- return;
- }
-
- this.needsArrow = Math.abs(this.scrollable.scrollTop) >= 250;
- }
-
- @action
- computeScrollState() {
- cancel(this._onScrollEndedHandler);
-
- if (!this.scrollable) {
- return;
- }
-
- this.chat.activeMessage = null;
-
- if (this.#isAtTop()) {
- this.fetchMoreMessages({ direction: PAST });
- this.onScrollEnded();
- } else if (this.#isAtBottom()) {
- this.updateLastReadMessage();
- this.hasNewMessages = false;
- this.fetchMoreMessages({ direction: FUTURE });
- this.onScrollEnded();
- } else {
- this.isScrolling = true;
- this._onScrollEndedHandler = discourseLater(
- this,
- this.onScrollEnded,
- 150
- );
- }
- }
-
- @bind
- onScrollEnded() {
+ onScrollEnd(state) {
+ resetIdle();
+ this.needsArrow =
+ (this.messagesLoader.fetchedOnce &&
+ this.messagesLoader.canLoadMoreFuture) ||
+ (state.distanceToBottom.pixels > 250 && !state.atBottom);
this.isScrolling = false;
- }
+ this.atBottom = state.atBottom;
- removeMessage(msgData) {
- const message = this.args.channel?.findMessage(msgData.id);
- if (message) {
- this.args.channel?.removeMessage(message);
+ if (state.atBottom) {
+ this.fetchMoreMessages({ direction: FUTURE });
}
}
@@ -669,7 +491,7 @@ export default class ChatLivePane extends Component {
if (data.chat_message.user.id === this.currentUser.id && data.staged_id) {
const stagedMessage = handleStagedMessage(
this.args.channel,
- this.args.channel.messagesManager,
+ this.messagesManager,
data
);
if (stagedMessage) {
@@ -677,27 +499,13 @@ export default class ChatLivePane extends Component {
}
}
- if (this.args.channel?.canLoadMoreFuture) {
- // If we can load more messages, we just notice the user of new messages
- this.hasNewMessages = true;
- } else if (this.#isTowardsBottom()) {
- // If we are at the bottom, we append the message and scroll to it
- const message = ChatMessage.create(this.args.channel, data.chat_message);
- this.args.channel.addMessages([message]);
- this.args.channel.lastMessage = message;
- this.scrollToLatestMessage();
- this.updateLastReadMessage();
- } else {
- // If we are almost at the bottom, we append the message and notice the user
- const message = ChatMessage.create(this.args.channel, data.chat_message);
- this.args.channel.addMessages([message]);
- this.args.channel.lastMessage = message;
- this.hasNewMessages = true;
- }
- }
-
- get _selfDeleted() {
- return this.isDestroying || this.isDestroyed;
+ const message = ChatMessage.create(this.args.channel, data.chat_message);
+ message.manager = this.args.channel.messagesManager;
+ stackingContextFix(this.scrollable, () => {
+ this.messagesManager.addMessages([message]);
+ });
+ this.debouncedUpdateLastReadMessage();
+ this.args.channel.lastMessage = message;
}
@action
@@ -726,11 +534,9 @@ export default class ChatLivePane extends Component {
this.resetComposerMessage();
try {
- return await this.chatApi.editMessage(
- this.args.channel.id,
- message.id,
- data
- );
+ stackingContextFix(this.scrollable, async () => {
+ await this.chatApi.editMessage(this.args.channel.id, message.id, data);
+ });
} catch (e) {
popupAjaxError(e);
} finally {
@@ -744,10 +550,14 @@ export default class ChatLivePane extends Component {
resetIdle();
- await this.args.channel.stageMessage(message);
+ stackingContextFix(this.scrollable, async () => {
+ await this.args.channel.stageMessage(message);
+ });
+
+ message.manager = this.args.channel.messagesManager;
this.resetComposerMessage();
- if (!this.args.channel.canLoadMoreFuture) {
+ if (!this.capabilities.isIOS && !this.messagesLoader.canLoadMoreFuture) {
this.scrollToLatestMessage();
}
@@ -759,20 +569,20 @@ export default class ChatLivePane extends Component {
upload_ids: message.uploads.map((upload) => upload.id),
});
- this.scrollToLatestMessage();
+ if (!this.capabilities.isIOS) {
+ this.scrollToLatestMessage();
+ }
} catch (error) {
this._onSendError(message.id, error);
- this.scrollToBottom();
} finally {
- if (!this._selfDeleted) {
- this.chatDraftsManager.remove({ channelId: this.args.channel.id });
- this.pane.sending = false;
- }
+ this.chatDraftsManager.remove({ channelId: this.args.channel.id });
+ this.pane.sending = false;
}
}
_onSendError(id, error) {
- const stagedMessage = this.args.channel.findStagedMessage(id);
+ const stagedMessage =
+ this.args.channel.messagesManager.findStagedMessage(id);
if (stagedMessage) {
if (error.jqXHR?.responseJSON?.errors?.length) {
// only network errors are retryable
@@ -810,17 +620,10 @@ export default class ChatLivePane extends Component {
this.chat.markNetworkAsReliable();
})
.finally(() => {
- if (this._selfDeleted) {
- return;
- }
this.pane.sending = false;
});
}
- get chatProgressBarContainer() {
- return document.querySelector("#chat-progress-bar-container");
- }
-
@action
onCloseFullScreen() {
this.chatStateManager.prefersDrawer();
@@ -853,49 +656,6 @@ export default class ChatLivePane extends Component {
this.chatChannelPaneSubscriptionsManager.subscribe(channel);
}
- @bind
- _forceBodyScroll() {
- // when keyboard is visible this will ensure body
- // doesn’t scroll out of viewport
- if (
- this.capabilities.isIOS &&
- document.documentElement.classList.contains("keyboard-visible") &&
- !isZoomed()
- ) {
- document.documentElement.scrollTo(0, 0);
- }
- }
-
- _fetchAndScrollToLatest() {
- this.loadedOnce = false;
- return this.debounceFetchMessages({
- fetchFromLastMessage: true,
- });
- }
-
- @bind
- _handleErrors(error) {
- switch (error?.jqXHR?.status) {
- case 429:
- popupAjaxError(error);
- break;
- case 404:
- // avoids handling 404 errors from a channel
- // that is not the current one, this is very likely in tests
- // which will destroy the channel after the test is done
- if (
- this.args.channel?.id &&
- error.jqXHR?.requestedUrl ===
- `/chat/api/channels/${this.args.channel.id}`
- ) {
- popupAjaxError(error);
- }
- break;
- default:
- throw error;
- }
- }
-
@action
addAutoFocusEventListener() {
document.addEventListener("keydown", this._autoFocus);
@@ -938,60 +698,9 @@ export default class ChatLivePane extends Component {
return;
}
- @action
- computeDatesSeparators() {
- cancel(this._laterComputeHandler);
- this._computeDatesSeparators();
- this._laterComputeHandler = later(this, this._computeDatesSeparators, 100);
- }
-
- // A more consistent way to scroll to the bottom when we are sure this is our goal
- // it will also limit issues with any element changing the height while we are scrolling
- // to the bottom
- @action
- scrollToBottom() {
- if (!this.scrollable) {
- return;
- }
-
- this.scrollable.scrollTop = -1;
- this.forceRendering(() => {
- this.scrollable.scrollTop = 0;
- });
- }
-
- // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling
- // we now use this hack to disable it
@bind
- forceRendering(callback) {
- if (this.capabilities.isIOS) {
- this.scrollable.style.overflow = "hidden";
- }
-
- callback?.();
-
- if (this.capabilities.isIOS) {
- next(() => {
- schedule("afterRender", () => {
- if (this._selfDeleted || !this.scrollable) {
- return;
- }
- this.scrollable.style.overflow = "auto";
- });
- });
- }
- }
-
- _computeDatesSeparators() {
+ computeDatesSeparators() {
schedule("afterRender", () => {
- if (this._selfDeleted) {
- return;
- }
-
- if (!this.scrollable) {
- return;
- }
-
const dates = [
...this.scrollable.querySelectorAll(".chat-message-separator-date"),
].reverse();
@@ -1033,55 +742,24 @@ export default class ChatLivePane extends Component {
});
}
- #isAtBottom() {
- if (!this.scrollable) {
- return false;
- }
-
- return Math.abs(this.scrollable.scrollTop) <= 2;
- }
-
- #isTowardsBottom() {
- if (!this.scrollable) {
- return false;
- }
-
- return Math.abs(this.scrollable.scrollTop) <= 50;
- }
-
- #isAtTop() {
- if (!this.scrollable) {
- return false;
- }
-
- return (
- Math.abs(this.scrollable.scrollTop) >=
- this.scrollable.scrollHeight - this.scrollable.offsetHeight - 2
- );
- }
-
- #isBottomOfMessageVisible(element, container) {
- const rect = element.getBoundingClientRect();
- const containerRect = container.getBoundingClientRect();
- // - 5.0 to account for rounding errors, especially on firefox
- return rect.bottom - 5.0 <= containerRect.bottom;
- }
-
#cancelHandlers() {
+ cancel(this._debouncedHighlightOrFetchMessageHandler);
+ cancel(this._debouncedUpdateLastReadMessageHandler);
cancel(this._debouncedFillPaneAttemptHandler);
- cancel(this._onScrollEndedHandler);
- cancel(this._laterComputeHandler);
- cancel(this._debounceFetchMessagesHandler);
}
- #preloadThreadTrackingState(storedThread, threadTracking) {
- if (!threadTracking[storedThread.id]) {
+ #preloadThreadTrackingState(thread, threadTracking) {
+ if (!threadTracking[thread.id]) {
return;
}
- storedThread.tracking.unreadCount =
- threadTracking[storedThread.id].unread_count;
- storedThread.tracking.mentionCount =
- threadTracking[storedThread.id].mention_count;
+ thread.tracking.unreadCount = threadTracking[thread.id].unread_count;
+ thread.tracking.mentionCount = threadTracking[thread.id].mention_count;
+ }
+
+ #flushIgnoreNextScroll() {
+ const prev = this._ignoreNextScroll;
+ this._ignoreNextScroll = false;
+ return prev;
}
}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs
index a926032a91c..9d12ae0bc5d 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs
@@ -96,6 +96,7 @@
disabled={{or this.disabled (not this.sendEnabled)}}
tabindex={{if this.sendEnabled 0 -1}}
{{on "click" this.onSend}}
+ {{on "mousedown" this.trapMouseDown}}
{{on "focus" (fn this.computeIsFocused true)}}
{{on "blur" (fn this.computeIsFocused false)}}
/>
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
index 0bb149018df..2a22d869978 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
@@ -214,11 +214,18 @@ export default class ChatComposer extends Component {
}
@action
- async onSend() {
+ trapMouseDown(event) {
+ event?.preventDefault();
+ }
+
+ @action
+ async onSend(event) {
if (!this.sendEnabled) {
return;
}
+ event?.preventDefault();
+
if (
this.currentMessage.editing &&
this.currentMessage.message.length === 0
@@ -232,15 +239,8 @@ export default class ChatComposer extends Component {
return;
}
- if (this.site.mobileView) {
- // prevents to hide the keyboard after sending a message
- // we use direct DOM manipulation here because textareaInteractor.focus()
- // is using the runloop which is too late
- this.composer.textarea.textarea.focus();
- }
-
await this.args.onSendMessage(this.currentMessage);
- this.composer.focus({ refreshHeight: true });
+ this.composer.textarea.refreshHeight();
}
reportReplyingPresence() {
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs
index 5c3ff0fd953..9b6c3930539 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs
@@ -24,7 +24,10 @@
{{did-update this.fetchChannelAndThread @params.threadId}}
>
{{#if this.chat.activeChannel.activeThread}}
-
+
{{/if}}
{{/if}}
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs
index 370a3d2cb30..054f8df7498 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs
@@ -3,6 +3,7 @@
{{did-insert this.setup}}
{{did-update this.setup this.chat.activeMessage.model.id}}
{{on "mouseleave" this.onMouseleave}}
+ {{on "wheel" this.onWheel passive=true}}
{{will-destroy this.teardown}}
class={{concat-class
"chat-message-actions-container"
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js
index 45454acbe13..997ceb1321d 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js
@@ -42,6 +42,12 @@ export default class ChatMessageActionsDesktop extends Component {
return this.size === FULL;
}
+ @action
+ onWheel() {
+ // prevents menu to stop scroll on the list of messages
+ this.chat.activeMessage = null;
+ }
+
@action
onMouseleave(event) {
// if the mouse is leaving the actions menu for the actual menu, don't close it
@@ -105,5 +111,6 @@ export default class ChatMessageActionsDesktop extends Component {
@action
teardown() {
this.popper?.destroy();
+ this.popper = null;
}
}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.js
index a19d455317d..d77121cebe6 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.js
@@ -1,6 +1,5 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
-import { escapeExpression } from "discourse/lib/utilities";
import { action } from "@ember/object";
import { bind } from "discourse-common/utils/decorators";
import { tracked } from "@glimmer/tracking";
@@ -84,8 +83,4 @@ export default class ChatMessageThreadIndicator extends Component {
...this.args.message.thread.routeModels
);
}
-
- get threadTitle() {
- return escapeExpression(this.args.message.threadTitle);
- }
}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs
index 105ec95af60..73421462cf2 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs
@@ -39,10 +39,7 @@
this.onLongPressEnd
this.onLongPressCancel
}}
- {{chat/track-message
- (fn (mut @message.visible) true)
- (fn (mut @message.visible) false)
- }}
+ ...attributes
>
{{#if this.show}}
{{#if this.pane.selectingMessages}}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js
index 4de0a790ad9..57f796172c9 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js
@@ -134,6 +134,7 @@ export default class ChatMessage extends Component {
cancel(this._invitationSentTimer);
cancel(this._disableMessageActionsHandler);
cancel(this._makeMessageActiveHandler);
+ cancel(this._debounceDecorateCookedMessageHandler);
this.#teardownMentionedUsers();
}
@@ -163,27 +164,36 @@ export default class ChatMessage extends Component {
@action
didInsertMessage(element) {
this.messageContainer = element;
- this.decorateCookedMessage();
+ this.debounceDecorateCookedMessage();
this.refreshStatusOnMentions();
}
@action
didUpdateMessageId() {
- this.decorateCookedMessage();
+ this.debounceDecorateCookedMessage();
}
@action
didUpdateMessageVersion() {
- this.decorateCookedMessage();
+ this.debounceDecorateCookedMessage();
this.refreshStatusOnMentions();
this.initMentionedUsers();
}
+ debounceDecorateCookedMessage() {
+ this._debounceDecorateCookedMessageHandler = discourseDebounce(
+ this,
+ this.decorateCookedMessage,
+ this.args.message,
+ 100
+ );
+ }
+
@action
- decorateCookedMessage() {
+ decorateCookedMessage(message) {
schedule("afterRender", () => {
_chatMessageDecorators.forEach((decorator) => {
- decorator.call(this, this.messageContainer, this.args.message.channel);
+ decorator.call(this, this.messageContainer, message.channel);
});
});
}
@@ -264,6 +274,10 @@ export default class ChatMessage extends Component {
}
_setActiveMessage() {
+ if (this.args.disableMouseEvents) {
+ return;
+ }
+
cancel(this._onMouseEnterMessageDebouncedHandler);
if (!this.chat.userCanInteractWithChat) {
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs
index ec61c01b0a3..0d5fb60712d 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs
@@ -1,41 +1,52 @@
{{#if @includeHeader}}
{{/if}}
- {{#each @thread.messages key="id" as |message|}}
+ {{#each this.messagesManager.messages key="id" as |message|}}
{{/each}}
- {{#if this.loading}}
-
- {{/if}}
+
+ {{#unless this.messagesLoader.fetchedOnce}}
+ {{#if this.messagesLoader.loading}}
+
+ {{/if}}
+ {{/unless}}
+
+
{{#if this.chatThreadPane.selectingMessages}}
{{else}}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js
index bada1f9a9fb..98261307bcb 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js
@@ -1,39 +1,67 @@
import Component from "@glimmer/component";
import { NotificationLevels } from "discourse/lib/notification-levels";
import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership";
-import { Promise } from "rsvp";
-import { tracked } from "@glimmer/tracking";
+import { cached, tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { popupAjaxError } from "discourse/lib/ajax-error";
-import { bind, debounce } from "discourse-common/utils/decorators";
+import { bind } from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service";
-import { cancel, next, schedule } from "@ember/runloop";
-import discourseLater from "discourse-common/lib/later";
+import { cancel, next } from "@ember/runloop";
import { resetIdle } from "discourse/lib/desktop-notifications";
+import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader";
+import { getOwner } from "discourse-common/lib/get-owner";
+import {
+ FUTURE,
+ PAST,
+ READ_INTERVAL_MS,
+} from "discourse/plugins/chat/discourse/lib/chat-constants";
+import discourseDebounce from "discourse-common/lib/debounce";
+import {
+ bodyScrollFix,
+ stackingContextFix,
+} from "discourse/plugins/chat/discourse/lib/chat-ios-hacks";
+import {
+ scrollListToBottom,
+ scrollListToMessage,
+ scrollListToTop,
+} from "discourse/plugins/chat/discourse/lib/scroll-helpers";
-const PAGE_SIZE = 100;
-const READ_INTERVAL_MS = 1000;
-
-export default class ChatThreadPanel extends Component {
- @service siteSettings;
- @service currentUser;
+export default class ChatThread extends Component {
+ @service appEvents;
+ @service capabilities;
@service chat;
- @service router;
@service chatApi;
@service chatComposerPresenceManager;
+ @service chatHistory;
@service chatThreadComposer;
@service chatThreadPane;
@service chatThreadPaneSubscriptionsManager;
- @service appEvents;
- @service capabilities;
- @service chatHistory;
+ @service currentUser;
+ @service router;
+ @service siteSettings;
- @tracked loading;
+ @tracked isAtBottom = true;
+ @tracked isScrolling = false;
+ @tracked needsArrow = false;
@tracked uploadDropZone;
scrollable = null;
+ @action
+ resetIdle() {
+ resetIdle();
+ }
+
+ @cached
+ get messagesLoader() {
+ return new ChatMessagesLoader(getOwner(this), this.args.thread);
+ }
+
+ get messagesManager() {
+ return this.args.thread.messagesManager;
+ }
+
@action
handleKeydown(event) {
if (event.key === "Escape") {
@@ -46,7 +74,7 @@ export default class ChatThreadPanel extends Component {
@action
didUpdateThread() {
- this.subscribeToUpdates();
+ this.messagesManager.clear();
this.chatThreadComposer.focus();
this.loadMessages();
this.resetComposerMessage();
@@ -63,66 +91,71 @@ export default class ChatThreadPanel extends Component {
}
@action
- unsubscribeFromUpdates() {
+ teardown() {
this.chatThreadPaneSubscriptionsManager.unsubscribe();
+ cancel(this._debouncedFillPaneAttemptHandler);
+ cancel(this._debounceUpdateLastReadMessageHandler);
}
@action
- computeScrollState() {
- cancel(this.onScrollEndedHandler);
+ onScroll(state) {
+ next(() => {
+ if (this.#flushIgnoreNextScroll()) {
+ return;
+ }
- if (!this.scrollable) {
- return;
- }
+ bodyScrollFix();
- this.chat.activeMessage = null;
-
- if (this.#isAtBottom()) {
- this.updateLastReadMessage();
- this.onScrollEnded();
- } else {
+ this.needsArrow =
+ (this.messagesLoader.fetchedOnce &&
+ this.messagesLoader.canLoadMoreFuture) ||
+ (state.distanceToBottom.pixels > 250 && !state.atBottom);
this.isScrolling = true;
- this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 150);
+ this.debounceUpdateLastReadMessage();
+
+ if (
+ state.atTop ||
+ (!this.capabilities.isIOS &&
+ state.up &&
+ state.distanceToTop.percentage < 40)
+ ) {
+ this.fetchMoreMessages({ direction: PAST });
+ } else if (state.atBottom) {
+ this.fetchMoreMessages({ direction: FUTURE });
+ }
+ });
+ }
+
+ @action
+ onScrollEnd(state) {
+ this.needsArrow =
+ (this.messagesLoader.fetchedOnce &&
+ this.messagesLoader.canLoadMoreFuture) ||
+ (state.distanceToBottom.pixels > 250 && !state.atBottom);
+ this.isScrolling = false;
+ this.resetIdle();
+ this.atBottom = state.atBottom;
+
+ if (state.atBottom) {
+ this.fetchMoreMessages({ direction: FUTURE });
}
}
- #isAtBottom() {
- if (!this.scrollable) {
- return false;
- }
-
- // This is different from the channel scrolling because the scrolling here
- // is inverted -- in the channel's case scrollTop is 0 when scrolled to the
- // bottom of the channel, but in the negatives when scrolling up to past messages.
- //
- // c.f. https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
- return (
- Math.abs(
- this.scrollable.scrollHeight -
- this.scrollable.clientHeight -
- this.scrollable.scrollTop
- ) <= 2
+ debounceUpdateLastReadMessage() {
+ this._debounceUpdateLastReadMessageHandler = discourseDebounce(
+ this,
+ this.updateLastReadMessage,
+ READ_INTERVAL_MS
);
}
@bind
- onScrollEnded() {
- this.isScrolling = false;
- }
-
- @debounce(READ_INTERVAL_MS)
updateLastReadMessage() {
- schedule("afterRender", () => {
- if (this._selfDeleted) {
- return;
- }
-
- // HACK: We don't have proper scroll visibility over
- // what message we are looking at, don't have the lastReadMessageId
- // for the thread, and this updateLastReadMessage function is only
- // called when scrolling all the way to the bottom.
- this.markThreadAsRead();
- });
+ // HACK: We don't have proper scroll visibility over
+ // what message we are looking at, don't have the lastReadMessageId
+ // for the thread, and this updateLastReadMessage function is only
+ // called when scrolling all the way to the bottom.
+ this.markThreadAsRead();
}
@action
@@ -132,94 +165,158 @@ export default class ChatThreadPanel extends Component {
@action
loadMessages() {
- this.args.thread.messagesManager.clearMessages();
this.fetchMessages();
+ this.subscribeToUpdates();
}
@action
didResizePane() {
- this.forceRendering();
+ this._ignoreNextScroll = true;
+ this.debounceFillPaneAttempt();
+ this.debounceUpdateLastReadMessage();
}
- get _selfDeleted() {
- return this.isDestroying || this.isDestroyed;
- }
-
- @debounce(100)
- fetchMessages() {
- if (this._selfDeleted) {
- return Promise.resolve();
- }
-
+ async fetchMessages(findArgs = {}) {
if (this.args.thread.staged) {
const message = this.args.thread.originalMessage;
message.thread = this.args.thread;
- this.args.thread.messagesManager.addMessages([message]);
- return Promise.resolve();
+ message.manager = this.messagesManager;
+ this.messagesManager.addMessages([message]);
+ return;
}
- this.loading = true;
+ if (this.messagesLoader.loading) {
+ return;
+ }
- const findArgs = {
- pageSize: PAGE_SIZE,
- threadId: this.args.thread.id,
- includeMessages: true,
- };
- return this.chatApi
- .channel(this.args.thread.channel.id, findArgs)
- .then((result) => {
- if (this._selfDeleted) {
- return;
- }
+ this.messagesManager.clear();
- if (this.args.thread.channel.id !== result.meta.channel_id) {
- if (this.chatHistory.previousRoute?.name === "chat.channel.index") {
- this.router.transitionTo(
- "chat.channel",
- "-",
- result.meta.channel_id
- );
- } else {
- this.router.transitionTo("chat.channel.threads");
- }
- }
+ findArgs.targetMessageId ??=
+ this.args.targetMessageId ||
+ this.args.thread.currentUserMembership?.lastReadMessageId;
- const [messages, meta] = this.afterFetchCallback(
- this.args.thread,
- result
- );
- this.args.thread.messagesManager.addMessages(messages);
- this.args.thread.details = meta;
- this.markThreadAsRead();
- })
- .catch(this.#handleErrors)
- .finally(() => {
- if (this._selfDeleted) {
- return;
- }
+ if (!findArgs.targetMessageId) {
+ findArgs.direction = FUTURE;
+ }
- this.loading = false;
+ const result = await this.messagesLoader.load(findArgs);
+ if (!result) {
+ return;
+ }
+
+ const [messages, meta] = this.processMessages(this.args.thread, result);
+ stackingContextFix(this.scrollable, () => {
+ this.messagesManager.addMessages(messages);
+ });
+ this.args.thread.details = meta;
+
+ if (this.args.targetMessageId) {
+ this.scrollToMessageId(this.args.targetMessageId, { highlight: true });
+ } else if (this.args.thread.currentUserMembership?.lastReadMessageId) {
+ this.scrollToMessageId(
+ this.args.thread.currentUserMembership?.lastReadMessageId
+ );
+ } else {
+ this.scrollToTop();
+ }
+
+ this.debounceFillPaneAttempt();
+ }
+
+ @action
+ async fetchMoreMessages({ direction }) {
+ if (this.messagesLoader.loading) {
+ return;
+ }
+
+ const result = await this.messagesLoader.loadMore({ direction });
+ if (!result) {
+ return;
+ }
+
+ const [messages, meta] = this.processMessages(this.args.thread, result);
+ if (!messages?.length) {
+ return;
+ }
+
+ stackingContextFix(this.scrollable, () => {
+ this.messagesManager.addMessages(messages);
+ });
+ this.args.thread.details = meta;
+
+ if (direction === FUTURE) {
+ this.scrollToMessageId(messages.firstObject.id, {
+ position: "end",
+ behavior: "auto",
});
+ } else if (direction === PAST) {
+ this.scrollToMessageId(messages.lastObject.id);
+ }
+
+ this.debounceFillPaneAttempt();
+ }
+
+ @action
+ scrollToLatestMessage() {
+ if (this.messagesLoader.canLoadMoreFuture) {
+ this.fetchMessages();
+ } else if (this.messagesManager.messages.length > 0) {
+ this.scrollToBottom();
+ }
+ }
+
+ debounceFillPaneAttempt() {
+ if (!this.messagesLoader.fetchedOnce) {
+ return;
+ }
+
+ this._debouncedFillPaneAttemptHandler = discourseDebounce(
+ this,
+ this.fillPaneAttempt,
+ 500
+ );
+ }
+
+ async fillPaneAttempt() {
+ // safeguard
+ if (this.messagesManager.messages.length > 200) {
+ return;
+ }
+
+ if (!this.messagesLoader.canLoadMorePast) {
+ return;
+ }
+
+ const firstMessage = this.messagesManager.messages.firstObject;
+ if (!firstMessage?.visible) {
+ return;
+ }
+
+ await this.fetchMoreMessages({ direction: PAST });
+ }
+
+ scrollToMessageId(
+ messageId,
+ opts = { highlight: false, position: "start", autoExpand: false }
+ ) {
+ this._ignoreNextScroll = true;
+ const message = this.messagesManager.findMessage(messageId);
+ scrollListToMessage(this.scrollable, message, opts);
}
@bind
- afterFetchCallback(thread, result) {
- const messages = [];
+ processMessages(thread, result) {
+ const messages = result.messages.map((messageData) => {
+ const ignored = this.currentUser.ignored_users || [];
+ const hidden = ignored.includes(messageData.user.username);
- result.chat_messages.forEach((messageData) => {
- // If a message has been hidden it is because the current user is ignoring
- // the user who sent it, so we want to unconditionally hide it, even if
- // we are going directly to the target
- if (this.currentUser.ignored_users) {
- messageData.hidden = this.currentUser.ignored_users.includes(
- messageData.user.username
- );
- }
-
- messageData.expanded = !(messageData.hidden || messageData.deleted_at);
- const message = ChatMessage.create(thread.channel, messageData);
- message.thread = thread;
- messages.push(message);
+ return ChatMessage.create(thread.channel, {
+ ...messageData,
+ hidden,
+ expanded: !(hidden || messageData.deleted_at),
+ manager: this.messagesManager,
+ thread,
+ });
});
return [messages, result.meta];
@@ -229,6 +326,10 @@ export default class ChatThreadPanel extends Component {
// and scrolling; for now it's enough to do it when the thread panel
// opens/messages are loaded since we have no pagination for threads.
markThreadAsRead() {
+ if (!this.args.thread || this.args.thread.staged) {
+ return;
+ }
+
return this.chatApi.markThreadAsRead(
this.args.thread.channel.id,
this.args.thread.id
@@ -258,44 +359,42 @@ export default class ChatThreadPanel extends Component {
}
this.chatThreadPane.sending = true;
- await this.args.thread.stageMessage(message);
+ this._ignoreNextScroll = true;
+ stackingContextFix(this.scrollable, async () => {
+ await this.args.thread.stageMessage(message);
+ });
this.resetComposerMessage();
- this.scrollToBottom();
+
+ if (!this.messagesLoader.canLoadMoreFuture) {
+ this.scrollToLatestMessage();
+ }
try {
- await this.chatApi
- .sendMessage(this.args.thread.channel.id, {
+ const response = await this.chatApi.sendMessage(
+ this.args.thread.channel.id,
+ {
message: message.message,
in_reply_to_id: message.thread.staged
- ? message.thread.originalMessage.id
+ ? message.thread.originalMessage?.id
: null,
staged_id: message.id,
upload_ids: message.uploads.map((upload) => upload.id),
thread_id: message.thread.staged ? null : message.thread.id,
staged_thread_id: message.thread.staged ? message.thread.id : null,
- })
- .then((response) => {
- this.args.thread.currentUserMembership ??=
- UserChatThreadMembership.create({
- notification_level: NotificationLevels.TRACKING,
- last_read_message_id: response.message_id,
- });
- })
- .catch((error) => {
- this.#onSendError(message.id, error);
- })
- .finally(() => {
- if (this._selfDeleted) {
- return;
- }
- this.chatThreadPane.sending = false;
+ }
+ );
+
+ this.args.thread.currentUserMembership ??=
+ UserChatThreadMembership.create({
+ notification_level: NotificationLevels.TRACKING,
+ last_read_message_id: response.message_id,
});
+
+ this.scrollToLatestMessage();
} catch (error) {
this.#onSendError(message.id, error);
} finally {
- if (!this._selfDeleted) {
- this.chatThreadPane.sending = false;
- }
+ this.chatThreadPane.sending = false;
}
}
@@ -322,61 +421,21 @@ export default class ChatThreadPanel extends Component {
}
}
- // A more consistent way to scroll to the bottom when we are sure this is our goal
- // it will also limit issues with any element changing the height while we are scrolling
- // to the bottom
@action
scrollToBottom() {
- next(() => {
- schedule("afterRender", () => {
- if (!this.scrollable) {
- return;
- }
-
- this.scrollable.scrollTop = this.scrollable.scrollHeight + 1;
- this.forceRendering(() => {
- this.scrollable.scrollTop = this.scrollable.scrollHeight;
- });
- });
- });
+ this._ignoreNextScroll = true;
+ scrollListToBottom(this.scrollable);
}
- // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling
- // we now use this hack to disable it
- @bind
- forceRendering(callback) {
- if (this.capabilities.isIOS) {
- this.scrollable.style.overflow = "hidden";
- }
-
- callback?.();
-
- if (this.capabilities.isIOS) {
- next(() => {
- schedule("afterRender", () => {
- if (this._selfDeleted || !this.scrollable) {
- return;
- }
- this.scrollable.style.overflow = "auto";
- });
- });
- }
+ @action
+ scrollToTop() {
+ this._ignoreNextScroll = true;
+ scrollListToTop(this.scrollable);
}
@action
resendStagedMessage() {}
- #handleErrors(error) {
- switch (error?.jqXHR?.status) {
- case 429:
- case 404:
- popupAjaxError(error);
- break;
- default:
- throw error;
- }
- }
-
#onSendError(stagedId, error) {
const stagedMessage =
this.args.thread.messagesManager.findStagedMessage(stagedId);
@@ -391,4 +450,10 @@ export default class ChatThreadPanel extends Component {
this.resetComposerMessage();
}
+
+ #flushIgnoreNextScroll() {
+ const prev = this._ignoreNextScroll;
+ this._ignoreNextScroll = false;
+ return prev;
+ }
}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js
index d8b406aef43..ed67eede4c9 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js
@@ -62,7 +62,7 @@ export default class ChatComposerChannel extends ChatComposer {
}
lastUserMessage(user) {
- return this.args.channel.lastUserMessage(user);
+ return this.args.channel.messagesManager.findLastUserMessage(user);
}
get placeholder() {
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js
index 5bca803ae5a..dd061e33b0c 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js
@@ -39,7 +39,7 @@ export default class ChatComposerThread extends ChatComposer {
}
lastUserMessage(user) {
- return this.args.thread.lastUserMessage(user);
+ return this.args.thread.messagesManager.findLastUserMessage(user);
}
handleEscape(event) {
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-scroll-to-bottom-arrow.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/scroll-to-bottom-arrow.hbs
similarity index 55%
rename from plugins/chat/assets/javascripts/discourse/components/chat-scroll-to-bottom-arrow.hbs
rename to plugins/chat/assets/javascripts/discourse/components/chat/scroll-to-bottom-arrow.hbs
index 5b660d2fd55..93d5f270a2d 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-scroll-to-bottom-arrow.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat/scroll-to-bottom-arrow.hbs
@@ -1,11 +1,11 @@
-