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