FEATURE: implements drafts for threads (#24483)

This commit implements drafts for threads by adding a new `thread_id` column to `chat_drafts` table. This column is used to create draft keys on the frontend which are a compound key of the channel and the thread. If the draft is only for the channel, the key will be `c-${channelId}`, if for a thread: `c-${channelId}:t-${threadId}`.

This commit also moves the draft holder from the service to the channel or thread model. The current draft can now always be accessed by doing: `channel.draft` or `thread.draft`.

Other notable changes of this commit:
- moves ChatChannel to gjs
- moves ChatThread to gjs
This commit is contained in:
Joffrey JAFFEUX 2023-11-22 11:54:23 +01:00 committed by GitHub
parent 39aa70d7cb
commit 906caa63d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 867 additions and 476 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,74 @@
# frozen_string_literal: true
module Chat
# Service responsible to create draft for a channel, or a channels 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

View File

@ -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;
}
<template>
<div
class={{concatClass
"chat-channel"
(if this.messagesLoader.loading "loading")
(if this.pane.sending "chat-channel--sending")
(unless this.messagesLoader.fetchedOnce "chat-channel--not-loaded-once")
}}
{{willDestroy this.teardown}}
{{didInsert this.setup}}
{{didUpdate this.loadMessages @targetMessageId}}
data-id={{@channel.id}}
>
<ChatFullPageHeader
@channel={{@channel}}
@onCloseFullScreen={{this.onCloseFullScreen}}
@displayed={{this.includeHeader}}
/>
<ChatNotices @channel={{@channel}} />
<ChatMentionWarnings />
<div
class="chat-messages-scroll chat-messages-container popper-viewport"
{{didInsert this.setScrollable}}
{{ChatScrollableList
(hash
onScroll=this.onScroll onScrollEnd=this.onScrollEnd reverse=true
)
}}
>
<div
class="chat-messages-container"
{{ChatOnResize this.didResizePane (hash delay=100 immediate=true)}}
>
{{#each this.messagesManager.messages key="id" as |message|}}
<Message
@message={{message}}
@disableMouseEvents={{this.isScrolling}}
@resendStagedMessage={{this.resendStagedMessage}}
@fetchMessagesByDate={{this.fetchMessagesByDate}}
@context="channel"
/>
{{else}}
{{#unless this.messagesLoader.fetchedOnce}}
<ChatSkeleton />
{{/unless}}
{{/each}}
</div>
{{! at bottom even if shown at top due to column-reverse }}
{{#if this.messagesLoader.loadedPast}}
<div class="all-loaded-message">
{{i18n "chat.all_loaded"}}
</div>
{{/if}}
</div>
<ChatScrollToBottomArrow
@onScrollToBottom={{this.scrollToLatestMessage}}
@isVisible={{this.needsArrow}}
/>
{{#if this.pane.selectingMessages}}
<ChatSelectionManager
@enableMove={{and
(not @channel.isDirectMessageChannel)
@channel.canModerate
}}
@pane={{this.pane}}
/>
{{else}}
{{#if (and (not @channel.isFollowing) @channel.isCategoryChannel)}}
<ChatChannelPreviewCard @channel={{@channel}} />
{{else}}
<ChatComposerChannel
@channel={{@channel}}
@uploadDropZone={{this.uploadDropZone}}
@onSendMessage={{this.onSendMessage}}
/>
{{/if}}
{{/if}}
<ChatUploadDropZone @model={{@channel}} />
</div>
</template>
}

View File

@ -1,87 +0,0 @@
<div
class={{concat-class
"chat-channel"
(if this.messagesLoader.loading "loading")
(if this.pane.sending "chat-channel--sending")
(unless this.messagesLoader.fetchedOnce "chat-channel--not-loaded-once")
}}
{{did-insert this.setUploadDropZone}}
{{did-insert this.setupListeners}}
{{will-destroy this.teardownListeners}}
{{did-insert this.didUpdateChannel}}
{{did-insert this.addAutoFocusEventListener}}
{{will-destroy this.removeAutoFocusEventListener}}
{{did-update this.loadMessages @targetMessageId}}
data-id={{@channel.id}}
>
<ChatFullPageHeader
@channel={{@channel}}
@onCloseFullScreen={{this.onCloseFullScreen}}
@displayed={{this.includeHeader}}
/>
<Chat::Notices @channel={{@channel}} />
<ChatMentionWarnings />
<div
class="chat-messages-scroll chat-messages-container popper-viewport"
{{did-insert this.setScrollable}}
{{chat/scrollable-list
(hash onScroll=this.onScroll onScrollEnd=this.onScrollEnd reverse=true)
}}
>
<div
class="chat-messages-container"
{{chat/on-resize this.didResizePane (hash delay=100 immediate=true)}}
>
{{#each this.messagesManager.messages key="id" as |message|}}
<ChatMessage
@message={{message}}
@disableMouseEvents={{this.isScrolling}}
@resendStagedMessage={{this.resendStagedMessage}}
@fetchMessagesByDate={{this.fetchMessagesByDate}}
@context="channel"
/>
{{else}}
{{#unless this.messagesLoader.fetchedOnce}}
<ChatSkeleton />
{{/unless}}
{{/each}}
</div>
{{! at bottom even if shown at top due to column-reverse }}
{{#if this.messagesLoader.loadedPast}}
<div class="all-loaded-message">
{{i18n "chat.all_loaded"}}
</div>
{{/if}}
</div>
<Chat::ScrollToBottomArrow
@onScrollToBottom={{this.scrollToLatestMessage}}
@isVisible={{this.needsArrow}}
/>
{{#if this.pane.selectingMessages}}
<Chat::SelectionManager
@enableMove={{and
(not @channel.isDirectMessageChannel)
@channel.canModerate
}}
@pane={{this.pane}}
/>
{{else}}
{{#if (and (not @channel.isFollowing) @channel.isCategoryChannel)}}
<ChatChannelPreviewCard @channel={{@channel}} />
{{else}}
<Chat::Composer::Channel
@channel={{@channel}}
@uploadDropZone={{this.uploadDropZone}}
@onSendMessage={{this.onSendMessage}}
/>
{{/if}}
{{/if}}
<ChatUploadDropZone @model={{@channel}} />
</div>

View File

@ -4,12 +4,8 @@
<div class="chat-composer__wrapper">
{{#if this.shouldRenderMessageDetails}}
<ChatComposerMessageDetails
@message={{if
this.currentMessage.editing
this.currentMessage
this.currentMessage.inReplyTo
}}
@cancelAction={{this.composer.cancel}}
@message={{if this.draft.editing this.draft this.draft.inReplyTo}}
@cancelAction={{this.resetDraft}}
/>
{{/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 @@
>
<DTextarea
id={{this.composerId}}
value={{readonly this.currentMessage.message}}
value={{readonly this.draft.message}}
type="text"
class="chat-composer__input"
disabled={{this.disabled}}
@ -101,19 +97,15 @@
<ChatComposerUploads
@fileUploadElementId={{this.fileUploadElementId}}
@onUploadChanged={{this.onUploadChanged}}
@existingUploads={{this.currentMessage.uploads}}
@existingUploads={{this.draft.uploads}}
@uploadDropZone={{@uploadDropZone}}
@composerInputEl={{this.composer.textarea.element}}
/>
{{/if}}
{{#if this.shouldRenderReplyingIndicator}}
<div class="chat-replying-indicator-container">
<ChatReplyingIndicator
@presenceChannelName={{this.presenceChannelName}}
/>
</div>
{{/if}}
<div class="chat-replying-indicator-container">
<ChatReplyingIndicator @presenceChannelName={{this.presenceChannelName}} />
</div>
<ChatEmojiPicker
@context={{this.context}}

View File

@ -48,8 +48,8 @@ export default class ChatComposer extends Component {
get shouldRenderMessageDetails() {
return (
this.currentMessage?.editing ||
(this.context === "channel" && this.currentMessage?.inReplyTo)
this.draft?.editing ||
(this.context === "channel" && this.draft?.inReplyTo)
);
}
@ -95,7 +95,7 @@ export default class ChatComposer extends Component {
@action
didUpdateMessage() {
this.cancelPersistDraft();
this.composer.textarea.value = this.currentMessage.message;
this.composer.textarea.value = this.draft.message;
this.persistDraft();
this.captureMentions({ skipDebounce: true });
}
@ -118,25 +118,21 @@ export default class ChatComposer extends Component {
buttonAction();
}
get currentMessage() {
return this.composer.message;
}
get hasContent() {
const minLength = this.siteSettings.chat_minimum_message_length || 1;
return (
this.currentMessage?.message?.length >= 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();
}
}

View File

@ -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;
}
<template>
<div
class={{concatClass
"chat-thread"
(if this.messagesLoader.loading "loading")
}}
data-id={{@thread.id}}
{{didInsert this.setup}}
{{willDestroy this.teardown}}
>
{{#if @includeHeader}}
<ChatThreadHeader @channel={{@thread.channel}} @thread={{@thread}} />
{{/if}}
<div
class="chat-thread__body popper-viewport chat-messages-scroll"
{{didInsert this.setScrollable}}
{{ChatScrollableList
(hash
onScroll=this.onScroll onScrollEnd=this.onScrollEnd reverse=true
)
}}
>
<div
class="chat-messages-container"
{{ChatOnResize this.didResizePane (hash delay=100 immediate=true)}}
>
{{#each this.messagesManager.messages key="id" as |message|}}
<Message
@message={{message}}
@disableMouseEvents={{this.isScrolling}}
@resendStagedMessage={{this.resendStagedMessage}}
@context="thread"
/>
{{/each}}
{{#unless this.messagesLoader.fetchedOnce}}
{{#if this.messagesLoader.loading}}
<ChatSkeleton />
{{/if}}
{{/unless}}
</div>
</div>
<ChatScrollToBottomArrow
@onScrollToBottom={{this.scrollToLatestMessage}}
@isVisible={{this.needsArrow}}
/>
{{#if this.chatThreadPane.selectingMessages}}
<ChatSelectionManager @pane={{this.chatThreadPane}} />
{{else}}
<ChatComposerThread
@channel={{@channel}}
@thread={{@thread}}
@onSendMessage={{this.onSendMessage}}
@uploadDropZone={{this.uploadDropZone}}
/>
{{/if}}
<ChatUploadDropZone @model={{@thread}} />
</div>
</template>
}

View File

@ -1,60 +0,0 @@
<div
class={{concat-class
"chat-thread"
(if this.messagesLoader.loading "loading")
}}
data-id={{@thread.id}}
{{did-insert this.setUploadDropZone}}
{{did-insert this.didUpdateThread}}
{{will-destroy this.teardown}}
>
{{#if @includeHeader}}
<Chat::Thread::Header @channel={{@thread.channel}} @thread={{@thread}} />
{{/if}}
<div
class="chat-thread__body popper-viewport chat-messages-scroll"
{{did-insert this.setScrollable}}
{{chat/scrollable-list
(hash onScroll=this.onScroll onScrollEnd=this.onScrollEnd reverse=true)
}}
>
<div
class="chat-messages-container"
{{chat/on-resize this.didResizePane (hash delay=100 immediate=true)}}
>
{{#each this.messagesManager.messages key="id" as |message|}}
<ChatMessage
@message={{message}}
@disableMouseEvents={{this.isScrolling}}
@resendStagedMessage={{this.resendStagedMessage}}
@context="thread"
/>
{{/each}}
{{#unless this.messagesLoader.fetchedOnce}}
{{#if this.messagesLoader.loading}}
<ChatSkeleton />
{{/if}}
{{/unless}}
</div>
</div>
<Chat::ScrollToBottomArrow
@onScrollToBottom={{this.scrollToLatestMessage}}
@isVisible={{this.needsArrow}}
/>
{{#if this.chatThreadPane.selectingMessages}}
<Chat::SelectionManager @pane={{this.chatThreadPane}} />
{{else}}
<Chat::Composer::Thread
@channel={{@channel}}
@thread={{@thread}}
@onSendMessage={{this.onSendMessage}}
@uploadDropZone={{this.uploadDropZone}}
/>
{{/if}}
<ChatUploadDropZone @model={{@thread}} />
</div>

View File

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

View File

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

View File

@ -231,5 +231,7 @@ export default class ChatChannelSubscriptionManager {
if (message?.thread) {
message.thread.preview = ChatThreadPreview.create(data.preview);
}
message.thread.preview.yolo = 1;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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