diff --git a/plugins/chat/app/controllers/api/chat_threads_controller.rb b/plugins/chat/app/controllers/api/chat_threads_controller.rb new file mode 100644 index 0000000000..d90336fe30 --- /dev/null +++ b/plugins/chat/app/controllers/api/chat_threads_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Chat::Api::ChatThreadsController < Chat::Api + def show + render_serialized( + ChatThread.includes(:original_message, :original_message_user).find(params[:thread_id]), + ChatThreadSerializer, + root: "thread", + ) + end +end diff --git a/plugins/chat/app/models/chat_channel.rb b/plugins/chat/app/models/chat_channel.rb index c7ee81ea35..6d8869426c 100644 --- a/plugins/chat/app/models/chat_channel.rb +++ b/plugins/chat/app/models/chat_channel.rb @@ -9,6 +9,7 @@ class ChatChannel < ActiveRecord::Base foreign_key: "chatable_id" has_many :chat_messages + has_many :threads, class_name: "ChatThread", foreign_key: :channel_id has_many :user_chat_channel_memberships has_one :chat_channel_archive diff --git a/plugins/chat/app/serializers/chat_channel_serializer.rb b/plugins/chat/app/serializers/chat_channel_serializer.rb index e6707acfd6..c163b47737 100644 --- a/plugins/chat/app/serializers/chat_channel_serializer.rb +++ b/plugins/chat/app/serializers/chat_channel_serializer.rb @@ -20,7 +20,8 @@ class ChatChannelSerializer < ApplicationSerializer :archive_topic_id, :memberships_count, :current_user_membership, - :meta + :meta, + :threading_enabled def initialize(object, opts) super(object, opts) @@ -115,6 +116,10 @@ class ChatChannelSerializer < ApplicationSerializer } end + def threading_enabled + SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled + end + alias_method :include_archive_topic_id?, :include_archive_status? alias_method :include_total_messages?, :include_archive_status? alias_method :include_archived_messages?, :include_archive_status? diff --git a/plugins/chat/app/serializers/chat_message_serializer.rb b/plugins/chat/app/serializers/chat_message_serializer.rb index 0bcbd64c3d..f24b3aa403 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_serializer.rb b/plugins/chat/app/serializers/chat_thread_serializer.rb new file mode 100644 index 0000000000..06ad43b28a --- /dev/null +++ b/plugins/chat/app/serializers/chat_thread_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ChatThreadSerializer < ApplicationSerializer + has_one :original_message_user, serializer: BasicUserSerializer, embed: :objects + + attributes :id, :title, :status, :original_message_id, :original_message_excerpt, :created_at + + def original_message_excerpt + object.original_message.excerpt + end +end diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb index e02a52328a..a56b841ae9 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 d303778eae..c2745e08e9 100644 --- a/plugins/chat/assets/javascripts/discourse/chat-route-map.js +++ b/plugins/chat/assets/javascripts/discourse/chat-route-map.js @@ -14,6 +14,8 @@ export default function () { this.route("members", { path: "/members" }); this.route("settings", { path: "/settings" }); }); + + this.route("thread", { path: "/t/:threadId" }); }); this.route("draft-channel", { path: "/draft-channel" }); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs index a6a17db58f..33d204a729 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs @@ -152,4 +152,4 @@ {{else}} {{/if}} -{{/if}} \ No newline at end of file +{{/if}} 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 42fdf41345..c832920140 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js @@ -789,6 +789,7 @@ export default Component.extend({ id: data.chat_message.id, staged_id: null, excerpt: data.chat_message.excerpt, + thread_id: data.chat_message.thread_id, }); // some markdown is cooked differently on the server-side, e.g. diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs index 104a4bb92d..a224e358aa 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs @@ -21,4 +21,4 @@ -{{/if}} \ No newline at end of file +{{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs index a6e06bb6e7..cb2187a921 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs @@ -28,6 +28,13 @@ {{format-chat-date @message @details}} + {{!-- TODO (martin): Remove this before merge. --}} + {{#if @message.thread_id}} + + THREAD ID: {{@message.thread_id}} + + {{/if}} + {{#if @message.bookmark}} @@ -45,4 +52,4 @@ {{/if}} {{/if}} - \ No newline at end of file + diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs index c673929683..d972db6e80 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs @@ -82,9 +82,10 @@ {{else}}
{{#if this.message.in_reply_to}} + {{!-- TOOD: Maybe split this into ChatMessageReplyTo component? --}}
{{d-icon "share" title="chat.in_reply_to"}} @@ -227,4 +228,4 @@
{{/if}} {{/if}} -
\ No newline at end of file + diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js index 6e89eee173..c7d901ad7c 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, @@ -678,8 +679,15 @@ export default Component.extend({ }, @action - viewReply() { - this.replyMessageClicked(this.message.in_reply_to); + viewReplyOrThread() { + // TODO (martin) Clean this up, hack + if (this.chatChannel.threading_enabled) { + return this.router.transitionTo("chat.channel.thread", { + threadId: this.message.thread_id, + }); + } else { + this.replyMessageClicked(this.message.in_reply_to); + } }, @action diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread-pane.hbs new file mode 100644 index 0000000000..5633d3016a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread-pane.hbs @@ -0,0 +1,10 @@ +
+

Thread ID {{@thread.id}}, started by {{@thread.original_message_user.username}}

+ +

Excerpt: {{@thread.original_message_excerpt}}

+ +

Close thread

+
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread-pane.js new file mode 100644 index 0000000000..2dcf8c07ba --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread-pane.js @@ -0,0 +1,20 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatThreadPane extends Component { + @service siteSettings; + @service currentUser; + @service chat; + @service router; + + channel = null; + thread = null; + + @action + closeThread() { + return this.router.transitionTo("chat.channel", { + channelId: this.args.channel.id, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs index eb443dabcd..b20ebde372 100644 --- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs @@ -1,7 +1,12 @@ {{#if this.chat.activeChannel}} -{{/if}} \ No newline at end of file + +{{/if}} 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 0000000000..eb176e5e72 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js @@ -0,0 +1,77 @@ +import RestModel from "discourse/models/rest"; +import I18n from "I18n"; +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 function threadStatusName(status) { + switch (status) { + case THREAD_STATUSES.open: + return I18n.t("chat.thread_status.open"); + case THREAD_STATUSES.readOnly: + return I18n.t("chat.thread_status.read_only"); + case THREAD_STATUSES.closed: + return I18n.t("chat.thread_status.closed"); + case THREAD_STATUSES.archived: + return I18n.t("chat.thread_status.archived"); + } +} + +const READONLY_STATUSES = [ + THREAD_STATUSES.closed, + THREAD_STATUSES.readOnly, + THREAD_STATUSES.archived, +]; + +const STAFF_READONLY_STATUSES = [ + THREAD_STATUSES.readOnly, + THREAD_STATUSES.archived, +]; + +export default class ChatThread extends RestModel { + @tracked title; + @tracked status; + + get escapedTitle() { + return escapeExpression(this.title); + } + + get isOpen() { + return !this.status || this.status === THREAD_STATUSES.open; + } + + get isReadOnly() { + return this.status === THREAD_STATUSES.readOnly; + } + + get isClosed() { + return this.status === THREAD_STATUSES.closed; + } + + get isArchived() { + return this.status === THREAD_STATUSES.archived; + } + + canModifyMessages(user) { + if (user.staff) { + return !STAFF_READONLY_STATUSES.includes(this.status); + } + + return !READONLY_STATUSES.includes(this.status); + } +} + +ChatThread.reopenClass({ + create(args) { + args = args || {}; + args.original_message_user = User.create(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 0000000000..a37264f16a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js @@ -0,0 +1,31 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import { schedule } from "@ember/runloop"; + +export default class ChatChannelThread extends DiscourseRoute { + @service chatChannelsManager; + @service chat; + @service router; + + async model(params) { + return this.chatChannelsManager.findThread(params.threadId); + } + + afterModel(model) { + this.chat.setActiveThread(model); + } + + @action + didTransition() { + const { channelId } = this.paramsFor("chat.channel"); + const { threadId } = this.paramsFor(this.routeName); + + if (channelId && threadId) { + schedule("afterRender", () => { + this.chat.openThreadSidebar(channelId, threadId); + }); + } + return true; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index e0157f0f61..0050a9e55b 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -29,6 +29,21 @@ export default class ChatApi extends Service { ); } + /** + * Get a thread by its ID. + * @param {number} threadId - The ID of the thread. + * @returns {Promise} + * + * @example + * + * this.chatApi.thread(1).then(thread => { ... }) + */ + thread(threadId) { + return this.#getRequest(`/threads/${threadId}`).then((result) => + this.chatChannelsManager.storeThread(result.thread) + ); + } + /** * List all accessible category channels of the current user. * @returns {module:Collection} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js index 9d2cae5539..5970fb49db 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js @@ -1,6 +1,7 @@ import Service, { inject as service } from "@ember/service"; import Promise from "rsvp"; import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +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"; @@ -18,6 +19,7 @@ export default class ChatChannelsManager extends Service { @service chatApi; @service currentUser; @tracked _cached = new TrackedObject(); + @tracked _cachedThreads = new TrackedObject(); async find(id, options = { fetchIfNotFound: true }) { const existingChannel = this.#findStale(id); @@ -30,6 +32,20 @@ export default class ChatChannelsManager extends Service { } } + // TODO (martin) Maybe we should make a ChatThreadManager as well? + // Or have one defined for each channel? We need to keep track of all + // threads in channel and have a way to load their messages, and cache. + async findThread(id, options = { fetchIfNotFound: true }) { + const existingThread = this.#findStaleThread(id); + if (existingThread) { + return Promise.resolve(existingThread); + } else if (options.fetchIfNotFound) { + return this.#findThread(id); + } else { + return Promise.resolve(); + } + } + get channels() { return Object.values(this._cached); } @@ -45,6 +61,17 @@ export default class ChatChannelsManager extends Service { return model; } + storeThread(threadObject) { + let model = this.#findStaleThread(threadObject.id); + + if (!model) { + model = ChatThread.create(threadObject); + this.#cacheThread(model); + } + + return model; + } + async follow(model) { this.chatSubscriptionsManager.startChannelSubscription(model); @@ -125,14 +152,32 @@ export default class ChatChannelsManager extends Service { }); } + async #findThread(id) { + return this.chatApi + .thread(id) + .catch(popupAjaxError) + .then((thread) => { + this.#cacheThread(thread); + return thread; + }); + } + #cache(channel) { this._cached[channel.id] = channel; } + #cacheThread(thread) { + this._cachedThreads[thread.id] = thread; + } + #findStale(id) { return this._cached[id]; } + #findStaleThread(id) { + return this._cachedThreads[id]; + } + #sortDirectMessageChannels(channels) { return channels.sort((a, b) => { const unreadCountA = a.currentUserMembership.unread_count || 0; diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index 0cdde90a6a..b048d6fd51 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -36,6 +36,7 @@ export default class Chat extends Service { @service chatChannelsManager; activeChannel = null; + activeThread = null; cook = null; presenceChannel = null; sidebarActive = false; @@ -120,6 +121,10 @@ export default class Chat extends Service { this.set("activeChannel", channel); } + setActiveThread(thread) { + this.set("activeThread", thread); + } + loadCookFunction(categories) { if (this.cook) { return Promise.resolve(this.cook); @@ -271,6 +276,14 @@ export default class Chat extends Service { }); } + async openThreadSidebar(channelId, threadId) { + const channel = await this.chatChannelsManager.find(channelId); + this.setActiveChannel(channel); + + const thread = await this.chatChannelsManager.findThread(threadId); + this.setActiveThread(thread); + } + async openChannel(channel) { return this._openFoundChannelAtMessage(channel); } 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 0000000000..89a38629ad --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-thread.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/stylesheets/common/chat-message-info.scss b/plugins/chat/assets/stylesheets/common/chat-message-info.scss index fb82db5baa..3438c8ddd2 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-info.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-info.scss @@ -33,7 +33,8 @@ } } -.chat-message-info__date { +.chat-message-info__date, +.chat-message-info__thread_id { color: var(--primary-high); font-size: var(--font-down-1); @@ -48,6 +49,9 @@ margin-left: 0.25em; } } +.chat-message-info__thread_id { + color: red; +} .chat-message-info__flag { color: var(--secondary-medium); diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-pane.scss b/plugins/chat/assets/stylesheets/common/chat-thread-pane.scss new file mode 100644 index 0000000000..e3d5222989 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-pane.scss @@ -0,0 +1,8 @@ +.chat-thread-pane { + background-color: #aee6bd; + display: none; + + &--active-thread { + display: block; + } +} diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/common.scss index 340df2cc16..fd27216e88 100644 --- a/plugins/chat/assets/stylesheets/common/common.scss +++ b/plugins/chat/assets/stylesheets/common/common.scss @@ -590,6 +590,9 @@ html.has-full-page-chat { #main-chat-outlet { min-height: 0; + + display: flex; + flex-direction: row; } } } diff --git a/plugins/chat/lib/chat_message_creator.rb b/plugins/chat/lib/chat_message_creator.rb index cb2ee31d89..b15bc176f0 100644 --- a/plugins/chat/lib/chat_message_creator.rb +++ b/plugins/chat/lib/chat_message_creator.rb @@ -190,5 +190,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 329d07209e..23b7a70ce5 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -68,6 +68,7 @@ register_asset "stylesheets/common/chat-skeleton.scss" 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-thread-pane.scss" register_asset "stylesheets/common/chat-channel-settings-saved-indicator.scss" register_svg_icon "comments" @@ -153,6 +154,7 @@ 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_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", @@ -229,6 +231,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_chatables_controller.rb", __FILE__) + load File.expand_path("../app/controllers/api/chat_threads_controller.rb", __FILE__) load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__) if Discourse.allow_dev_populate? @@ -596,6 +599,8 @@ after_initialize do # Hints for JIT warnings. get "/mentions/groups" => "hints#check_group_mentions", :format => :json + + get "/threads/:thread_id" => "chat_threads#show" end # direct_messages_controller routes @@ -640,6 +645,7 @@ after_initialize do base_c_route = "/c/:channel_title/:channel_id" get base_c_route => "chat#respond", :as => "channel" get "#{base_c_route}/:message_id" => "chat#respond" + get "#{base_c_route}/t/:thread_id" => "chat#respond" %w[info info/about info/members info/settings].each do |route| get "#{base_c_route}/#{route}" => "chat#respond"