From 07ab20131a15ab907c1974fee405d9bdce0c0723 Mon Sep 17 00:00:00 2001 From: Martin Brennan <martin@discourse.org> Date: Tue, 14 Feb 2023 11:38:41 +1000 Subject: [PATCH] FEATURE: Chat side panel with threads initial skeleton (#20209) This commit introduces the skeleton of the chat thread UI. The structure of the components looks like this. Its done this way so the side panel can be used for other things as well if we wish, not just for threads: ``` .main-chat-outlet <ChatLivePane /> <ChatSidePanel> <-- rendered with {{outlet}} --> <ChatThread /> </ChatSidePanel> ``` Later on the `ChatThreadList` will be rendered here as well. Now, when you go to a channel you can open a thread by clicking on either the Open Thread message action button or by clicking on the reply indicator. This will take you to a route like `chat/c/:slug/:channelId/t/:threadId`. This works on mobile as well. This commit includes basic serializers and routes for threads, as well as a new `ChatThreadsManager` service in JS that caches threads for a channel the same way the channel threads manager does. The chat messages inside the thread are intentionally left out until a later PR. **NOTE: These changes are gated behind the site setting enable_experimental_chat_threaded_discussions and the threading_enabled boolean on a ChatChannel** --- .../api/chat_channel_threads_controller.rb | 21 +++++ plugins/chat/app/models/chat_message.rb | 4 +- plugins/chat/app/models/chat_thread.rb | 6 ++ .../serializers/chat_channel_serializer.rb | 7 +- .../serializers/chat_message_serializer.rb | 3 +- ...chat_thread_original_message_serializer.rb | 11 +++ .../app/serializers/chat_thread_serializer.rb | 8 ++ plugins/chat/app/services/chat_publisher.rb | 1 + .../javascripts/discourse/chat-route-map.js | 1 + .../discourse/components/chat-live-pane.js | 7 ++ .../chat-message-actions-desktop.hbs | 9 +++ .../discourse/components/chat-message.hbs | 2 +- .../discourse/components/chat-message.js | 29 ++++++- .../discourse/components/chat-side-panel.hbs | 5 ++ .../discourse/components/chat-side-panel.js | 6 ++ .../discourse/components/chat-thread.hbs | 36 +++++++++ .../discourse/components/chat-thread.js | 22 +++++ .../javascripts/discourse/controllers/chat.js | 19 +++++ .../discourse/models/chat-thread.js | 31 +++++++ .../discourse/routes/chat-channel-thread.js | 21 +++++ .../discourse/routes/chat-channel.js | 23 +++++- .../discourse/services/chat-api.js | 17 ++++ .../discourse/services/chat-state-manager.js | 9 +++ .../services/chat-threads-manager.js | 70 ++++++++++++++++ .../javascripts/discourse/services/chat.js | 3 +- .../templates/chat-channel-thread.hbs | 2 + .../discourse/templates/chat-channel.hbs | 5 +- .../javascripts/discourse/templates/chat.hbs | 5 +- .../common/chat-message-actions.scss | 1 + .../stylesheets/common/chat-side-panel.scss | 23 ++++++ .../stylesheets/common/chat-thread.scss | 55 +++++++++++++ .../assets/stylesheets/common/common.scss | 2 +- .../stylesheets/desktop/chat-message.scss | 1 + .../mobile/chat-message-actions.scss | 1 + .../assets/stylesheets/mobile/mobile.scss | 15 +++- plugins/chat/config/locales/client.en.yml | 5 ++ plugins/chat/config/settings.yml | 1 + plugins/chat/lib/chat_message_creator.rb | 2 + plugins/chat/plugin.rb | 9 +++ plugins/chat/spec/models/chat_message_spec.rb | 2 +- plugins/chat/spec/plugin_helper.rb | 27 +++++++ .../chat_channel_threads_controller_spec.rb | 75 +++++++++++++++++ .../system/page_objects/chat/chat_channel.rb | 20 +++++ .../page_objects/chat/chat_side_panel.rb | 11 +++ .../system/page_objects/chat/chat_thread.rb | 19 +++++ .../chat/spec/system/single_thread_spec.rb | 80 +++++++++++++++++++ plugins/chat/spec/system/transcript_spec.rb | 11 +-- spec/rails_helper.rb | 16 +--- 48 files changed, 721 insertions(+), 38 deletions(-) create mode 100644 plugins/chat/app/controllers/api/chat_channel_threads_controller.rb create mode 100644 plugins/chat/app/serializers/chat_thread_original_message_serializer.rb create mode 100644 plugins/chat/app/serializers/chat_thread_serializer.rb create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-thread.js create mode 100644 plugins/chat/assets/javascripts/discourse/models/chat-thread.js create mode 100644 plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js create mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-threads-manager.js create mode 100644 plugins/chat/assets/javascripts/discourse/templates/chat-channel-thread.hbs create mode 100644 plugins/chat/assets/stylesheets/common/chat-side-panel.scss create mode 100644 plugins/chat/assets/stylesheets/common/chat-thread.scss create mode 100644 plugins/chat/spec/requests/api/chat_channel_threads_controller_spec.rb create mode 100644 plugins/chat/spec/system/page_objects/chat/chat_side_panel.rb create mode 100644 plugins/chat/spec/system/page_objects/chat/chat_thread.rb create mode 100644 plugins/chat/spec/system/single_thread_spec.rb diff --git a/plugins/chat/app/controllers/api/chat_channel_threads_controller.rb b/plugins/chat/app/controllers/api/chat_channel_threads_controller.rb new file mode 100644 index 00000000000..920588dbda1 --- /dev/null +++ b/plugins/chat/app/controllers/api/chat_channel_threads_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Chat::Api::ChatChannelThreadsController < Chat::Api + def show + params.require(:channel_id) + params.require(:thread_id) + + raise Discourse::NotFound if !SiteSetting.enable_experimental_chat_threaded_discussions + + thread = + ChatThread + .includes(:channel) + .includes(original_message_user: :user_status) + .includes(original_message: :chat_webhook_event) + .find_by!(id: params[:thread_id], channel_id: params[:channel_id]) + + guardian.ensure_can_preview_chat_channel!(thread.channel) + + render_serialized(thread, ChatThreadSerializer, root: "thread") + end +end diff --git a/plugins/chat/app/models/chat_message.rb b/plugins/chat/app/models/chat_message.rb index e50afe6083a..677c14aa222 100644 --- a/plugins/chat/app/models/chat_message.rb +++ b/plugins/chat/app/models/chat_message.rb @@ -82,7 +82,7 @@ class ChatMessage < ActiveRecord::Base UploadReference.insert_all!(ref_record_attrs) end - def excerpt + def 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) @@ -90,7 +90,7 @@ class ChatMessage < ActiveRecord::Base 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, 50, {}) + PrettyText.excerpt(cooked, max_length, { text_entities: true }) end def cooked_for_excerpt diff --git a/plugins/chat/app/models/chat_thread.rb b/plugins/chat/app/models/chat_thread.rb index 53d685d98f3..c320281728d 100644 --- a/plugins/chat/app/models/chat_thread.rb +++ b/plugins/chat/app/models/chat_thread.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ChatThread < ActiveRecord::Base + EXCERPT_LENGTH = 150 + belongs_to :channel, foreign_key: "channel_id", class_name: "ChatChannel" belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User" belongs_to :original_message, foreign_key: "original_message_id", class_name: "ChatMessage" @@ -19,6 +21,10 @@ class ChatThread < ActiveRecord::Base def relative_url "#{channel.relative_url}/t/#{self.id}" end + + def excerpt + original_message.excerpt(max_length: EXCERPT_LENGTH) + end end # == Schema Information diff --git a/plugins/chat/app/serializers/chat_channel_serializer.rb b/plugins/chat/app/serializers/chat_channel_serializer.rb index e6707acfd6b..ffa800496af 100644 --- a/plugins/chat/app/serializers/chat_channel_serializer.rb +++ b/plugins/chat/app/serializers/chat_channel_serializer.rb @@ -20,7 +20,12 @@ class ChatChannelSerializer < ApplicationSerializer :archive_topic_id, :memberships_count, :current_user_membership, - :meta + :meta, + :threading_enabled + + def threading_enabled + SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled + end def initialize(object, opts) super(object, opts) diff --git a/plugins/chat/app/serializers/chat_message_serializer.rb b/plugins/chat/app/serializers/chat_message_serializer.rb index 0bcbd64c3d0..f24b3aa403e 100644 --- a/plugins/chat/app/serializers/chat_message_serializer.rb +++ b/plugins/chat/app/serializers/chat_message_serializer.rb @@ -13,7 +13,8 @@ class ChatMessageSerializer < ApplicationSerializer :edited, :reactions, :bookmark, - :available_flags + :available_flags, + :thread_id has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects diff --git a/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb b/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb new file mode 100644 index 00000000000..0cbf498bcc0 --- /dev/null +++ b/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ChatThreadOriginalMessageSerializer < ApplicationSerializer + attributes :id, :created_at, :excerpt, :thread_id + + has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects + + def excerpt + WordWatcher.censor(object.excerpt(max_length: ChatThread::EXCERPT_LENGTH)) + end +end diff --git a/plugins/chat/app/serializers/chat_thread_serializer.rb b/plugins/chat/app/serializers/chat_thread_serializer.rb new file mode 100644 index 00000000000..614f5d79dbc --- /dev/null +++ b/plugins/chat/app/serializers/chat_thread_serializer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ChatThreadSerializer < ApplicationSerializer + has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects + has_one :original_message, serializer: ChatThreadOriginalMessageSerializer, embed: :objects + + attributes :id, :title, :status +end diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb index e02a52328a3..a56b841ae99 100644 --- a/plugins/chat/app/services/chat_publisher.rb +++ b/plugins/chat/app/services/chat_publisher.rb @@ -24,6 +24,7 @@ module ChatPublisher message_id: chat_message.id, user_id: chat_message.user.id, username: chat_message.user.username, + thread_id: chat_message.thread_id, }, permissions, ) diff --git a/plugins/chat/assets/javascripts/discourse/chat-route-map.js b/plugins/chat/assets/javascripts/discourse/chat-route-map.js index a005be2715b..a5b359ab96f 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("thread", { path: "/t/:threadId" }); }); this.route( diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js index fd5502163cc..6cf2c81b3d5 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js @@ -776,8 +776,15 @@ export default Component.extend({ id: data.chat_message.id, staged_id: null, excerpt: data.chat_message.excerpt, + thread_id: data.chat_message.thread_id, }); + const inReplyToMsg = + this.messageLookup[data.chat_message.in_reply_to?.id]; + if (inReplyToMsg && !inReplyToMsg.thread_id) { + inReplyToMsg.set("thread_id", data.chat_message.thread_id); + } + // some markdown is cooked differently on the server-side, e.g. // quotes, avatar images etc. if ( diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs index 651e0419b5e..cc0a38248ab 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs @@ -37,6 +37,15 @@ /> {{/if}} + {{#if this.messageCapabilities.hasThread}} + <DButton + @class="btn-flat chat-message-thread-btn" + @action={{this.messageActions.openThread}} + @icon="puzzle-piece" + @title="chat.threads.open" + /> + {{/if}} + {{#if this.secondaryButtons.length}} <DropdownSelectBox @class="more-buttons" diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs index c6739296837..7f34bb828eb 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs @@ -84,7 +84,7 @@ {{#if this.message.in_reply_to}} <div role="button" - onclick={{action "viewReply"}} + onclick={{action "viewReplyOrThread"}} class="chat-reply is-direct-reply" > {{d-icon "share" title="chat.in_reply_to"}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js index 6e89eee1731..17ce8d8f415 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -49,6 +49,7 @@ export default Component.extend({ tagName: "", chat: service(), dialog: service(), + router: service(), chatMessageActionsMobileAnchor: null, chatMessageActionsDesktopAnchor: null, chatMessageEmojiPickerAnchor: null, @@ -237,6 +238,14 @@ export default Component.extend({ }); } + if (this.hasThread) { + buttons.push({ + id: "openThread", + name: I18n.t("chat.threads.open"), + icon: "puzzle-piece", + }); + } + return buttons; }, @@ -252,6 +261,7 @@ export default Component.extend({ restore: this.restore, rebakeMessage: this.rebakeMessage, toggleBookmark: this.toggleBookmark, + openThread: this.openThread, startReactionForMessageActions: this.startReactionForMessageActions, }; }, @@ -261,9 +271,15 @@ export default Component.extend({ canReact: this.canReact, canReply: this.canReply, canBookmark: this.showBookmarkButton, + hasThread: this.canReply && this.hasThread, }; }, + @discourseComputed("message.thread_id") + hasThread() { + return this.chatChannel.threading_enabled && this.message.thread_id; + }, + @discourseComputed("message", "details.can_moderate") show(message, canModerate) { return ( @@ -678,8 +694,12 @@ export default Component.extend({ }, @action - viewReply() { - this.replyMessageClicked(this.message.in_reply_to); + viewReplyOrThread() { + if (this.hasThread) { + this.router.transitionTo("chat.channel.thread", this.message.thread_id); + } else { + this.replyMessageClicked(this.message.in_reply_to); + } }, @action @@ -719,6 +739,11 @@ export default Component.extend({ ).catch(popupAjaxError); }, + @action + openThread() { + this.router.transitionTo("chat.channel.thread", this.message.thread_id); + }, + @action toggleBookmark() { return openBookmarkModal( diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs new file mode 100644 index 00000000000..a177d7d0c7e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs @@ -0,0 +1,5 @@ +{{#if this.chatStateManager.isSidePanelExpanded}} + <div class="chat-side-panel"> + {{yield}} + </div> +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js new file mode 100644 index 00000000000..53be6e18eb9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatSidePanel extends Component { + @service chatStateManager; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs new file mode 100644 index 00000000000..3238d28a611 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs @@ -0,0 +1,36 @@ +<div class="chat-thread" data-id={{this.thread.id}}> + <div class="chat-thread__header"> + <div class="chat-thread__info"> + <div class="chat-thread__title"> + <h2>{{this.title}}</h2> + + <LinkTo + class="chat-thread__close" + @route="chat.channel" + @models={{this.chat.activeChannel.routeModels}} + > + {{d-icon "times"}} + </LinkTo> + </div> + + <p class="chat-thread__om"> + {{replace-emoji this.thread.original_message.excerpt}} + </p> + + <div class="chat-thread__omu"> + <span class="chat-thread__started-by">{{i18n + "chat.threads.started_by" + }}</span> + <ChatMessageAvatar + class="chat-thread__omu-avatar" + @message={{this.thread.original_message}} + /> + <span + class="chat-thread__omu-username" + >{{this.thread.original_message_user.username}}</span> + </div> + </div> + </div> + <div class="chat-thread__messages"> + </div> +</div> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js new file mode 100644 index 00000000000..26bf057456a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js @@ -0,0 +1,22 @@ +import Component from "@glimmer/component"; +import I18n from "I18n"; +import { inject as service } from "@ember/service"; + +export default class ChatThreadPanel extends Component { + @service siteSettings; + @service currentUser; + @service chat; + @service router; + + get thread() { + return this.chat.activeThread; + } + + get title() { + if (this.thread.title) { + this.thread.escapedTitle; + } + + return I18n.t("chat.threads.op_said"); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat.js b/plugins/chat/assets/javascripts/discourse/controllers/chat.js index 1ea1e5a478a..888996dd833 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat.js @@ -3,6 +3,8 @@ import { inject as service } from "@ember/service"; export default class ChatController extends Controller { @service chat; + @service chatStateManager; + @service router; get shouldUseChatSidebar() { if (this.site.mobileView) { @@ -19,4 +21,21 @@ export default class ChatController extends Controller { get shouldUseCoreSidebar() { return this.siteSettings.navigation_menu === "sidebar"; } + + get mainOutletModifierClasses() { + let modifierClasses = []; + + if (this.chatStateManager.isSidePanelExpanded) { + modifierClasses.push("has-side-panel-expanded"); + } + + if ( + !this.router.currentRouteName.startsWith("chat.channel.info") && + !this.router.currentRouteName.startsWith("chat.browse") + ) { + modifierClasses.push("chat-view"); + } + + return modifierClasses.join(" "); + } } diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js new file mode 100644 index 00000000000..abe42551d0b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js @@ -0,0 +1,31 @@ +import RestModel from "discourse/models/rest"; +import User from "discourse/models/user"; +import { escapeExpression } from "discourse/lib/utilities"; +import { tracked } from "@glimmer/tracking"; + +export const THREAD_STATUSES = { + open: "open", + readOnly: "read_only", + closed: "closed", + archived: "archived", +}; + +export default class ChatThread extends RestModel { + @tracked title; + @tracked status; + + get escapedTitle() { + return escapeExpression(this.title); + } +} + +ChatThread.reopenClass({ + create(args) { + args = args || {}; + if (!args.original_message_user instanceof User) { + args.original_message_user = User.create(args.original_message_user); + } + args.original_message.user = args.original_message_user; + return this._super(args); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js new file mode 100644 index 00000000000..8621dc311da --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js @@ -0,0 +1,21 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelThread extends DiscourseRoute { + @service router; + @service chatThreadsManager; + @service chatStateManager; + @service chat; + + async model(params) { + return this.chatThreadsManager.find( + this.modelFor("chat.channel").id, + params.threadId + ); + } + + afterModel(model) { + this.chat.activeThread = model; + this.chatStateManager.openSidePanel(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js index 96658d1ada4..e34f57c9ff1 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js @@ -1,5 +1,26 @@ import DiscourseRoute from "discourse/routes/discourse"; import withChatChannel from "./chat-channel-decorator"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; @withChatChannel -export default class ChatChannelRoute extends DiscourseRoute {} +export default class ChatChannelRoute extends DiscourseRoute { + @service chatThreadsManager; + @service chatStateManager; + + @action + willTransition(transition) { + this.chat.activeThread = null; + this.chatStateManager.closeSidePanel(); + + if (!transition?.to?.name?.startsWith("chat.")) { + this.chatStateManager.storeChatURL(); + this.chat.activeChannel = null; + this.chat.updatePresence(); + } + } + + beforeModel() { + this.chatThreadsManager.resetCache(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index 7fd532dfa5c..c379bc1ff64 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -11,6 +11,7 @@ import Collection from "../lib/collection"; */ export default class ChatApi extends Service { @service chatChannelsManager; + @service chatThreadsManager; /** * Get a channel by its ID. @@ -27,6 +28,22 @@ export default class ChatApi extends Service { ); } + /** + * Get a thread in a channel by its ID. + * @param {number} channelId - The ID of the channel. + * @param {number} threadId - The ID of the thread. + * @returns {Promise} + * + * @example + * + * this.chatApi.thread(5, 1).then(thread => { ... }) + */ + thread(channelId, threadId) { + return this.#getRequest(`/channels/${channelId}/threads/${threadId}`).then( + (result) => this.chatThreadsManager.store(result.thread) + ); + } + /** * List all accessible category channels of the current user. * @returns {Collection} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js index ee79bb20bb3..9062f743932 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js @@ -14,6 +14,7 @@ export default class ChatStateManager extends Service { @service router; isDrawerExpanded = false; isDrawerActive = false; + isSidePanelExpanded = false; @tracked _chatURL = null; @tracked _appURL = null; @@ -33,6 +34,14 @@ export default class ChatStateManager extends Service { this._store.setObject({ key: PREFERRED_MODE_KEY, value: DRAWER_CHAT }); } + openSidePanel() { + this.set("isSidePanelExpanded", true); + } + + closeSidePanel() { + this.set("isSidePanelExpanded", false); + } + didOpenDrawer(URL = null) { this.set("isDrawerActive", true); this.set("isDrawerExpanded", true); diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-threads-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-threads-manager.js new file mode 100644 index 00000000000..4bcd79b8af6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-threads-manager.js @@ -0,0 +1,70 @@ +import Service, { inject as service } from "@ember/service"; +import Promise from "rsvp"; +import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread"; +import { tracked } from "@glimmer/tracking"; +import { TrackedObject } from "@ember-compat/tracked-built-ins"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +/* + The ChatThreadsManager service is responsible for managing the loaded chat threads + for the current chat channel. + + It provides helpers to facilitate using and managing loaded threads instead of constantly + fetching them from the server. +*/ + +export default class ChatThreadsManager extends Service { + @service chatSubscriptionsManager; + @service chatApi; + @service currentUser; + @tracked _cached = new TrackedObject(); + + async find(channelId, threadId, options = { fetchIfNotFound: true }) { + const existingThread = this.#findStale(threadId); + if (existingThread) { + return Promise.resolve(existingThread); + } else if (options.fetchIfNotFound) { + return this.#find(channelId, threadId); + } else { + return Promise.resolve(); + } + } + + // whenever the active channel changes, do this + resetCache() { + this._cached = new TrackedObject(); + } + + get threads() { + return Object.values(this._cached); + } + + store(threadObject) { + let model = this.#findStale(threadObject.id); + + if (!model) { + model = ChatThread.create(threadObject); + this.#cache(model); + } + + return model; + } + + async #find(channelId, threadId) { + return this.chatApi + .thread(channelId, threadId) + .catch(popupAjaxError) + .then((thread) => { + this.#cache(thread); + return thread; + }); + } + + #cache(thread) { + this._cached[thread.id] = thread; + } + + #findStale(id) { + return this._cached[id]; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index 0996763ed67..a0d736a983b 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -35,9 +35,8 @@ export default class Chat extends Service { @service router; @service site; @service chatChannelsManager; - @tracked activeChannel = null; - + @tracked activeThread = null; cook = null; presenceChannel = null; sidebarActive = false; diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-thread.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-thread.hbs new file mode 100644 index 00000000000..52c32e8d5ae --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-thread.hbs @@ -0,0 +1,2 @@ +{{! ChatThreadList will go here later }} +<ChatThread /> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel.hbs index 27155a709e3..7db7118fd48 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat-channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel.hbs @@ -1 +1,4 @@ -<FullPageChat @targetMessageId={{this.targetMessageId}} /> \ No newline at end of file +<FullPageChat @targetMessageId={{this.targetMessageId}} /> +<ChatSidePanel> + {{outlet}} +</ChatSidePanel> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat.hbs index d7a30317d22..3fe25fd695d 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat.hbs @@ -17,7 +17,10 @@ <ChannelsList /> {{/if}} - <div id="main-chat-outlet"> + <div + id="main-chat-outlet" + class={{concat-class "main-chat-outlet" this.mainOutletModifierClasses}} + > {{outlet}} </div> </div> diff --git a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss index 356cf39dfa6..815d561d643 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss @@ -44,6 +44,7 @@ .react-btn, .reply-btn, + .chat-message-thread-btn, .bookmark-btn { margin-right: -1px; padding: 0.5em 0; diff --git a/plugins/chat/assets/stylesheets/common/chat-side-panel.scss b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss new file mode 100644 index 00000000000..0839fd90178 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss @@ -0,0 +1,23 @@ +#main-chat-outlet.chat-view { + min-height: 0; + display: grid; + grid-template-rows: 1fr; + grid-template-areas: "main threads"; + grid-template-columns: 1fr; + + &.has-side-panel-expanded { + grid-template-columns: 3fr 2fr; + } +} + +.chat-side-panel { + grid-area: threads; + min-height: 100%; + box-sizing: border-box; + border-left: 1px solid var(--primary-medium); + + &__list { + flex-grow: 1; + padding: 0 1.5em 1em; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread.scss b/plugins/chat/assets/stylesheets/common/chat-thread.scss new file mode 100644 index 00000000000..91b7bab73bb --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread.scss @@ -0,0 +1,55 @@ +.chat-thread { + display: flex; + flex-direction: column; + padding-block: 1rem; + height: 100%; + box-sizing: border-box; + + &__header { + } + + &__close { + color: var(--primary-medium); + + &:visited { + color: var(--primary-medium); + } + } + + &__info { + padding-inline: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--primary-low); + } + + &__om { + margin-top: 0; + } + + &__omu { + display: flex; + flex-direction: row; + align-items: center; + + .chat-message-avatar { + width: var(--message-left-width); + } + } + + &__started-by { + margin-right: 0.5rem; + } + + &__title { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__messages { + flex-grow: 1; + overflow: hidden; + overflow-y: scroll; + padding-inline: 1.5rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/common.scss index 340df2cc16b..7cc3ae8d392 100644 --- a/plugins/chat/assets/stylesheets/common/common.scss +++ b/plugins/chat/assets/stylesheets/common/common.scss @@ -588,7 +588,7 @@ html.has-full-page-chat { padding-bottom: env(safe-area-inset-bottom); } - #main-chat-outlet { + .main-chat-outlet { min-height: 0; } } diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message.scss b/plugins/chat/assets/stylesheets/desktop/chat-message.scss index 8ea9c38357c..87f02837b59 100644 --- a/plugins/chat/assets/stylesheets/desktop/chat-message.scss +++ b/plugins/chat/assets/stylesheets/desktop/chat-message.scss @@ -1,6 +1,7 @@ .chat-message-actions { .react-btn, .reply-btn, + .chat-message-thread-btn, .bookmark-btn { border: 1px solid transparent; border-bottom-color: var(--primary-low); diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss index eac83541f83..e8361c052e2 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss @@ -75,6 +75,7 @@ .chat-message-reaction, .reply-btn, + .chat-message-thread-btn, .react-btn, .bookmark-btn { flex-grow: 1; diff --git a/plugins/chat/assets/stylesheets/mobile/mobile.scss b/plugins/chat/assets/stylesheets/mobile/mobile.scss index ff9c1b60b23..264b6b0194a 100644 --- a/plugins/chat/assets/stylesheets/mobile/mobile.scss +++ b/plugins/chat/assets/stylesheets/mobile/mobile.scss @@ -6,13 +6,24 @@ padding-top: 0.75em; } -body.has-full-page-chat { +html.has-full-page-chat { .footer-nav { display: none !important; } - #main-outlet { + body #main-outlet { padding: 0; + + .main-chat-outlet { + &.has-side-panel-expanded { + grid-template-columns: 1fr; + grid-template-areas: "threads"; + + .chat-live-pane { + display: none; + } + } + } } } diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 5a623ccc0be..2d403a19912 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -442,6 +442,11 @@ en: search_placeholder: "Search by emoji name and alias..." no_results: "No results" + threads: + op_said: "OP said:" + started_by: "Started by" + open: "Open Thread" + draft_channel_screen: header: "New Message" cancel: "Cancel" diff --git a/plugins/chat/config/settings.yml b/plugins/chat/config/settings.yml index b02f897720e..e62299b59cc 100644 --- a/plugins/chat/config/settings.yml +++ b/plugins/chat/config/settings.yml @@ -116,3 +116,4 @@ chat: enable_experimental_chat_threaded_discussions: default: false hidden: true + client: true diff --git a/plugins/chat/lib/chat_message_creator.rb b/plugins/chat/lib/chat_message_creator.rb index 4039f24ad12..08b5a711a08 100644 --- a/plugins/chat/lib/chat_message_creator.rb +++ b/plugins/chat/lib/chat_message_creator.rb @@ -200,5 +200,7 @@ class Chat::ChatMessageCreator FROM thread_updater WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id SQL + + @chat_message.thread_id = thread.id end end diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index 16b2c8e93ae..c5b2f5c6255 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -69,6 +69,8 @@ register_asset "stylesheets/colors.scss", :color_definitions register_asset "stylesheets/common/reviewable-chat-message.scss" register_asset "stylesheets/common/chat-mention-warnings.scss" register_asset "stylesheets/common/chat-channel-settings-saved-indicator.scss" +register_asset "stylesheets/common/chat-thread.scss" +register_asset "stylesheets/common/chat-side-panel.scss" register_svg_icon "comments" register_svg_icon "comment-slash" @@ -155,6 +157,8 @@ after_initialize do load File.expand_path("../app/serializers/chat_channel_serializer.rb", __FILE__) load File.expand_path("../app/serializers/chat_channel_index_serializer.rb", __FILE__) load File.expand_path("../app/serializers/chat_channel_search_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_thread_original_message_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_thread_serializer.rb", __FILE__) load File.expand_path("../app/serializers/chat_view_serializer.rb", __FILE__) load File.expand_path( "../app/serializers/user_with_custom_fields_and_status_serializer.rb", @@ -237,6 +241,7 @@ after_initialize do ) load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__) load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__) + load File.expand_path("../app/controllers/api/chat_channel_threads_controller.rb", __FILE__) load File.expand_path("../app/controllers/api/chat_chatables_controller.rb", __FILE__) load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__) @@ -605,6 +610,8 @@ after_initialize do # Hints for JIT warnings. get "/mentions/groups" => "hints#check_group_mentions", :format => :json + + get "/channels/:channel_id/threads/:thread_id" => "chat_channel_threads#show" end # direct_messages_controller routes @@ -657,6 +664,8 @@ after_initialize do # /channel -> /c redirects get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}") + get "#{base_c_route}/t/:thread_id" => "chat#respond" + base_channel_route = "/channel/:channel_id/:channel_title" redirect_base = "/chat/c/%{channel_title}/%{channel_id}" diff --git a/plugins/chat/spec/models/chat_message_spec.rb b/plugins/chat/spec/models/chat_message_spec.rb index 3633ace5639..0ff44ba4999 100644 --- a/plugins/chat/spec/models/chat_message_spec.rb +++ b/plugins/chat/spec/models/chat_message_spec.rb @@ -294,7 +294,7 @@ describe ChatMessage do "wow check out these birbs https://twitter.com/EffinBirds/status/1518743508378697729", ) expect(message.excerpt).to eq( - "wow check out these birbs <a href=\"https://twitter.com/EffinBirds/status/1518743508378697729\" class=\"inline-onebox-loading\" rel=\"noopener nofollow ugc\">https://twitter.com/Effi…</a>", + "wow check out these birbs <a href=\"https://twitter.com/EffinBirds/status/1518743508378697729\" class=\"inline-onebox-loading\" rel=\"noopener nofollow ugc\">https://twitter.com/Effi...</a>", ) end diff --git a/plugins/chat/spec/plugin_helper.rb b/plugins/chat/spec/plugin_helper.rb index 836b8593dcb..834dbbe1f41 100644 --- a/plugins/chat/spec/plugin_helper.rb +++ b/plugins/chat/spec/plugin_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "faker" + module ChatSystemHelpers def chat_system_bootstrap(user = Fabricate(:admin), channels_for_membership = []) # ensures we have one valid registered admin/user @@ -20,6 +22,31 @@ module ChatSystemHelpers # this is reset after each test Bookmark.register_bookmarkable(ChatMessageBookmarkable) end + + def chat_thread_chain_bootstrap(channel:, users:, messages_count: 4) + last_user = nil + last_message = nil + + messages_count.times do |i| + in_reply_to = i.zero? ? nil : last_message.id + thread_id = i.zero? ? nil : last_message.thread_id + last_user = last_user.present? ? (users - [last_user]).sample : users.sample + creator = + Chat::ChatMessageCreator.new( + chat_channel: channel, + in_reply_to_id: in_reply_to, + thread_id: thread_id, + user: last_user, + content: Faker::Lorem.paragraph, + ) + creator.create + + raise creator.error if creator.error + last_message = creator.chat_message + end + + last_message.thread + end end RSpec.configure do |config| diff --git a/plugins/chat/spec/requests/api/chat_channel_threads_controller_spec.rb b/plugins/chat/spec/requests/api/chat_channel_threads_controller_spec.rb new file mode 100644 index 00000000000..0a2e4f054aa --- /dev/null +++ b/plugins/chat/spec/requests/api/chat_channel_threads_controller_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::Api::ChatChannelThreadsController do + fab!(:current_user) { Fabricate(:user) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + SiteSetting.enable_experimental_chat_threaded_discussions = true + Group.refresh_automatic_groups! + sign_in(current_user) + end + + describe "show" do + context "when thread does not exist" do + fab!(:thread) { Fabricate(:chat_thread, original_message: Fabricate(:chat_message)) } + + it "returns 404" do + thread.destroy! + get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}" + expect(response.status).to eq(404) + end + end + + context "when thread exists" do + fab!(:thread) { Fabricate(:chat_thread, original_message: Fabricate(:chat_message)) } + + it "works" do + get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}" + expect(response.status).to eq(200) + expect(response.parsed_body["thread"]["id"]).to eq(thread.id) + end + + context "when the channel_id does not match the thread id" do + fab!(:other_channel) { Fabricate(:chat_channel) } + + it "returns 404" do + get "/chat/api/channels/#{other_channel.id}/threads/#{thread.id}" + 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/#{thread.channel_id}/threads/#{thread.id}" + expect(response.status).to eq(404) + end + end + + context "when user cannot access the channel" do + before do + thread.channel.update!(chatable: Fabricate(:private_category, group: Fabricate(:group))) + end + + it "returns 403" do + get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}" + expect(response.status).to eq(403) + end + end + + context "when user cannot chat" do + before { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_4] } + + it "returns 403" do + get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}" + expect(response.status).to eq(403) + end + end + end + end +end diff --git a/plugins/chat/spec/system/page_objects/chat/chat_channel.rb b/plugins/chat/spec/system/page_objects/chat/chat_channel.rb index 715c2a3d2ef..96ebb4478da 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat_channel.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat_channel.rb @@ -3,6 +3,8 @@ module PageObjects module Pages class ChatChannel < PageObjects::Pages::Base + include SystemHelpers + def type_in_composer(input) find(".chat-composer-input").send_keys(input) end @@ -32,6 +34,19 @@ module PageObjects click_more_buttons(message) end + def expand_message_actions_mobile(message, delay: 2) + message_by_id(message.id).click(delay: delay) + end + + def click_message_action_mobile(message, message_action) + i = 0.5 + try_until_success(timeout: 20) do + expand_message_actions_mobile(message, delay: i) + first(".chat-message-action-item[data-id=\"#{message_action}\"]") + end + find(".chat-message-action-item[data-id=\"#{message_action}\"] button").click + end + def hover_message(message) message_by_id(message.id).hover end @@ -51,6 +66,11 @@ module PageObjects find("[data-value='flag']").click end + def open_message_thread(message) + hover_message(message) + find(".chat-message-thread-btn").click + end + def select_message(message) hover_message(message) click_more_buttons(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 new file mode 100644 index 00000000000..01dfb1f2da6 --- /dev/null +++ b/plugins/chat/spec/system/page_objects/chat/chat_side_panel.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class ChatSidePanel < PageObjects::Pages::Base + def has_open_thread?(thread) + has_css?(".chat-side-panel .chat-thread[data-id='#{thread.id}']") + end + end + end +end diff --git a/plugins/chat/spec/system/page_objects/chat/chat_thread.rb b/plugins/chat/spec/system/page_objects/chat/chat_thread.rb new file mode 100644 index 00000000000..860bd131531 --- /dev/null +++ b/plugins/chat/spec/system/page_objects/chat/chat_thread.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class ChatThread < PageObjects::Pages::Base + def header + find(".chat-thread__header") + end + + def omu + header.find(".chat-thread__omu") + end + + def has_header_content?(content) + header.has_content?(content) + end + end + end +end diff --git a/plugins/chat/spec/system/single_thread_spec.rb b/plugins/chat/spec/system/single_thread_spec.rb new file mode 100644 index 00000000000..52f1b2e5d39 --- /dev/null +++ b/plugins/chat/spec/system/single_thread_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +describe "Single thread in side panel", type: :system, js: true do + fab!(:current_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(:open_thread) { PageObjects::Pages::ChatThread.new } + + before do + chat_system_bootstrap(current_user, [channel]) + sign_in(current_user) + end + + context "when enable_experimental_chat_threaded_discussions is disabled" do + fab!(:channel) { Fabricate(:chat_channel) } + before { SiteSetting.enable_experimental_chat_threaded_discussions = false } + + it "does not open the side panel for a single thread" do + thread = + chat_thread_chain_bootstrap(channel: channel, users: [current_user, Fabricate(:user)]) + chat_page.visit_channel(channel) + channel_page.hover_message(thread.original_message) + expect(page).not_to have_css(".chat-message-thread-btn") + end + end + + context "when threading_enabled is false for the channel" do + fab!(:channel) { Fabricate(:chat_channel) } + before do + SiteSetting.enable_experimental_chat_threaded_discussions = true + channel.update!(threading_enabled: false) + end + + it "does not open the side panel for a single thread" do + thread = + chat_thread_chain_bootstrap(channel: channel, users: [current_user, Fabricate(:user)]) + chat_page.visit_channel(channel) + channel_page.hover_message(thread.original_message) + expect(page).not_to have_css(".chat-message-thread-btn") + end + end + + context "when enable_experimental_chat_threaded_discussions is true and threading is enabled for the channel" do + fab!(:user_2) { Fabricate(:user) } + fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) } + fab!(:thread) { chat_thread_chain_bootstrap(channel: channel, users: [current_user, user_2]) } + + before { SiteSetting.enable_experimental_chat_threaded_discussions = true } + + it "opens the side panel for a single thread from the message actions menu" do + chat_page.visit_channel(channel) + channel_page.open_message_thread(thread.original_message) + expect(side_panel).to have_open_thread(thread) + end + + it "shows the excerpt of the thread original message" do + chat_page.visit_channel(channel) + channel_page.open_message_thread(thread.original_message) + expect(open_thread).to have_header_content(thread.excerpt) + end + + it "shows the avatar and username of the original message user" do + chat_page.visit_channel(channel) + channel_page.open_message_thread(thread.original_message) + expect(open_thread.omu).to have_css(".chat-user-avatar img.avatar") + expect(open_thread.omu).to have_content(thread.original_message_user.username) + end + + context "when using mobile" do + it "opens the side panel for a single thread from the mobile message actions menu", + mobile: true do + chat_page.visit_channel(channel) + channel_page.click_message_action_mobile(thread.chat_messages.last, "openThread") + expect(side_panel).to have_open_thread(thread) + end + end + end +end diff --git a/plugins/chat/spec/system/transcript_spec.rb b/plugins/chat/spec/system/transcript_spec.rb index 08809cec57a..886219096aa 100644 --- a/plugins/chat/spec/system/transcript_spec.rb +++ b/plugins/chat/spec/system/transcript_spec.rb @@ -25,15 +25,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do end end - def select_message_mobile(message) - i = 0.5 - try_until_success(timeout: 20) do - chat_channel_page.message_by_id(message.id).click(delay: i) - first(".chat-message-action-item[data-id=\"selectMessage\"]") - end - find(".chat-message-action-item[data-id=\"selectMessage\"] button").click - end - def cdp_allow_clipboard_access! cdp_params = { origin: page.server_url, @@ -230,7 +221,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do expect(chat_channel_page).to have_no_loading_skeleton - select_message_mobile(message_1) + chat_channel_page.click_message_action_mobile(message_1, "selectMessage") click_selection_button("quote") expect(topic_page).to have_expanded_composer diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index c822777e709..e526c685b9f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -283,13 +283,13 @@ RSpec.configure do |config| end Capybara.register_driver :selenium_chrome do |app| - Capybara::Selenium::Driver.new(app, browser: :chrome, capabilities: chrome_browser_options) + Capybara::Selenium::Driver.new(app, browser: :chrome, options: chrome_browser_options) end Capybara.register_driver :selenium_chrome_headless do |app| chrome_browser_options.add_argument("--headless") - Capybara::Selenium::Driver.new(app, browser: :chrome, capabilities: chrome_browser_options) + Capybara::Selenium::Driver.new(app, browser: :chrome, options: chrome_browser_options) end mobile_chrome_browser_options = @@ -304,20 +304,12 @@ RSpec.configure do |config| end Capybara.register_driver :selenium_mobile_chrome do |app| - Capybara::Selenium::Driver.new( - app, - browser: :chrome, - capabilities: mobile_chrome_browser_options, - ) + Capybara::Selenium::Driver.new(app, browser: :chrome, options: mobile_chrome_browser_options) end Capybara.register_driver :selenium_mobile_chrome_headless do |app| mobile_chrome_browser_options.add_argument("--headless") - Capybara::Selenium::Driver.new( - app, - browser: :chrome, - capabilities: mobile_chrome_browser_options, - ) + Capybara::Selenium::Driver.new(app, browser: :chrome, options: mobile_chrome_browser_options) end if ENV["ELEVATED_UPLOADS_ID"]