FEATURE: Skeleton for loading threads in a side pane

This commit includes:

* Additions to message and channel serializers for threads
* New route and controller for a single thread
* JS route for thread pane
* Extremely basic thread pane component
* Additions to channel manager to deal with threads, and ChatThread JS model
* Changes to chat publisher and existing JS to get new thread ID when
  message is created
This commit is contained in:
Martin Brennan 2023-02-03 17:27:16 +10:00
parent af6660f725
commit 7e34b840ca
27 changed files with 300 additions and 11 deletions

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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" });

View File

@ -152,4 +152,4 @@
{{else}}
<ChatChannelPreviewCard @channel={{this.chatChannel}} />
{{/if}}
{{/if}}
{{/if}}

View File

@ -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.

View File

@ -21,4 +21,4 @@
</ul>
</div>
</div>
{{/if}}
{{/if}}

View File

@ -28,6 +28,13 @@
{{format-chat-date @message @details}}
</span>
{{!-- TODO (martin): Remove this before merge. --}}
{{#if @message.thread_id}}
<span class="chat-message-info__thread_id">
THREAD ID: {{@message.thread_id}}
</span>
{{/if}}
{{#if @message.bookmark}}
<span class="chat-message-info__bookmark">
<BookmarkIcon @bookmark={{@message.bookmark}} />
@ -45,4 +52,4 @@
{{/if}}
</span>
{{/if}}
</div>
</div>

View File

@ -82,9 +82,10 @@
{{else}}
<div class={{this.chatMessageClasses}}>
{{#if this.message.in_reply_to}}
{{!-- TOOD: Maybe split this into ChatMessageReplyTo component? --}}
<div
role="button"
onclick={{action "viewReply"}}
onclick={{action "viewReplyOrThread"}}
class="chat-reply is-direct-reply"
>
{{d-icon "share" title="chat.in_reply_to"}}
@ -227,4 +228,4 @@
</div>
{{/if}}
{{/if}}
</div>
</div>

View File

@ -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

View File

@ -0,0 +1,10 @@
<div class={{concat-class
"chat-thread-pane"
(if @thread "chat-thread-pane--active-thread")
}}>
<p>Thread ID {{@thread.id}}, started by {{@thread.original_message_user.username}}</p>
<p>Excerpt: {{@thread.original_message_excerpt}}</p>
<p><a href onclick={{action "closeThread"}}>Close thread</a></p>
</div>

View File

@ -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,
});
}
}

View File

@ -1,7 +1,12 @@
{{#if this.chat.activeChannel}}
<ChatLivePane
@chatChannel={{this.chat.activeChannel}}
@thread={{this.chat.activeThread}}
@onBackClick={{action "navigateToIndex"}}
@onSwitchChannel={{action "switchChannel"}}
/>
{{/if}}
<ChatThreadPane
@channel={{this.chat.activeChannel}}
@thread={{this.chat.activeThread}}
/>
{{/if}}

View File

@ -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);
},
});

View File

@ -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;
}
}

View File

@ -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}

View File

@ -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;

View File

@ -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);
}

View File

@ -0,0 +1 @@
<FullPageChat />

View File

@ -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);

View File

@ -0,0 +1,8 @@
.chat-thread-pane {
background-color: #aee6bd;
display: none;
&--active-thread {
display: block;
}
}

View File

@ -590,6 +590,9 @@ html.has-full-page-chat {
#main-chat-outlet {
min-height: 0;
display: flex;
flex-direction: row;
}
}
}

View File

@ -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

View File

@ -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"