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:
parent
af6660f725
commit
7e34b840ca
11
plugins/chat/app/controllers/api/chat_threads_controller.rb
Normal file
11
plugins/chat/app/controllers/api/chat_threads_controller.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
|
||||
11
plugins/chat/app/serializers/chat_thread_serializer.rb
Normal file
11
plugins/chat/app/serializers/chat_thread_serializer.rb
Normal 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
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -152,4 +152,4 @@
|
||||
{{else}}
|
||||
<ChatChannelPreviewCard @channel={{this.chatChannel}} />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -21,4 +21,4 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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}}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
<FullPageChat />
|
||||
@ -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);
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
.chat-thread-pane {
|
||||
background-color: #aee6bd;
|
||||
display: none;
|
||||
|
||||
&--active-thread {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@ -590,6 +590,9 @@ html.has-full-page-chat {
|
||||
|
||||
#main-chat-outlet {
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user