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 @@
+
\ 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}}
-
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"]