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 62a3525e749..abec9aabd1b 100644 --- a/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb @@ -1,6 +1,27 @@ # frozen_string_literal: true class Chat::Api::ChannelThreadsController < Chat::ApiController + def index + with_service(::Chat::LookupChannelThreads) do + on_success do + render_serialized( + ::Chat::ThreadsView.new( + user: guardian.user, + threads: result.threads, + channel: result.channel, + ), + ::Chat::ThreadListSerializer, + root: false, + ) + end + on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound } + on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } + on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess } + on_model_not_found(:channel) { raise Discourse::NotFound } + on_model_not_found(:threads) { render json: success_json.merge(threads: []) } + end + end + def show with_service(::Chat::LookupThread) do on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") } @@ -9,4 +30,17 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController on_model_not_found(:thread) { raise Discourse::NotFound } end end + + def update + with_service(::Chat::UpdateThread) do + on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound } + on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } + on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess } + on_failed_policy(:can_edit_thread) { raise Discourse::InvalidAccess } + on_model_not_found(:thread) { raise Discourse::NotFound } + on_failed_step(:update) do + render json: failed_json.merge(errors: [result["result.step.update"].error]), status: 422 + end + end + end end diff --git a/plugins/chat/app/models/chat/message.rb b/plugins/chat/app/models/chat/message.rb index 3186bd40b5a..7c3c65fccba 100644 --- a/plugins/chat/app/models/chat/message.rb +++ b/plugins/chat/app/models/chat/message.rb @@ -128,6 +128,24 @@ module Chat PrettyText.excerpt(message, max_length, { text_entities: true }) end + # TODO (martin) Replace the above #excerpt method usage with this one. The + # issue with the above one is that we cannot actually render nice HTML + # fore replies/excerpts in the UI because text_entitites: true will + # allow through even denied HTML because of 07ab20131a15ab907c1974fee405d9bdce0c0723. + # + # For now only the thread index uses this new version since it is not interactive, + # we can go back to the interactive reply/edit cases in another PR. + def rich_excerpt(max_length: 50) + # just show the URL if the whole message is a URL, because we cannot excerpt oneboxes + return message if UrlHelper.relaxed_parse(message).is_a?(URI) + + # upload-only messages are better represented as the filename + return uploads.first.original_filename if cooked.blank? && uploads.present? + + # this may return blank for some complex things like quotes, that is acceptable + PrettyText.excerpt(cooked, max_length) + end + def cooked_for_excerpt (cooked.blank? && uploads.present?) ? "
#{uploads.first.original_filename}
" : cooked end diff --git a/plugins/chat/app/models/chat/thread.rb b/plugins/chat/app/models/chat/thread.rb index 60c08b61525..dd6eec88205 100644 --- a/plugins/chat/app/models/chat/thread.rb +++ b/plugins/chat/app/models/chat/thread.rb @@ -3,6 +3,7 @@ module Chat class Thread < ActiveRecord::Base EXCERPT_LENGTH = 150 + MAX_TITLE_LENGTH = 100 include Chat::ThreadCache @@ -24,6 +25,8 @@ module Chat enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false + validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH } + def replies self.chat_messages.where.not(id: self.original_message_id) end @@ -37,7 +40,7 @@ module Chat end def excerpt - original_message.excerpt(max_length: EXCERPT_LENGTH) + original_message.rich_excerpt(max_length: EXCERPT_LENGTH) end def self.grouped_messages(thread_ids: nil, message_ids: nil, include_original_message: true) diff --git a/plugins/chat/app/models/chat/threads_view.rb b/plugins/chat/app/models/chat/threads_view.rb new file mode 100644 index 00000000000..6c51ccbc7a3 --- /dev/null +++ b/plugins/chat/app/models/chat/threads_view.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Chat + class ThreadsView + attr_reader :user, :channel, :threads + + def initialize(channel:, threads:, user:) + @channel = channel + @threads = threads + @user = user + end + end +end diff --git a/plugins/chat/app/serializers/chat/message_serializer.rb b/plugins/chat/app/serializers/chat/message_serializer.rb index 6c91a6f3f61..b1c13073fa3 100644 --- a/plugins/chat/app/serializers/chat/message_serializer.rb +++ b/plugins/chat/app/serializers/chat/message_serializer.rb @@ -17,6 +17,7 @@ module Chat :available_flags, :thread_id, :thread_reply_count, + :thread_title, :chat_channel_id has_one :user, serializer: Chat::MessageUserSerializer, embed: :objects @@ -168,5 +169,9 @@ module Chat def thread_reply_count object.thread&.replies_count_cache || 0 end + + def thread_title + object.thread&.title + end end end diff --git a/plugins/chat/app/serializers/chat/thread_list_serializer.rb b/plugins/chat/app/serializers/chat/thread_list_serializer.rb new file mode 100644 index 00000000000..edb53b03fcd --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_list_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Chat + class ThreadListSerializer < ApplicationSerializer + attributes :meta, :threads + + def threads + ActiveModel::ArraySerializer.new( + object.threads, + each_serializer: Chat::ThreadSerializer, + scope: scope, + ) + end + + def meta + { channel_id: object.channel.id } + end + end +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 57efe2c49c6..cab5d76a8b5 100644 --- a/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb +++ b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb @@ -1,13 +1,37 @@ # frozen_string_literal: true module Chat - class ThreadOriginalMessageSerializer < ApplicationSerializer - attributes :id, :created_at, :excerpt, :thread_id - - has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects - + class ThreadOriginalMessageSerializer < Chat::MessageSerializer def excerpt - WordWatcher.censor(object.excerpt(max_length: Chat::Thread::EXCERPT_LENGTH)) + WordWatcher.censor(object.rich_excerpt(max_length: Chat::Thread::EXCERPT_LENGTH)) + 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 end end diff --git a/plugins/chat/app/serializers/chat/thread_serializer.rb b/plugins/chat/app/serializers/chat/thread_serializer.rb index d2f37bd72f5..76f6260e251 100644 --- a/plugins/chat/app/serializers/chat/thread_serializer.rb +++ b/plugins/chat/app/serializers/chat/thread_serializer.rb @@ -5,17 +5,22 @@ module Chat has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects - attributes :id, :title, :status, :channel_id, :meta + attributes :id, :title, :status, :channel_id, :meta, :reply_count def initialize(object, opts) super(object, opts) @opts = opts + original_message.thread = object end def meta { message_bus_last_ids: { thread_message_bus_last_id: thread_message_bus_last_id } } end + def reply_count + object.replies_count_cache || 0 + end + private def thread_message_bus_last_id diff --git a/plugins/chat/app/services/chat/lookup_channel_threads.rb b/plugins/chat/app/services/chat/lookup_channel_threads.rb new file mode 100644 index 00000000000..8f73e010142 --- /dev/null +++ b/plugins/chat/app/services/chat/lookup_channel_threads.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Chat + # Gets a list of threads for a channel to be shown in an index. + # In future pagination and filtering will be added -- for now + # we just want to return N threads ordered by the latest + # message that the user has sent in a thread. + # + # @example + # Chat::LookupChannelThreads.call(channel_id: 2, guardian: guardian) + # + class LookupChannelThreads + include Service::Base + + # @!method call(channel_id:, guardian:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + policy :threaded_discussions_enabled + contract + model :channel + policy :threading_enabled_for_channel + policy :can_view_channel + model :threads + + # @!visibility private + class Contract + attribute :channel_id, :integer + validates :channel_id, presence: true + end + + private + + def threaded_discussions_enabled + SiteSetting.enable_experimental_chat_threaded_discussions + end + + def fetch_channel(contract:, **) + Chat::Channel.find_by(id: contract.channel_id) + end + + def threading_enabled_for_channel(channel:, **) + channel.threading_enabled + end + + def can_view_channel(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) + end + + def fetch_threads(guardian:, channel:, **) + Chat::Thread + .includes( + :channel, + original_message_user: :user_status, + original_message: :chat_webhook_event, + ) + .select("chat_threads.*, MAX(chat_messages.created_at) AS last_posted_at") + .joins( + "LEFT JOIN chat_messages ON chat_threads.id = chat_messages.thread_id AND chat_messages.chat_channel_id = #{channel.id}", + ) + .joins( + "LEFT JOIN chat_messages original_messages ON chat_threads.original_message_id = original_messages.id", + ) + .where("chat_messages.user_id = ? OR chat_messages.user_id IS NULL", guardian.user.id) + .where(channel_id: channel.id) + .where("original_messages.deleted_at IS NULL AND chat_messages.deleted_at IS NULL") + .group("chat_threads.id") + .order("last_posted_at DESC NULLS LAST") + .limit(50) + end + end +end diff --git a/plugins/chat/app/services/chat/publisher.rb b/plugins/chat/app/services/chat/publisher.rb index 10ec97971eb..c34b7ee9f1c 100644 --- a/plugins/chat/app/services/chat/publisher.rb +++ b/plugins/chat/app/services/chat/publisher.rb @@ -72,6 +72,7 @@ module Chat type: :update_thread_original_message, original_message_id: thread.original_message_id, replies_count: thread.replies_count_cache, + title: thread.title, }, ) end diff --git a/plugins/chat/app/services/chat/update_thread.rb b/plugins/chat/app/services/chat/update_thread.rb new file mode 100644 index 00000000000..4b7e7327a66 --- /dev/null +++ b/plugins/chat/app/services/chat/update_thread.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Chat + # Updates a thread. The thread_id and channel_id must + # match. For now we do not want to allow updating threads if the + # enable_experimental_chat_threaded_discussions hidden site setting + # is not turned on, and the channel must specifically have threading + # enabled. + # + # Only the thread title can be updated. + # + # @example + # Chat::UpdateThread.call(thread_id: 88, channel_id: 2, guardian: guardian, title: "Restaurant for Saturday") + # + class UpdateThread + include Service::Base + + # @!method call(thread_id:, channel_id:, guardian:, **params_to_edit) + # @param [Integer] thread_id + # @param [Integer] channel_id + # @param [Guardian] guardian + # @option params_to_edit [String,nil] title + # @return [Service::Base::Context] + + policy :threaded_discussions_enabled + contract + model :thread, :fetch_thread + policy :can_view_channel + policy :can_edit_thread + policy :threading_enabled_for_channel + step :update + step :publish_metadata + + # @!visibility private + class Contract + attribute :thread_id, :integer + attribute :channel_id, :integer + attribute :title, :string + + validates :thread_id, :channel_id, presence: true + validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH } + end + + private + + def threaded_discussions_enabled + SiteSetting.enable_experimental_chat_threaded_discussions + end + + def fetch_thread(contract:, **) + Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id) + end + + def can_view_channel(guardian:, thread:, **) + guardian.can_preview_chat_channel?(thread.channel) + end + + def can_edit_thread(guardian:, thread:, **) + guardian.can_edit_thread?(thread) + end + + def threading_enabled_for_channel(thread:, **) + thread.channel.threading_enabled + end + + def update(thread:, contract:, **) + thread.update(title: contract.title) + fail!(thread.errors.full_messages.join(", ")) if thread.invalid? + end + + def publish_metadata(thread:, **) + Chat::Publisher.publish_thread_original_message_metadata!(thread) + 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 a5b359ab96f..dad81b407dc 100644 --- a/plugins/chat/assets/javascripts/discourse/chat-route-map.js +++ b/plugins/chat/assets/javascripts/discourse/chat-route-map.js @@ -7,6 +7,7 @@ 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" }); }); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs index 083ce749674..792bbe324dc 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs @@ -36,6 +36,7 @@ class="chat-messages-container" {{chat/on-resize this.didResizePane (hash delay=25 immediate=true)}} > + {{#if this.loadedOnce}} {{#each @channel.messages key="id" as |message|}}