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:
Joffrey JAFFEUX 2023-09-15 18:09:45 +02:00 committed by GitHub
parent 1d14474e1d
commit c8fff19b99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 733 additions and 603 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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