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}} + + {{/if}} + {{#if this.secondaryButtons.length}} {{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}} +
+ {{yield}} +
+{{/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 @@ +
+
+
+
+

{{this.title}}

+ + + {{d-icon "times"}} + +
+ +

+ {{replace-emoji this.thread.original_message.excerpt}} +

+ +
+ {{i18n + "chat.threads.started_by" + }} + + {{this.thread.original_message_user.username}} +
+
+
+
+
+
\ 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 }} + \ 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 @@ - \ No newline at end of file + + + {{outlet}} + \ 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 @@ {{/if}} -
+
{{outlet}}
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 https://twitter.com/Effi…", + "wow check out these birbs https://twitter.com/Effi...", ) 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"]