Compare commits
4 Commits
main
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e34b840ca | ||
|
|
af6660f725 | ||
|
|
1a97cffcec | ||
|
|
0acfff0916 |
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,7 @@ en:
|
||||
over_chat_max_direct_message_users:
|
||||
one: "You can only create a direct message with yourself."
|
||||
other: "You can't create a direct message with more than %{count} other users."
|
||||
root_message_not_found: "The ancestor of the message you are replying cannot be found or has been deleted."
|
||||
reviewables:
|
||||
message_already_handled: "Thanks, but we've already reviewed this message and determined it does not need to be flagged again."
|
||||
actions:
|
||||
|
||||
@ -11,6 +11,7 @@ class Chat::ChatMessageCreator
|
||||
def initialize(
|
||||
chat_channel:,
|
||||
in_reply_to_id: nil,
|
||||
thread_id: nil,
|
||||
user:,
|
||||
content:,
|
||||
staged_id: nil,
|
||||
@ -20,11 +21,15 @@ class Chat::ChatMessageCreator
|
||||
@chat_channel = chat_channel
|
||||
@user = user
|
||||
@guardian = Guardian.new(user)
|
||||
|
||||
# NOTE: We confirm this exists and the user can access it in the ChatController,
|
||||
# but in future the checks should be here
|
||||
@in_reply_to_id = in_reply_to_id
|
||||
@content = content
|
||||
@staged_id = staged_id
|
||||
@incoming_chat_webhook = incoming_chat_webhook
|
||||
@upload_ids = upload_ids || []
|
||||
@thread_id = thread_id
|
||||
@error = nil
|
||||
|
||||
@chat_message =
|
||||
@ -42,9 +47,13 @@ class Chat::ChatMessageCreator
|
||||
validate_channel_status!
|
||||
uploads = get_uploads
|
||||
validate_message!(has_uploads: uploads.any?)
|
||||
validate_reply_chain!
|
||||
validate_existing_thread!
|
||||
@chat_message.thread_id = @existing_thread&.id
|
||||
@chat_message.cook
|
||||
@chat_message.save!
|
||||
create_chat_webhook_event
|
||||
create_thread
|
||||
@chat_message.attach_uploads(uploads)
|
||||
ChatDraft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all
|
||||
ChatPublisher.publish_new!(@chat_channel, @chat_message, @staged_id)
|
||||
@ -81,6 +90,52 @@ class Chat::ChatMessageCreator
|
||||
end
|
||||
end
|
||||
|
||||
def validate_reply_chain!
|
||||
return if @in_reply_to_id.blank?
|
||||
|
||||
@root_message_id = DB.query_single(<<~SQL).last
|
||||
WITH RECURSIVE root_message_finder( id, in_reply_to_id )
|
||||
AS (
|
||||
-- start with the message id we want to find the parents of
|
||||
SELECT id, in_reply_to_id
|
||||
FROM chat_messages
|
||||
WHERE id = #{@in_reply_to_id}
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- get all parents of the message
|
||||
SELECT cm.id, cm.in_reply_to_id
|
||||
FROM root_message_finder rm
|
||||
JOIN chat_messages cm ON rm.in_reply_to_id = cm.id
|
||||
)
|
||||
SELECT id FROM root_message_finder
|
||||
WHERE in_reply_to_id IS NULL;
|
||||
SQL
|
||||
|
||||
raise StandardError.new(I18n.t("chat.errors.root_message_not_found")) if @root_message_id.blank?
|
||||
|
||||
@root_message = ChatMessage.with_deleted.find_by(id: @root_message_id)
|
||||
raise StandardError.new(I18n.t("chat.errors.root_message_not_found")) if @root_message&.trashed?
|
||||
end
|
||||
|
||||
def validate_existing_thread!
|
||||
return if @thread_id.blank?
|
||||
@existing_thread = ChatThread.find(@thread_id)
|
||||
|
||||
if @existing_thread.channel_id != @chat_channel.id
|
||||
raise StandardError.new(I18n.t("chat.errors.thread_invalid_for_channel"))
|
||||
end
|
||||
|
||||
reply_to_thread_mismatch =
|
||||
@chat_message.in_reply_to&.thread_id &&
|
||||
@chat_message.in_reply_to.thread_id != @existing_thread.id
|
||||
root_message_has_no_thread = @root_message && @root_message.thread_id.blank?
|
||||
root_message_thread_mismatch = @root_message && @root_message.thread_id != @existing_thread.id
|
||||
if reply_to_thread_mismatch || root_message_has_no_thread || root_message_thread_mismatch
|
||||
raise StandardError.new(I18n.t("chat.errors.thread_does_not_match_parent"))
|
||||
end
|
||||
end
|
||||
|
||||
def validate_message!(has_uploads:)
|
||||
@chat_message.validate_message(has_uploads: has_uploads)
|
||||
if @chat_message.errors.present?
|
||||
@ -101,4 +156,41 @@ class Chat::ChatMessageCreator
|
||||
|
||||
Upload.where(id: @upload_ids, user_id: @user.id)
|
||||
end
|
||||
|
||||
def create_thread
|
||||
return if @in_reply_to_id.blank?
|
||||
return if @chat_message.thread_id.present?
|
||||
|
||||
thread =
|
||||
@root_message.thread ||
|
||||
ChatThread.create!(
|
||||
original_message: @chat_message.in_reply_to,
|
||||
original_message_user: @chat_message.in_reply_to.user,
|
||||
channel: @chat_message.chat_channel,
|
||||
)
|
||||
|
||||
# NOTE: We intentionally do not try to correct thread IDs within the chain
|
||||
# if they are incorrect, and only set the thread ID of messages where the
|
||||
# thread ID is NULL. In future we may want some sync/background job to correct
|
||||
# any inconsistencies.
|
||||
DB.exec(<<~SQL)
|
||||
WITH RECURSIVE thread_updater AS (
|
||||
SELECT cm.id, cm.in_reply_to_id
|
||||
FROM chat_messages cm
|
||||
WHERE cm.in_reply_to_id IS NULL AND cm.id = #{@root_message_id}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT cm.id, cm.in_reply_to_id
|
||||
FROM chat_messages cm
|
||||
JOIN thread_updater ON cm.in_reply_to_id = thread_updater.id
|
||||
)
|
||||
UPDATE chat_messages
|
||||
SET thread_id = #{thread.id}
|
||||
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"
|
||||
|
||||
@ -357,6 +357,321 @@ describe Chat::ChatMessageCreator do
|
||||
}.to change { ChatMention.count }.by(1)
|
||||
end
|
||||
|
||||
describe "replies" do
|
||||
fab!(:reply_message) do
|
||||
Fabricate(:chat_message, chat_channel: public_chat_channel, user: user2)
|
||||
end
|
||||
fab!(:unrelated_message_1) { Fabricate(:chat_message, chat_channel: public_chat_channel) }
|
||||
fab!(:unrelated_message_2) { Fabricate(:chat_message, chat_channel: public_chat_channel) }
|
||||
|
||||
it "links the message that the user is replying to" do
|
||||
message =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
).chat_message
|
||||
|
||||
expect(message.in_reply_to_id).to eq(reply_message.id)
|
||||
end
|
||||
|
||||
it "creates a thread and includes the original message and the reply" do
|
||||
message = nil
|
||||
expect {
|
||||
message =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
).chat_message
|
||||
}.to change { ChatThread.count }.by(1)
|
||||
|
||||
expect(message.reload.thread).not_to eq(nil)
|
||||
expect(message.in_reply_to.thread).to eq(message.thread)
|
||||
expect(message.thread.original_message).to eq(reply_message)
|
||||
expect(message.thread.original_message_user).to eq(reply_message.user)
|
||||
end
|
||||
|
||||
context "when the thread_id is provided" do
|
||||
fab!(:existing_thread) { Fabricate(:chat_thread, channel: public_chat_channel) }
|
||||
|
||||
it "does not create a thread when one is passed in" do
|
||||
message = nil
|
||||
expect {
|
||||
message =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
thread_id: existing_thread.id,
|
||||
).chat_message
|
||||
}.not_to change { ChatThread.count }
|
||||
|
||||
expect(message.reload.thread).to eq(existing_thread)
|
||||
end
|
||||
|
||||
it "errors when the thread ID is for a different channel" do
|
||||
other_channel_thread = Fabricate(:chat_thread, channel: Fabricate(:chat_channel))
|
||||
result =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
thread_id: other_channel_thread.id,
|
||||
)
|
||||
expect(result.error.message).to eq(I18n.t("chat.errors.thread_invalid_for_channel"))
|
||||
end
|
||||
|
||||
it "errors when the thread does not match the in_reply_to thread" do
|
||||
reply_message.update!(thread: existing_thread)
|
||||
result =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
thread_id: Fabricate(:chat_thread, channel: public_chat_channel).id,
|
||||
)
|
||||
expect(result.error.message).to eq(I18n.t("chat.errors.thread_does_not_match_parent"))
|
||||
end
|
||||
|
||||
it "errors when the root message does not have a thread ID" do
|
||||
reply_message.update!(thread: nil)
|
||||
result =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
thread_id: existing_thread.id,
|
||||
)
|
||||
expect(result.error.message).to eq(I18n.t("chat.errors.thread_does_not_match_parent"))
|
||||
end
|
||||
end
|
||||
|
||||
context "for missing root messages" do
|
||||
fab!(:root_message) do
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
chat_channel: public_chat_channel,
|
||||
user: user2,
|
||||
created_at: 1.day.ago,
|
||||
)
|
||||
end
|
||||
|
||||
before { reply_message.update!(in_reply_to: root_message) }
|
||||
|
||||
it "raises an error when the root message has been trashed" do
|
||||
root_message.trash!
|
||||
result =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
)
|
||||
expect(result.error.message).to eq(I18n.t("chat.errors.root_message_not_found"))
|
||||
end
|
||||
|
||||
it "uses the next message in the chain as the root when the root is deleted" do
|
||||
root_message.destroy!
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
)
|
||||
expect(reply_message.reload.thread).not_to eq(nil)
|
||||
end
|
||||
end
|
||||
|
||||
context "when there is an existing reply chain" do
|
||||
fab!(:old_message_1) do
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
created_at: 6.hours.ago,
|
||||
)
|
||||
end
|
||||
fab!(:old_message_2) do
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
chat_channel: public_chat_channel,
|
||||
user: user2,
|
||||
in_reply_to: old_message_1,
|
||||
created_at: 4.hours.ago,
|
||||
)
|
||||
end
|
||||
fab!(:old_message_3) do
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
in_reply_to: old_message_2,
|
||||
created_at: 1.hour.ago,
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
reply_message.update!(
|
||||
created_at: old_message_3.created_at + 1.hour,
|
||||
in_reply_to: old_message_3,
|
||||
)
|
||||
end
|
||||
|
||||
it "creates a thread and updates all the messages in the chain" do
|
||||
thread_count = ChatThread.count
|
||||
message =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
).chat_message
|
||||
|
||||
expect(ChatThread.count).to eq(thread_count + 1)
|
||||
expect(message.reload.thread).not_to eq(nil)
|
||||
expect(message.reload.in_reply_to.thread).to eq(message.thread)
|
||||
expect(old_message_1.reload.thread).to eq(message.thread)
|
||||
expect(old_message_2.reload.thread).to eq(message.thread)
|
||||
expect(old_message_3.reload.thread).to eq(message.thread)
|
||||
expect(message.thread.chat_messages.count).to eq(5)
|
||||
message =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
).chat_message
|
||||
end
|
||||
|
||||
context "when a thread already exists and the thread_id is passed in" do
|
||||
let!(:last_message) do
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
).chat_message
|
||||
end
|
||||
let!(:existing_thread) { last_message.reload.thread }
|
||||
|
||||
it "does not create a new thread" do
|
||||
thread_count = ChatThread.count
|
||||
|
||||
message =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message again",
|
||||
in_reply_to_id: last_message.id,
|
||||
thread_id: existing_thread.id,
|
||||
).chat_message
|
||||
|
||||
expect(ChatThread.count).to eq(thread_count)
|
||||
expect(message.reload.thread).to eq(existing_thread)
|
||||
expect(message.reload.in_reply_to.thread).to eq(existing_thread)
|
||||
expect(message.thread.chat_messages.count).to eq(6)
|
||||
end
|
||||
|
||||
it "errors when the thread does not match the root thread" do
|
||||
old_message_1.update!(thread: Fabricate(:chat_thread, channel: public_chat_channel))
|
||||
result =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
thread_id: existing_thread.id,
|
||||
)
|
||||
expect(result.error.message).to eq(I18n.t("chat.errors.thread_does_not_match_parent"))
|
||||
end
|
||||
|
||||
it "errors when the root message does not have a thread ID" do
|
||||
old_message_1.update!(thread: nil)
|
||||
result =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
thread_id: existing_thread.id,
|
||||
)
|
||||
expect(result.error.message).to eq(I18n.t("chat.errors.thread_does_not_match_parent"))
|
||||
end
|
||||
end
|
||||
|
||||
context "when there are hundreds of messages in a reply chain already" do
|
||||
before do
|
||||
previous_message = nil
|
||||
1000.times do |i|
|
||||
previous_message =
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
chat_channel: public_chat_channel,
|
||||
user: [user1, user2].sample,
|
||||
in_reply_to: previous_message,
|
||||
created_at: i.hours.ago,
|
||||
)
|
||||
end
|
||||
@last_message_in_chain = previous_message
|
||||
end
|
||||
|
||||
xit "works" do
|
||||
thread_count = ChatThread.count
|
||||
|
||||
message = nil
|
||||
puts Benchmark.measure {
|
||||
message =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: @last_message_in_chain.id,
|
||||
).chat_message
|
||||
}
|
||||
|
||||
expect(ChatThread.count).to eq(thread_count + 1)
|
||||
expect(message.reload.thread).not_to eq(nil)
|
||||
expect(message.reload.in_reply_to.thread).to eq(message.thread)
|
||||
expect(message.thread.chat_messages.count).to eq(1001)
|
||||
end
|
||||
end
|
||||
|
||||
context "if the root message alread had a thread" do
|
||||
fab!(:old_thread) { Fabricate(:chat_thread, original_message: old_message_1) }
|
||||
fab!(:incorrect_thread) { Fabricate(:chat_thread, channel: public_chat_channel) }
|
||||
|
||||
before do
|
||||
old_message_1.update!(thread: old_thread)
|
||||
old_message_3.update!(thread: incorrect_thread)
|
||||
end
|
||||
|
||||
it "does not change any messages in the chain, assumes they have the correct thread ID" do
|
||||
thread_count = ChatThread.count
|
||||
message =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: public_chat_channel,
|
||||
user: user1,
|
||||
content: "this is a message",
|
||||
in_reply_to_id: reply_message.id,
|
||||
).chat_message
|
||||
|
||||
expect(ChatThread.count).to eq(thread_count)
|
||||
expect(message.reload.thread).to eq(old_thread)
|
||||
expect(message.reload.in_reply_to.thread).to eq(old_thread)
|
||||
expect(old_message_1.reload.thread).to eq(old_thread)
|
||||
expect(old_message_2.reload.thread).to eq(old_thread)
|
||||
expect(old_message_3.reload.thread).to eq(incorrect_thread)
|
||||
expect(message.thread.chat_messages.count).to eq(4)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "group mentions" do
|
||||
it "creates chat mentions for group mentions where the group is mentionable" do
|
||||
expect {
|
||||
|
||||
@ -139,3 +139,14 @@ Fabricator(:chat_draft) do
|
||||
{ value: attrs[:value], replyToMsg: attrs[:reply_to_msg], uploads: attrs[:uploads] }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Fabricator(:chat_thread) do
|
||||
before_create do |thread, transients|
|
||||
thread.original_message_user = original_message.user
|
||||
thread.channel = original_message.chat_channel
|
||||
end
|
||||
|
||||
transient :channel
|
||||
|
||||
original_message { |attrs| Fabricate(:chat_message, chat_channel: attrs[:channel]) }
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user