diff --git a/plugins/chat/app/controllers/chat/api/channels_drafts_controller.rb b/plugins/chat/app/controllers/chat/api/channels_drafts_controller.rb new file mode 100644 index 00000000000..6c2a51f543b --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channels_drafts_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelsDraftsController < Chat::ApiController + def create + with_service(Chat::UpsertDraft) { on_model_not_found(:channel) { raise Discourse::NotFound } } + end +end diff --git a/plugins/chat/app/controllers/chat/api/channels_threads_drafts_controller.rb b/plugins/chat/app/controllers/chat/api/channels_threads_drafts_controller.rb new file mode 100644 index 00000000000..a0366c5d21f --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channels_threads_drafts_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelsThreadsDraftsController < Chat::ApiController + def create + with_service(Chat::UpsertDraft) do + on_model_not_found(:channel) { raise Discourse::NotFound } + on_failed_step(:check_thread_exists) { raise Discourse::NotFound } + end + end +end diff --git a/plugins/chat/app/controllers/chat/chat_controller.rb b/plugins/chat/app/controllers/chat/chat_controller.rb index ceebd897ca8..2ba17b38562 100644 --- a/plugins/chat/app/controllers/chat/chat_controller.rb +++ b/plugins/chat/app/controllers/chat/chat_controller.rb @@ -117,19 +117,6 @@ module Chat end end - def set_draft - if params[:data].present? - Chat::Draft.find_or_initialize_by( - user: current_user, - chat_channel_id: @chat_channel.id, - ).update!(data: params[:data]) - else - Chat::Draft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all - end - - render json: success_json - end - private def preloaded_chat_message_query diff --git a/plugins/chat/app/services/chat/upsert_draft.rb b/plugins/chat/app/services/chat/upsert_draft.rb new file mode 100644 index 00000000000..a07e3a153af --- /dev/null +++ b/plugins/chat/app/services/chat/upsert_draft.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Chat + # Service responsible to create draft for a channel, or a channel’s thread. + # + # @example + # ::Chat::UpsertDraft.call( + # guardian: guardian, + # channel_id: 1, + # thread_id: 1, + # data: { message: "foo" } + # ) + # + class UpsertDraft + include Service::Base + + # @!method call(guardian:, channel_id:, thread_id:, data:) + # @param [Guardian] guardian + # @param [Integer] channel_id of the channel + # @param [String] json object as string containing the data of the draft (message, uploads, replyToMsg and editing keys) + # @option [Integer] thread_id of the channel + # @return [Service::Base::Context] + contract + model :channel + policy :can_upsert_draft + step :check_thread_exists + step :upsert_draft + + # @!visibility private + class Contract + attribute :channel_id, :integer + validates :channel_id, presence: true + + attribute :thread_id, :integer + attribute :data, :string + end + + private + + def fetch_channel(contract:, **) + Chat::Channel.find_by(id: contract.channel_id) + end + + def can_upsert_draft(guardian:, channel:, **) + guardian.can_chat? && guardian.can_join_chat_channel?(channel) + end + + def check_thread_exists(contract:, channel:, **) + if contract.thread_id.present? + fail!("Thread not found") if !channel.threads.exists?(id: contract.thread_id) + end + end + + def upsert_draft(contract:, guardian:, **) + if contract.data.present? + draft = + Chat::Draft.find_or_initialize_by( + user_id: guardian.user.id, + chat_channel_id: contract.channel_id, + thread_id: contract.thread_id, + ) + draft.data = contract.data + draft.save! + else + # when data is empty, we destroy the draft + Chat::Draft.where( + user: guardian.user, + chat_channel_id: contract.channel_id, + thread_id: contract.thread_id, + ).destroy_all + end + end + end +end diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs similarity index 81% rename from plugins/chat/assets/javascripts/discourse/components/chat-channel.js rename to plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs index 922901f5637..6c007145380 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs @@ -1,9 +1,14 @@ import Component from "@glimmer/component"; import { cached, tracked } from "@glimmer/tracking"; import { getOwner } from "@ember/application"; +import { hash } from "@ember/helper"; import { action } from "@ember/object"; +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 { cancel, next, schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; +import concatClass from "discourse/helpers/concat-class"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { resetIdle } from "discourse/lib/desktop-notifications"; import DiscourseURL from "discourse/lib/url"; @@ -11,8 +16,11 @@ import { onPresenceChange, removeOnPresenceChange, } from "discourse/lib/user-presence"; +import i18n from "discourse-common/helpers/i18n"; import discourseDebounce from "discourse-common/lib/debounce"; import { bind } from "discourse-common/utils/decorators"; +import and from "truth-helpers/helpers/and"; +import not from "truth-helpers/helpers/not"; import ChatChannelSubscriptionManager from "discourse/plugins/chat/discourse/lib/chat-channel-subscription-manager"; import { FUTURE, @@ -31,6 +39,18 @@ import { } from "discourse/plugins/chat/discourse/lib/scroll-helpers"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import { stackingContextFix } from "../lib/chat-ios-hacks"; +import ChatOnResize from "../modifiers/chat/on-resize"; +import ChatScrollableList from "../modifiers/chat/scrollable-list"; +import ChatComposerChannel from "./chat/composer/channel"; +import ChatScrollToBottomArrow from "./chat/scroll-to-bottom-arrow"; +import ChatSelectionManager from "./chat/selection-manager"; +import ChatChannelPreviewCard from "./chat-channel-preview-card"; +import ChatFullPageHeader from "./chat-full-page-header"; +import ChatMentionWarnings from "./chat-mention-warnings"; +import Message from "./chat-message"; +import ChatNotices from "./chat-notices"; +import ChatSkeleton from "./chat-skeleton"; +import ChatUploadDropZone from "./chat-upload-drop-zone"; export default class ChatChannel extends Component { @service appEvents; @@ -75,23 +95,14 @@ export default class ChatChannel extends Component { return this.args.channel.currentUserMembership; } - @action - setUploadDropZone(element) { - this.uploadDropZone = element; - } - @action setScrollable(element) { this.scrollable = element; } @action - setupListeners() { - onPresenceChange({ callback: this.onPresenceChangeCallback }); - } - - @action - teardownListeners() { + teardown() { + document.removeEventListener("keydown", this._autoFocus); this.#cancelHandlers(); removeOnPresenceChange(this.onPresenceChangeCallback); this.subscriptionManager.teardown(); @@ -104,7 +115,11 @@ export default class ChatChannel extends Component { } @action - didUpdateChannel() { + setup(element) { + this.uploadDropZone = element; + document.addEventListener("keydown", this._autoFocus); + onPresenceChange({ callback: this.onPresenceChangeCallback }); + this.messagesManager.clear(); if ( @@ -114,14 +129,11 @@ export default class ChatChannel extends Component { this.chatChannelsManager.follow(this.args.channel); } - const existingDraft = this.chatDraftsManager.get({ - channelId: this.args.channel.id, - }); - if (existingDraft) { - this.composer.message = existingDraft; - } else { - this.resetComposerMessage(); - } + this.args.channel.draft = + this.chatDraftsManager.get(this.args.channel?.id) || + ChatMessage.createDraftMessage(this.args.channel, { + user: this.currentUser, + }); this.composer.focus(); this.loadMessages(); @@ -485,7 +497,7 @@ export default class ChatChannel extends Component { @action resetComposerMessage() { - this.composer.reset(this.args.channel); + this.args.channel.resetDraft(this.currentUser); } async #sendEditMessage(message) { @@ -506,7 +518,7 @@ export default class ChatChannel extends Component { popupAjaxError(e); } finally { message.editing = false; - this.chatDraftsManager.remove({ channelId: this.args.channel.id }); + this.resetComposerMessage(); this.pane.sending = false; } } @@ -541,7 +553,7 @@ export default class ChatChannel extends Component { } catch (error) { this._onSendError(message.id, error); } finally { - this.chatDraftsManager.remove({ channelId: this.args.channel.id }); + this.resetComposerMessage(); this.pane.sending = false; } } @@ -599,16 +611,6 @@ export default class ChatChannel extends Component { }); } - @action - addAutoFocusEventListener() { - document.addEventListener("keydown", this._autoFocus); - } - - @action - removeAutoFocusEventListener() { - document.removeEventListener("keydown", this._autoFocus); - } - @bind _autoFocus(event) { if (this.chatStateManager.isDrawerActive) { @@ -705,4 +707,92 @@ export default class ChatChannel extends Component { this._ignoreNextScroll = false; return prev; } + + } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs deleted file mode 100644 index f088d578e9c..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs +++ /dev/null @@ -1,87 +0,0 @@ -
- - - - - - -
-
- {{#each this.messagesManager.messages key="id" as |message|}} - - {{else}} - {{#unless this.messagesLoader.fetchedOnce}} - - {{/unless}} - {{/each}} -
- - {{! at bottom even if shown at top due to column-reverse }} - {{#if this.messagesLoader.loadedPast}} -
- {{i18n "chat.all_loaded"}} -
- {{/if}} -
- - - - {{#if this.pane.selectingMessages}} - - {{else}} - {{#if (and (not @channel.isFollowing) @channel.isCategoryChannel)}} - - {{else}} - - {{/if}} - {{/if}} - - -
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs index 66a32b97f1e..959330da855 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs @@ -4,12 +4,8 @@
{{#if this.shouldRenderMessageDetails}} {{/if}} @@ -22,10 +18,10 @@ (if this.pane.sending "is-sending") (if this.sendEnabled "is-send-enabled" "is-send-disabled") (if this.disabled "is-disabled" "is-enabled") - (if this.currentMessage.draftSaved "is-draft-saved" "is-draft-unsaved") + (if this.draft.draftSaved "is-draft-saved" "is-draft-unsaved") }} - {{did-update this.didUpdateMessage this.currentMessage}} - {{did-update this.didUpdateInReplyTo this.currentMessage.inReplyTo}} + {{did-update this.didUpdateMessage this.draft}} + {{did-update this.didUpdateInReplyTo this.draft.inReplyTo}} {{did-insert this.setup}} {{will-destroy this.teardown}} {{will-destroy this.cancelPersistDraft}} @@ -43,7 +39,7 @@ > {{/if}} - {{#if this.shouldRenderReplyingIndicator}} -
- -
- {{/if}} +
+ +
= minLength || + this.draft?.message?.length >= minLength || (this.canAttachUploads && this.hasUploads) ); } get hasUploads() { - return this.currentMessage?.uploads?.length > 0; + return this.draft?.uploads?.length > 0; } get sendEnabled() { return ( - (this.hasContent || this.currentMessage?.editing) && + (this.hasContent || this.draft?.editing) && !this.pane.sending && !this.inProgressUploadsCount > 0 ); @@ -196,8 +192,8 @@ export default class ChatComposer extends Component { @action onInput(event) { - this.currentMessage.draftSaved = false; - this.currentMessage.message = event.target.value; + this.draft.draftSaved = false; + this.draft.message = event.target.value; this.composer.textarea.refreshHeight(); this.reportReplyingPresence(); this.persistDraft(); @@ -206,7 +202,7 @@ export default class ChatComposer extends Component { @action onUploadChanged(uploads, { inProgressUploadsCount }) { - this.currentMessage.draftSaved = false; + this.draft.draftSaved = false; this.inProgressUploadsCount = inProgressUploadsCount || 0; @@ -214,9 +210,9 @@ export default class ChatComposer extends Component { typeof uploads !== "undefined" && inProgressUploadsCount !== "undefined" && inProgressUploadsCount === 0 && - this.currentMessage + this.draft ) { - this.currentMessage.uploads = cloneJSON(uploads); + this.draft.uploads = cloneJSON(uploads); } this.composer.textarea?.focus(); @@ -238,26 +234,26 @@ export default class ChatComposer extends Component { event?.preventDefault(); if ( - this.currentMessage.editing && + this.draft.editing && !this.hasUploads && - this.currentMessage.message.length === 0 + this.draft.message.length === 0 ) { this.#deleteEmptyMessage(); return; } - await this.args.onSendMessage(this.currentMessage); + await this.args.onSendMessage(this.draft); this.composer.textarea.refreshHeight(); } reportReplyingPresence() { - if (!this.args.channel || !this.currentMessage) { + if (!this.args.channel || !this.draft) { return; } this.chatComposerPresenceManager.notifyState( this.presenceChannelName, - !this.currentMessage.editing && this.hasContent + !this.draft.editing && this.hasContent ); } @@ -330,17 +326,14 @@ export default class ChatComposer extends Component { return false; } - if ( - event.key === "ArrowUp" && - !this.hasContent && - !this.currentMessage.editing - ) { + if (event.key === "ArrowUp" && !this.hasContent && !this.draft.editing) { if (event.shiftKey && this.lastMessage?.replyable) { this.composer.replyTo(this.lastMessage); } else { const editableMessage = this.lastUserMessage(this.currentUser); if (editableMessage?.editable) { this.composer.edit(editableMessage); + this.args.channel.draft = editableMessage; } } } @@ -381,7 +374,7 @@ export default class ChatComposer extends Component { captureMentions(opts = { skipDebounce: false }) { if (this.hasContent) { this.chatComposerWarningsTracker.trackMentions( - this.currentMessage, + this.draft, opts.skipDebounce ); } else { @@ -398,7 +391,7 @@ export default class ChatComposer extends Component { #addMentionedUser(userData) { const user = this.store.createRecord("user", userData); - this.currentMessage.mentionedUsers.set(user.id, user); + this.draft.mentionedUsers.set(user.id, user); } #applyUserAutocomplete($textarea) { @@ -598,9 +591,9 @@ export default class ChatComposer extends Component { #deleteEmptyMessage() { new ChatMessageInteractor( getOwner(this), - this.currentMessage, + this.draft, this.context ).delete(); - this.reset(this.args.channel, this.args.thread); + this.resetDraft(); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs similarity index 77% rename from plugins/chat/assets/javascripts/discourse/components/chat-thread.js rename to plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs index 3f931e73c5a..020882ce5fa 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs @@ -1,9 +1,13 @@ import Component from "@glimmer/component"; import { cached, tracked } from "@glimmer/tracking"; import { getOwner } from "@ember/application"; +import { hash } from "@ember/helper"; import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; import { cancel, next } from "@ember/runloop"; import { inject as service } from "@ember/service"; +import concatClass from "discourse/helpers/concat-class"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { resetIdle } from "discourse/lib/desktop-notifications"; import { NotificationLevels } from "discourse/lib/notification-levels"; @@ -27,6 +31,15 @@ import { } from "discourse/plugins/chat/discourse/lib/scroll-helpers"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership"; +import ChatOnResize from "../modifiers/chat/on-resize"; +import ChatScrollableList from "../modifiers/chat/scrollable-list"; +import ChatComposerThread from "./chat/composer/thread"; +import ChatScrollToBottomArrow from "./chat/scroll-to-bottom-arrow"; +import ChatSelectionManager from "./chat/selection-manager"; +import ChatThreadHeader from "./chat/thread/header"; +import Message from "./chat-message"; +import ChatSkeleton from "./chat-skeleton"; +import ChatUploadDropZone from "./chat-upload-drop-zone"; export default class ChatThread extends Component { @service appEvents; @@ -35,6 +48,7 @@ export default class ChatThread extends Component { @service chatApi; @service chatComposerPresenceManager; @service chatHistory; + @service chatDraftsManager; @service chatThreadComposer; @service chatThreadPane; @service currentUser; @@ -73,16 +87,21 @@ export default class ChatThread extends Component { } @action - didUpdateThread() { + setup(element) { + this.uploadDropZone = element; + this.messagesManager.clear(); + this.args.thread.draft = + this.chatDraftsManager.get( + this.args.thread.channel?.id, + this.args.thread.id + ) || + ChatMessage.createDraftMessage(this.args.thread.channel, { + user: this.currentUser, + thread: this.args.thread, + }); this.chatThreadComposer.focus(); this.loadMessages(); - this.resetComposerMessage(); - } - - @action - setUploadDropZone(element) { - this.uploadDropZone = element; } @action @@ -346,7 +365,13 @@ export default class ChatThread extends Component { @action resetComposerMessage() { - this.chatThreadComposer.reset(this.args.thread); + this.args.thread.draft = ChatMessage.createDraftMessage( + this.args.thread.channel, + { + user: this.currentUser, + thread: this.args.thread, + } + ); } async #sendNewMessage(message) { @@ -387,6 +412,10 @@ export default class ChatThread extends Component { } catch (error) { this.#onSendError(message.id, error); } finally { + this.chatDraftsManager.remove( + this.args.thread.channel.id, + this.args.thread.id + ); this.chatThreadPane.sending = false; } } @@ -410,6 +439,10 @@ export default class ChatThread extends Component { } catch (e) { popupAjaxError(e); } finally { + this.chatDraftsManager.remove( + this.args.thread.channel.id, + this.args.thread.id + ); this.chatThreadPane.sending = false; } } @@ -449,4 +482,68 @@ export default class ChatThread extends Component { this._ignoreNextScroll = false; return prev; } + + } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs deleted file mode 100644 index d15b08148d3..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs +++ /dev/null @@ -1,60 +0,0 @@ -
- {{#if @includeHeader}} - - {{/if}} - -
-
- {{#each this.messagesManager.messages key="id" as |message|}} - - {{/each}} - - {{#unless this.messagesLoader.fetchedOnce}} - {{#if this.messagesLoader.loading}} - - {{/if}} - {{/unless}} -
-
- - - - {{#if this.chatThreadPane.selectingMessages}} - - {{else}} - - {{/if}} - - -
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js index a5bc9008c35..cdf07815fda 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js @@ -1,21 +1,36 @@ import { action } from "@ember/object"; import { inject as service } from "@ember/service"; -import discourseDebounce from "discourse-common/lib/debounce"; +import { debounce } from "discourse-common/utils/decorators"; import I18n from "discourse-i18n"; import ChatComposer from "../../chat-composer"; export default class ChatComposerChannel extends ChatComposer { @service("chat-channel-composer") composer; @service("chat-channel-pane") pane; - @service chatDraftsManager; @service currentUser; + @service chatDraftsManager; context = "channel"; composerId = "channel-composer"; - get shouldRenderReplyingIndicator() { - return this.args.channel; + @debounce(2000) + persistDraft() { + this.chatDraftsManager.add(this.draft, this.args.channel.id); + } + + @action + destroyDraft() { + this.chatDraftsManager.remove(this.args.channel.id); + } + + @action + resetDraft() { + this.args.channel.resetDraft(this.currentUser); + } + + get draft() { + return this.args.channel.draft; } get presenceChannelName() { @@ -30,33 +45,6 @@ export default class ChatComposerChannel extends ChatComposer { ); } - @action - reset() { - this.composer.reset(this.args.channel); - } - - @action - persistDraft() { - this.chatDraftsManager.add(this.currentMessage); - - this._persistHandler = discourseDebounce( - this, - this._debouncedPersistDraft, - this.args.channel.id, - this.currentMessage.toJSONDraft(), - 2000 - ); - } - - @action - _debouncedPersistDraft(channelId, jsonDraft) { - this.chatApi.saveDraft(channelId, jsonDraft).then(() => { - if (this.currentMessage) { - this.currentMessage.draftSaved = true; - } - }); - } - get lastMessage() { return this.args.channel.lastMessage; } @@ -82,10 +70,10 @@ export default class ChatComposerChannel extends ChatComposer { handleEscape(event) { event.stopPropagation(); - if (this.currentMessage?.inReplyTo) { - this.reset(); - } else if (this.currentMessage?.editing) { - this.composer.cancel(this.args.channel); + if (this.draft?.inReplyTo) { + this.draft.inReplyTo = null; + } else if (this.draft?.editing) { + this.args.channel.resetDraft(this.currentUser); } else { event.target.blur(); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js index 2be6e96dd72..5962b8f7b93 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js @@ -1,6 +1,8 @@ import { action } from "@ember/object"; import { inject as service } from "@ember/service"; +import { debounce } from "discourse-common/utils/decorators"; import I18n from "discourse-i18n"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatComposer from "../../chat-composer"; export default class ChatComposerThread extends ChatComposer { @@ -8,18 +10,36 @@ export default class ChatComposerThread extends ChatComposer { @service("chat-thread-composer") composer; @service("chat-thread-pane") pane; @service currentUser; + @service chatDraftsManager; context = "thread"; composerId = "thread-composer"; - @action - reset() { - this.composer.reset(this.args.thread); + @debounce(2000) + persistDraft() { + this.chatDraftsManager.add( + this.draft, + this.args.thread.channel.id, + this.args.thread.id + ); } - get shouldRenderReplyingIndicator() { - return this.args.thread; + @action + destroyDraft() { + this.chatDraftsManager.remove( + this.args.thread.channel.id, + this.args.thread.id + ); + } + + @action + resetDraft() { + this.args.thread.resetDraft(this.currentUser); + } + + get draft() { + return this.args.thread.draft; } get disabled() { @@ -43,9 +63,13 @@ export default class ChatComposerThread extends ChatComposer { } handleEscape(event) { - if (this.currentMessage.editing) { + if (this.draft.editing) { event.stopPropagation(); - this.composer.cancel(this.args.thread); + this.args.thread.draft = ChatMessage.createDraftMessage( + this.args.thread.channel, + { user: this.currentUser, thread: this.args.thread } + ); + return; } diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-channel-subscription-manager.js b/plugins/chat/assets/javascripts/discourse/lib/chat-channel-subscription-manager.js index bcf61be0032..ca2bc606bb3 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-channel-subscription-manager.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-channel-subscription-manager.js @@ -231,5 +231,7 @@ export default class ChatChannelSubscriptionManager { if (message?.thread) { message.thread.preview = ChatThreadPreview.create(data.preview); } + + message.thread.preview.yolo = 1; } } diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js index 35cad514bc7..ee93f91f850 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -70,6 +70,7 @@ export default class ChatChannel { @tracked archive; @tracked tracking; @tracked threadingEnabled = false; + @tracked draft; threadsManager = new ChatThreadsManager(getOwnerWithFallback(this)); messagesManager = new ChatMessagesManager(getOwnerWithFallback(this)); @@ -204,6 +205,12 @@ export default class ChatChannel { message.manager = this.messagesManager; } + resetDraft(user) { + this.draft = ChatMessage.createDraftMessage(this, { + user, + }); + } + canModifyMessages(user) { if (user.staff) { return !STAFF_READONLY_STATUSES.includes(this.status); diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js index eb50f51c757..58871f2131c 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js @@ -29,8 +29,8 @@ export default class ChatThread { @tracked threadMessageBusLastId; @tracked replyCount; @tracked tracking; - @tracked currentUserMembership = null; - @tracked preview = null; + @tracked currentUserMembership; + @tracked preview; messagesManager = new ChatMessagesManager(getOwnerWithFallback(this)); @@ -38,7 +38,6 @@ export default class ChatThread { this.id = args.id; this.channel = channel; this.status = args.status; - this.draft = args.draft; this.staged = args.staged; this.replyCount = args.reply_count; @@ -58,6 +57,13 @@ export default class ChatThread { this.preview = ChatThreadPreview.create(args.preview); } + resetDraft(user) { + this.draft = ChatMessage.createDraftMessage(this.channel, { + user, + thread: this, + }); + } + async stageMessage(message) { message.id = guid(); message.staged = true; diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index d38968844c8..76c421c799b 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -313,11 +313,16 @@ export default class ChatApi extends Service { * @param {object} data - The draft data, see ChatMessage.toJSONDraft() for more details. * @returns {Promise} */ - saveDraft(channelId, data) { - return ajax("/chat/drafts", { + saveDraft(channelId, data, options = {}) { + let endpoint = `/chat/api/channels/${channelId}`; + if (options.threadId) { + endpoint += `/threads/${options.threadId}`; + } + endpoint += "/drafts"; + + return ajax(endpoint, { type: "POST", data: { - chat_channel_id: channelId, data, }, ignoreUnsent: false, diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js index c6c22ad0361..d45c59aa066 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js @@ -1,7 +1,6 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import Service, { inject as service } from "@ember/service"; -import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; export default class ChatChannelComposer extends Service { @service chat; @@ -11,7 +10,6 @@ export default class ChatChannelComposer extends Service { @service("chat-thread-composer") threadComposer; @service loadingSlider; - @tracked message; @tracked textarea; @action @@ -24,29 +22,11 @@ export default class ChatChannelComposer extends Service { this.textarea.blur(); } - @action - reset(channel) { - this.message = ChatMessage.createDraftMessage(channel, { - user: this.currentUser, - }); - } - - @action - cancel() { - if (this.message.editing) { - this.reset(this.message.channel); - } else if (this.message.inReplyTo) { - this.message.inReplyTo = null; - } - - this.focus({ ensureAtEnd: true, refreshHeight: true }); - } - @action edit(message) { this.chat.activeMessage = null; message.editing = true; - this.message = message; + message.channel.draft = message; this.focus({ refreshHeight: true, ensureAtEnd: true }); } @@ -56,20 +36,22 @@ export default class ChatChannelComposer extends Service { if (message.channel.threadingEnabled) { if (!message.thread?.id) { - 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 - ); + try { + this.loadingSlider.transitionStarted(); + const threadObject = await this.chatApi.createThread( + message.channel.id, + message.id + ); + message.thread = message.channel.threadsManager.add( + message.channel, + threadObject + ); + } finally { + this.loadingSlider.transitionEnded(); + } } - this.reset(message.channel); + message.channel.resetDraft(this.currentUser); await this.router.transitionTo( "chat.channel.thread", @@ -78,7 +60,7 @@ export default class ChatChannelComposer extends Service { this.threadComposer.focus({ ensureAtEnd: true, refreshHeight: true }); } else { - this.message.inReplyTo = message; + message.channel.draft.inReplyTo = message; this.focus({ ensureAtEnd: true, refreshHeight: true }); } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-drafts-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-drafts-manager.js index 00662f03edc..becbf58b43b 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-drafts-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-drafts-manager.js @@ -1,25 +1,53 @@ -import Service from "@ember/service"; -import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { cancel } from "@ember/runloop"; +import Service, { inject as service } from "@ember/service"; + export default class ChatDraftsManager extends Service { + @service chatApi; + drafts = {}; - add(message) { - if (message instanceof ChatMessage) { - this.drafts[message.channel.id] = message; - } else { - throw new Error("message must be an instance of ChatMessage"); + async add(message, channelId, threadId) { + try { + this.drafts[this.key(channelId, threadId)] = message; + await this.persistDraft(message, channelId, threadId); + } catch (e) { + // eslint-disable-next-line no-console + console.log("Couldn't save draft", e); } } - get({ channelId }) { - return this.drafts[channelId]; + get(channelId, threadId) { + return this.drafts[this.key(channelId, threadId)]; } - remove({ channelId }) { - delete this.drafts[channelId]; + remove(channelId, threadId) { + delete this.drafts[this.key(channelId, threadId)]; } reset() { this.drafts = {}; } + + key(channelId, threadId) { + let key = `c-${channelId}`; + if (threadId) { + key += `:t-${threadId}`; + } + return key.toString(); + } + + async persistDraft(message, channelId, threadId) { + try { + await this.chatApi.saveDraft(channelId, message.toJSONDraft(), { + threadId, + }); + message.draftSaved = true; + } catch (e) { + // We don't want to throw an error if the draft fails to save + } + } + + willDestroy() { + cancel(this?._persistHandler); + } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js index 87c2d887952..0d9c0bd099d 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js @@ -1,12 +1,10 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import Service, { inject as service } from "@ember/service"; -import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; export default class ChatThreadComposer extends Service { @service chat; - @tracked message; @tracked textarea; @action @@ -19,28 +17,11 @@ export default class ChatThreadComposer extends Service { this.textarea?.blur(); } - @action - reset(thread) { - this.message = ChatMessage.createDraftMessage(thread.channel, { - user: this.currentUser, - thread, - }); - } - - @action - cancel() { - if (this.message.editing) { - this.reset(this.message.thread); - } else if (this.message.inReplyTo) { - this.message.inReplyTo = null; - } - } - @action edit(message) { this.chat.activeMessage = null; message.editing = true; - this.message = message; + message.thread.draft = message; this.focus({ refreshHeight: true, ensureAtEnd: true }); } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index 4ce30b11360..19f23169746 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -183,11 +183,11 @@ export default class Chat extends Service { ...channelsView.direct_message_channels, ].forEach((channelObject) => { const storedChannel = this.chatChannelsManager.store(channelObject); - const storedDraft = (this.currentUser?.chat_drafts || []).find( + const storedDrafts = (this.currentUser?.chat_drafts || []).filter( (draft) => draft.channel_id === storedChannel.id ); - if (storedDraft) { + storedDrafts.forEach((storedDraft) => { this.chatDraftsManager.add( ChatMessage.createDraftMessage( storedChannel, @@ -195,9 +195,11 @@ export default class Chat extends Service { { user: this.currentUser }, JSON.parse(storedDraft.data) ) - ) + ), + storedDraft.channel_id, + storedDraft.thread_id ); - } + }); if (channelsView.unread_thread_overview?.[storedChannel.id]) { storedChannel.threadsManager.unreadThreadOverview = diff --git a/plugins/chat/config/routes.rb b/plugins/chat/config/routes.rb index a6d4f69ea24..8e5c16d05de 100644 --- a/plugins/chat/config/routes.rb +++ b/plugins/chat/config/routes.rb @@ -8,6 +8,7 @@ Chat::Engine.routes.draw do post "/channels" => "channels#create" put "/channels/read/" => "reads#update_all" put "/channels/:channel_id/read/:message_id" => "reads#update" + post "/channels/:channel_id/drafts" => "channels_drafts#create" delete "/channels/:channel_id" => "channels#destroy" put "/channels/:channel_id" => "channels#update" get "/channels/:channel_id" => "channels#show" @@ -38,6 +39,7 @@ Chat::Engine.routes.draw do get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show" get "/channels/:channel_id/threads/:thread_id/messages" => "channel_thread_messages#index" put "/channels/:channel_id/threads/:thread_id/read" => "thread_reads#update" + post "/channels/:channel_id/threads/:thread_id/drafts" => "channels_threads_drafts#create" put "/channels/:channel_id/threads/:thread_id/notifications-settings/me" => "channel_threads_current_user_notifications_settings#update" @@ -78,7 +80,6 @@ Chat::Engine.routes.draw do post "/:chat_channel_id/:message_id/flag" => "chat#flag" post "/:chat_channel_id/quote" => "chat#quote_messages" put "/user_chat_enabled/:user_id" => "chat#set_user_chat_status" - post "/drafts" => "chat#set_draft" post "/:chat_channel_id" => "api/channel_messages#create" put "/flag" => "chat#flag" get "/emojis" => "emojis#index" diff --git a/plugins/chat/db/migrate/20231110214451_adds_thread_id_to_chat_drafts.rb b/plugins/chat/db/migrate/20231110214451_adds_thread_id_to_chat_drafts.rb new file mode 100644 index 00000000000..ffe248b3cd5 --- /dev/null +++ b/plugins/chat/db/migrate/20231110214451_adds_thread_id_to_chat_drafts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddsThreadIdToChatDrafts < ActiveRecord::Migration[7.0] + def change + add_column :chat_drafts, :thread_id, :bigint + end +end diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index 2fc5fa0450c..af5d8b3292b 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -217,8 +217,8 @@ after_initialize do .where(user_id: object.id) .order(updated_at: :desc) .limit(20) - .pluck(:chat_channel_id, :data) - .map { |row| { channel_id: row[0], data: row[1] } } + .pluck(:chat_channel_id, :data, :thread_id) + .map { |row| { channel_id: row[0], data: row[1], thread_id: row[2] } } end add_to_serializer(:user_option, :chat_enabled) { object.chat_enabled } diff --git a/plugins/chat/spec/plugin_helper.rb b/plugins/chat/spec/plugin_helper.rb index 7ba1498efd9..f14c71429f5 100644 --- a/plugins/chat/spec/plugin_helper.rb +++ b/plugins/chat/spec/plugin_helper.rb @@ -113,6 +113,18 @@ module ChatSpecHelpers service_failed!(result) if result.failure? result end + + def create_draft(channel, thread: nil, user: Discourse.system_user, data: { message: "draft" }) + result = + ::Chat::UpsertDraft.call( + guardian: user.guardian, + channel_id: channel.id, + thread_id: thread&.id, + data: data.to_json, + ) + service_failed!(result) if result.failure? + result + end end RSpec.configure do |config| diff --git a/plugins/chat/spec/requests/chat/api/channels_drafts_controller_spec.rb b/plugins/chat/spec/requests/chat/api/channels_drafts_controller_spec.rb new file mode 100644 index 00000000000..ca7a1350b34 --- /dev/null +++ b/plugins/chat/spec/requests/chat/api/channels_drafts_controller_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.describe Chat::Api::ChannelsDraftsController do + fab!(:current_user) { Fabricate(:user) } + fab!(:channel_1) { Fabricate(:chat_channel) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + channel_1.add(current_user) + sign_in(current_user) + end + + describe "#create" do + describe "success" do + it "works" do + post "/chat/api/channels/#{channel_1.id}/drafts", params: { data: { message: "a" } } + + expect(response.status).to eq(200) + end + end + + context "when user can’t create drafts" do + before { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] } + + it "returns a 403" do + post "/chat/api/channels/#{channel_1.id}/drafts", params: { data: { message: "a" } } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"].first).to eq(I18n.t("invalid_access")) + end + end + + context "when channel is not found" do + it "returns a 404" do + post "/chat/api/channels/-999/drafts", params: { data: { message: "a" } } + + expect(response.status).to eq(404) + end + end + end +end diff --git a/plugins/chat/spec/requests/chat/api/channels_threads_drafts_controller_spec.rb b/plugins/chat/spec/requests/chat/api/channels_threads_drafts_controller_spec.rb new file mode 100644 index 00000000000..94a7eb704a4 --- /dev/null +++ b/plugins/chat/spec/requests/chat/api/channels_threads_drafts_controller_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +RSpec.describe Chat::Api::ChannelsThreadsDraftsController do + fab!(:current_user) { Fabricate(:user) } + fab!(:channel_1) { Fabricate(:chat_channel) } + fab!(:thread_1) { Fabricate(:chat_thread, channel: channel_1) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + channel_1.add(current_user) + sign_in(current_user) + end + + describe "#create" do + describe "success" do + it "works" do + post "/chat/api/channels/#{channel_1.id}/threads/#{thread_1.id}/drafts", + params: { + data: { + message: "a", + }, + } + + expect(response.status).to eq(200) + end + end + + context "when user can’t create drafts" do + before { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] } + + it "returns a 403" do + post "/chat/api/channels/#{channel_1.id}/threads/#{thread_1.id}/drafts", + params: { + data: { + message: "a", + }, + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"].first).to eq(I18n.t("invalid_access")) + end + end + + context "when thread is not found" do + it "returns a 404" do + post "/chat/api/channels/#{channel_1.id}/threads/-999/drafts", + params: { + data: { + message: "a", + }, + } + + expect(response.status).to eq(404) + end + end + + context "when channel is not found" do + it "returns a 404" do + post "/chat/api/channels/-999/threads/#{thread_1.id}/drafts", + params: { + data: { + message: "a", + }, + } + + expect(response.status).to eq(404) + end + end + end +end diff --git a/plugins/chat/spec/requests/chat_controller_spec.rb b/plugins/chat/spec/requests/chat_controller_spec.rb index d2a029cacdb..d2f3c106073 100644 --- a/plugins/chat/spec/requests/chat_controller_spec.rb +++ b/plugins/chat/spec/requests/chat_controller_spec.rb @@ -539,61 +539,6 @@ RSpec.describe Chat::ChatController do end end - describe "#set_draft" do - fab!(:chat_channel) { Fabricate(:category_channel) } - let(:dm_channel) { Fabricate(:direct_message_channel) } - - before { sign_in(user) } - - it "can create and destroy chat drafts" do - expect { - post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } - }.to change { Chat::Draft.count }.by(1) - - expect { post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id } }.to change { - Chat::Draft.count - }.by(-1) - end - - it "cannot create chat drafts for a category channel the user cannot access" do - group = Fabricate(:group) - private_category = Fabricate(:private_category, group: group) - chat_channel.update!(chatable: private_category) - - post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } - expect(response.status).to eq(403) - - GroupUser.create!(user: user, group: group) - expect { - post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } - }.to change { Chat::Draft.count }.by(1) - end - - it "cannot create chat drafts for a direct message channel the user cannot access" do - post "/chat/drafts.json", params: { chat_channel_id: dm_channel.id, data: "{}" } - expect(response.status).to eq(403) - - Chat::DirectMessageUser.create(user: user, direct_message: dm_channel.chatable) - - expect { - post "/chat/drafts.json", params: { chat_channel_id: dm_channel.id, data: "{}" } - }.to change { Chat::Draft.count }.by(1) - end - - it "cannot create a too long chat draft" do - SiteSetting.max_chat_draft_length = 100 - - post "/chat/drafts.json", - params: { - chat_channel_id: chat_channel.id, - data: { value: "a" * (SiteSetting.max_chat_draft_length + 1) }.to_json, - } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to eq([I18n.t("chat.errors.draft_too_long")]) - end - end - describe "#message_link" do it "ensures message's channel can be seen" do channel = Fabricate(:category_channel, chatable: Fabricate(:category)) diff --git a/plugins/chat/spec/services/chat/upsert_draft_spec.rb b/plugins/chat/spec/services/chat/upsert_draft_spec.rb new file mode 100644 index 00000000000..b4fd12f3286 --- /dev/null +++ b/plugins/chat/spec/services/chat/upsert_draft_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +RSpec.describe Chat::UpsertDraft do + describe described_class::Contract, type: :model do + subject(:contract) { described_class.new(data: nil, channel_id: nil, thread_id: nil) } + + it { is_expected.to validate_presence_of :channel_id } + end + + describe ".call" do + subject(:result) { described_class.call(params) } + + fab!(:current_user) { Fabricate(:user) } + fab!(:channel_1) { Fabricate(:chat_channel) } + fab!(:thread_1) { Fabricate(:chat_thread, channel: channel_1) } + + let(:guardian) { Guardian.new(current_user) } + let(:data) { nil } + let(:channel_id) { channel_1.id } + let(:thread_id) { nil } + let(:params) do + { guardian: guardian, channel_id: channel_id, thread_id: thread_id, data: data } + end + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + channel_1.add(current_user) + end + + context "when all steps pass" do + it "creates draft if data provided and not existing draft" do + params[:data] = MultiJson.dump(message: "a") + + expect { result }.to change { Chat::Draft.count }.by(1) + expect(Chat::Draft.last.data).to eq(params[:data]) + end + + it "updates draft if data provided and existing draft" do + params[:data] = MultiJson.dump(message: "a") + + described_class.call(**params) + + params[:data] = MultiJson.dump(message: "b") + + expect { result }.to_not change { Chat::Draft.count } + expect(Chat::Draft.last.data).to eq(params[:data]) + end + + it "destroys draft if empty data provided and existing draft" do + params[:data] = MultiJson.dump(message: "a") + + described_class.call(**params) + + params[:data] = "" + + expect { result }.to change { Chat::Draft.count }.by(-1) + end + + it "destroys draft if no data provided and existing draft" do + params[:data] = MultiJson.dump(message: "a") + + described_class.call(**params) + + params[:data] = nil + + expect { result }.to change { Chat::Draft.count }.by(-1) + end + end + + context "when user can’t chat" do + before { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] } + + it { is_expected.to fail_a_policy(:can_upsert_draft) } + end + + context "when user can’t access the channel" do + fab!(:channel_1) { Fabricate(:private_category_channel) } + + it { is_expected.to fail_a_policy(:can_upsert_draft) } + end + + context "when channel is not found" do + let(:channel_id) { -999 } + + it { is_expected.to fail_to_find_a_model(:channel) } + end + + context "when thread is not found" do + let(:thread_id) { -999 } + + it { is_expected.to fail_a_step(:check_thread_exists) } + end + end +end diff --git a/plugins/chat/spec/system/chat_composer_draft_spec.rb b/plugins/chat/spec/system/chat_composer_draft_spec.rb index a0c254f8d6c..60ec32d67b4 100644 --- a/plugins/chat/spec/system/chat_composer_draft_spec.rb +++ b/plugins/chat/spec/system/chat_composer_draft_spec.rb @@ -13,19 +13,13 @@ RSpec.describe "Chat composer draft", type: :system do let(:chat_page) { PageObjects::Pages::Chat.new } let(:channel_page) { PageObjects::Pages::ChatChannel.new } + let(:thread_page) { PageObjects::Pages::ChatThread.new } before { chat_system_bootstrap } context "when loading a channel with a draft" do - fab!(:draft_1) do - Chat::Draft.create!( - chat_channel: channel_1, - user: current_user, - data: { message: "draft" }.to_json, - ) - end - before do + create_draft(channel_1, user: current_user) channel_1.add(current_user) sign_in(current_user) end @@ -39,16 +33,11 @@ RSpec.describe "Chat composer draft", type: :system do context "when loading another channel and back" do fab!(:channel_2) { Fabricate(:chat_channel) } - fab!(:draft_2) do - Chat::Draft.create!( - chat_channel: channel_2, - user: current_user, - data: { message: "draft2" }.to_json, - ) + before do + create_draft(channel_2, user: current_user, data: { message: "draft2" }) + channel_2.add(current_user) end - before { channel_2.add(current_user) } - it "loads the correct drafts" do chat_page.visit_channel(channel_1) @@ -64,12 +53,16 @@ RSpec.describe "Chat composer draft", type: :system do end end - context "with editing" do - fab!(:draft_1) do - Chat::Draft.create!( - chat_channel: channel_1, + context "when editing" do + before do + create_draft( + channel_1, user: current_user, - data: { message: message_1.message, id: message_1.id, editing: true }.to_json, + data: { + message: message_1.message, + id: message_1.id, + editing: true, + }, ) end @@ -103,12 +96,8 @@ RSpec.describe "Chat composer draft", type: :system do ) end - fab!(:draft_1) do - Chat::Draft.create!( - chat_channel: channel_1, - user: current_user, - data: { message: "draft", uploads: [upload_1] }.to_json, - ) + before do + create_draft(channel_1, user: current_user, data: { message: "draft", uploads: [upload_1] }) end it "loads the draft with the upload" do @@ -120,9 +109,9 @@ RSpec.describe "Chat composer draft", type: :system do end context "when replying" do - fab!(:draft_1) do - Chat::Draft.create!( - chat_channel: channel_1, + before do + create_draft( + channel_1, user: current_user, data: { message: "draft", @@ -136,7 +125,7 @@ RSpec.describe "Chat composer draft", type: :system do username: message_1.user.username, }, }, - }.to_json, + }, ) end @@ -149,4 +138,109 @@ RSpec.describe "Chat composer draft", type: :system do end end end + + context "when loading a thread with a draft" do + fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: true) } + fab!(:thread_1) { Fabricate(:chat_thread, channel: channel_1) } + + before do + create_draft(channel_1, user: current_user, thread: thread_1) + channel_1.add(current_user) + sign_in(current_user) + end + + it "loads the draft" do + chat_page.visit_thread(thread_1) + + expect(thread_page.composer.value).to eq("draft") + end + + context "when loading another channel and back" do + fab!(:channel_2) { Fabricate(:chat_channel, threading_enabled: true) } + fab!(:thread_2) { Fabricate(:chat_thread, channel: channel_2) } + + before do + create_draft(channel_2, user: current_user, thread: thread_2, data: { message: "draft2" }) + channel_2.add(current_user) + end + + it "loads the correct drafts" do + chat_page.visit_thread(thread_1) + + expect(thread_page.composer.value).to eq("draft") + + chat_page.visit_thread(thread_2) + + expect(thread_page.composer.value).to eq("draft2") + + chat_page.visit_thread(thread_1) + + expect(thread_page.composer.value).to eq("draft") + end + end + + context "when editing" do + before do + create_draft( + channel_1, + user: current_user, + thread: thread_1, + data: { + message: message_1.message, + id: message_1.id, + editing: true, + }, + ) + end + + it "loads the draft with the editing state" do + chat_page.visit_thread(thread_1) + + expect(thread_page.composer).to be_editing_message(message_1) + end + + context "when canceling editing" do + it "resets the draft" do + chat_page.visit_thread(thread_1) + thread_page.composer.message_details.cancel_edit + + expect(thread_page.composer).to be_blank + expect(thread_page.composer).to have_unsaved_draft + expect(thread_page.composer).to have_saved_draft + end + end + end + + context "with uploads" do + fab!(:upload_1) do + Fabricate( + :upload, + url: "/images/logo-dark.png", + original_filename: "logo_dark.png", + width: 400, + height: 300, + extension: "png", + ) + end + + before do + create_draft( + channel_1, + user: current_user, + thread: thread_1, + data: { + message: "draft", + uploads: [upload_1], + }, + ) + end + + it "loads the draft with the upload" do + chat_page.visit_thread(thread_1) + + expect(thread_page.composer.value).to eq("draft") + expect(page).to have_selector(".chat-composer-upload--image", count: 1) + end + end + end end diff --git a/plugins/chat/test/javascripts/helpers/chat-default-pretender.js b/plugins/chat/test/javascripts/helpers/chat-default-pretender.js new file mode 100644 index 00000000000..e152c29544a --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/chat-default-pretender.js @@ -0,0 +1,8 @@ +export default function applyDefaultHandlers(helpers) { + this.post("/chat/api/channels/:channel_id/drafts", () => + helpers.response({}) + ); + this.post("/chat/api/channels/:channel_id/threads/:thread_id/drafts", () => + helpers.response({}) + ); +} diff --git a/plugins/chat/test/javascripts/unit/services/chat-drafts-manager-test.js b/plugins/chat/test/javascripts/unit/services/chat-drafts-manager-test.js index d1981d1c05d..1c5a4bad213 100644 --- a/plugins/chat/test/javascripts/unit/services/chat-drafts-manager-test.js +++ b/plugins/chat/test/javascripts/unit/services/chat-drafts-manager-test.js @@ -12,36 +12,24 @@ module( this.subject = getOwner(this).lookup("service:chat-drafts-manager"); }); - hooks.afterEach(function () { - this.subject.reset(); - }); - - test("storing and retrieving message", function (assert) { + test("storing and retrieving message", async function (assert) { const message1 = fabricators.message(); - this.subject.add(message1); - assert.strictEqual( - this.subject.get({ channelId: message1.channel.id }), - message1 - ); + await this.subject.add(message1, message1.channel.id); + + assert.strictEqual(this.subject.get(message1.channel.id), message1); const message2 = fabricators.message(); - this.subject.add(message2); - assert.strictEqual( - this.subject.get({ channelId: message2.channel.id }), - message2 - ); + await this.subject.add(message2, message2.channel.id); + + assert.strictEqual(this.subject.get(message2.channel.id), message2); }); - test("stores only chat messages", function (assert) { - assert.throws(function () { - this.subject.add({ foo: "bar" }); - }, /instance of ChatMessage/); - }); + test("#reset", async function (assert) { + const message = fabricators.message(); - test("#reset", function (assert) { - this.subject.add(fabricators.message()); + await this.subject.add(message, message.channel.id); assert.strictEqual(Object.keys(this.subject.drafts).length, 1);