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;
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#if this.pane.selectingMessages}}
+
+ {{else}}
+ {{#if (and (not @channel.isFollowing) @channel.isCategoryChannel)}}
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
+
+
+
+
}
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 @@
-
-
-
-
-
-
-
-
-
-
-
- {{#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;
}
+
+
+
+ {{#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}}
+
+
+
+
}
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);