From c6b43ce68b625a90ec09c62971ef0998676f994f Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 10 May 2023 11:42:32 +0200 Subject: [PATCH] FEATURE: Thread list initial UI (#21412) This commit adds an initial thread list UI. There are several limitations with this that will be addressed in future PRs: * There is no MessageBus reactivity, so e.g. if someone edits the original message of the thread it will not be reflected in the list. However if the thread title is updated the original message indicator will be updated. * There is no unread functionality for threads in the list, if new messages come into the thread there is no indicator in the UI. * There is no unread indicator on the actual button to open the thread list. * No pagination. In saying that, this is the functionality so far: * We show a list of the 50 threads that the user has most recently participated in (i.e. sent a message) for the channel in descending order. * Each thread we show a rich excerpt, the title, and the user who is the OM creator. * The title is editable by staff and by the OM creator. * Thread indicators show a title. We also replace emojis in the titles. * Thread list works in the drawer/mobile. --- .../chat/api/channel_threads_controller.rb | 34 +++++ plugins/chat/app/models/chat/message.rb | 18 +++ plugins/chat/app/models/chat/thread.rb | 5 +- plugins/chat/app/models/chat/threads_view.rb | 13 ++ .../serializers/chat/message_serializer.rb | 5 + .../chat/thread_list_serializer.rb | 19 +++ .../thread_original_message_serializer.rb | 36 ++++- .../app/serializers/chat/thread_serializer.rb | 7 +- .../services/chat/lookup_channel_threads.rb | 73 ++++++++++ plugins/chat/app/services/chat/publisher.rb | 1 + .../chat/app/services/chat/update_thread.rb | 75 ++++++++++ .../javascripts/discourse/chat-route-map.js | 1 + .../discourse/components/chat-channel.hbs | 1 + .../chat-drawer/header/right-actions.hbs | 6 + .../chat-drawer/header/right-actions.js | 1 + .../chat-drawer/header/thread-list-button.hbs | 9 ++ .../chat-drawer/header/thread-list-button.js | 11 ++ .../components/chat-drawer/thread.hbs | 6 +- .../components/chat-drawer/thread.js | 17 +++ .../components/chat-drawer/threads.hbs | 28 ++++ .../components/chat-drawer/threads.js | 23 +++ .../components/chat-full-page-header.hbs | 27 +++- .../chat-message-thread-indicator.hbs | 5 +- .../chat-message-thread-indicator.js | 7 +- .../discourse/components/chat-thread.hbs | 1 - .../discourse/components/chat-thread.js | 15 +- .../components/chat/thread/list-item.hbs | 29 ++++ .../components/chat/thread/list-item.js | 37 +++++ .../discourse/components/chat/thread/list.hbs | 34 +++++ .../discourse/components/chat/thread/list.js | 36 +++++ .../chat/thread/original-message.hbs | 15 ++ .../chat/thread/original-message.js | 3 + .../chat/thread/settings-modal-inner.hbs | 22 +++ .../chat/thread/settings-modal-inner.js | 33 +++++ .../controllers/chat-thread-settings-modal.js | 8 ++ .../discourse/lib/chat-threads-manager.js | 17 +++ .../discourse/models/chat-message.js | 2 + .../discourse/models/chat-thread.js | 11 +- .../discourse/routes/chat-channel-thread.js | 8 +- .../discourse/routes/chat-channel-threads.js | 25 ++++ .../javascripts/discourse/routes/chat.js | 1 + .../discourse/services/chat-api.js | 25 ++++ .../services/chat-channel-composer.js | 12 +- ...chat-channel-pane-subscriptions-manager.js | 1 + .../services/chat-channel-thread-composer.js | 7 - .../services/chat-channel-thread-list-pane.js | 15 ++ .../discourse/services/chat-drawer-router.js | 21 +++ .../templates/chat-channel-threads.hbs | 1 + .../modal/chat-thread-settings-modal.hbs | 4 + .../stylesheets/common/chat-channel.scss | 21 ++- .../common/chat-message-thread-indicator.scss | 3 + .../common/chat-thread-list-item.scss | 56 ++++++++ .../stylesheets/common/chat-thread-list.scss | 28 ++++ .../common/chat-thread-original-message.scss | 21 +++ .../chat/assets/stylesheets/common/index.scss | 3 + .../mobile/chat-thread-settings-modal.scss | 9 ++ .../stylesheets/mobile/chat-threads-list.scss | 7 + .../chat/assets/stylesheets/mobile/index.scss | 2 + plugins/chat/config/locales/client.en.yml | 9 +- plugins/chat/config/routes.rb | 2 + plugins/chat/lib/chat/guardian_extensions.rb | 7 + .../chat/spec/fabricators/chat_fabricator.rb | 7 +- .../regular/update_thread_reply_count_spec.rb | 1 + plugins/chat/spec/plugin_helper.rb | 3 +- .../api/channel_threads_controller_spec.rb | 133 ++++++++++++++++++ .../chat/lookup_channel_threads_spec.rb | 114 +++++++++++++++ .../spec/services/chat/update_thread_spec.rb | 106 ++++++++++++++ .../chat/spec/support/chat_service_matcher.rb | 4 + .../spec/system/page_objects/chat/chat.rb | 4 + .../page_objects/chat/chat_side_panel.rb | 4 + .../page_objects/chat/chat_thread_list.rb | 15 ++ .../page_objects/chat_drawer/chat_drawer.rb | 4 + .../spec/system/thread_list/drawer_spec.rb | 82 +++++++++++ .../spec/system/thread_list/full_page_spec.rb | 120 ++++++++++++++++ 74 files changed, 1512 insertions(+), 64 deletions(-) create mode 100644 plugins/chat/app/models/chat/threads_view.rb create mode 100644 plugins/chat/app/serializers/chat/thread_list_serializer.rb create mode 100644 plugins/chat/app/services/chat/lookup_channel_threads.rb create mode 100644 plugins/chat/app/services/chat/update_thread.rb create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/thread-list-button.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/thread-list-button.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/thread/list-item.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/thread/list-item.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/thread/list.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/thread/list.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/thread/original-message.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/thread/original-message.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/thread/settings-modal-inner.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/thread/settings-modal-inner.js create mode 100644 plugins/chat/assets/javascripts/discourse/controllers/chat-thread-settings-modal.js create mode 100644 plugins/chat/assets/javascripts/discourse/routes/chat-channel-threads.js create mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-list-pane.js create mode 100644 plugins/chat/assets/javascripts/discourse/templates/chat-channel-threads.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/templates/modal/chat-thread-settings-modal.hbs create mode 100644 plugins/chat/assets/stylesheets/common/chat-thread-list-item.scss create mode 100644 plugins/chat/assets/stylesheets/common/chat-thread-list.scss create mode 100644 plugins/chat/assets/stylesheets/common/chat-thread-original-message.scss create mode 100644 plugins/chat/assets/stylesheets/mobile/chat-thread-settings-modal.scss create mode 100644 plugins/chat/assets/stylesheets/mobile/chat-threads-list.scss create mode 100644 plugins/chat/spec/services/chat/lookup_channel_threads_spec.rb create mode 100644 plugins/chat/spec/services/chat/update_thread_spec.rb create mode 100644 plugins/chat/spec/system/page_objects/chat/chat_thread_list.rb create mode 100644 plugins/chat/spec/system/thread_list/drawer_spec.rb create mode 100644 plugins/chat/spec/system/thread_list/full_page_spec.rb 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|}}
+ {{#if this.chat.activeChannel.threadingEnabled}} + + {{/if}} + + +
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.js index 1e61355c881..fe0826ce799 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.js @@ -2,5 +2,6 @@ import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; export default class ChatDrawerHeaderRightActions extends Component { + @service chat; @service chatStateManager; } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/thread-list-button.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/thread-list-button.hbs new file mode 100644 index 00000000000..9c885d733e3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/thread-list-button.hbs @@ -0,0 +1,9 @@ + + {{d-icon "comments"}} + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/thread-list-button.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/thread-list-button.js new file mode 100644 index 00000000000..b7a5d7d9ada --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/thread-list-button.js @@ -0,0 +1,11 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +export default class ChatDrawerThreadListButton extends Component { + @service chat; + + @action + stopPropagation(event) { + event.stopPropagation(); + } +} 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 4aa95ff443b..5c3ff0fd953 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs @@ -3,9 +3,9 @@
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js index cc6304fb125..c3b5e9f0e4e 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js @@ -7,6 +7,23 @@ export default class ChatDrawerThread extends Component { @service chat; @service chatStateManager; @service chatChannelsManager; + @service chatDrawerRouter; + + get backLink() { + const link = { + models: this.chat.activeChannel.routeModels, + }; + + if (this.chatDrawerRouter.previousRouteName === "chat.channel.threads") { + link.title = "chat.return_to_threads_list"; + link.route = "chat.channel.threads"; + } else { + link.title = "chat.return_to_list"; + link.route = "chat.channel"; + } + + return link; + } @action fetchChannelAndThread() { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.hbs new file mode 100644 index 00000000000..3f42b9af9a9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.hbs @@ -0,0 +1,28 @@ + + {{#if (and this.chatStateManager.isDrawerExpanded this.chat.activeChannel)}} +
+
+ +
+
+ {{/if}} + + + + +
+ +{{#if this.chatStateManager.isDrawerExpanded}} +
+ {{#if this.chat.activeChannel}} + + {{/if}} +
+{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.js new file mode 100644 index 00000000000..ad13a3922a1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.js @@ -0,0 +1,23 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatDrawerThreads extends Component { + @service appEvents; + @service chat; + @service chatStateManager; + @service chatChannelsManager; + + @action + fetchChannel() { + if (!this.args.params?.channelId) { + return; + } + + return this.chatChannelsManager + .find(this.args.params.channelId) + .then((channel) => { + this.chat.activeChannel = channel; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs index 2d4bdd0425a..108ddd3b059 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs @@ -29,14 +29,27 @@ - {{#if this.site.desktopView}} + {{#if (or @channel.threadingEnabled this.site.desktopView)}}
- + {{#if @channel.threadingEnabled}} + + {{d-icon "comments"}} + + {{/if}} + + {{#if this.site.desktopView}} + + {{/if}}
{{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.hbs index 2412c1192b4..2e4607e1a77 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.hbs @@ -6,7 +6,8 @@ {{i18n "chat.thread.replies" count=@message.threadReplyCount}} - - {{i18n "chat.thread.view_thread"}} + + {{i18n "chat.thread.view_thread"}}{{#if this.threadTitle}}: + {{replace-emoji this.threadTitle}}{{/if}} \ No newline at end of file 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 a2ef54b411f..d2823521ab2 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,3 +1,8 @@ import Component from "@glimmer/component"; +import { escapeExpression } from "discourse/lib/utilities"; -export default class ChatMessageThreadIndicator extends Component {} +export default class ChatMessageThreadIndicator extends Component { + get threadTitle() { + return escapeExpression(this.args.message.threadTitle); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs index 5be4543e540..976cdc33bdf 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs @@ -6,7 +6,6 @@ {{did-update this.subscribeToUpdates this.thread.id}} {{did-insert this.loadMessages}} {{did-update this.loadMessages this.thread}} - {{did-insert this.setupMessage}} {{will-destroy this.unsubscribeFromUpdates}} > {{#if @includeHeader}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js index d0513a2326d..82b26d18300 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js @@ -43,14 +43,6 @@ export default class ChatThreadPanel extends Component { this.uploadDropZone = element; } - @action - setupMessage() { - this.chatChannelThreadComposer.message = ChatMessage.createDraftMessage( - this.channel, - { user: this.currentUser, thread_id: this.thread.id } - ); - } - @action subscribeToUpdates() { this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread); @@ -68,6 +60,13 @@ export default class ChatThreadPanel extends Component { @action loadMessages() { + const message = ChatMessage.createDraftMessage(this.channel, { + user: this.currentUser, + }); + message.thread = this.thread; + message.inReplyTo = this.thread.originalMessage; + this.chatChannelThreadComposer.message = message; + this.thread.messagesManager.clearMessages(); this.fetchMessages(); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/list-item.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/list-item.hbs new file mode 100644 index 00000000000..7944468193f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/list-item.hbs @@ -0,0 +1,29 @@ +
+
+
+
+
+ {{replace-emoji this.title}} +
+
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/list-item.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread/list-item.js new file mode 100644 index 00000000000..457f1b0f503 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/list-item.js @@ -0,0 +1,37 @@ +import Component from "@glimmer/component"; +import showModal from "discourse/lib/show-modal"; +import { inject as service } from "@ember/service"; +import I18n from "I18n"; +import { action } from "@ember/object"; + +export default class ChatThreadListItem extends Component { + @service currentUser; + @service router; + + get title() { + return ( + this.args.thread.escapedTitle || + `${I18n.t("chat.thread.default_title", { + thread_id: this.args.thread.id, + })}` + ); + } + + get canChangeThreadSettings() { + return ( + this.currentUser.staff || + this.currentUser.id === this.args.thread.originalMessage.user.id + ); + } + + @action + openThreadSettings() { + const controller = showModal("chat-thread-settings-modal"); + controller.set("thread", this.args.thread); + } + + @action + openThread(thread) { + this.router.transitionTo("chat.channel.thread", ...thread.routeModels); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/list.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/list.hbs new file mode 100644 index 00000000000..6bcd79f2c7e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/list.hbs @@ -0,0 +1,34 @@ +
+ {{#if @includeHeader}} +
+ {{i18n "chat.threads.list"}} + + {{d-icon "times"}} + +
+ {{/if}} + +
+ {{#if this.loading}} + {{loading-spinner size="medium"}} + {{else}} + {{#each this.threads as |thread|}} + + {{else}} +
+ {{i18n "chat.threads.none"}} +
+ {{/each}} + {{/if}} +
+
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/list.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread/list.js new file mode 100644 index 00000000000..f679f6164e2 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/list.js @@ -0,0 +1,36 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatThreadList extends Component { + @service chat; + + @tracked threads; + @tracked loading = true; + + @action + loadThreads() { + if (!this.args.channel) { + return; + } + + this.loading = true; + this.args.channel.threadsManager + .index(this.args.channel.id) + .then((result) => { + if (result.meta.channel_id === this.args.channel.id) { + this.threads = result.threads; + } + }) + .finally(() => { + this.loading = false; + }); + } + + @action + teardown() { + this.loading = true; + this.threads = null; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/original-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/original-message.hbs new file mode 100644 index 00000000000..8af08dc0b69 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/original-message.hbs @@ -0,0 +1,15 @@ +
+
+
+ {{replace-emoji (html-safe @message.excerpt)}} +
+
+ + + + + {{@message.user.username}} + +
+
+
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/original-message.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread/original-message.js new file mode 100644 index 00000000000..22885dd7981 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/original-message.js @@ -0,0 +1,3 @@ +import Component from "@glimmer/component"; + +export default class ChatThreadOriginalMessage extends Component {} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/settings-modal-inner.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/settings-modal-inner.hbs new file mode 100644 index 00000000000..15b4ae124bb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/settings-modal-inner.hbs @@ -0,0 +1,22 @@ + +
+ + +
+
+ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/settings-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread/settings-modal-inner.js new file mode 100644 index 00000000000..18af082ba10 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/settings-modal-inner.js @@ -0,0 +1,33 @@ +import Component from "@glimmer/component"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatThreadSettingsModalInner extends Component { + @service chatApi; + + @tracked editedTitle = this.args.thread.title || ""; + @tracked saving = false; + + get buttonDisabled() { + return this.saving; + } + + @action + saveThread() { + this.saving = true; + this.chatApi + .editThread(this.args.thread.channel.id, this.args.thread.id, { + title: this.editedTitle, + }) + .then(() => { + this.args.thread.title = this.editedTitle; + this.args.closeModal(); + }) + .catch(popupAjaxError) + .finally(() => { + this.saving = false; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-thread-settings-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-thread-settings-modal.js new file mode 100644 index 00000000000..7a59f9b99e5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-thread-settings-modal.js @@ -0,0 +1,8 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default class ChatThreadSettingsModalController extends Controller.extend( + ModalFunctionality +) { + thread = null; +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js b/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js index fb806415c9b..fa228ad5182 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js @@ -16,6 +16,7 @@ import { TrackedObject } from "@ember-compat/tracked-built-ins"; export default class ChatThreadsManager { @service chatSubscriptionsManager; @service chatApi; + @service chat; @service currentUser; @tracked _cached = new TrackedObject(); @@ -34,6 +35,18 @@ export default class ChatThreadsManager { } } + async index(channelId) { + return this.#loadIndex(channelId).then((result) => { + const threads = result.threads.map((thread) => { + return this.chat.activeChannel.threadsManager.store( + this.chat.activeChannel, + thread + ); + }); + return { threads, meta: result.meta }; + }); + } + get threads() { return Object.values(this._cached); } @@ -73,4 +86,8 @@ export default class ChatThreadsManager { #findStale(id) { return this._cached[id]; } + + async #loadIndex(channelId) { + return this.chatApi.threads(channelId); + } } diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js index dd79233b143..693b29f135a 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -51,6 +51,7 @@ export default class ChatMessage { @tracked thread; @tracked threadReplyCount; @tracked manager = null; + @tracked threadTitle = null; @tracked _cooked; @@ -64,6 +65,7 @@ export default class ChatMessage { this.availableFlags = args.availableFlags || args.available_flags; this.hidden = args.hidden; this.threadReplyCount = args.threadReplyCount || args.thread_reply_count; + this.threadTitle = args.threadTitle || args.thread_title; this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event; this.createdAt = args.createdAt || args.created_at; this.deletedAt = args.deletedAt || args.deleted_at; diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js index ee4926842cc..f39238d2ae0 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js @@ -1,6 +1,5 @@ import { getOwner } from "discourse-common/lib/get-owner"; import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager"; -import User from "discourse/models/user"; import { escapeExpression } from "discourse/lib/utilities"; import { tracked } from "@glimmer/tracking"; import guid from "pretty-text/guid"; @@ -22,6 +21,7 @@ export default class ChatThread { @tracked channel; @tracked originalMessage; @tracked threadMessageBusLastId; + @tracked replyCount; messagesManager = new ChatMessagesManager(getOwner(this)); @@ -32,6 +32,7 @@ export default class ChatThread { this.status = args.status; this.draft = args.draft; this.staged = args.staged; + this.replyCount = args.reply_count; this.originalMessage = ChatMessage.create(channel, args.original_message); } @@ -64,12 +65,4 @@ export default class ChatThread { get escapedTitle() { return escapeExpression(this.title); } - - #initUserModel(user) { - if (!user || user instanceof User) { - return user; - } - - return User.create(user); - } } diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js index 54ce405ee3b..755b5cd291f 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js @@ -24,6 +24,10 @@ export default class ChatChannelThread extends DiscourseRoute { this.chatChannelThreadPane.close(); } + activate() { + this.chatChannelThreadPane.open(this.currentModel); + } + beforeModel(transition) { const channel = this.modelFor("chat.channel"); @@ -54,8 +58,4 @@ export default class ChatChannelThread extends DiscourseRoute { } } } - - afterModel(model) { - this.chatChannelThreadPane.open(model); - } } diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-threads.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-threads.js new file mode 100644 index 00000000000..0198d703861 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-threads.js @@ -0,0 +1,25 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelThreads extends DiscourseRoute { + @service router; + @service chatChannelThreadListPane; + + deactivate() { + this.chatChannelThreadListPane.close(); + } + + beforeModel(transition) { + const channel = this.modelFor("chat.channel"); + + if (!channel.threadingEnabled) { + transition.abort(); + this.router.transitionTo("chat.channel", ...channel.routeModels); + return; + } + } + + activate() { + this.chatChannelThreadListPane.open(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat.js b/plugins/chat/assets/javascripts/discourse/routes/chat.js index d28562bb431..07f6bcdda65 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat.js @@ -22,6 +22,7 @@ export default class ChatRoute extends DiscourseRoute { const INTERCEPTABLE_ROUTES = [ "chat.channel", "chat.channel.thread", + "chat.channel.threads", "chat.channel.index", "chat.channel.near-message", "chat.channel-legacy", diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index dd6a8a4b1dd..7b13d74b152 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -48,6 +48,19 @@ export default class ChatApi extends Service { ); } + /** + * Loads all threads for a channel. + * For now we only get the 50 threads ordered + * by the last message sent by the user then the + * thread creation date, later we will paginate + * and add filters. + * @param {number} channelId - The ID of the channel. + * @returns {Promise} + */ + threads(channelId) { + return this.#getRequest(`/channels/${channelId}/threads`); + } + /** * List all accessible category channels of the current user. * @returns {Collection} @@ -421,6 +434,18 @@ export default class ChatApi extends Service { return this.#putRequest(`/channels/${channelId}/threads/${threadId}/read`); } + /** + * Updates settings of a thread. + * + * @param {number} channelId - The ID of the channel for the thread being edited. + * @param {number} threadId - The ID of the thread being edited. + * @param {object} data - Params of the edit. + * @param {string} data.title - The new title for the thread. + */ + editThread(channelId, threadId, data) { + return this.#putRequest(`/channels/${channelId}/threads/${threadId}`, data); + } + get #basePath() { return "/chat/api"; } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js index 58a580bce2c..d82b24ec8a0 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js @@ -1,7 +1,6 @@ import { inject as service } from "@ember/service"; import { action } from "@ember/object"; import ChatComposer from "./chat-composer"; -import { next } from "@ember/runloop"; export default class ChatChannelComposer extends ChatComposer { @service chat; @@ -25,17 +24,10 @@ export default class ChatChannelComposer extends ChatComposer { message.thread = thread; } - this.router - .transitionTo("chat.channel.thread", ...thread.routeModels) - .finally(() => this._setReplyToAfterTransition(message)); + this.chat.activeMessage = null; + this.router.transitionTo("chat.channel.thread", ...thread.routeModels); } else { this.message.inReplyTo = message; } } - - _setReplyToAfterTransition(message) { - next(() => { - this.chatChannelThreadComposer.replyTo(message); - }); - } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane-subscriptions-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane-subscriptions-manager.js index 1d8a7fbe950..14d5ebc8262 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane-subscriptions-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane-subscriptions-manager.js @@ -26,6 +26,7 @@ export default class ChatChannelPaneSubscriptionsManager extends ChatPaneBaseSub if (data.replies_count) { message.threadReplyCount = data.replies_count; } + message.threadTitle = data.title; } } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js index 6de4b84b6f8..e6145df394b 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js @@ -10,11 +10,4 @@ export default class ChatChannelThreadComposer extends ChatComposer { }); this.message.thread = thread; } - - @action - replyTo(message) { - this.chat.activeMessage = null; - this.message.thread = message.thread; - this.message.inReplyTo = message; - } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-list-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-list-pane.js new file mode 100644 index 00000000000..2e71401d227 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-list-pane.js @@ -0,0 +1,15 @@ +import Service, { inject as service } from "@ember/service"; + +export default class ChatChannelThreadListPane extends Service { + @service chat; + @service chatStateManager; + + close() { + this.chatStateManager.closeSidePanel(); + } + + open() { + this.chat.activeMessage = null; + this.chatStateManager.openSidePanel(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js index fd2a8573550..4c958d2a8d5 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js @@ -3,6 +3,7 @@ import { tracked } from "@glimmer/tracking"; import ChatDrawerDraftChannel from "discourse/plugins/chat/discourse/components/chat-drawer/draft-channel"; import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/channel"; import ChatDrawerThread from "discourse/plugins/chat/discourse/components/chat-drawer/thread"; +import ChatDrawerThreads from "discourse/plugins/chat/discourse/components/chat-drawer/threads"; import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index"; const COMPONENTS_MAP = { @@ -17,6 +18,14 @@ const COMPONENTS_MAP = { }; }, }, + "chat.channel.threads": { + name: ChatDrawerThreads, + extractParams: (route) => { + return { + channelId: route.parent.params.channelId, + }; + }, + }, chat: { name: ChatDrawerIndex }, "chat.channel.near-message": { name: ChatDrawerChannel, @@ -42,8 +51,20 @@ export default class ChatDrawerRouter extends Service { @service router; @tracked component = null; @tracked params = null; + @tracked history = []; + + get previousRouteName() { + if (this.history.length > 1) { + return this.history[this.history.length - 2]; + } + } stateFor(route) { + this.history.push(route.name); + if (this.history.length > 10) { + this.history.shift(); + } + const component = COMPONENTS_MAP[route.name]; this.params = component?.extractParams?.(route) || route.params; this.component = component?.name || ChatDrawerIndex; diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-threads.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-threads.hbs new file mode 100644 index 00000000000..e1f8056f2c5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-threads.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-thread-settings-modal.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-thread-settings-modal.hbs new file mode 100644 index 00000000000..d94e9d24ae3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-thread-settings-modal.hbs @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/stylesheets/common/chat-channel.scss b/plugins/chat/assets/stylesheets/common/chat-channel.scss index 30f08e0e6e4..c0718a791aa 100644 --- a/plugins/chat/assets/stylesheets/common/chat-channel.scss +++ b/plugins/chat/assets/stylesheets/common/chat-channel.scss @@ -8,7 +8,8 @@ width: 100%; min-width: 250px; - .open-drawer-btn { + .open-drawer-btn, + .open-thread-list-btn { color: var(--primary-low-mid); &:visited { @@ -24,6 +25,24 @@ } } + .open-thread-list-btn { + .d-icon { + margin-right: 0; + } + + &:hover { + .discourse-touch & { + background: none !important; + } + } + + &:active { + .discourse-touch & { + background: var(--secondary-very-high) !important; + } + } + } + .chat-messages-scroll { flex-grow: 1; overflow-y: scroll; diff --git a/plugins/chat/assets/stylesheets/common/chat-message-thread-indicator.scss b/plugins/chat/assets/stylesheets/common/chat-message-thread-indicator.scss index 04beed8b0e7..4309c83ca81 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-thread-indicator.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-thread-indicator.scss @@ -15,10 +15,13 @@ &__replies-count { color: var(--primary-medium); font-size: var(--font-down-1); + max-width: 60px; + flex: 1; } &__view-thread { font-size: var(--font-down-1); + flex: 1; .chat-message-thread-indicator:hover & { text-decoration: underline; diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-list-item.scss b/plugins/chat/assets/stylesheets/common/chat-thread-list-item.scss new file mode 100644 index 00000000000..b383703d3e0 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-list-item.scss @@ -0,0 +1,56 @@ +@mixin thread-list-item { + display: flex; + flex-direction: row; + padding: 0.5rem; + border-radius: 6px; + background-color: var(--d-selected); + border: 1px solid transparent; +} + +.chat-thread-list-item { + @include thread-list-item; + cursor: pointer; + + .touch & { + &:active { + background-color: var(--d-hover); + border: 1px solid var(--primary-medium); + } + } + + .no-touch & { + &:hover, + &:active { + background-color: var(--d-hover); + border: 1px solid var(--primary-medium); + } + } + + &__main { + flex: 1 1 100%; + } + + &__header { + display: flex; + flex-direction: row; + align-items: center; + } + + &__title { + flex: 1; + font-weight: bold; + } + + &__open-button { + display: flex; + flex-direction: column; + box-sizing: border-box; + justify-content: center; + color: var(--primary); + + &:hover, + &:visited { + color: var(--primary); + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-list.scss b/plugins/chat/assets/stylesheets/common/chat-thread-list.scss new file mode 100644 index 00000000000..32c14ab9165 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-list.scss @@ -0,0 +1,28 @@ +.chat-thread-list { + display: flex; + flex-direction: column; + height: 100%; + position: relative; + + &__items { + overflow-y: scroll; + @include chat-scrollbar(); + box-sizing: border-box; + flex-grow: 1; + overscroll-behavior: contain; + display: flex; + flex-direction: column; + + .chat-thread-list-item { + margin: 0.5rem 0.25rem 0.5rem 0.5rem; + + & + .chat-thread-list-item { + margin-top: 0; + } + } + } + + &__no-threads { + @include thread-list-item; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-original-message.scss b/plugins/chat/assets/stylesheets/common/chat-thread-original-message.scss new file mode 100644 index 00000000000..8608faebf65 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-original-message.scss @@ -0,0 +1,21 @@ +.chat-thread-original-message { + display: flex; + margin: 0.5rem 0rem; + + &__inner-container { + width: 100%; + } + + &__excerpt { + padding-bottom: 0.25rem; + } + + &__author { + display: flex; + align-items: center; + } + + &__avatar { + padding: 0.25rem 0.25rem 0.25rem 0; + } +} diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss index 713e5ef5460..a3249918baa 100644 --- a/plugins/chat/assets/stylesheets/common/index.scss +++ b/plugins/chat/assets/stylesheets/common/index.scss @@ -47,3 +47,6 @@ @import "full-page-chat-header"; @import "incoming-chat-webhooks"; @import "reviewable-chat-message"; +@import "chat-thread-list-item"; +@import "chat-thread-list"; +@import "chat-thread-original-message"; diff --git a/plugins/chat/assets/stylesheets/mobile/chat-thread-settings-modal.scss b/plugins/chat/assets/stylesheets/mobile/chat-thread-settings-modal.scss new file mode 100644 index 00000000000..b27bcfd979a --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-thread-settings-modal.scss @@ -0,0 +1,9 @@ +.chat-thread-settings-modal-modal { + .modal-inner-container { + width: 98%; + + .thread-title-input { + width: 100%; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-threads-list.scss b/plugins/chat/assets/stylesheets/mobile/chat-threads-list.scss new file mode 100644 index 00000000000..f53e77e768b --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-threads-list.scss @@ -0,0 +1,7 @@ +.chat-thread-list { + &__items { + .chat-thread-list-item { + margin: 0.5rem; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/index.scss b/plugins/chat/assets/stylesheets/mobile/index.scss index 0ad573b513d..d0bcc924e2b 100644 --- a/plugins/chat/assets/stylesheets/mobile/index.scss +++ b/plugins/chat/assets/stylesheets/mobile/index.scss @@ -9,3 +9,5 @@ @import "chat-composer-upload"; @import "chat-side-panel"; @import "chat-thread"; +@import "chat-threads-list"; +@import "chat-thread-settings-modal"; diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 2b6114ddc96..534dd98879d 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -250,6 +250,7 @@ en: save: "Save" select: "Select" return_to_list: "Return to channels list" + return_to_threads_list: "Return to ongoing discussions" scroll_to_bottom: "Scroll to bottom" scroll_to_new_messages: "See new messages" sound: @@ -541,15 +542,21 @@ en: no_results: "No results" thread: + title: "Title" view_thread: View thread + default_title: "Thread #%{thread_id}" replies: one: "%{count} reply" other: "%{count} replies" label: Thread close: "Close Thread" + original_message: + started_by: "Started by" + settings: "Settings" threads: - started_by: "Started by" open: "Open Thread" + list: "Ongoing discussions" + none: "You are not participating in any threads in this channel." draft_channel_screen: header: "New Message" diff --git a/plugins/chat/config/routes.rb b/plugins/chat/config/routes.rb index 594f23f2b3c..736ad850e88 100644 --- a/plugins/chat/config/routes.rb +++ b/plugins/chat/config/routes.rb @@ -28,6 +28,8 @@ Chat::Engine.routes.draw do # Hints for JIT warnings. get "/mentions/groups" => "hints#check_group_mentions", :format => :json + get "/channels/:channel_id/threads" => "channel_threads#index" + put "/channels/:channel_id/threads/:thread_id" => "channel_threads#update" get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show" put "/channels/:channel_id/threads/:thread_id/read" => "thread_reads#update" diff --git a/plugins/chat/lib/chat/guardian_extensions.rb b/plugins/chat/lib/chat/guardian_extensions.rb index f5a067e295a..68da310183c 100644 --- a/plugins/chat/lib/chat/guardian_extensions.rb +++ b/plugins/chat/lib/chat/guardian_extensions.rb @@ -42,6 +42,13 @@ module Chat is_staff? end + # The only part of the thread that can be changed is the title + # so this isn't too dangerous, if we end up wanting to change + # more things in future we may want to re-evaluate to be staff-only here. + def can_edit_thread?(thread) + is_staff? || thread.original_message_user_id == @user.id + end + def can_move_chat_messages?(channel) can_moderate_chat?(channel.chatable) end diff --git a/plugins/chat/spec/fabricators/chat_fabricator.rb b/plugins/chat/spec/fabricators/chat_fabricator.rb index ef37517d62f..29f0e78edc3 100644 --- a/plugins/chat/spec/fabricators/chat_fabricator.rb +++ b/plugins/chat/spec/fabricators/chat_fabricator.rb @@ -142,9 +142,14 @@ Fabricator(:chat_thread, class_name: "Chat::Thread") do end transient :channel + transient :original_message_user original_message do |attrs| - Fabricate(:chat_message, chat_channel: attrs[:channel] || Fabricate(:chat_channel)) + Fabricate( + :chat_message, + chat_channel: attrs[:channel] || Fabricate(:chat_channel), + user: attrs[:original_message_user] || Fabricate(:user), + ) end after_create { |thread| thread.original_message.update!(thread_id: thread.id) } diff --git a/plugins/chat/spec/jobs/regular/update_thread_reply_count_spec.rb b/plugins/chat/spec/jobs/regular/update_thread_reply_count_spec.rb index d67afa54e52..f9eacbcdc85 100644 --- a/plugins/chat/spec/jobs/regular/update_thread_reply_count_spec.rb +++ b/plugins/chat/spec/jobs/regular/update_thread_reply_count_spec.rb @@ -49,6 +49,7 @@ RSpec.describe Jobs::Chat::UpdateThreadReplyCount do "original_message_id" => thread.original_message_id, "replies_count" => 2, "type" => "update_thread_original_message", + "title" => thread.title, }, ) end diff --git a/plugins/chat/spec/plugin_helper.rb b/plugins/chat/spec/plugin_helper.rb index 22616ee9a24..4c6efb05e1d 100644 --- a/plugins/chat/spec/plugin_helper.rb +++ b/plugins/chat/spec/plugin_helper.rb @@ -27,7 +27,7 @@ module ChatSystemHelpers Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user) end - def chat_thread_chain_bootstrap(channel:, users:, messages_count: 4) + def chat_thread_chain_bootstrap(channel:, users:, messages_count: 4, thread_attrs: {}) last_user = nil last_message = nil @@ -50,6 +50,7 @@ module ChatSystemHelpers end last_message.thread.set_replies_count_cache(messages_count - 1, update_db: true) + last_message.thread.update!(thread_attrs) if thread_attrs.any? last_message.thread end end diff --git a/plugins/chat/spec/requests/chat/api/channel_threads_controller_spec.rb b/plugins/chat/spec/requests/chat/api/channel_threads_controller_spec.rb index c4b6c30fe9b..db9d5e95f7e 100644 --- a/plugins/chat/spec/requests/chat/api/channel_threads_controller_spec.rb +++ b/plugins/chat/spec/requests/chat/api/channel_threads_controller_spec.rb @@ -92,4 +92,137 @@ RSpec.describe Chat::Api::ChannelThreadsController do end end end + + describe "index" do + fab!(:thread_1) { Fabricate(:chat_thread, channel: public_channel) } + fab!(:thread_2) { Fabricate(:chat_thread, channel: public_channel) } + fab!(:thread_3) { Fabricate(:chat_thread, channel: public_channel) } + fab!(:message_1) do + Fabricate( + :chat_message, + user: current_user, + chat_channel: public_channel, + thread: thread_1, + created_at: 10.minutes.ago, + ) + end + fab!(:message_2) do + Fabricate( + :chat_message, + user: current_user, + chat_channel: public_channel, + thread: thread_3, + created_at: 2.seconds.ago, + ) + end + + it "returns the threads the user has sent messages in for the channel" do + get "/chat/api/channels/#{public_channel.id}/threads" + expect(response.status).to eq(200) + expect(response.parsed_body["threads"].map { |thread| thread["id"] }).to eq( + [thread_3.id, thread_1.id], + ) + end + + context "when the channel is not accessible to the useer" do + before do + public_channel.update!(chatable: Fabricate(:private_category, group: Fabricate(:group))) + end + + it "returns 404" do + get "/chat/api/channels/#{public_channel.id}/threads" + expect(response.status).to eq(403) + end + end + + context "when channel does not have threading enabled" do + before { public_channel.update!(threading_enabled: false) } + + it "returns 404" do + get "/chat/api/channels/#{public_channel.id}/threads" + expect(response.status).to eq(404) + end + end + + context "when enable_experimental_chat_threaded_discussions is disabled" do + before { SiteSetting.enable_experimental_chat_threaded_discussions = false } + + it "returns 404" do + get "/chat/api/channels/#{public_channel.id}/threads" + expect(response.status).to eq(404) + end + end + end + + describe "update" do + let(:title) { "New title" } + let(:params) { { title: title } } + fab!(:thread) do + Fabricate(:chat_thread, channel: public_channel, original_message_user: current_user) + end + + context "when thread does not exist" do + it "returns 404" do + thread.destroy! + put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params + expect(response.status).to eq(404) + end + end + + context "when thread exists" do + it "updates the title" do + put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params + expect(response.status).to eq(200) + expect(thread.reload.title).to eq(title) + end + + context "when user cannot view the channel" do + before { thread.update!(channel: Fabricate(:private_category_channel)) } + + it "returns 403" do + put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params + expect(response.status).to eq(403) + end + end + + context "when the user is not the original message user" do + before { thread.update!(original_message_user: Fabricate(:user)) } + + it "returns 403" do + put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params + expect(response.status).to eq(403) + end + end + + context "when the title is too long" do + let(:title) { "x" * Chat::Thread::MAX_TITLE_LENGTH + "x" } + + it "returns 400" do + put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params + expect(response.status).to eq(400) + expect(response.parsed_body["errors"]).to eq( + ["Title is too long (maximum is #{Chat::Thread::MAX_TITLE_LENGTH} characters)"], + ) + end + end + end + + context "when channel does not have threading enabled" do + before { public_channel.update!(threading_enabled: false) } + + it "returns 404" do + put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params + expect(response.status).to eq(404) + end + end + + context "when enable_experimental_chat_threaded_discussions is disabled" do + before { SiteSetting.enable_experimental_chat_threaded_discussions = false } + + it "returns 404" do + put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params + expect(response.status).to eq(404) + end + end + end end diff --git a/plugins/chat/spec/services/chat/lookup_channel_threads_spec.rb b/plugins/chat/spec/services/chat/lookup_channel_threads_spec.rb new file mode 100644 index 00000000000..97d06d736d4 --- /dev/null +++ b/plugins/chat/spec/services/chat/lookup_channel_threads_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +RSpec.describe Chat::LookupChannelThreads do + describe Chat::LookupChannelThreads::Contract, type: :model do + it { is_expected.to validate_presence_of :channel_id } + end + + describe ".call" do + subject(:result) { described_class.call(params) } + + fab!(:current_user) { Fabricate(:user) } + fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) } + fab!(:thread_1) { Fabricate(:chat_thread, channel: channel) } + fab!(:thread_2) { Fabricate(:chat_thread, channel: channel) } + fab!(:thread_3) { Fabricate(:chat_thread, channel: channel) } + + let(:guardian) { Guardian.new(current_user) } + let(:params) { { guardian: guardian, channel_id: thread_1.channel_id } } + + context "when enable_experimental_chat_threaded_discussions is disabled" do + before { SiteSetting.enable_experimental_chat_threaded_discussions = false } + + it { is_expected.to fail_a_policy(:threaded_discussions_enabled) } + end + + context "when enable_experimental_chat_threaded_discussions is enabled" do + before { SiteSetting.enable_experimental_chat_threaded_discussions = true } + + context "when all steps pass" do + before do + Fabricate( + :chat_message, + user: current_user, + chat_channel: channel, + thread: thread_1, + created_at: 10.minutes.ago, + ) + Fabricate( + :chat_message, + user: current_user, + chat_channel: channel, + thread: thread_2, + created_at: 1.day.ago, + ) + Fabricate( + :chat_message, + user: current_user, + chat_channel: channel, + thread: thread_3, + created_at: 2.seconds.ago, + ) + end + + it "sets the service result as successful" do + expect(result).to be_a_success + end + + it "returns the threads ordered by the last thread the current user posted in" do + expect(result.threads.map(&:id)).to eq([thread_3.id, thread_1.id, thread_2.id]) + end + + it "does not return threads where the original message is deleted" do + thread_1.original_message.trash! + expect(result.threads.map(&:id)).to eq([thread_3.id, thread_2.id]) + end + + it "does not count deleted messages for sort order" do + Chat::Message.find_by(user: current_user, thread: thread_3).trash! + expect(result.threads.map(&:id)).to eq([thread_1.id, thread_2.id]) + end + + it "does not return threads from the channel where the user has not sent a message" do + Fabricate(:chat_thread, channel: channel) + expect(result.threads.map(&:id)).to eq([thread_3.id, thread_1.id, thread_2.id]) + end + + it "does not return threads from another channel" do + thread_4 = Fabricate(:chat_thread) + Fabricate( + :chat_message, + user: current_user, + thread: thread_4, + chat_channel: thread_4.channel, + created_at: 2.seconds.ago, + ) + expect(result.threads.map(&:id)).to eq([thread_3.id, thread_1.id, thread_2.id]) + end + end + + context "when params are not valid" do + before { params.delete(:channel_id) } + + it { is_expected.to fail_a_contract } + end + + context "when user cannot see channel" do + fab!(:private_channel) { Fabricate(:private_category_channel, group: Fabricate(:group)) } + + before do + thread_1.update!(channel: private_channel) + private_channel.update!(threading_enabled: true) + end + + it { is_expected.to fail_a_policy(:can_view_channel) } + end + + context "when threading is not enabled for the channel" do + before { channel.update!(threading_enabled: false) } + + it { is_expected.to fail_a_policy(:threading_enabled_for_channel) } + end + end + end +end diff --git a/plugins/chat/spec/services/chat/update_thread_spec.rb b/plugins/chat/spec/services/chat/update_thread_spec.rb new file mode 100644 index 00000000000..2d4238a1df5 --- /dev/null +++ b/plugins/chat/spec/services/chat/update_thread_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +RSpec.describe Chat::UpdateThread do + describe Chat::UpdateThread::Contract, type: :model do + it { is_expected.to validate_presence_of :channel_id } + it { is_expected.to validate_presence_of :thread_id } + end + + describe ".call" do + subject(:result) { described_class.call(params) } + + fab!(:current_user) { Fabricate(:user) } + fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) } + fab!(:private_channel) { Fabricate(:private_category_channel, group: Fabricate(:group)) } + fab!(:thread) { Fabricate(:chat_thread, channel: channel, original_message_user: current_user) } + fab!(:other_thread) { Fabricate(:chat_thread) } + + let(:guardian) { Guardian.new(current_user) } + let(:title) { "some new title :D" } + let(:params) do + { guardian: guardian, thread_id: thread.id, channel_id: thread.channel_id, title: title } + end + + context "when enable_experimental_chat_threaded_discussions is disabled" do + before { SiteSetting.enable_experimental_chat_threaded_discussions = false } + + it { is_expected.to fail_a_policy(:threaded_discussions_enabled) } + end + + context "when enable_experimental_chat_threaded_discussions is enabled" do + before { SiteSetting.enable_experimental_chat_threaded_discussions = true } + + context "when all steps pass" do + it "sets the service result as successful" do + expect(result).to be_a_success + end + + it "updates the title of the thread" do + result + expect(thread.reload.title).to eq(title) + end + + it "publishes a MessageBus message" do + message = + MessageBus + .track_publish(Chat::Publisher.root_message_bus_channel(thread.channel_id)) { result } + .first + + expect(message.data["type"]).to eq("update_thread_original_message") + expect(message.data["title"]).to eq(title) + end + end + + context "when params are not valid" do + before { params.delete(:thread_id) } + + it { is_expected.to fail_a_contract } + end + + context "when title is too long" do + let(:title) { "a" * Chat::Thread::MAX_TITLE_LENGTH + "a" } + + it { is_expected.to fail_a_contract } + end + + context "when thread is not found because the channel ID differs" do + before { params[:thread_id] = other_thread.id } + + it { is_expected.to fail_to_find_a_model(:thread) } + end + + context "when thread is not found" do + before { thread.destroy! } + + it { is_expected.to fail_to_find_a_model(:thread) } + end + + context "when user cannot see channel" do + before { thread.update!(channel: private_channel) } + + it { is_expected.to fail_a_policy(:can_view_channel) } + end + + context "when user is not the thread original message creator" do + before { thread.update!(original_message_user: Fabricate(:user)) } + + it { is_expected.to fail_a_policy(:can_edit_thread) } + end + + context "when user is not the thread original message creator but they are staff" do + before do + thread.original_message.update!(user: Fabricate(:user)) + current_user.update!(admin: true) + end + + it { is_expected.not_to fail_a_policy(:can_edit_thread) } + end + + context "when threading is not enabled for the channel" do + before { channel.update!(threading_enabled: false) } + + it { is_expected.to fail_a_policy(:threading_enabled_for_channel) } + end + end + end +end diff --git a/plugins/chat/spec/support/chat_service_matcher.rb b/plugins/chat/spec/support/chat_service_matcher.rb index b4df23678ac..e3ca0ac067d 100644 --- a/plugins/chat/spec/support/chat_service_matcher.rb +++ b/plugins/chat/spec/support/chat_service_matcher.rb @@ -120,6 +120,10 @@ module Chat FailWithInvalidModel.new(name) end + def fail_a_step(name = "model") + FailStep.new(name) + end + def inspect_steps(result) inspector = Chat::StepsInspector.new(result) puts "Steps:" diff --git a/plugins/chat/spec/system/page_objects/chat/chat.rb b/plugins/chat/spec/system/page_objects/chat/chat.rb index a3bc3a90c58..96cb18c7d3d 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat.rb @@ -52,6 +52,10 @@ module PageObjects find(".open-drawer-btn").click end + def open_thread_list + find(".open-thread-list-btn").click + end + def has_message?(message) container = find(".chat-message-container[data-id=\"#{message.id}\"") container.has_content?(message.message) diff --git a/plugins/chat/spec/system/page_objects/chat/chat_side_panel.rb b/plugins/chat/spec/system/page_objects/chat/chat_side_panel.rb index 78d932244e3..acd4bc9e4d4 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat_side_panel.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat_side_panel.rb @@ -14,6 +14,10 @@ module PageObjects def has_no_open_thread? !has_css?(".chat-side-panel .chat-thread") end + + def has_open_thread_list? + has_css?(".chat-side-panel .chat-thread-list") + end end end end diff --git a/plugins/chat/spec/system/page_objects/chat/chat_thread_list.rb b/plugins/chat/spec/system/page_objects/chat/chat_thread_list.rb new file mode 100644 index 00000000000..30aaa341b04 --- /dev/null +++ b/plugins/chat/spec/system/page_objects/chat/chat_thread_list.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class ChatThreadList < PageObjects::Pages::Base + def item_by_id(id) + find(item_by_id_selector(id)) + end + + def item_by_id_selector(id) + ".chat-thread-list__items .chat-thread-list-item[data-thread-id=\"#{id}\"]" + end + end + end +end diff --git a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb index 8d07c4dd16f..acf1184270f 100644 --- a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb +++ b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb @@ -42,6 +42,10 @@ module PageObjects def has_open_channel?(channel) has_css?("#{VISIBLE_DRAWER} .chat-channel[data-id='#{channel.id}']") end + + def has_open_thread_list? + has_css?("#{VISIBLE_DRAWER} .chat-thread-list") + end end end end diff --git a/plugins/chat/spec/system/thread_list/drawer_spec.rb b/plugins/chat/spec/system/thread_list/drawer_spec.rb new file mode 100644 index 00000000000..217cd9c725f --- /dev/null +++ b/plugins/chat/spec/system/thread_list/drawer_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +describe "Thread list in side panel | drawer", type: :system, js: true do + fab!(:current_user) { Fabricate(:admin) } + fab!(:channel) { Fabricate(:chat_channel) } + fab!(:other_user) { Fabricate(:user) } + + let(:chat_page) { PageObjects::Pages::Chat.new } + let(:channel_page) { PageObjects::Pages::ChatChannel.new } + let(:side_panel) { PageObjects::Pages::ChatSidePanel.new } + let(:thread_page) { PageObjects::Pages::ChatThread.new } + let(:thread_list_page) { PageObjects::Pages::ChatThreadList.new } + let(:drawer_page) { PageObjects::Pages::ChatDrawer.new } + + before do + SiteSetting.enable_experimental_chat_threaded_discussions = true + chat_system_bootstrap(current_user, [channel]) + sign_in(current_user) + end + + context "when threading not enabled for the channel" do + before { channel.update!(threading_enabled: false) } + + it "does not show the thread list button in drawer header" do + visit("/") + chat_page.open_from_header + drawer_page.open_channel(channel) + expect(find(".chat-drawer-header__right-actions")).not_to have_css(".open-thread-list-btn") + end + end + + context "when threading is enabled for the channel" do + before { channel.update!(threading_enabled: true) } + + fab!(:thread_1) do + chat_thread_chain_bootstrap( + channel: channel, + users: [current_user, other_user], + thread_attrs: { + title: "favourite album?", + }, + ) + end + fab!(:thread_2) do + chat_thread_chain_bootstrap( + channel: channel, + users: [current_user, other_user], + thread_attrs: { + title: "current event", + }, + ) + end + + it "opens the thread list from the header button" do + visit("/") + chat_page.open_from_header + drawer_page.open_channel(channel) + find(".open-thread-list-btn").click + expect(drawer_page).to have_open_thread_list + end + + it "shows the titles of the threads the user is participating in" do + visit("/") + chat_page.open_from_header + drawer_page.open_channel(channel) + find(".open-thread-list-btn").click + expect(drawer_page).to have_open_thread_list + expect(thread_list_page).to have_content(thread_1.title) + expect(thread_list_page).to have_content(thread_2.title) + end + + it "opens a thread" do + visit("/") + chat_page.open_from_header + drawer_page.open_channel(channel) + find(".open-thread-list-btn").click + expect(drawer_page).to have_open_thread_list + thread_list_page.item_by_id(thread_1.id).click + expect(drawer_page).to have_open_thread(thread_1) + end + end +end diff --git a/plugins/chat/spec/system/thread_list/full_page_spec.rb b/plugins/chat/spec/system/thread_list/full_page_spec.rb new file mode 100644 index 00000000000..cd0ae36e4d5 --- /dev/null +++ b/plugins/chat/spec/system/thread_list/full_page_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +describe "Thread list in side panel | full page", type: :system, js: true do + fab!(:current_user) { Fabricate(:user) } + fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) } + fab!(:other_user) { Fabricate(:user) } + + let(:chat_page) { PageObjects::Pages::Chat.new } + let(:channel_page) { PageObjects::Pages::ChatChannel.new } + let(:side_panel) { PageObjects::Pages::ChatSidePanel.new } + let(:thread_page) { PageObjects::Pages::ChatThread.new } + let(:thread_list_page) { PageObjects::Pages::ChatThreadList.new } + + before do + SiteSetting.enable_experimental_chat_threaded_discussions = true + chat_system_bootstrap(current_user, [channel]) + sign_in(current_user) + end + + context "when there are no threads that the user is participating in" do + it "shows a message" do + chat_page.visit_channel(channel) + chat_page.open_thread_list + expect(page).to have_content(I18n.t("js.chat.threads.none")) + end + end + + context "when there are threads that the user is participating in" do + before { chat_system_user_bootstrap(user: other_user, channel: channel) } + + fab!(:thread_1) do + chat_thread_chain_bootstrap(channel: channel, users: [current_user, other_user]) + end + fab!(:thread_2) do + chat_thread_chain_bootstrap(channel: channel, users: [current_user, other_user]) + end + + it "shows a default title for threads without a title" do + chat_page.visit_channel(channel) + chat_page.open_thread_list + expect(page).to have_content(I18n.t("js.chat.thread.default_title", thread_id: thread_1.id)) + end + + it "shows the thread title with emoji" do + thread_1.update!(title: "What is for dinner? :hamburger:") + chat_page.visit_channel(channel) + chat_page.open_thread_list + expect(thread_list_page.item_by_id(thread_1.id)).to have_content("What is for dinner?") + expect(thread_list_page.item_by_id(thread_1.id)).to have_css("img.emoji[alt='hamburger']") + end + + it "shows an excerpt of the original message of the thread" do + thread_1.original_message.update!(message: "This is a long message for the excerpt") + thread_1.original_message.rebake! + chat_page.visit_channel(channel) + chat_page.open_thread_list + expect(thread_list_page.item_by_id(thread_1.id)).to have_content( + "This is a long message for the excerpt", + ) + end + + it "shows the thread original message user username and avatar" do + chat_page.visit_channel(channel) + chat_page.open_thread_list + expect(thread_list_page.item_by_id(thread_1.id)).to have_css( + ".chat-thread-original-message__avatar .chat-user-avatar .chat-user-avatar-container img", + ) + expect( + thread_list_page.item_by_id(thread_1.id).find(".chat-thread-original-message__username"), + ).to have_content(thread_1.original_message.user.username) + end + + it "opens a thread" do + chat_page.visit_channel(channel) + chat_page.open_thread_list + thread_list_page.item_by_id(thread_1.id).click + expect(side_panel).to have_open_thread(thread_1) + end + + describe "updating the title of the thread" do + let(:new_title) { "wow new title" } + + def open_thread_list + chat_page.visit_channel(channel) + chat_page.open_thread_list + expect(side_panel).to have_open_thread_list + end + + it "allows updating when user is admin" do + current_user.update!(admin: true) + open_thread_list + thread_list_page.item_by_id(thread_1.id).find(".chat-thread-list-item__settings").click + find(".thread-title-input").fill_in(with: new_title) + find(".modal-footer .btn-primary").click + expect(thread_list_page.item_by_id(thread_1.id)).to have_content(new_title) + end + + it "allows updating when user is same as the chat original message user" do + thread_1.update!(original_message_user: current_user) + thread_1.original_message.update!(user: current_user) + open_thread_list + thread_list_page.item_by_id(thread_1.id).find(".chat-thread-list-item__settings").click + find(".thread-title-input").fill_in(with: new_title) + find(".modal-footer .btn-primary").click + expect(thread_list_page.item_by_id(thread_1.id)).to have_content(new_title) + end + + it "does not allow updating if user is neither admin nor original message user" do + thread_1.update!(original_message_user: other_user) + thread_1.original_message.update!(user: other_user) + open_thread_list + expect( + thread_list_page.item_by_id(thread_1.id).find(".chat-thread-list-item__settings")[ + :disabled + ], + ).to eq("true") + end + end + end +end