Compare commits

...
This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.

4 Commits

Author SHA1 Message Date
Martin Brennan
7e34b840ca 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
2023-02-03 17:32:08 +10:00
Martin Brennan
af6660f725 FIX: Allow passing in a thread_id to ChatMessageCreator
This also adds validation to check if the thread_id that
is passed in is valid within the context of the channel
and the existing reply chain.
2023-02-03 16:57:50 +10:00
Martin Brennan
1a97cffcec Merge branch 'main' into feature/chat-thread-auto-creation 2023-02-03 11:45:42 +10:00
Martin Brennan
0acfff0916 FEATURE: Automatically create chat threads in background
Whenever we create a chat message that is in_reply_to another
message, we want to lazily populate the thread record for the
message chain.

If there is no thread yet for the root message in the reply chain,
we create a new thread with the appropriate details, and use that
thread ID for every message in the chain that does not yet have
a thread ID.

If there is a thread for the root message in the reply chain, we
do not create one, and use the thread ID for the newly created chat
message.
2023-02-02 11:56:59 +10:00
30 changed files with 717 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

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

View File

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

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"

View File

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

View File

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