DEV: removes the notion of staged thread (#23612)
While very fast and powerful staged threads forces a lot of gymnastic and edge cases. This patch adds a new service `Chat::CreateThread` and uses it to create a thread unconditionally when a user replies to a message in a threading enabled channel. If the user actually doesn’t send a message we will have a thread with no messages which has no important impact and could even be periodically cleaned if necessary. Note that this commit also moves message actions to .gjs as it was the original goal of this PR to correctly check for staged thread to show the menu or not.
This commit is contained in:
parent
1d14474e1d
commit
c8fff19b99
|
@ -18,7 +18,6 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController
|
|||
root: false,
|
||||
)
|
||||
end
|
||||
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
|
||||
on_model_not_found(:channel) { raise Discourse::NotFound }
|
||||
|
@ -39,7 +38,7 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController
|
|||
participants: result.participants,
|
||||
)
|
||||
end
|
||||
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
|
||||
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_model_not_found(:thread) { raise Discourse::NotFound }
|
||||
end
|
||||
|
@ -47,7 +46,6 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController
|
|||
|
||||
def update
|
||||
with_service(::Chat::UpdateThread) do
|
||||
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
|
||||
on_failed_policy(:can_edit_thread) { raise Discourse::InvalidAccess }
|
||||
|
@ -57,4 +55,24 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
with_service(::Chat::CreateThread) do
|
||||
on_success do
|
||||
render_serialized(
|
||||
result.thread,
|
||||
::Chat::ThreadSerializer,
|
||||
root: false,
|
||||
membership: result.membership,
|
||||
include_thread_original_message: true,
|
||||
)
|
||||
end
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
|
||||
on_failed_step(:create_thread) do
|
||||
render json: failed_json.merge(errors: [result["result.step.create_thread"].error]),
|
||||
status: 422
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,7 +9,7 @@ module Chat
|
|||
class CreateMessage
|
||||
include Service::Base
|
||||
|
||||
# @!method call(chat_channel_id:, guardian:, in_reply_to_id:, message:, staged_id:, upload_ids:, thread_id:, staged_thread_id:, incoming_chat_webhook:)
|
||||
# @!method call(chat_channel_id:, guardian:, in_reply_to_id:, message:, staged_id:, upload_ids:, thread_id:, incoming_chat_webhook:)
|
||||
# @param guardian [Guardian]
|
||||
# @param chat_channel_id [Integer]
|
||||
# @param message [String]
|
||||
|
@ -17,7 +17,6 @@ module Chat
|
|||
# @param thread_id [Integer] ID of a thread to reply to
|
||||
# @param upload_ids [Array<Integer>] IDs of uploaded documents
|
||||
# @param staged_id [String] arbitrary string that will be sent back to the client
|
||||
# @param staged_thread_id [String] arbitrary string that will be sent back to the client (for a new thread)
|
||||
# @param incoming_chat_webhook [Chat::IncomingWebhook]
|
||||
|
||||
policy :no_silenced_user
|
||||
|
@ -53,7 +52,6 @@ module Chat
|
|||
attribute :staged_id, :string
|
||||
attribute :upload_ids, :array
|
||||
attribute :thread_id, :string
|
||||
attribute :staged_thread_id, :string
|
||||
attribute :incoming_chat_webhook
|
||||
|
||||
validates :chat_channel_id, presence: true
|
||||
|
@ -167,16 +165,11 @@ module Chat
|
|||
def publish_new_thread(reply:, contract:, channel:, thread:, **)
|
||||
return unless channel.threading_enabled?
|
||||
return unless reply&.thread_id_previously_changed?(from: nil)
|
||||
Chat::Publisher.publish_thread_created!(channel, reply, thread.id, contract.staged_thread_id)
|
||||
Chat::Publisher.publish_thread_created!(channel, reply, thread.id)
|
||||
end
|
||||
|
||||
def publish_new_message_events(channel:, message:, contract:, guardian:, **)
|
||||
Chat::Publisher.publish_new!(
|
||||
channel,
|
||||
message,
|
||||
contract.staged_id,
|
||||
staged_thread_id: contract.staged_thread_id,
|
||||
)
|
||||
Chat::Publisher.publish_new!(channel, message, contract.staged_id)
|
||||
Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: message.id })
|
||||
Chat::Notifier.notify_new(chat_message: message, timestamp: message.created_at)
|
||||
DiscourseEvent.trigger(:chat_message_created, message, channel, guardian.user)
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
# Creates a thread.
|
||||
#
|
||||
# @example
|
||||
# Chat::CreateThread.call(channel_id: 2, original_message_id: 3, guardian: guardian, title: "Restaurant for Saturday")
|
||||
#
|
||||
class CreateThread
|
||||
include Service::Base
|
||||
|
||||
# @!method call(thread_id:, channel_id:, guardian:, **params_to_edit)
|
||||
# @param [Integer] original_message_id
|
||||
# @param [Integer] channel_id
|
||||
# @param [Guardian] guardian
|
||||
# @option params_to_create [String,nil] title
|
||||
# @return [Service::Base::Context]
|
||||
|
||||
contract
|
||||
model :channel
|
||||
policy :can_view_channel
|
||||
policy :threading_enabled_for_channel
|
||||
model :original_message
|
||||
transaction do
|
||||
step :create_thread
|
||||
step :associate_thread_to_message
|
||||
step :fetch_membership
|
||||
step :publish_new_thread
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class Contract
|
||||
attribute :original_message_id, :integer
|
||||
attribute :channel_id, :integer
|
||||
attribute :title, :string
|
||||
|
||||
validates :original_message_id, :channel_id, presence: true
|
||||
validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_channel(contract:, **)
|
||||
::Chat::Channel.find_by(id: contract.channel_id)
|
||||
end
|
||||
|
||||
def fetch_original_message(channel:, contract:, **)
|
||||
::Chat::Message.find_by(
|
||||
id: contract.original_message_id,
|
||||
chat_channel_id: contract.channel_id,
|
||||
)
|
||||
end
|
||||
|
||||
def can_view_channel(guardian:, channel:, **)
|
||||
guardian.can_preview_chat_channel?(channel)
|
||||
end
|
||||
|
||||
def threading_enabled_for_channel(channel:, **)
|
||||
channel.threading_enabled
|
||||
end
|
||||
|
||||
def create_thread(channel:, original_message:, contract:, **)
|
||||
context.thread =
|
||||
channel.threads.create(
|
||||
title: contract.title,
|
||||
original_message: original_message,
|
||||
original_message_user: original_message.user,
|
||||
)
|
||||
fail!(context.thread.errors.full_messages.join(", ")) if context.thread.invalid?
|
||||
end
|
||||
|
||||
def associate_thread_to_message(original_message:, **)
|
||||
original_message.update(thread: context.thread)
|
||||
end
|
||||
|
||||
def fetch_membership(guardian:, **)
|
||||
context.membership = context.thread.membership_for(guardian.user)
|
||||
end
|
||||
|
||||
def publish_new_thread(channel:, original_message:, **)
|
||||
::Chat::Publisher.publish_thread_created!(channel, original_message, context.thread.id)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -14,7 +14,7 @@ module Chat
|
|||
"#{root_message_bus_channel(chat_channel_id)}/thread/#{thread_id}"
|
||||
end
|
||||
|
||||
def self.calculate_publish_targets(channel, message, staged_thread_id: nil)
|
||||
def self.calculate_publish_targets(channel, message)
|
||||
return [root_message_bus_channel(channel.id)] if !allow_publish_to_thread?(channel)
|
||||
|
||||
if message.thread_om?
|
||||
|
@ -22,9 +22,8 @@ module Chat
|
|||
root_message_bus_channel(channel.id),
|
||||
thread_message_bus_channel(channel.id, message.thread_id),
|
||||
]
|
||||
elsif staged_thread_id || message.thread_reply?
|
||||
elsif message.thread_reply?
|
||||
targets = [thread_message_bus_channel(channel.id, message.thread_id)]
|
||||
targets << thread_message_bus_channel(channel.id, staged_thread_id) if staged_thread_id
|
||||
targets
|
||||
else
|
||||
[root_message_bus_channel(channel.id)]
|
||||
|
@ -35,16 +34,12 @@ module Chat
|
|||
channel.threading_enabled
|
||||
end
|
||||
|
||||
def self.publish_new!(chat_channel, chat_message, staged_id, staged_thread_id: nil)
|
||||
message_bus_targets =
|
||||
calculate_publish_targets(chat_channel, chat_message, staged_thread_id: staged_thread_id)
|
||||
def self.publish_new!(chat_channel, chat_message, staged_id)
|
||||
message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
|
||||
publish_to_targets!(
|
||||
message_bus_targets,
|
||||
chat_channel,
|
||||
serialize_message_with_type(chat_message, :sent).merge(
|
||||
staged_id: staged_id,
|
||||
staged_thread_id: staged_thread_id,
|
||||
),
|
||||
serialize_message_with_type(chat_message, :sent).merge(staged_id: staged_id),
|
||||
)
|
||||
|
||||
if !chat_message.thread_reply? || !allow_publish_to_thread?(chat_channel)
|
||||
|
@ -100,14 +95,10 @@ module Chat
|
|||
)
|
||||
end
|
||||
|
||||
def self.publish_thread_created!(chat_channel, chat_message, thread_id, staged_thread_id)
|
||||
def self.publish_thread_created!(chat_channel, chat_message, thread_id)
|
||||
publish_to_channel!(
|
||||
chat_channel,
|
||||
serialize_message_with_type(
|
||||
chat_message,
|
||||
:thread_created,
|
||||
{ thread_id: thread_id, staged_thread_id: staged_thread_id },
|
||||
),
|
||||
serialize_message_with_type(chat_message, :thread_created, { thread_id: thread_id }),
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import { getOwner } from "@ember/application";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import chatMessageContainer from "discourse/plugins/chat/discourse/lib/chat-message-container";
|
||||
import { action } from "@ember/object";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import BookmarkIcon from "discourse/components/bookmark-icon";
|
||||
import ChatMessageReaction from "discourse/plugins/chat/discourse/components/chat-message-reaction";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
|
||||
import { on } from "@ember/modifier";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import { concat, hash } from "@ember/helper";
|
||||
|
||||
const MSG_ACTIONS_VERTICAL_PADDING = -10;
|
||||
const FULL = "full";
|
||||
const REDUCED = "reduced";
|
||||
const REDUCED_WIDTH_THRESHOLD = 500;
|
||||
|
||||
export default class ChatMessageActionsDesktop extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
{{#if (and this.site.desktopView this.chat.activeMessage.model.persisted)}}
|
||||
<div
|
||||
{{didInsert this.setup}}
|
||||
{{didUpdate this.setup this.chat.activeMessage.model.id}}
|
||||
{{on "wheel" this.onWheel passive=true}}
|
||||
{{willDestroy this.teardown}}
|
||||
class={{concatClass
|
||||
"chat-message-actions-container"
|
||||
(concat "is-size-" this.size)
|
||||
}}
|
||||
data-id={{this.message.id}}
|
||||
>
|
||||
<div
|
||||
class={{concatClass
|
||||
"chat-message-actions"
|
||||
(unless
|
||||
this.messageInteractor.secondaryActions.length
|
||||
"has-no-secondary-actions"
|
||||
)
|
||||
}}
|
||||
>
|
||||
{{#if this.shouldRenderFavoriteReactions}}
|
||||
{{#each
|
||||
this.messageInteractor.emojiReactions key="emoji"
|
||||
as |reaction|
|
||||
}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@onReaction={{this.messageInteractor.react}}
|
||||
@message={{this.message}}
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canInteractWithMessage}}
|
||||
<DButton
|
||||
@action={{this.messageInteractor.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
class="btn-flat react-btn"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canBookmark}}
|
||||
<DButton
|
||||
@action={{this.messageInteractor.toggleBookmark}}
|
||||
class="btn-flat bookmark-btn"
|
||||
>
|
||||
<BookmarkIcon @bookmark={{this.message.bookmark}} />
|
||||
</DButton>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canReply}}
|
||||
<DButton
|
||||
@action={{this.messageInteractor.reply}}
|
||||
@icon="reply"
|
||||
@title="chat.reply"
|
||||
class="btn-flat reply-btn"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if
|
||||
(and
|
||||
this.messageInteractor.message
|
||||
this.messageInteractor.secondaryActions.length
|
||||
)
|
||||
}}
|
||||
<DropdownSelectBox
|
||||
@class="more-buttons secondary-actions"
|
||||
@options={{hash icon="ellipsis-v" placement="left"}}
|
||||
@content={{this.messageInteractor.secondaryActions}}
|
||||
@onChange={{this.messageInteractor.handleSecondaryActions}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
|
||||
@service chat;
|
||||
@service chatEmojiPickerManager;
|
||||
@service site;
|
||||
|
||||
@tracked size = FULL;
|
||||
|
||||
popper = null;
|
||||
|
||||
get message() {
|
||||
return this.chat.activeMessage.model;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.chat.activeMessage.context;
|
||||
}
|
||||
|
||||
get messageInteractor() {
|
||||
return new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.message,
|
||||
this.context
|
||||
);
|
||||
}
|
||||
|
||||
get shouldRenderFavoriteReactions() {
|
||||
return this.size === FULL;
|
||||
}
|
||||
|
||||
@action
|
||||
onWheel() {
|
||||
// prevents menu to stop scroll on the list of messages
|
||||
this.chat.activeMessage = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setup(element) {
|
||||
this.popper?.destroy();
|
||||
|
||||
schedule("afterRender", () => {
|
||||
const messageContainer = chatMessageContainer(
|
||||
this.message.id,
|
||||
this.context
|
||||
);
|
||||
|
||||
if (!messageContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = messageContainer.closest(".popper-viewport");
|
||||
this.size =
|
||||
viewport.clientWidth < REDUCED_WIDTH_THRESHOLD ? REDUCED : FULL;
|
||||
|
||||
if (!messageContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.popper = createPopper(messageContainer, element, {
|
||||
placement: "top-end",
|
||||
strategy: "fixed",
|
||||
modifiers: [
|
||||
{
|
||||
name: "flip",
|
||||
enabled: true,
|
||||
options: {
|
||||
boundary: viewport,
|
||||
fallbackPlacements: ["bottom-end"],
|
||||
},
|
||||
},
|
||||
{ name: "hide", enabled: true },
|
||||
{ name: "eventListeners", options: { scroll: false } },
|
||||
{
|
||||
name: "offset",
|
||||
options: { offset: [-2, MSG_ACTIONS_VERTICAL_PADDING] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this.popper?.destroy();
|
||||
this.popper = null;
|
||||
}
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
{{#if (and this.site.desktopView this.chat.activeMessage.model.id)}}
|
||||
<div
|
||||
{{did-insert this.setup}}
|
||||
{{did-update this.setup this.chat.activeMessage.model.id}}
|
||||
{{on "wheel" this.onWheel passive=true}}
|
||||
{{will-destroy this.teardown}}
|
||||
class={{concat-class
|
||||
"chat-message-actions-container"
|
||||
(concat "is-size-" this.size)
|
||||
}}
|
||||
data-id={{this.message.id}}
|
||||
>
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-message-actions"
|
||||
(unless
|
||||
this.messageInteractor.secondaryActions.length
|
||||
"has-no-secondary-actions"
|
||||
)
|
||||
}}
|
||||
>
|
||||
{{#if this.shouldRenderFavoriteReactions}}
|
||||
{{#each
|
||||
this.messageInteractor.emojiReactions key="emoji"
|
||||
as |reaction|
|
||||
}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@onReaction={{this.messageInteractor.react}}
|
||||
@message={{this.message}}
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canInteractWithMessage}}
|
||||
<DButton
|
||||
@action={{this.messageInteractor.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
class="btn-flat react-btn"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canBookmark}}
|
||||
<DButton
|
||||
@action={{this.messageInteractor.toggleBookmark}}
|
||||
class="btn-flat bookmark-btn"
|
||||
>
|
||||
<BookmarkIcon @bookmark={{this.message.bookmark}} />
|
||||
</DButton>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canReply}}
|
||||
<DButton
|
||||
@action={{this.messageInteractor.reply}}
|
||||
@icon="reply"
|
||||
@title="chat.reply"
|
||||
class="btn-flat reply-btn"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if
|
||||
(and
|
||||
this.messageInteractor.message
|
||||
this.messageInteractor.secondaryActions.length
|
||||
)
|
||||
}}
|
||||
<DropdownSelectBox
|
||||
@class="more-buttons secondary-actions"
|
||||
@options={{hash icon="ellipsis-v" placement="left"}}
|
||||
@content={{this.messageInteractor.secondaryActions}}
|
||||
@onChange={{action this.messageInteractor.handleSecondaryActions}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
|
@ -1,101 +0,0 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import { getOwner } from "@ember/application";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import chatMessageContainer from "discourse/plugins/chat/discourse/lib/chat-message-container";
|
||||
import { action } from "@ember/object";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
|
||||
const MSG_ACTIONS_VERTICAL_PADDING = -10;
|
||||
const FULL = "full";
|
||||
const REDUCED = "reduced";
|
||||
const REDUCED_WIDTH_THRESHOLD = 500;
|
||||
|
||||
export default class ChatMessageActionsDesktop extends Component {
|
||||
@service chat;
|
||||
@service chatEmojiPickerManager;
|
||||
@service site;
|
||||
|
||||
@tracked size = FULL;
|
||||
|
||||
popper = null;
|
||||
|
||||
get message() {
|
||||
return this.chat.activeMessage.model;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.chat.activeMessage.context;
|
||||
}
|
||||
|
||||
get messageInteractor() {
|
||||
return new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.message,
|
||||
this.context
|
||||
);
|
||||
}
|
||||
|
||||
get shouldRenderFavoriteReactions() {
|
||||
return this.size === FULL;
|
||||
}
|
||||
|
||||
@action
|
||||
onWheel() {
|
||||
// prevents menu to stop scroll on the list of messages
|
||||
this.chat.activeMessage = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setup(element) {
|
||||
this.popper?.destroy();
|
||||
|
||||
schedule("afterRender", () => {
|
||||
const messageContainer = chatMessageContainer(
|
||||
this.message.id,
|
||||
this.context
|
||||
);
|
||||
|
||||
if (!messageContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = messageContainer.closest(".popper-viewport");
|
||||
this.size =
|
||||
viewport.clientWidth < REDUCED_WIDTH_THRESHOLD ? REDUCED : FULL;
|
||||
|
||||
if (!messageContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.popper = createPopper(messageContainer, element, {
|
||||
placement: "top-end",
|
||||
strategy: "fixed",
|
||||
modifiers: [
|
||||
{
|
||||
name: "flip",
|
||||
enabled: true,
|
||||
options: {
|
||||
boundary: viewport,
|
||||
fallbackPlacements: ["bottom-end"],
|
||||
},
|
||||
},
|
||||
{ name: "hide", enabled: true },
|
||||
{ name: "eventListeners", options: { scroll: false } },
|
||||
{
|
||||
name: "offset",
|
||||
options: { offset: [-2, MSG_ACTIONS_VERTICAL_PADDING] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this.popper?.destroy();
|
||||
this.popper = null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,197 @@
|
|||
import Component from "@glimmer/component";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { action } from "@ember/object";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { inject as service } from "@ember/service";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { on } from "@ember/modifier";
|
||||
import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat/user-avatar";
|
||||
import ChatMessageReaction from "discourse/plugins/chat/discourse/components/chat-message-reaction";
|
||||
import { fn } from "@ember/helper";
|
||||
import or from "truth-helpers/helpers/or";
|
||||
import BookmarkIcon from "discourse/components/bookmark-icon";
|
||||
|
||||
export default class ChatMessageActionsMobile extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
{{#if (and this.site.mobileView this.chat.activeMessage.model.persisted)}}
|
||||
<div
|
||||
class={{concatClass
|
||||
"chat-message-actions-backdrop"
|
||||
(if this.showFadeIn "fade-in")
|
||||
}}
|
||||
{{didInsert this.fadeAndVibrate}}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
class="collapse-area"
|
||||
{{on "touchstart" this.collapseMenu passive=false bubbles=false}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="chat-message-actions">
|
||||
<div class="selected-message-container">
|
||||
<div class="selected-message">
|
||||
<ChatUserAvatar @user={{this.message.user}} />
|
||||
<span
|
||||
{{on "touchstart" this.expandReply passive=true}}
|
||||
role="button"
|
||||
class={{concatClass
|
||||
"selected-message-reply"
|
||||
(if this.hasExpandedReply "is-expanded")
|
||||
}}
|
||||
>
|
||||
{{this.message.message}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="secondary-actions">
|
||||
{{#each this.messageInteractor.secondaryActions as |button|}}
|
||||
<li class="chat-message-action-item" data-id={{button.id}}>
|
||||
<DButton
|
||||
@translatedLabel={{button.name}}
|
||||
@icon={{button.icon}}
|
||||
@action={{fn this.actAndCloseMenu button.id}}
|
||||
class="chat-message-action"
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{#if
|
||||
(or this.messageInteractor.canReact this.messageInteractor.canReply)
|
||||
}}
|
||||
<div class="main-actions">
|
||||
{{#if this.messageInteractor.canReact}}
|
||||
{{#each this.messageInteractor.emojiReactions as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@onReaction={{this.messageInteractor.react}}
|
||||
@message={{this.message}}
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
<DButton
|
||||
@action={{this.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
data-id="react"
|
||||
class="btn-flat react-btn"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canBookmark}}
|
||||
<DButton
|
||||
@action={{fn this.actAndCloseMenu "toggleBookmark"}}
|
||||
data-id="bookmark"
|
||||
class="btn-flat bookmark-btn"
|
||||
>
|
||||
<BookmarkIcon @bookmark={{this.message.bookmark}} />
|
||||
</DButton>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canReply}}
|
||||
<DButton
|
||||
@action={{fn this.actAndCloseMenu "reply"}}
|
||||
@icon="reply"
|
||||
@title="chat.reply"
|
||||
data-id="reply"
|
||||
class="chat-message-action reply-btn btn-flat"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
|
||||
@service chat;
|
||||
@service site;
|
||||
@service capabilities;
|
||||
|
||||
@tracked hasExpandedReply = false;
|
||||
@tracked showFadeIn = false;
|
||||
|
||||
get message() {
|
||||
return this.chat.activeMessage.model;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.chat.activeMessage.context;
|
||||
}
|
||||
|
||||
get messageInteractor() {
|
||||
return new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.message,
|
||||
this.context
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
fadeAndVibrate() {
|
||||
discourseLater(this.#addFadeIn.bind(this));
|
||||
|
||||
if (this.capabilities.canVibrate && !isTesting()) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
expandReply(event) {
|
||||
event.stopPropagation();
|
||||
this.hasExpandedReply = true;
|
||||
}
|
||||
|
||||
@action
|
||||
collapseMenu(event) {
|
||||
event.preventDefault();
|
||||
this.#onCloseMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
actAndCloseMenu(fnId) {
|
||||
this.messageInteractor[fnId]();
|
||||
this.#onCloseMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
openEmojiPicker(_, event) {
|
||||
this.messageInteractor.openEmojiPicker(_, event);
|
||||
this.#onCloseMenu();
|
||||
}
|
||||
|
||||
#onCloseMenu() {
|
||||
this.#removeFadeIn();
|
||||
|
||||
// we don't want to remove the component right away as it's animating
|
||||
// 200 is equal to the duration of the css animation
|
||||
discourseLater(() => {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// by ensuring we are not hovering any message anymore
|
||||
// we also ensure the menu is fully removed
|
||||
this.chat.activeMessage = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
#addFadeIn() {
|
||||
this.showFadeIn = true;
|
||||
}
|
||||
|
||||
#removeFadeIn() {
|
||||
this.showFadeIn = false;
|
||||
}
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
{{#if (and this.site.mobileView this.chat.activeMessage)}}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-message-actions-backdrop"
|
||||
(if this.showFadeIn "fade-in")
|
||||
}}
|
||||
{{did-insert this.fadeAndVibrate}}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
class="collapse-area"
|
||||
{{on "touchstart" this.collapseMenu passive=false bubbles=false}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="chat-message-actions">
|
||||
<div class="selected-message-container">
|
||||
<div class="selected-message">
|
||||
<Chat::UserAvatar @user={{this.message.user}} />
|
||||
<span
|
||||
{{on "touchstart" this.expandReply passive=true}}
|
||||
role="button"
|
||||
class={{concat-class
|
||||
"selected-message-reply"
|
||||
(if this.hasExpandedReply "is-expanded")
|
||||
}}
|
||||
>
|
||||
{{this.message.message}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="secondary-actions">
|
||||
{{#each this.messageInteractor.secondaryActions as |button|}}
|
||||
<li class="chat-message-action-item" data-id={{button.id}}>
|
||||
<DButton
|
||||
@translatedLabel={{button.name}}
|
||||
@icon={{button.icon}}
|
||||
@action={{fn this.actAndCloseMenu button.id}}
|
||||
class="chat-message-action"
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{#if
|
||||
(or this.messageInteractor.canReact this.messageInteractor.canReply)
|
||||
}}
|
||||
<div class="main-actions">
|
||||
{{#if this.messageInteractor.canReact}}
|
||||
{{#each this.messageInteractor.emojiReactions as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@onReaction={{this.messageInteractor.react}}
|
||||
@message={{this.message}}
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
<DButton
|
||||
@action={{this.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
data-id="react"
|
||||
class="btn-flat react-btn"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canBookmark}}
|
||||
<DButton
|
||||
@action={{fn this.actAndCloseMenu "toggleBookmark"}}
|
||||
data-id="bookmark"
|
||||
class="btn-flat bookmark-btn"
|
||||
>
|
||||
<BookmarkIcon @bookmark={{this.message.bookmark}} />
|
||||
</DButton>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canReply}}
|
||||
<DButton
|
||||
@action={{fn this.actAndCloseMenu "reply"}}
|
||||
@icon="reply"
|
||||
@title="chat.reply"
|
||||
data-id="reply"
|
||||
class="chat-message-action reply-btn btn-flat"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
|
@ -1,90 +0,0 @@
|
|||
import Component from "@glimmer/component";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { action } from "@ember/object";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatMessageActionsMobile extends Component {
|
||||
@service chat;
|
||||
@service site;
|
||||
@service capabilities;
|
||||
|
||||
@tracked hasExpandedReply = false;
|
||||
@tracked showFadeIn = false;
|
||||
|
||||
get message() {
|
||||
return this.chat.activeMessage.model;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.chat.activeMessage.context;
|
||||
}
|
||||
|
||||
get messageInteractor() {
|
||||
return new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.message,
|
||||
this.context
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
fadeAndVibrate() {
|
||||
discourseLater(this.#addFadeIn.bind(this));
|
||||
|
||||
if (this.capabilities.canVibrate && !isTesting()) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
expandReply(event) {
|
||||
event.stopPropagation();
|
||||
this.hasExpandedReply = true;
|
||||
}
|
||||
|
||||
@action
|
||||
collapseMenu(event) {
|
||||
event.preventDefault();
|
||||
this.#onCloseMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
actAndCloseMenu(fnId) {
|
||||
this.messageInteractor[fnId]();
|
||||
this.#onCloseMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
openEmojiPicker(_, event) {
|
||||
this.messageInteractor.openEmojiPicker(_, event);
|
||||
this.#onCloseMenu();
|
||||
}
|
||||
|
||||
#onCloseMenu() {
|
||||
this.#removeFadeIn();
|
||||
|
||||
// we don't want to remove the component right away as it's animating
|
||||
// 200 is equal to the duration of the css animation
|
||||
discourseLater(() => {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// by ensuring we are not hovering any message anymore
|
||||
// we also ensure the menu is fully removed
|
||||
this.chat.activeMessage = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
#addFadeIn() {
|
||||
this.showFadeIn = true;
|
||||
}
|
||||
|
||||
#removeFadeIn() {
|
||||
this.showFadeIn = false;
|
||||
}
|
||||
}
|
|
@ -460,7 +460,7 @@ export default class ChatMessage extends Component {
|
|||
|
||||
@action
|
||||
onLongPressStart(element, event) {
|
||||
if (!this.args.message.expanded) {
|
||||
if (!this.args.message.expanded || !this.args.message.persisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
class={{concat-class
|
||||
"chat-thread"
|
||||
(if this.messagesLoader.loading "loading")
|
||||
(if @thread.staged "staged")
|
||||
}}
|
||||
data-id={{@thread.id}}
|
||||
{{did-insert this.setUploadDropZone}}
|
||||
|
|
|
@ -177,14 +177,6 @@ export default class ChatThread extends Component {
|
|||
}
|
||||
|
||||
async fetchMessages(findArgs = {}) {
|
||||
if (this.args.thread.staged) {
|
||||
const message = this.args.thread.originalMessage;
|
||||
message.thread = this.args.thread;
|
||||
message.manager = this.messagesManager;
|
||||
this.messagesManager.addMessages([message]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.messagesLoader.loading) {
|
||||
return;
|
||||
}
|
||||
|
@ -326,7 +318,7 @@ export default class ChatThread extends Component {
|
|||
// and scrolling; for now it's enough to do it when the thread panel
|
||||
// opens/messages are loaded since we have no pagination for threads.
|
||||
markThreadAsRead() {
|
||||
if (!this.args.thread || this.args.thread.staged) {
|
||||
if (!this.args.thread) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -374,13 +366,10 @@ export default class ChatThread extends Component {
|
|||
this.args.thread.channel.id,
|
||||
{
|
||||
message: message.message,
|
||||
in_reply_to_id: message.thread.staged
|
||||
? message.thread.originalMessage?.id
|
||||
: null,
|
||||
in_reply_to_id: null,
|
||||
staged_id: message.id,
|
||||
upload_ids: message.uploads.map((upload) => upload.id),
|
||||
thread_id: message.thread.staged ? null : message.thread.id,
|
||||
staged_thread_id: message.thread.staged ? message.thread.id : null,
|
||||
thread_id: message.thread.id,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-thread
|
|||
import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import guid from "pretty-text/guid";
|
||||
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
|
||||
import ChatDirectMessage from "discourse/plugins/chat/discourse/models/chat-direct-message";
|
||||
import ChatChannelArchive from "discourse/plugins/chat/discourse/models/chat-channel-archive";
|
||||
import Category from "discourse/models/category";
|
||||
|
@ -190,23 +189,6 @@ export default class ChatChannel {
|
|||
return this.meta.can_join_chat_channel;
|
||||
}
|
||||
|
||||
createStagedThread(message) {
|
||||
const clonedMessage = message.duplicate();
|
||||
|
||||
const thread = new ChatThread(this, {
|
||||
id: `staged-thread-${message.channel.id}-${message.id}`,
|
||||
original_message: message,
|
||||
staged: true,
|
||||
created_at: moment.utc().format(),
|
||||
});
|
||||
|
||||
clonedMessage.thread = thread;
|
||||
clonedMessage.manager = thread.messagesManager;
|
||||
thread.messagesManager.addMessages([clonedMessage]);
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
async stageMessage(message) {
|
||||
message.id = guid();
|
||||
message.staged = true;
|
||||
|
|
|
@ -57,7 +57,6 @@ export default class ChatMessage {
|
|||
@tracked _thread;
|
||||
|
||||
constructor(channel, args = {}) {
|
||||
// when modifying constructor, be sure to update duplicate function accordingly
|
||||
this.id = args.id;
|
||||
this.channel = channel;
|
||||
this.manager = args.manager;
|
||||
|
@ -99,35 +98,8 @@ export default class ChatMessage {
|
|||
}
|
||||
}
|
||||
|
||||
duplicate() {
|
||||
// This is important as a message can exist in the context of a channel or a thread
|
||||
// The current strategy is to have a different message object in each cases to avoid
|
||||
// side effects
|
||||
const message = new ChatMessage(this.channel, {
|
||||
id: this.id,
|
||||
newest: this.newest,
|
||||
staged: this.staged,
|
||||
edited: this.edited,
|
||||
availableFlags: this.availableFlags,
|
||||
hidden: this.hidden,
|
||||
chatWebhookEvent: this.chatWebhookEvent,
|
||||
createdAt: this.createdAt,
|
||||
deletedAt: this.deletedAt,
|
||||
excerpt: this.excerpt,
|
||||
reviewableId: this.reviewableId,
|
||||
userFlagStatus: this.userFlagStatus,
|
||||
draft: this.draft,
|
||||
message: this.message,
|
||||
cooked: this.cooked,
|
||||
});
|
||||
|
||||
message.reactions = this.reactions;
|
||||
message.user = this.user;
|
||||
message.inReplyTo = this.inReplyTo;
|
||||
message.bookmark = this.bookmark;
|
||||
message.uploads = this.uploads;
|
||||
|
||||
return message;
|
||||
get persisted() {
|
||||
return !!this.id && !this.staged;
|
||||
}
|
||||
|
||||
get replyable() {
|
||||
|
|
|
@ -6,7 +6,6 @@ export default class ChatChannelThread extends DiscourseRoute {
|
|||
@service router;
|
||||
@service chatStateManager;
|
||||
@service chat;
|
||||
@service chatStagedThreadMapping;
|
||||
@service chatThreadPane;
|
||||
|
||||
model(params, transition) {
|
||||
|
@ -46,24 +45,6 @@ export default class ChatChannelThread extends DiscourseRoute {
|
|||
return;
|
||||
}
|
||||
|
||||
// This is a very special logic to attempt to reconciliate a staged thread id
|
||||
// it happens after creating a new thread and having a temp ID in the URL
|
||||
// if users presses reload at this moment, we would have a 404
|
||||
// replacing the ID in the URL sooner would also cause a reload
|
||||
const { threadId } = this.paramsFor(this.routeName);
|
||||
|
||||
if (threadId?.startsWith("staged-thread-")) {
|
||||
const mapping = this.chatStagedThreadMapping.getMapping();
|
||||
|
||||
if (mapping[threadId]) {
|
||||
transition.abort();
|
||||
return this.router.transitionTo(
|
||||
this.routeName,
|
||||
...[...channel.routeModels, mapping[threadId]]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { messageId } = this.paramsFor(this.routeName + ".near-message");
|
||||
if (
|
||||
!messageId &&
|
||||
|
|
|
@ -153,7 +153,6 @@ export default class ChatApi extends Service {
|
|||
* @param {number} [data.in_reply_to_id] - The ID of the replied-to message.
|
||||
* @param {number} [data.staged_id] - The staged ID of the message before it was persisted.
|
||||
* @param {number} [data.thread_id] - The ID of the thread where this message should be posted.
|
||||
* @param {number} [data.staged_thread_id] - The staged ID of the thread before it was persisted.
|
||||
* @param {Array.<number>} [data.upload_ids] - Array of upload ids linked to the message.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
|
@ -204,6 +203,21 @@ export default class ChatApi extends Service {
|
|||
return this.#putRequest(`/channels/${channelId}`, { channel: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a thread.
|
||||
* @param {number} channelId - The ID of the channel.
|
||||
* @param {number} originalMessageId - The ID of the original message.
|
||||
* @param {object} data - Params of the thread.
|
||||
* @param {string} [data.title] - Title of the thread.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
createThread(channelId, originalMessageId, data = {}) {
|
||||
return this.#postRequest(`/channels/${channelId}/threads`, {
|
||||
title: data.title,
|
||||
original_message_id: originalMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of a channel.
|
||||
* @param {number} channelId - The ID of the channel.
|
||||
|
|
|
@ -5,9 +5,11 @@ import { tracked } from "@glimmer/tracking";
|
|||
|
||||
export default class ChatChannelComposer extends Service {
|
||||
@service chat;
|
||||
@service chatApi;
|
||||
@service currentUser;
|
||||
@service router;
|
||||
@service("chat-thread-composer") threadComposer;
|
||||
@service loadingSlider;
|
||||
|
||||
@tracked message;
|
||||
@tracked textarea;
|
||||
|
@ -54,7 +56,17 @@ export default class ChatChannelComposer extends Service {
|
|||
|
||||
if (message.channel.threadingEnabled) {
|
||||
if (!message.thread?.id) {
|
||||
message.thread = message.channel.createStagedThread(message);
|
||||
this.loadingSlider.transitionStarted();
|
||||
const threadObject = await this.chatApi.createThread(
|
||||
message.channel.id,
|
||||
message.id
|
||||
);
|
||||
this.loadingSlider.transitionEnded();
|
||||
|
||||
message.thread = message.channel.threadsManager.add(
|
||||
message.channel,
|
||||
threadObject
|
||||
);
|
||||
}
|
||||
|
||||
this.reset(message.channel);
|
||||
|
|
|
@ -36,7 +36,6 @@ export function handleStagedMessage(channel, messagesManager, data) {
|
|||
export default class ChatPaneBaseSubscriptionsManager extends Service {
|
||||
@service chat;
|
||||
@service currentUser;
|
||||
@service chatStagedThreadMapping;
|
||||
|
||||
messageBusChannel = null;
|
||||
messageBusLastId = null;
|
||||
|
@ -215,40 +214,14 @@ export default class ChatPaneBaseSubscriptionsManager extends Service {
|
|||
|
||||
handleNewThreadCreated(data) {
|
||||
this.model.threadsManager
|
||||
.find(this.model.id, data.staged_thread_id, { fetchIfNotFound: false })
|
||||
.then((stagedThread) => {
|
||||
if (stagedThread) {
|
||||
this.chatStagedThreadMapping.setMapping(
|
||||
data.thread_id,
|
||||
stagedThread.id
|
||||
);
|
||||
stagedThread.staged = false;
|
||||
stagedThread.id = data.thread_id;
|
||||
stagedThread.originalMessage.thread = stagedThread;
|
||||
stagedThread.originalMessage.thread.preview.replyCount ??= 1;
|
||||
.find(this.model.id, data.thread_id, { fetchIfNotFound: true })
|
||||
.then((thread) => {
|
||||
const channelOriginalMessage = this.model.messagesManager.findMessage(
|
||||
thread.originalMessage.id
|
||||
);
|
||||
|
||||
// We have to do this because the thread manager cache is keyed by
|
||||
// staged_thread_id, but the thread_id is what we want to use to
|
||||
// look up the thread, otherwise calls to .find() will not return
|
||||
// the thread by its actual ID, and we will end up with double-ups
|
||||
// in places like the thread list when .add() is called.
|
||||
this.model.threadsManager.remove({ id: data.staged_thread_id });
|
||||
this.model.threadsManager.add(this.model, stagedThread, {
|
||||
replace: true,
|
||||
});
|
||||
} else if (data.thread_id) {
|
||||
this.model.threadsManager
|
||||
.find(this.model.id, data.thread_id, { fetchIfNotFound: true })
|
||||
.then((thread) => {
|
||||
const channelOriginalMessage =
|
||||
this.model.messagesManager.findMessage(
|
||||
thread.originalMessage.id
|
||||
);
|
||||
|
||||
if (channelOriginalMessage) {
|
||||
channelOriginalMessage.thread = thread;
|
||||
}
|
||||
});
|
||||
if (channelOriginalMessage) {
|
||||
channelOriginalMessage.thread = thread;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
import Service from "@ember/service";
|
||||
|
||||
export default class ChatStagedThreadMapping extends Service {
|
||||
STORE_NAMESPACE = "discourse_chat_";
|
||||
KEY = "staged_thread";
|
||||
|
||||
store = new KeyValueStore(this.STORE_NAMESPACE);
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
if (!this.store.getObject(this.USER_EMOJIS_STORE_KEY)) {
|
||||
this.storedFavorites = [];
|
||||
}
|
||||
}
|
||||
|
||||
getMapping() {
|
||||
return JSON.parse(this.store.getObject(this.KEY) || "{}");
|
||||
}
|
||||
|
||||
setMapping(id, stagedId) {
|
||||
const mapping = {};
|
||||
mapping[stagedId] = id;
|
||||
this.store.setObject({
|
||||
key: this.KEY,
|
||||
value: JSON.stringify(mapping),
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.store.setObject({ key: this.KEY, value: "{}" });
|
||||
}
|
||||
}
|
|
@ -30,6 +30,7 @@ Chat::Engine.routes.draw do
|
|||
get "/mentions/groups" => "hints#check_group_mentions", :format => :json
|
||||
|
||||
get "/channels/:channel_id/threads" => "channel_threads#index"
|
||||
post "/channels/:channel_id/threads" => "channel_threads#create"
|
||||
put "/channels/:channel_id/threads/:thread_id" => "channel_threads#update"
|
||||
get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show"
|
||||
get "/channels/:channel_id/threads/:thread_id/messages" => "channel_thread_messages#index"
|
||||
|
|
|
@ -234,4 +234,63 @@ RSpec.describe Chat::Api::ChannelThreadsController do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "create" do
|
||||
fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: true) }
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
|
||||
|
||||
let(:title) { "a very nice cat" }
|
||||
let(:params) { { title: title, original_message_id: message_1.id } }
|
||||
let(:channel_id) { channel_1.id }
|
||||
|
||||
context "when channel does not exist" do
|
||||
it "returns 404" do
|
||||
channel_1.destroy!
|
||||
post "/chat/api/channels/#{channel_id}", params: params
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when channel exists" do
|
||||
it "creates the thread" do
|
||||
post "/chat/api/channels/#{channel_id}/threads", params: params
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body["title"]).to eq(title)
|
||||
end
|
||||
|
||||
context "when user cannot view the channel" do
|
||||
let(:channel_id) { Fabricate(:private_category_channel).id }
|
||||
|
||||
it "returns 403" do
|
||||
post "/chat/api/channels/#{channel_id}/threads", params: params
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the title is too long" do
|
||||
let(:title) { "x" * Chat::Thread::MAX_TITLE_LENGTH + "x" }
|
||||
|
||||
it "returns 400" do
|
||||
post "/chat/api/channels/#{channel_id}/threads", params: params
|
||||
|
||||
expect(response.status).to eq(400)
|
||||
expect(response.parsed_body["errors"]).to eq(
|
||||
["Title is too long (maximum is #{Chat::Thread::MAX_TITLE_LENGTH} characters)"],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when channel does not have threading enabled" do
|
||||
fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: false) }
|
||||
|
||||
it "returns 404" do
|
||||
post "/chat/api/channels/#{channel_id}/threads", params: params
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -301,32 +301,6 @@ RSpec.describe Chat::Api::ChannelMessagesController do
|
|||
expect(messages.first.data["last_read_message_id"]).to eq(Chat::Message.last.id)
|
||||
end
|
||||
|
||||
context "when sending a message in a staged thread" do
|
||||
it "creates the thread and publishes with the staged id" do
|
||||
sign_in(user)
|
||||
chat_channel.update!(threading_enabled: true)
|
||||
|
||||
messages =
|
||||
MessageBus.track_publish do
|
||||
post "/chat/#{chat_channel.id}.json",
|
||||
params: {
|
||||
message: message,
|
||||
in_reply_to_id: message_1.id,
|
||||
staged_thread_id: "stagedthreadid",
|
||||
}
|
||||
end
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
thread_event = messages.find { |m| m.data["type"] == "thread_created" }
|
||||
expect(thread_event.data["staged_thread_id"]).to eq("stagedthreadid")
|
||||
expect(Chat::Thread.find(thread_event.data["thread_id"])).to be_persisted
|
||||
|
||||
sent_event = messages.find { |m| m.data["type"] == "sent" }
|
||||
expect(sent_event.data["staged_thread_id"]).to eq("stagedthreadid")
|
||||
end
|
||||
end
|
||||
|
||||
context "when sending a message in a thread" do
|
||||
fab!(:thread) do
|
||||
Fabricate(:chat_thread, channel: chat_channel, original_message: message_1)
|
||||
|
|
|
@ -69,12 +69,7 @@ RSpec.describe Chat::CreateMessage do
|
|||
end
|
||||
|
||||
it "publishes the new message" do
|
||||
Chat::Publisher.expects(:publish_new!).with(
|
||||
channel,
|
||||
instance_of(Chat::Message),
|
||||
nil,
|
||||
staged_thread_id: nil,
|
||||
)
|
||||
Chat::Publisher.expects(:publish_new!).with(channel, instance_of(Chat::Message), nil)
|
||||
result
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Chat::CreateThread do
|
||||
describe Chat::CreateThread::Contract, type: :model do
|
||||
it { is_expected.to validate_presence_of :channel_id }
|
||||
it { is_expected.to validate_presence_of :original_message_id }
|
||||
end
|
||||
|
||||
describe ".call" do
|
||||
subject(:result) { described_class.call(params) }
|
||||
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: true) }
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
|
||||
|
||||
let(:guardian) { Guardian.new(current_user) }
|
||||
let(:title) { nil }
|
||||
let(:params) do
|
||||
{
|
||||
guardian: guardian,
|
||||
original_message_id: message_1.id,
|
||||
channel_id: channel_1.id,
|
||||
title: title,
|
||||
}
|
||||
end
|
||||
|
||||
context "when all steps pass" do
|
||||
it "sets the service result as successful" do
|
||||
expect(result).to be_a_success
|
||||
end
|
||||
|
||||
it "creates a thread" do
|
||||
result
|
||||
expect(result.thread).to be_persisted
|
||||
end
|
||||
|
||||
it "associates the original message to the thread" do
|
||||
expect {
|
||||
result
|
||||
message_1.reload
|
||||
}.to change { message_1.thread_id }.from(nil).to(result.thread.id)
|
||||
end
|
||||
|
||||
it "fetches the membership" do
|
||||
result
|
||||
expect(result.membership).to eq(result.thread.membership_for(current_user))
|
||||
end
|
||||
|
||||
it "publishes a `thread_created` MessageBus event" do
|
||||
message = MessageBus.track_publish("/chat/#{channel_1.id}") { result }.first
|
||||
expect(message.data["type"]).to eq("thread_created")
|
||||
end
|
||||
|
||||
it "sets the title when existing" do
|
||||
params[:title] = "Restaurant for Saturday"
|
||||
result
|
||||
expect(result.thread.title).to eq(params[:title])
|
||||
end
|
||||
end
|
||||
|
||||
context "when params are not valid" do
|
||||
before { params.delete(:original_message_id) }
|
||||
|
||||
it { is_expected.to fail_a_contract }
|
||||
end
|
||||
|
||||
context "when title is too long" do
|
||||
let(:title) { "a" * Chat::Thread::MAX_TITLE_LENGTH + "a" }
|
||||
|
||||
it { is_expected.to fail_a_contract }
|
||||
end
|
||||
|
||||
context "when original message is not found" do
|
||||
fab!(:channel_2) { Fabricate(:chat_channel, threading_enabled: true) }
|
||||
|
||||
before { params[:channel_id] = channel_2.id }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:original_message) }
|
||||
end
|
||||
|
||||
context "when original message is not found" do
|
||||
before { message_1.destroy! }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:original_message) }
|
||||
end
|
||||
|
||||
context "when user cannot see channel" do
|
||||
fab!(:private_channel_1) { Fabricate(:private_category_channel, group: Fabricate(:group)) }
|
||||
|
||||
before { params[:channel_id] = private_channel_1.id }
|
||||
|
||||
it { is_expected.to fail_a_policy(:can_view_channel) }
|
||||
end
|
||||
|
||||
context "when threading is not enabled for the channel" do
|
||||
before { channel_1.update!(threading_enabled: false) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:threading_enabled_for_channel) }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -181,32 +181,6 @@ describe Chat::Publisher do
|
|||
end
|
||||
end
|
||||
|
||||
context "when a staged thread has been provided" do
|
||||
fab!(:thread) do
|
||||
Fabricate(
|
||||
:chat_thread,
|
||||
original_message: Fabricate(:chat_message, chat_channel: channel),
|
||||
channel: channel,
|
||||
)
|
||||
end
|
||||
|
||||
before { message_1.update!(thread: thread) }
|
||||
|
||||
it "generates the correct targets" do
|
||||
targets =
|
||||
described_class.calculate_publish_targets(
|
||||
channel,
|
||||
message_1,
|
||||
staged_thread_id: "stagedthreadid",
|
||||
)
|
||||
|
||||
expect(targets).to contain_exactly(
|
||||
"/chat/#{channel.id}/thread/#{thread.id}",
|
||||
"/chat/#{channel.id}/thread/stagedthreadid",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the message is a thread reply" do
|
||||
fab!(:thread) do
|
||||
Fabricate(
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { module, test } from "qunit";
|
||||
import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
|
||||
module("Discourse Chat | Unit | Models | chat-message", function () {
|
||||
test(".persisted", function (assert) {
|
||||
const channel = fabricators.channel();
|
||||
let message = ChatMessage.create(channel, { id: null });
|
||||
assert.strictEqual(message.persisted, false);
|
||||
|
||||
message = ChatMessage.create(channel, {
|
||||
id: 1,
|
||||
staged: true,
|
||||
});
|
||||
assert.strictEqual(message.persisted, false);
|
||||
|
||||
message = ChatMessage.create(channel, {
|
||||
id: 1,
|
||||
staged: false,
|
||||
});
|
||||
assert.strictEqual(message.persisted, true);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue